34 KiB
Web UI
Two web surfaces share the same skeleton: the dashboard (port 7000)
and the per-agent UIs (manager on :8000, sub-agents on a hashed
:8100-8999). Both are SPAs — GET / returns a static shell,
/api/state returns JSON, JS renders. No full-page reloads.
Shape (shared by both)
GET /→index.htmlfrom the bundled frontend dist (seefrontend/). Both binaries' routers declare their dynamic endpoints first and thenfallback_service(ServeDir::new(...))pointed atHIVE_STATIC_DIR— anything not matched by an API or action route is served from the dist. Dashboard dist lives at${frontend}/dashboard; per-agent dist is the mergedhyperhive.frontend.mergedDist(default agent dist + per-agentextraFilesoverlay).GET /static/*→ bundled CSS + JS produced by esbuild (frontend/packages/{dashboard,agent}/build.mjs). Both pages pull the shared terminal pane + Catppuccin palette + typography from@hive/shared(washive-fr0nt); the CSS bundle inlinesbase.css+terminal.cssvia esbuild's@importresolution.terminal.jsexports{ create, linkify }as ES module members (no morewindow.HiveTerminalglobal outside the back-compat shim the IIFE bodies still use). The dashboard's#msgflowand the per-agent#livelog are both backed by this terminal — sticky-bottom auto-scroll, "↓ N new" pill, history backfill, SSE plumbing all live there. Each page registers a kind→renderer map; unknown kinds fall through to a JSON-dump note row. Barehttp(s)://URLs in row text are turned into clickable new-tab links bylinkify(text-node based, noinnerHTML— XSS-safe); markdown bodies get the same treatment viamarked's autolink (npm dep, replacing the vendored UMD bundle), with the rendered<a>s rewritten totarget="_blank"(issue #233).GET /api/state→ JSON snapshot the JS app renders into the DOM. Includes a top-levelseq(the dashboard event channel's high-water mark at the moment the snapshot was assembled); clients use it to dedupe their buffered SSE traffic against the snapshot (drop frames withseq <= snapshot.seq).GET /dashboard/stream(dashboard) /GET /events/stream(per-agent) →text/event-streamSSE for live updates. The dashboard stream carries brokerSent/Delivered(mirrored by a forwarder task from the broker's intra-process channel) plus mutation events (approval_added/approval_resolved,question_added/question_resolved,transient_set/transient_cleared). Each frame carries aseq. The matching backfill endpoint isGET /dashboard/history(last ~200 broker messages wrapped in{ seq, events }) on the dashboard andGET /events/history(last 2000LiveEvents also wrapped in{ seq, events }) on the agent.
The JS app handles all form[data-async] submissions via a delegated
listener: read data-confirm, swap the button to a spinner, POST
application/x-www-form-urlencoded, re-enable the button on success
(refreshState may keep the form mounted, so we don't rely on a
re-render), call refreshState(). State shapes live in
dashboard.rs::StateSnapshot and web_ui.rs::StateSnapshot — when
adding state fields, plumb through the snapshot struct and the
relevant assets/app.js render function.
Focus preservation: refreshState checks whether
document.activeElement sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
<details> open-state preservation: any collapsible element
tagged with data-restore-key="<stable-key>" survives the
refresh. snapshotOpenDetails() walks managed sections before
render, restoreOpenDetails() re-applies after. Long-content
drill-ins (file previews, diffs, journald logs) now open in the
side panel (see below) rather than expanding inline, so the
only restore-keyed <details> left is the answered-questions
history list.
Side panel (dashboard): long content opens in a drawer that
swipes in from the right — a singleton #side-panel with a
titled header, a close button, and a scrollable body. Closes on
the button, a backdrop click, or Escape. Panel.open(title, node) swaps the body; the JS builders for file previews,
approval diffs, and journald logs all render into it. File
previews are type-aware:
- Markdown (
.md/.markdown) — arendered/plaintabbed view:rendered(default) is the vendoredmarkedbundle (GET /static/marked.js),plainis the raw source. - SVG (
.svg) — arendered/sourcetabbed view;renderedshows the image via an<img>data:URI (the browser's secure static mode, so an untrusted SVG can't run scripts),sourceshows the raw markup. - Raster images (
.png/.jpg/.gif/.webp/.bmp/.ico/.avif) — render as an<img>pointed at/api/state-file, which serves them as binary with their real content-type (text files stay UTF-8-lossytext/plain). - Everything else — raw text in a
<pre>.
Both bind their listeners with SO_REUSEADDR via
tokio::net::TcpSocket plus a retry loop on AddrInUse (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
Dashboard layout
The dashboard (/) has a fixed chrome header at the top and a
<main> that shows exactly one tab pane at a time. The URL hash
(#swarm, #call, #system) drives which pane is active; hash
changes don't reload the page. FL0W is a separate full-page
terminal at /flow.html — its tab-strip entry is a cross-page
link (◆ FL0W ◆ →), not a pane swap.
Chrome header (fixed, overlays the active tab pane):
- Tab strip:
◆ SW4RM ◆,◆ Y3R C4LL ◆,◆ SYST3M ◆, and◆ FL0W ◆ →(page link). Count pills on SW4RM (container count) and Y3R C4LL (pending approvals + questions); FL0W pill mirrors the operator inbox length (hidden when zero). - Notification controls:
🔔 enable notificationswhen permission ungranted;🔕 mute / 🔔 unmutetoggle once granted. Always visible in the chrome regardless of active tab. - Banner-thin (
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░) — sits below the tab strip.
SW4RM tab
C0NTAINERS — live containers rendered as a depth-first
tree using ContainerView.parent (populated by topology.rs).
Each container's row is prefixed with ASCII tree glyphs (├─,
└─, │ continuation columns) showing the agent
parent/child hierarchy. When every container has parent = null
(flat topology) the tree collapses to a plain list with no
glyphs. Children are sorted alphabetically within each parent;
roots likewise. Cycles in the parent graph are tolerated —
orphaned containers (not reachable from any root) are appended
as roots so no agent disappears. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(port_conflicts from /api/state): the operator must rename
one of them and rebuild. lifecycle::{spawn,rebuild} also
preflight this and refuse with a clear error message naming the
conflicting agent.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale.
Y3R C4LL tab
Things blocked on operator decision — approvals and questions share a tab because they're the same concept ("something is waiting on you").
P3NDING APPR0VALS — the queue (see "Approval card" below). The R3QU3ST SP4WN form lives at the top of this section.
M1ND H4S QU3STI0NS — pending operator-targeted ask
questions (amber pulsing border). Free-text fallback always
rendered alongside any option list; multi=true renders options
as checkboxes; submit merges selections + free text
comma-joined. Each row has a ✗ CANC3L button. Questions with
a ttl_seconds show a ⏳ MM:SS chip; the host-side watchdog
auto-cancels with [expired] when the deadline fires.
SYST3M tab
Passive / rare-interaction state.
M3T4 1NPUTS — inputs in meta/flake.lock the operator can
selectively nix flake update, rendered as an indented tree:
every fetched input at every depth (hyperhive,
hyperhive/nixpkgs, agent-<n>, agent-<n>/mcp-<x>, …), each
shown once at its shallowest path. read_meta_inputs walks the
lock graph with a visited set — follows aliases and rev-less
nodes are skipped (issue #275). A select all / select none
control sits above the tree. Checking inputs + submitting bumps
the lock in /meta/ and rebuilds the selected agents in
sequence; each outcome reaches the manager as a rebuilt
system event. POST /meta-update. While a lock-bump ripple runs,
the panel shows a pulsing "⏳ meta-update running" banner and the
update button is disabled (snapshot field meta_update_running,
live event meta_update_running).
R3BU1LD QU3U3 — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (⏸ queued / ▶ running / ✔ done /
✖ failed / ⊘ cancelled), kind glyph + verb (↻ rebuild,
◆ meta_update, ✨ spawn, 🗑 destroy), agent name, source
chip (manual | meta_update | auto_update | crash_recover),
timing, and an optional reason / error. Meta-update cascade
rebuilds nest under their parent entry (parent_id grouping;
rqe-child CSS class). Dedup: re-enqueueing a still-queued op
for the same agent collapses into the existing entry. Running
entries tick elapsed seconds live. Cold-loaded from
/api/state.rebuild_queue; live updates via rebuild_queue_changed
snapshot event.
QU3U3D R3M1ND3RS — reminders agents have scheduled for
themselves (via the remind tool) but not yet delivered.
Each row shows the owner, due time, and message; a CANC3L
button hard-deletes (POST /cancel-reminder/{id}) and a
R3TRY button re-arms one whose delivery failed
(POST /retry-reminder/{id}). Backed by GET /api/reminders.
K3PT ST4T3 — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: ⊕ R3V1V3 (queues a
Spawn approval; existing state is reused), PURG3 (wipes
state + applied dirs; POST /purge-tombstone/{name}).
FL0W page (/flow.html)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Reuses the same <header class="dashboard-chrome"> chrome
as the dashboard so the tab strip remains visible; SW4RM / Y3R
C4LL / SYST3M are cross-page links back to /#<tab>, and the
FL0W entry is marked active (aria-current="page").
0PER4T0R 1NB0X — recent messages addressed to operator,
derived client-side from the dashboard event stream. Cold load
seeds from /dashboard/history's 200-message backfill; subsequent
sent events with to == "operator" are appended live. Cap 50,
newest-first.
MESS4GE FL0W — live broker tail wrapped in a .terminal-wrap.
Cold load backfills the last ~200 messages from /dashboard/history;
live frames arrive on /dashboard/stream. Each row is one broker
event — sent or delivered — with from → to: body. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: @name picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
to confirm; @* broadcasts). POST /op-send drops
{from:"operator", to, body} into the broker; the resulting SSE
frame re-renders both the terminal row and the inbox section.
Manager is addressed as @manager (the broker recipient string),
not @hm1nd (the container name).
Container row
A full-height square agent icon on the left (the agent's
/icon, an <img> absolutely positioned inside a wrapper div so
its load state can never reflow the row), and the card body on
the right with three stacked lines
(assets/app.js::renderContainers). The <img> points straight
at <url>/icon; if it actually fails to load (container stopped
or mid-transient, web server not answering) the error handler
falls it back to the dimmed hyperhive mark (/favicon.svg)
instead of an empty box — a real load-failure fallback, not a
guess from container state.
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
icon-only nav strip populated async from the agent backend
(
📊 stats,🖥 screenwhen GUI is enabled,⬡ forge profile,↳ agent-configs mirror, plus any agent-declareddashboardLinksextras — issue #262). The dashboard JS fetchesGET /api/agent/{name}/links, a same-origin passthrough proxy that forwards the agent's own link list; the agent backend is the single source of truth. The frontend resolves eachAgentLink.kind(container→http://host:<container.port>,forge→http://host:3000,external→ already absolute). Status badges follow —⊘ rate limited(red, while the harness is parked after a 429),needs login,needs update— in-flight◐ pending-state…pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port, and actx · Nkchip showing the agent's last-turn context size (fromContainerView.ctx_tokens, read from the turn-stats sqlite on eachbuild_allsweep; absent until the first turn). The chip colour (green / yellow / red) is keyed off the model's real context window:build_allresolves the last turn's model against the host's per-modelcontextWindowTokensconfig and exposes it asContainerView.context_window_tokens; the badge goes yellow ≥ 50% and red ≥ 75% of that window (the harness compaction watermarks). When the window can't be resolved the badge falls back to fixed 100k / 150k thresholds. (issue #66) - Line 2: action buttons —
↻ R3BU1LDalways,DESTR0Y+PURG3on sub-agents,↺ R3ST4RT+ (sub-agents)■ ST0Pwhen running,▶ ST4RTwhen stopped. Buttons dim + disable while a transient lifecycle action is in flight. - Line 3: drill-in triggers —
↳ logs · <container>— opens the side panel and lazy- fetches journald viaGET /api/journal/{name}?unit=&lines=(journalctl -M <container> -b --no-pager --output=short-iso). A unit dropdown (harness service / full machine journal) and a refresh button live in the panel.- Plain navigation links (config repo, forge profile,
dashboardLinksextras) now live in the icon-only nav strip on Line 1 — see above (issue #262). The agent'sconfiglink goes to the repo root; the deployed sha shows separately on Line 1 as thedeployed:<sha>chip, since the agent harness can't know its own deployed commit.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale. Banner pulses on each broker SSE event
(pulseBanner with a 4s grace timer).
Approval card
Each pending approval renders as a card (assets/app.js:: renderApprovals) with three stacked sections:
- identity header — glyph,
#id, agent, kind chip, (forapply_commit) the short proposal sha as<code>, and a right-alignedrequested <N> agorelative time fromApprovalView.requested_at— amber once the request has been pending ≥ 1h so a stale approval stands out (issue #272). - what-changed body — the manager's description, then
drill-in triggers:
↳ view diffopens the diff in the side panel;↳ commit on forge ↗deep-links the proposal commit intoagent-configs/<agent>(shown only whenforge_present). Spawn approvals show a one-line "container will be created" note instead. - decision actions —
◆ APPR0VEandDENY. Deny pops aprompt()for an optional reason carried to the manager asHelperEvent::ApprovalResolved.note.
The diff panel has a 3-way base toggle — vs applied (the
running tree, served instantly from the diff already on the
approval), vs last-approved, vs previous proposal — the
latter two fetched on click from GET /api/approval-diff/{id} ?base=approved|previous. Each line is classified client-side
(+ / - / @@ / --- / +++ → add / del / hunk / file).
A pending · N / history · N tab pair switches the section
between the live queue and the last 30 resolved approvals.
Browser notifications
Pure frontend (Notification API). Three signals trigger them:
- new pending approval (per id, delta on
/api/state) - new pending operator question (per id)
- new broker message sent
to: "operator"(live via SSE)
First /api/state after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
Per-event tags (hyperhive:approval:<id>, hyperhive:question:<id>,
hyperhive:msg:<at>:<rand>) so distinct events stack in the OS
notification center instead of overwriting each other.
console.debug logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.
Dashboard endpoints
-
POST /approve/{id}— approve a pending approval. FiresApprovalResolvedon the dashboard event channel; client updates derived approvals state from the event. -
POST /deny/{id}(note=<reason>, optional) — deny a pending approval with an optional operator-supplied reason. The reason travels to the manager asHelperEvent::ApprovalResolved.noteand also rides on the dashboard'sApprovalResolvedevent. Dashboard prompts viawindow.prompt()on click. -
POST /{rebuild,kill,restart,start,destroy}/{name}— lifecycle.destroyacceptspurge=onto also wipe state dirs. -
POST /purge-tombstone/{name}— wipe a tombstone's state dirs. -
POST /answer-question/{id}— answer a pending operator question. -
POST /cancel-question/{id}— cancel a pending question with the sentinel[cancelled]. Same code path as a real answer. -
POST /request-spawn— queue a Spawn approval. -
POST /update-all— rebuild every stale container. -
POST /op-send(to=<name>,body=<text>) — drop an operator-authored message into<name>'s inbox.to=*fans out to every registered agent. Returns 200; the brokerSentevent re-renders both the message-flow terminal and the operator inbox without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W. -
GET /api/journal/{name}?unit=&lines=— journalctl viewer for a managed container; rendered in the side panel. -
GET /api/approval-diff/{id}?base=applied|approved|previous— on-demand unified diff for anApplyCommitapproval against the chosen base (running tree / last approved proposal / previous queued proposal). Raw diff text, classified client-side.GET /static/marked.jsserves the vendoredmarkedbundle the side panel uses for markdown previews. -
GET /api/state-file?path=<host-or-container-path>— bounded text read of a file under the per-agentstate/subtree or the shared/var/lib/hyperhive/shared/. Accepts the container-view forms (/agents/<n>/state/...,/shared/...) and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses/agents/<n>/claude/configsubtrees, truncates bodies at 1 MiB. Click-time backing for the inline path-link preview.Detection of which tokens are path links is done server-side at broker-message ingest, not client-side: the broker forwarder calls
scan_validated_paths(body)— same allow-list helper the read endpoint uses — and attaches the verified file tokens to the event asfile_refs: Vec<String>. The client trusts that list and linkifies only those tokens, so directories, missing files, and forbidden subtrees never become anchors. No probe endpoint, no client-side regex heuristics. Historical messages get the same treatment on/dashboard/historybackfill. -
GET /api/reminders— list pending reminders for the dashboard's queued-reminders panel. -
POST /cancel-reminder/{id}— hard-delete a pending reminder. -
POST /retry-reminder/{id}— re-arm a reminder whose delivery failed (clears the failure state so the scheduler retries). -
POST /meta-update—nix flake updatethe selectedmeta/flake.lockinputs, then rebuild the affected agents. -
GET /dashboard/stream— unified live event channel: brokersent/delivered, plus the mutation events listed below. Each frame carriesseq. -
GET /dashboard/history— last ~200 broker messages (wrapped as{ seq, events }) for the message-flow terminal's backfill on page load.
Dashboard event channel
Wire vocabulary on /dashboard/stream (kind tag is in the JSON
payload):
sent/delivered— broker traffic, mirrored from the intra-process channel by a forwarder task. Both carryid: i64(the broker row id) andin_reply_to: Option<i64>for thread rendering. The dashboard message-flow terminal renders reply rows with a↳ replytag that scroll-highlights the parent row on click. Used by the message-flow terminal renderer and the operator-inbox derived state.approval_added(id, agent, approval_kind, sha_short, diff, description) /approval_resolved(id, agent, approval_kind, sha_short, status, resolved_at, note, description) — pending queue + history mutations. Client mutates a derived store and re-renders only the approvals section.question_added(id, asker, question, options, multi, asked_at, deadline_at, target) /question_resolved(id, answer, answerer, answered_at, cancelled, target) — both operator-targeted and peer (agent-to-agent) threads fire these. The dashboard's questions pane surfaces both, with filter chips (all / @operator / @peer / per-participant) and an0V3RR1D3button on peer rows so the operator can answer when an agent is stuck. The ttl watchdog firesquestion_resolvedwithanswerer = "ttl-watchdog"on expiry.transient_set(name, transient_kind, since_unix) /transient_cleared(name) — lifecycle action spinners. The client ticks the elapsed-seconds badge offsince_unixclient-side, no polling.container_state_changed(container: ContainerView) /container_removed(name) — per-row container mutations, emitted byCoordinator::rescan_containers_and_emitfrom every mutation site (actions::approvepost-spawn,actions::destroy, the lifecycle_action wrapper,auto_update::rebuild_agent) and from the 10scrash_watchpoll. Client upserts/removes by name; the pending overlay is read fromtransientsStatesince the payload doesn't carry it.rebuild_queue_changed(seq, queue:Vec<QueueEntry>) — full snapshot of the rebuild queue on every mutation (enqueue, state transition, dedup collapse, terminal-history trim). Same snapshot-over-diff rationale astombstones_changed/meta_inputs_changed: the list is small and the client'sparent_idgrouping is most naturally re-derived from the full list. Cold-loaded from/api/state.rebuild_queue.
/api/state is only fetched on cold-load and on the few
forms that mutate non-event-derived state (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — approvals, questions,
transients, containers, operator inbox, message flow —
derives from /dashboard/stream after the initial snapshot,
maintaining its own client-side store and applying events on
top. The 5s periodic poll is gone.
Generalised form helpers: form[data-confirm="…"] pops
confirm() before submit; form[data-prompt="…"] pops
prompt() and stashes the answer in a hidden input named by
data-prompt-field (default note).
Per-agent page
Three fixed-position layers frame a full-viewport terminal:
Fixed-overlay header (<header class="agent-header">): frosted
glass — backdrop-filter: blur lets scrolled terminal rows show
through. Left to right:
- Agent icon (
<img src="/icon">). - Title (
<h2 id="title">) + meta-nav (<nav id="meta-links">): backend-suppliedStateSnapshot.linksrendered as icon-only anchors. Always includes📊 stats(kind = Container);🖥 screenwhen the VNC compositor is enabled;⬡ forge(profile)↳ config(agent-configs mirror) when the agent has a forge account; followed by anyhyperhive.dashboardLinksextras (kind = External). The dashboard card's icon strip is the same list viaGET /api/agent/{name}/links— agent backend is the single source of truth.
- State row (
<div id="state-row">): alive badge + state badge- model chip + ctx badge + cost badge + last-turn chip + cancel button + new-session button.
- Alive badge:
● alive(green) /⊘ rate limited(red) /◌ needs login/◌ logging in/○ offline/… connecting. Driven byLiveEvent::StatusChanged. - State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting+ age suffix. Driven byLiveEvent::TurnStateChanged ({ state, since_unix }). - Model chip:
model · <name>. Driven byLiveEvent::ModelChanged. - Ctx badge:
ctx · 142k— last inference's prompt size (the context window utilisation number to watch before compacting). Tooltip shows % of window whencontext_window_tokensis known. - Cost badge:
cost · 1.3M— cumulative tokens billed across every inference in the last turn. Tool-heavy turns rebill the cached prefix per call, so this routinely exceeds the window — cost signal, not size signal. - Both driven by
LiveEvent::TokenUsageChanged { ctx, cost }at turn-end. ■ cancel turn(visible while thinking) →POST /api/cancel.↻ new session(always, amber) →POST /api/new-session; next turn drops--continue.
- Inbox pill (
📬 inbox · N): hidden when empty; click opens the inbox flyout in the side panel. - Loose-ends pill (
🪢 loose ends · N): hidden when empty; click opens the loose-ends flyout in the side panel.
/api/state is fetched once on cold load (+ while
status === 'needs_login_in_progress'); all other updates arrive via
SSE. Snapshot includes context_window_tokens for the ctx badge tooltip.
Main content (<main class="agent-main">): fills the viewport
and scrolls behind the fixed header + footer.
#statusoverlay: empty when online; shows the login form / OAuth URL whenstatusisneeds_login_*.- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
↓ N newpill when not at bottom).
Fixed-overlay footer (<footer class="agent-composer">): frosted
glass, symmetric with the header. Contains the operator-input
textarea (#term-input) — multi-line, Enter sends, Shift+Enter
newlines, Tab-completes slash commands (see "Terminal-embedded
prompt" below).
Side panel (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and loose-ends flyouts
(opened via the header pills) as well as long content (file previews,
diffs, journald logs). Inbox flyout: last 30 messages addressed to
this agent (AgentRequest::Recent { limit: 30 }); reply messages
indented with ↳ reply · in amber. Loose-ends flyout: questions,
approvals, and reminders pending against this agent (GET /api/loose-ends);
question rows carry an inline answer form that POSTs cross-origin to
the core dashboard's /answer-question/{id} so the operator answers
as operator (see docs/boundary.md).
Live view
Each agent runs an events::Bus: a tokio::sync::broadcast<LiveEvent>
plus a sqlite-backed history at /state/hyperhive-events.sqlite.
The harness emits TurnStart { from, body, unread },
Stream(value) (one per parsed stream-json line), Note,
TurnEnd { ok, note }. The web UI:
- fetches
GET /events/historyon page load and replays the last 2000 events (oldest first, with.no-animso they don't stagger); - then subscribes to
GET /events/stream(SSE) for live tail; - shows a granular state badge above the terminal, driven
authoritatively from
/api/state.turn_state. SSE turn_start / turn_end still flip the badge instantly between renders; - sticky-bottom auto-scroll: scrolling up parks the view; new rows surface a "↓ N new" pill instead of yanking;
- terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up.
Per-stream rendering:
Streamtool_use→Write/Edit: collapsed<details>with a +/- diff body (-lines frominput.old_string,+lines frominput.new_stringor every line ofinput.content). Summary carries the path + line counts.- others (
Read /path,Bash $ cmd,mcp__hyperhive__send → operator: "...", etc.): flat one-line per-tool format.
Streamtool_resultshort → flat← ...; long → collapsed<details>▸ ← Nl · headline(click to expand full body).Streamthinking→ text content if claude provided one, otherwise the bare· thinking …indicator.Streamsystem init,result,rate_limit_eventare dropped — too noisy.Note→· text.TurnEnd→✓ turn ok/✗ turn fail — note, triggers arefreshState().
Terminal-embedded prompt
The operator input lives inside the terminal-wrap as a prompt-style textarea below the live tail: multi-line (Enter sends, Shift+Enter newlines), tab-completes slash commands.
Slash commands today:
/help— list commands locally./clear— wipe the local terminal view (server history kept)./cancel—POST /api/cancel→ host shelloutspkill -INT claude, emits a Note. Also surfaces as a■ cancel turnbutton in the state row while state=thinking./compact—POST /api/compact→ host spawnsturn::compact_sessionin the background; output streams into the live panel./model <name>—POST /api/modelflippingBus::set_model. Takes effect on the next turn; persisted to/state/hyperhive-modelso the override survives harness restart / rebuild./new-session—POST /api/new-session(confirms first). Arms a one-shot on the Bus; next turn runs without--continue, dropping the resume session entirely.
Unknown /foo shows an error row instead of being silently sent.
Per-agent endpoints
All POSTs return 200 (no 303 redirects). The matching mutations
fire LiveEvent variants on the per-agent bus, so the client
doesn't refetch /api/state on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
polls (session output streams in updates that aren't event-
shaped).
POST /send— operator-injected message into this agent's inbox.POST /login/{start,code,cancel}— claude OAuth login flow. Start/cancel emitLiveEvent::StatusChangedto flip the badge to/fromneeds_login_in_progress.POST /api/cancel— SIGINT the in-flight claude turn. Emits aLiveEvent::Note.POST /api/compact— run/compacton the persistent session (same MCP config + system prompt + allowed tools as a normal turn — only the stdin payload differs). Flips state toCompactingviaBus::set_state, which emitsTurnStateChanged.POST /api/model(model=<name>) — switch the model for future turns.Bus::set_modelemitsModelChanged.POST /api/new-session— arm a one-shot for the next turn to drop--continue. Emits aLiveEvent::Note.GET /events/history— replay buffer for the terminal.GET /screen— VNC viewer page (minimal RFB-over-WebSocket renderer). Only accessible whenhyperhive.gui.enable = truein the agent'sagent.nix; the harness shows a 🖥 screen link in the state row whengui_vnc_portis present. Toolbar:⤢ fitCSS-downscales the canvas to the window;⤡ match sizesends an RFBSetDesktopSizerequest so the server (weston) changes its real output resolution to the window dimensions — enabled once the server advertises theExtendedDesktopSizepseudo-encoding (issue #133).GET /screen/ws— raw RFB byte relay: proxies WebSocket frames to the weston VNC server at127.0.0.1:<vnc_port>. Transparent to any RFB variant. VNC port comes from/etc/hyperhive/gui.json(written by the weston startup script inweston-vnc.nix).
Bus events (new vocabulary on /events/stream):
status_changed { status }—online/rate_limited/needs_login_idle/needs_login_in_progress. Drives the alive-badge.rate_limitedis set when the harness detects a 429 response and cleared when the retry sleep expires.model_changed { model }— drives the model chip.token_usage_changed { ctx: TokenUsage, cost: TokenUsage }— drives the ctx + cost badges. Emitted fromBus::record_turn_usageat turn-end;ctxis the last inference's usage (current context size),costis the cumulative across every inference (theresultevent's totals).turn_state_changed { state, since_unix }— drives the state badge (idle/thinking/compacting).
Stats page
GET /stats is a separate per-agent page (served by the
harness, linked from the per-agent page's 📊 stats → and from
each dashboard container row). Turn analytics, read-only, from
/state/hyperhive-turn-stats.sqlite. GET /api/stats?window= 24h|7d|30d returns a time-bucketed Snapshot; the page renders
it with Chart.js (vendored from a CDN). Charts: turns,
duration (p50 · p95 · avg), context tokens, token cost per
bucket, a turns-by-model stacked bar (model choice drives
token cost, so it sits directly under the cost chart), and
doughnuts for tool / wake-source / result mix. A summary chip
row carries window totals. stats.rs opens the sqlite db
read-only and degrades to an empty snapshot on any error — the
page is decorative, never authoritative.