readme: manager mcp surface picks up update; operator-surface recap mentions /model + last-turn + model chip + the three collapsibles (inbox / journald / agent.nix). web-ui.md: details-restore-key story under shape; port-conflict banner mention on containers; agent.nix viewer alongside journald; notifications use per-event tags + console.debug log on block/show; deny endpoint takes note=<reason>; data-prompt / data-prompt-field generalisation noted. conventions.md: data-prompt and snapshot/restoreOpenDetails added to the async-forms section. persistence.md: operator_questions row picks up deadline_at (ttl) column with a migration note. todo.md: new 'Bugs' section captures the manager-question not-rendering issue with three suspect paths to chase. claude.md scratchpad rewritten as a clean handoff for the compaction + the upcoming config-git overhaul. flags the two-repo (proposed/ + applied/) split as the thing to reconsider.
12 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 /→assets/index.html(placeholders for state-driven sections, shipped viainclude_str!so the binary has no runtime file dependency).GET /static/*.css+GET /static/*.js→ static assets.GET /api/state→ JSON snapshot the JS app renders into the DOM.GET /events/stream(per-agent) /GET /messages/stream(dashboard) →text/event-streamSSE for live updates.
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. Used today for
the journald viewer (journal:<container>), the agent-config
viewer (agent-config:<name>), and approval diff blocks
(approval-diff:<id>). Setting .open = true programmatically
also fires the toggle event, so any lazy-fetch wired to it
re-runs cleanly on restore.
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 sections (top to bottom)
- Notification row —
🔔 enable notificationsbutton when permission ungranted;🔕 mute / 🔔 unmutetoggle once granted; inline "unsupported / blocked" message when applicable. Sits under the banner. - C0NTAINERS — live containers with their action surface.
Pulsing red banner at the top of this section if any two
sub-agents hash to the same port (
port_conflictsfrom/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. - 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}). - M1ND H4S QU3STI0NS — pending
ask_operatorquestions (amber pulsing border). Free-text fallback always rendered alongside any option list;multi=truerenders options as checkboxes; submit merges selections + free text comma-joined. Each row has a✗ CANC3Lbutton that resolves the question with[cancelled]. Questions with attl_secondsshow a⏳ MM:SSchip; the host-side watchdog auto-cancels with[expired]when the deadline fires. - 0PER4T0R 1NB0X — recent messages addressed to
operator(last 50, from the broker). - P3NDING APPR0VALS — the queue. The R3QU3ST SP4WN form lives at the top of this section since submitting it immediately queues an approval that lands directly below.
- MESS4GE FL0W — live broker SSE tail.
Container row
Two-line layout (assets/app.js::renderContainers):
- Line 1: agent name (link → new tab), m1nd/ag3nt chip,
needs login/needs updatewarning badges, in-flight◐ pending-state…pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port. - 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. - Plus two collapsible
<details>blocks:↳ logs · <container>— lazy-fetches journald output viaGET /api/journal/{name}?unit=...&lines=...(journalctl -M <container> -b --no-pager --output=short-iso). A unit dropdown switches between the harness service (default) and the full machine journal; refresh button re-fetches.↳ agent.nix · <name>— lazy-fetches the applied config file viaGET /api/agent-config/{name}(read-only mirror of/var/lib/hyperhive/applied/<name>/agent.nix). Mutating this still requiresrequest_apply_commit+ approval.
↻ 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).
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.POST /deny/{id}(note=<reason>, optional) — deny a pending approval with an optional operator-supplied reason. The reason travels to the manager asHelperEvent::ApprovalResolved.note. 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.GET /api/journal/{name}?unit=&lines=— journalctl viewer for a managed container.GET /api/agent-config/{name}— read-only view of the appliedagent.nix.
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
Layout, top to bottom:
- Banner (gradient shimmer while state=thinking).
- Title with
↑ DASHB04RDback-link (new tab) +↻ R3BU1LD. - Status section (online / needs login / login-in-progress).
- State row: state badge + model chip + last-turn timing +
cancel-turn button.
- State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting, with an age suffix (12s,2m 14s). Driven from/api/state.turn_state+turn_state_since; SSE turn_start/turn_end still flip it instantly between polls. Authoritative source is the harness'sBus::state_snapshot(). - Model chip:
model · <name>(e.g.model · haiku). - Last-turn chip:
last turn 12.3sappears after the first turn ends, computed from the state-since deltas. ■ cancel turnbutton: visible only while state=thinking, POSTs/api/cancel.
- State badge:
- Inbox
<details>block (collapsed):inbox · N— last 30 messages addressed to this agent, fetched viaAgentRequest::Recent { limit: 30 }. (Separate fromAgentRequest::Recv { wait_seconds }which the harness uses internally to long-poll the broker.) - Terminal-wrap: live event tail (sticky-bottom auto-scroll +
↓ N newpill when not at bottom) followed by an operator-input textarea acting as a prompt.
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.
Unknown /foo shows an error row instead of being silently sent.
Per-agent endpoints
POST /send— operator-injected message into this agent's inbox.POST /login/{start,code,cancel}— claude OAuth login flow.POST /api/cancel— SIGINT the in-flight claude turn.POST /api/compact— run/compacton the persistent session.POST /api/model(model=<name>) — switch the model for future turns.GET /events/history— replay buffer for the terminal.