# 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.html` from the bundled frontend dist (see `frontend/`). Both binaries' routers declare their dynamic endpoints first and then `fallback_service(ServeDir::new(...))` pointed at `HIVE_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 merged `hyperhive.frontend.mergedDist` (default agent dist + per-agent `extraFiles` overlay). - `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` (was `hive-fr0nt`); the CSS bundle inlines `base.css` + `terminal.css` via esbuild's `@import` resolution. `terminal.js` exports `{ create, linkify }` as ES module members (no more `window.HiveTerminal` global outside the back-compat shim the IIFE bodies still use). The dashboard's `#msgflow` and the per-agent `#live` log 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. Bare `http(s)://` URLs in row text are turned into clickable new-tab links by `linkify` (text-node based, no `innerHTML` — XSS-safe); markdown bodies get the same treatment via `marked`'s autolink (npm dep, replacing the vendored UMD bundle), with the rendered ``s rewritten to `target="_blank"` (issue #233). - `GET /api/state` → JSON snapshot the JS app renders into the DOM. Includes a top-level `seq` (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 with `seq <= snapshot.seq`). - `GET /dashboard/stream` (dashboard) / `GET /events/stream` (per-agent) → `text/event-stream` SSE for live updates. The dashboard stream carries broker `Sent` / `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 a `seq`. The matching backfill endpoint is `GET /dashboard/history` (last ~200 broker messages wrapped in `{ seq, events }`) on the dashboard and `GET /events/history` (last 2000 `LiveEvent`s 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. **`
` open-state preservation:** any collapsible element tagged with `data-restore-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 `
` 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`) — a `rendered` / `plain` tabbed view: `rendered` (default) is the vendored `marked` bundle (`GET /static/marked.js`), `plain` is the raw source. - **SVG** (`.svg`) — a `rendered` / `source` tabbed view; `rendered` shows the image via an `` `data:` URI (the browser's secure static mode, so an untrusted SVG can't run scripts), `source` shows the raw markup. - **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` / `.bmp` / `.ico` / `.avif`) — render as an `` pointed at `/api/state-file`, which serves them as binary with their real content-type (text files stay UTF-8-lossy `text/plain`). - **Everything else** — raw text in a `
`.

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
`
` 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 notifications` when permission ungranted; `🔕 mute / 🔔 unmute` toggle 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-`, `agent-/mcp-`, …), 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 `
` chrome as the dashboard so the tab strip remains visible; SW4RM / Y3R C4LL / SYST3M are cross-page links back to `/#`, 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 `` 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 `` points straight at `/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`, `🖥 screen` when GUI is enabled, `⬡ forge profile`, `↳ agent-configs mirror`, plus any agent-declared `dashboardLinks` extras — issue #262). The dashboard JS fetches `GET /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 each `AgentLink.kind` (`container` → `http://host:`, `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 operator-initiated start / stop / restart / rebuild / destroy). Additionally, when a rebuild-queue entry for this agent is `queued` or `running` but no operator-initiated transient is set, the card surfaces a `building…` / `meta-updating…` badge sourced from `rebuildQueueState` (#398) — so the SW4RM tab shows the same rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3. Container name + port, and a `ctx · Nk` chip showing the agent's last-turn context size (from `ContainerView.ctx_tokens`, read from the turn-stats sqlite on each `build_all` sweep; absent until the first turn). The chip colour (green / yellow / red) is keyed off the model's real context window: `build_all` resolves the last turn's model against the host's per-model `contextWindowTokens` config and exposes it as `ContainerView.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 — `↻ 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. - Line 3: drill-in triggers — - `↳ logs · ` — opens the side panel and lazy- fetches journald via `GET /api/journal/{name}?unit=&lines=` (`journalctl -M -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, `dashboardLinks` extras) now live in the icon-only nav strip on Line 1 — see above (issue #262). The agent's `config` link goes to the repo root; the deployed sha shows separately on Line 1 as the `deployed:` 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, (for `apply_commit`) the short proposal sha as ``, and a right-aligned `requested ago` relative time from `ApprovalView.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 diff` opens the diff in the side panel; `↳ commit on forge ↗` deep-links the proposal commit into `agent-configs/` (shown only when `forge_present`). Spawn approvals show a one-line "container will be created" note instead. - **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a `prompt()` for an optional reason carried to the manager as `HelperEvent::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:`, `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. Fires `ApprovalResolved` on the dashboard event channel; client updates derived approvals state from the event. - `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` and also rides on the dashboard's `ApprovalResolved` event. 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. `to=*` fans out to every registered agent. Returns 200; the broker `Sent` event 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 an `ApplyCommit` approval against the chosen base (running tree / last approved proposal / previous queued proposal). Raw diff text, classified client-side. `GET /static/marked.js` serves the vendored `marked` bundle the side panel uses for markdown previews. - `GET /api/state-file?path=` — bounded text read of a file under the per-agent `state/` subtree or the shared `/var/lib/hyperhive/shared/`. Accepts the container-view forms (`/agents//state/...`, `/shared/...`) and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses `/agents//claude` / `config` subtrees, 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 as `file_refs: Vec`. 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/history` backfill. - `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 update` the selected `meta/flake.lock` inputs, then rebuild the affected agents. - `GET /dashboard/stream` — unified live event channel: broker `sent` / `delivered`, plus the mutation events listed below. Each frame carries `seq`. - `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 carry `id: i64` (the broker row id) and `in_reply_to: Option` for thread rendering. The dashboard message-flow terminal renders reply rows with a `↳ reply` tag 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 an `0V3RR1D3` button on peer rows so the operator can answer when an agent is stuck. The ttl watchdog fires `question_resolved` with `answerer = "ttl-watchdog"` on expiry. - `transient_set` (name, transient_kind, since_unix) / `transient_cleared` (name) — lifecycle action spinners. The client ticks the elapsed-seconds badge off `since_unix` client-side, no polling. - `container_state_changed` (container: ContainerView) / `container_removed` (name) — per-row container mutations, emitted by `Coordinator::rescan_containers_and_emit` from every mutation site (`actions::approve` post-spawn, `actions::destroy`, the lifecycle_action wrapper, `auto_update::rebuild_agent`) and from the 10s `crash_watch` poll. Client upserts/removes by name; the pending overlay is read from `transientsState` since the payload doesn't carry it. - `rebuild_queue_changed` (seq, queue: `Vec`) — full snapshot of the rebuild queue on every mutation (enqueue, state transition, dedup collapse, terminal-history trim). Same snapshot-over-diff rationale as `tombstones_changed` / `meta_inputs_changed`: the list is small and the client's `parent_id` grouping 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** (`
`): frosted glass — `backdrop-filter: blur` lets scrolled terminal rows show through. Three flex columns (#394 redesign): - **Agent icon** (``): full-height square identity anchor (6em, `height: 100%; aspect-ratio: 1`). Falls back to the dimmed hyperhive mark on load error. - **Main column** (`.agent-header-main`): two rows. - Row 1 (`.agent-header-title-row`): title (`

`) + meta-nav (`