# 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 via `include_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-stream` SSE 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. **`
` open-state preservation:** any collapsible element tagged with `data-restore-key=""` survives the refresh. `snapshotOpenDetails()` walks managed sections before render, `restoreOpenDetails()` re-applies after. Used today for the journald viewer (`journal:`), the agent-config viewer (`agent-config:`), and approval diff blocks (`approval-diff:`). 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) 1. **Notification row** — `🔔 enable notifications` button when permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted; inline "unsupported / blocked" message when applicable. Sits under the banner. 2. **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_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. 3. **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}`). 4. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask` questions, i.e. rows with `target IS NULL` (peer-to-peer questions live in the same table but never surface here) (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 that resolves the question with `[cancelled]`. Questions with a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog auto-cancels with `[expired]` when the deadline fires. 5. **0PER4T0R 1NB0X** — recent messages addressed to `operator` (last 50, from the broker). 6. **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. 7. **MESS4GE FL0W** — live broker SSE tail (newest-first). Each row is one broker event — `sent` or `delivered` — with `from → to: body`; per-agent thinking / tool calls / claude chatter stay out of this view, only what passes through hive-c0re's broker. Below the stream sits a terminal-style compose box: `@name` picks the recipient (sticky across sends via localStorage; auto-complete from the live container list, Tab/Enter to confirm), starting a message with `@ body` retargets in one stroke, plain text sends to the sticky recipient. `POST /op-send` drops `{from:"operator", to, body}` into the broker — same shape any sub-agent sees as a regular inbox message. Manager is addressed as `@manager` (the broker recipient string), not `@hm1nd` (the container name); the auto-complete swaps automatically. ### Container row Two-line layout (`assets/app.js::renderContainers`): - Line 1: agent name (link → new tab), m1nd/ag3nt chip, `needs login` / `needs update` warning badges, in-flight `◐ pending-state…` pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port. - Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3` on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running, `▶ ST4RT` when stopped. Buttons dim + disable while a transient lifecycle action is in flight. - Plus two collapsible `
` blocks: - `↳ logs · ` — lazy-fetches journald output via `GET /api/journal/{name}?unit=...&lines=...` (`journalctl -M -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 · ` — lazy-fetches the applied config file via `GET /api/agent-config/{name}` (read-only mirror of `/var/lib/hyperhive/applied//agent.nix`). Mutating this still requires `request_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:`, `hyperhive:question:`, `hyperhive:msg::`) 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=`, optional) — deny a pending approval with an optional operator-supplied reason. The reason travels to the manager as `HelperEvent::ApprovalResolved.note`. Dashboard prompts via `window.prompt()` on click. - `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. `destroy` accepts `purge=on` to 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=`, `body=`) — drop an operator-authored message into ``'s inbox. Used by the compose textbox under MESS4GE FL0W. - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for a managed container. - `GET /api/agent-config/{name}` — read-only view of the applied `agent.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 `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`. - Status section (online / needs login / login-in-progress). - **State row**: state badge + model chip + last-turn timing + cancel-turn button + new-session 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's `Bus::state_snapshot()`. - Model chip: `model · ` (e.g. `model · haiku`). - Last-turn chip: `last turn 12.3s` appears after the first turn ends, computed from the state-since deltas. - `■ cancel turn` button: visible only while state=thinking, POSTs `/api/cancel`. - `↻ new session` button: always visible, amber. Confirms via `window.confirm()` then POSTs `/api/new-session` to arm a one-shot Bus flag — the next turn drops `--continue`, starting a fresh claude session. Subsequent turns resume normal `--continue`. - Inbox `
` block (collapsed): `inbox · N` — last 30 messages addressed to this agent, fetched via `AgentRequest::Recent { limit: 30 }`. (Separate from `AgentRequest::Recv { wait_seconds }` which the harness uses internally to long-poll the broker.) - Terminal-wrap: live event tail (sticky-bottom auto-scroll + `↓ N new` pill 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` 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/history` on page load and replays the last 2000 events (oldest first, with `.no-anim` so 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: - `Stream` `tool_use` → - `Write` / `Edit`: collapsed `
` with a +/- diff body (`-` lines from `input.old_string`, `+` lines from `input.new_string` or every line of `input.content`). Summary carries the path + line counts. - others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send → operator: "..."`, etc.): flat one-line per-tool format. - `Stream` `tool_result` short → flat `← ...`; long → collapsed `
` `▸ ← Nl · headline` (click to expand full body). - `Stream` `thinking` → text content if claude provided one, otherwise the bare `· thinking …` indicator. - `Stream` `system init`, `result`, `rate_limit_event` are dropped — too noisy. - `Note` → `· text`. - `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, triggers a `refreshState()`. ### 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 shellouts `pkill -INT claude`, emits a Note. Also surfaces as a `■ cancel turn` button in the state row while state=thinking. - `/compact` — `POST /api/compact` → host spawns `turn::compact_session` in the background; output streams into the live panel. - `/model ` — `POST /api/model` flipping `Bus::set_model`. Takes effect on the next turn; persisted to `/state/hyperhive-model` so 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 - `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 `/compact` on the persistent session (same MCP config + system prompt + allowed tools as a normal turn — only the stdin payload differs). - `POST /api/model` (`model=`) — switch the model for future turns. - `POST /api/new-session` — arm a one-shot for the next turn to drop `--continue`. - `GET /events/history` — replay buffer for the terminal.