hyperhive/docs/web-ui.md
iris 6bdfe52386 docs: scrub #NNN self-references (closes #719)
Last pass of the docs-from-code → docs/ epic (#708). Drops every
attribution cookie from docs/ + README.md + CLAUDE.md so the
source-tree files no longer reference the issue tracker. Issue
threads + commit history retain the references — those are the
canonical record.

- README.md: drop #701 / #660×2 / #551 from matrix + display-name
  sections, rephrase to convey the semantics directly
- CLAUDE.md: scrub 18 cookies from the file map (#655, #15, #784,
  #832, #444, #425, #361, #548, #598, #539, #544, #589, #701,
  #658, #280, #660, #551, #764, #772, #793, #14, #805)
- docs/agent-hierarchy.md: drop #658 ×3 (per-agent user is the
  current shape, not a transition)
- docs/conventions.md: drop #571 (replaced with a docs xref to
  persistence.md::matrix-avatar-sync)
- docs/gateway.md: scrub vhost-map table cookies + Sub-domain
  rationale + Per-agent unix-socket upstream + Self-signed TLS +
  Firewall posture + HIVE_FORGE_URL + Per-agent error pages
  sections; drop the trailing 'Sequencing history' issue list +
  the 'Next-up' issue-link footnote
- docs/matrix.md: scrub serverName/gatewayHost + Default-closed
  firewall + Provisioning flow + Initial rollout + Assertion
  rationale + fluffychat-web build fixes; drop the trailing
  'Sequencing history' issue list
- docs/network.md: drop 'Why ship before #14' #805 quote +
  Container shape #805 attribution + trailing 'Sequencing history'
  + Cross-references issue links; rename v2 column to 'after netns
  isolation'
- docs/web-ui.md: drop #784 from Container row, replace with a
  docs xref to docs/gateway.md::Per-agent unix-socket upstream

Only remaining #NNN in docs/ is the literal markdown-heading
example in docs/forge.md (`#tag`, `#123`, `#!/bin/bash`)
which demonstrates the renderer's behaviour — not an attribution
cookie.
2026-05-31 17:47:21 +02:00

66 KiB
Raw Blame History

Web UI

Two web surfaces share the same skeleton: the dashboard (port 7000) and the per-agent UIs (every container — including the manager — hashes into :8100-8999 via lifecycle::agent_web_port's FNV-1a). 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 <a>s rewritten to target="_blank".
  • 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 LiveEvents also wrapped in { seq, events }) on the agent. SSE multiplexing: the dashboard uses a SharedWorker (stream-worker.js) to hold one upstream EventSource per URL. All same-origin tabs share this worker — a second dashboard tab joins the existing connection rather than opening a duplicate. The worker fans SSE events out to each subscribed tab via MessagePort; on bfcache restore the page re-subscribes (gets a synthetic open event immediately if the upstream is already connected). Falls back gracefully when SharedWorker is unavailable (e.g. some private-mode browsers). Worker-death self-heal: Firefox kills "idle" SharedWorkers under memory pressure with no client-side signal — the port silently goes no-op. The worker now pings every connected port every 30s; the client bumps a last-activity timestamp on every message (incl. pings, which carry no URL — bumped before the URL filter). A visibility-gated watchdog polls every 15s and, if the page is visible AND has active subs AND hasn't heard from the worker in >90s (three missed pings), presumes the worker dead and re-subscribes on a fresh SharedWorker port (same code path bfcache-restore uses). Recovery is per-tab; pings are invisible on the healthy path.

Shared terminal pane

Both surfaces' scrollable log streams (#msgflow on the dashboard, #live on the per-agent page) are backed by the shared terminal factory in @hive/shared/terminal.js. The factory wires up sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and SSE replay. Pages register a kind → renderer map; unknown kinds fall through to a JSON-dump note row. The factory ships three row shapes the renderers call:

  • api.row(cls, text) — single-line row with an inline linkify pass over the text.
  • api.details(cls, summary, body) — collapsible <details> with a <pre> body (used by long tool-results and stack traces).
  • api.detailsDiff(cls, summary, body) — same shape, splits the body on newlines and tags each line as diff-add / diff-del / diff-ctx so the renderer's diff bodies get coloured without emitting raw HTML.

Sticky-bottom + snap animation. stickToBottom is the operator's intent: true means "keep snapping to bottom on every mutation", false means "I scrolled up, leave me alone". The flag flips when a scroll event lands further than NEAR_BOTTOM_PX = 48 from the bottom. New rows then either snap to bottom (when sticky) or bump the unseen-count and surface the "↓ N new" pill. The snap is a brief 140ms ease-out (SCROLL_ANIM_MS) — the browser's default behavior: 'smooth' ~500ms reads as "still smooth, but visibly slow"; 140ms feels snap-y while still reading as motion rather than a jump. Distances under SCROLL_SNAP_PX = 24 short-circuit to instant — animating a 12px nudge would just be jitter. Each new snap cancels the previous requestAnimationFrame so a burst of mutations coalesces into one ride to the latest bottom; the per-frame step re-reads scrollHeight - clientHeight so mutations landing mid-animation extend the destination smoothly rather than land short.

Mid-animation scroll-event guard. The scroll handler's isNearBottom check would flip stickToBottom false mid-snap as the smooth animation eases through positions that are technically "not near bottom yet", which would strand the operator partway. A smoothScrollingUntil timestamp gates the scroll handler — set to the animation end + ~80ms headroom, re-armed on each fresh snap. Programmatic scrollTop writes (the animation's per-frame update) fire scroll events that the gate swallows.

Post-append MutationObserver. Renderers commonly call api.row(cls, text) to create the row shell then append more children (badges, multi-line bodies, tool result panes) after the factory returned. The initial sticky-snap fires off the row's empty shape; the renderer's later appends grow the row past the visible bottom. A MutationObserver on the log subtree fires once per microtask after each batch of synchronous mutations and snaps again when stickToBottom is true. Programmatic scrollTop writes don't re-trigger the observer (scroll isn't a DOM mutation), so no feedback loop. The pre-append nearBottomBeforeAppend snapshot is still useful — it keeps the initial visual lag to one frame instead of one microtask + frame.

Backfill + SSE. Cold load fetches historyUrl (replay), then subscribes to streamUrl (live tail). Both endpoints return { seq, events } so the client can dedupe — events with seq <= snapshot.seq from the SSE stream are dropped silently (the snapshot already covers them). History rows render with a .no-anim class so they don't stagger in like live events. The optional streamFactory(url) callback lets the dashboard hand the factory a SharedWorker-backed EventSource facade (so multiple tabs share one upstream connection — see SSE multiplexing above); when omitted, the factory falls back to a plain new EventSource(url).

linkify (text-node based). Bare http(s):// URLs in row text get wrapped in <a target="_blank" rel="noopener noreferrer"> inside a fresh text node, so the autolinker never touches innerHTML and untrusted row content can't smuggle markup. The trailing-punctuation strip keeps .,;: outside the link surface. Markdown bodies go through marked separately and get the same target rewrite.

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/tabs.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.

Atomic section repaint: every managed-section renderer goes through paintAtomic(liveRoot, build): the builder appends into a fresh DocumentFragment (off-DOM) and the commit is one replaceChildren call. The naive root.innerHTML = ''; root.append(...) shape was visibly flashing empty on every poll cycle — on async paths the await yield gave the browser a paint opportunity between the clear and the re-append, and on complex builds (many el() allocations) layout could escape the per-task budget even on the synchronous path. The fragment approach keeps the intermediate empty state invisible. Builders receive the fragment as their root, so existing renderer code carries over unchanged; early returns inside the builder still commit whatever was appended before they returned.

<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. The drawer width is drag-to-resize: a thin 6px hit-strip on the left edge captures pointer events, resizes the drawer in real-time (pointer capture keeps dragging even if the cursor outpaces the handle), and persists the chosen width to localStorage (key hyperhive:side-panel-width) so it survives page reload. Width is clamped to CSS min-width: 320px / max-width: 96vw; the viewport-resize handler re-clamps persisted values after a window shrink. 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 <img> 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 <img> 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 <pre>.

Listener bind

Both bind their TCP listener with SO_REUSEADDR via tokio::net::TcpSocket plus a retry loop on AddrInUse (exponential backoff capped at 2s, no attempt cap) so an nspawn restart that races the previous process's socket release resolves itself. The retry is uncapped on purpose: a capped budget once left the harness silently UI-less for the rest of its lifetime when a back-to-back restart held the port longer than the cap allowed. Genuine port collisions are preflighted host-side (lifecycle::{spawn,rebuild} refuses with a clear error, surfaced on the dashboard as a banner), so at this layer a persistent AddrInUse always reflects a recoverable stale socket — retrying forever is the safe choice. The first 12 attempts log at WARN; after that the level drops to INFO so a long-held stale socket doesn't flood the journal.

The per-agent UI optionally binds a UnixListener instead of TCP when HIVE_WEB_SOCKET is set — the unix-socket transition mechanics (per-agent /run/hive-agent/<name>/ bind-mount, .bound marker filtering, agent-sockets.json consumer on the gateway side) live in docs/gateway.md::Per-agent unix-socket upstream. The env var is opt-in per agent so the two modes coexist while sub-agents transition.

Per-agent relative paths

The per-agent UI uses document-relative paths everywhere for assets, API calls, form actions, and the screen WebSocket. Bare references like static/app.js, api/state, events/stream, screen/ws resolve against document.baseURI — the page's URL without its last path segment.

That makes the page work under any prefix the agent ends up mounted at without rebuilding the dist. The cases that matter:

served at api/state resolves to
/ (own port, today's shape) /api/state
/agent/iris/ (gateway-prefixed) /agent/iris/api/state
/agent/iris/stats (subpage, no trailing slash) /agent/iris/api/state

The gateway upstream config strips the prefix before forwarding to the per-agent server, so the agent's Rust routes (api/state, events/stream, screen/ws, login/start, …) keep their absolute paths server-side. Only the browser-facing URLs are gated on the mount prefix.

Subpages (stats, screen) are served without a trailing slash so the relative-path resolution stays correct: static/app.js from /stats becomes /static/app.js (last segment stats gets replaced), not /stats/static/app.js. Adding a trailing slash to those routes would break the resolution; either keep them slash-less or use <base href> injection at serve time.

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, #schedules) 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 ◆, ◆ SCH3DUL3S ◆, ◆ M4TR1X ◆ → (optional page link, see below), and ◆ FL0W ◆ → (page link). Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S (active schedules); FL0W pill mirrors the operator inbox length (hidden when zero). The M4TR1X → entry is hidden when services.hyperhive.matrix.gui.enable is off (defaults to matrix.enable) so operators without the matrix GUI on don't see a dead link — tabs.js gates the hidden attribute on state.matrix_gui_enabled from /api/state.
  • 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-<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. 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 | approval — green for operator-approved config changes), 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, and when the worker has annotated the current phase a cyan ↳ <step> sub-line appears under the main row showing the in-flight step name (e.g. ↳ meta prepare_deploy↳ nixos-container update↳ finalize deploy). Terminal transitions clear step on the backend so Done / Failed rows don't render stale labels. Queued entries carry a cancel button on the right edge; running / done / failed / cancelled entries don't show it — the backend refuses cancellation for non-Queued rows anyway (POST /api/rebuild-queue/{id}/cancel). Successful cancel flips the row to ⊘ cancelled via the next rebuild_queue_changed snapshot. Cold-loaded from /api/state.rebuild_queue; live updates via rebuild_queue_changed snapshot event.

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}).

SCH3DUL3S tab

Anything that fires at a future time. Operator-set schedules are created inline in the table (last row); agent self-paced reminders surface at the bottom as a sibling list — they share enough conceptual ground to live together.

N3W SCH3DUL3 / QU3U3D SCH3DUL3S — operator-managed scheduled prompts. Single-table layout: each schedule is one <tr>; columns are # | src | next | every | owner | body | …agents… | actions. Agent columns are dynamic — operator + manager + every live container + any extra name that appears as a target on some schedule but isn't a current container (same buildTargetChips membership rule the new/edit forms use, so table and forms agree on what's addressable). Column headers tilt -45° via CSS so each column reads as a narrow ~28px strip; per-agent cells render as:

  • active target<button>✓</button> that cancels just that one target on click
  • cancelled target → muted glyph (no button — re-adding goes through the edit form's targets multi-select)
  • not a target → empty cell

Per-schedule action column: a ↯ fire now button sends an out-of-band manual pulse to every active target (recurring schedules keep their cadence; one-shots are consumed after the manual fire), an ✎ edit button expands an inline edit form as a colspan'd row directly under the schedule's row (body / description / interval / next-fire / targets all editable; targets are a multi-select diff'd against the original active set so unchecked-was-active = targets_remove, checked-not-originally-active = targets_add; submit PATCHes /api/schedules/{id}), and a button cancels the whole schedule (POST /api/schedules/{id}/cancel).

The table's last row is a permanent inline creation row: inputs live directly in table cells (targets as checkboxes, body textarea that expands on focus, datetime-local pre-filled to 5 minutes from now, mini d/h/m/s number inputs (blank or all-zero = one-shot), description). Click to POST to /api/schedules as JSON (or to clear the half-filled row); carry-state preserves partially-typed inputs across re-renders. The tab pill shows the count of active schedules (at least one live target not yet cancelled). Refreshed on tab activation and after each submit/cancel. Backed by GET /api/schedules. No backend changes for the table layout — it renders entirely from existing schedulesState + containersState.

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. Lives in the SCH3DUL3S tab alongside operator schedules so the operator has one place for everything time-fired.

M4TR1X page (/matrix/, optional)

A static matrix web client (default pkgs.fluffychat-web rebuilt with --base-href /matrix/, swappable via services.hyperhive.matrix.gui.package) served by the hive-gateway nginx container at /matrix/ when services.hyperhive.matrix.gui.enable is on (defaults to matrix.enable). c0re signals availability via the HIVE_MATRIX_GUI_ENABLED env var → state.matrix_gui_enabled in /api/state; the gateway does the actual static serving.

The operator opens /matrix/ from the M4TR1X → strip entry, logs in once with the in-host tuwunel homeserver URL (http://localhost:8008 or whatever the matrix module exposes).

The unified nginx-front re-root to https://matrix.${hyperhive.domain} + .well-known/matrix/client auto-discovery lives in docs/gateway.md (atlas's lane).

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 / SCH3DUL3S 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. The row is a flex-wrap: wrap container holding ts / arrow / from / sep / to chips inline; the body wraps to its own full-width line below the chips (flex: 1 1 100%) so the body always gets the full row width down to the content edge — long timestamps + agent names used to push the body ~30ch in and force awkward narrow-column wraps. min-width: 0 keeps word-break: break-word effective so the body doesn't force the row wider than its container. 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 (5em, capped) on the left. The icon is the selection toggle: click (or Enter/Space) adds/removes the agent from the selection set; aria-pressed reflects the state; the tooltip says "select … for bulk actions" or "deselect … (or press Esc to clear all)". The <img> points at <url>/icon; load failure falls back to the dimmed hyperhive mark (/favicon.svg). The card body sits to the right with three stacked lines (assets/tabs.js::renderContainers).

Icon layout + load strategy: the <img> is absolutely positioned (inset: 0) inside the .container-icon wrapper — the wrapper is the flex child and sizes itself via width: 5em + aspect-ratio: 1, the <img> is out of flow so its load state (pending, loaded, broken) can never contribute intrinsic size or reflow the row. Without that, the row would briefly grow as the image's natural dimensions arrived, then snap back on object-fit: contain. The load itself is fire-and-forget: the dashboard doesn't pre-check whether the agent is reachable, it just lets the <img> try and listens for an error event. On failure the handler swaps the src to /favicon.svg (served by the dashboard itself, always reachable) and adds the icon-unreachable class for the dimmed look. When the container is known stopped up front (ContainerView.running = false) the fallback fires immediately, skipping the doomed <url>/icon fetch entirely.

  • 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). 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 against a per-agent base URL that depends on whether hive-gateway is in front (StateSnapshot.gateway_enabled, sourced from the HIVE_GATEWAY_ENABLED env the c0re NixOS module sets when services.hyperhive.gateway.enable = true). Gateway-on (default): container/agent/<name>/<url> (same origin, gateway proxies to the per-agent harness — TCP or unix-domain depending on the agent's HIVE_WEB_SOCKET opt-in, see docs/gateway.md::Per-agent unix-socket upstream). Gateway-off (legacy / local dev): containerhttp://<host>:<container.port>/<url> (direct TCP fallback). Forge links resolve against http://<host>:3000, external links are already absolute. The same flag drives the primary agent-name link + favicon fetch (<url>/icon), so the whole row routes through the gateway as a unit. When the container is stopped (ContainerView.running = false), the host clears live-only fields before emitting the state, so the dashboard never renders stale data: the badge chain is replaced by a single muted ■ not running badge, the nav-strip fetch is skipped (the agent web server is down), and the self-reported status text is suppressed. The agent icon goes straight to the dimmed /favicon.svg fallback instead of attempting a doomed load from the container's URL. Static fields — needs_update, deployed_sha, pending_reminders, parent, config link — remain visible regardless of run state. When the container is running, 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 — so the SW4RM tab shows the same rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3. The row visual splits queued vs running: a queued entry shows only the pending-state pill (no row tint, so a long queue doesn't paint half the tab amber); a running entry keeps the amber row tint AND draws a rotating amber ring around the agent icon, so it's obvious at a glance which container is actually moving. Pending-state derivation: the pill is sourced from two separate stores in priority order. (1) The operator-initiated transient (transientsState) is set on the dashboard the moment the operator clicks start / stop / restart / rebuild / destroy / spawn — covers the create-and-start window where the container literally isn't up yet, before any backend state event has fired. (2) If no transient is set, the rebuild-queue entry for this agent is consulted (rebuildQueueState); this covers worker-driven ops — meta-update cascades, crash-recover rebuilds, approval-driven rebuilds — that the operator didn't click. ContainerStateChanged carries neither signal, so the dashboard reads from the two snapshots directly. The opRunning flag (driving the pending-running row class + spinner) is true when (1) is set OR (2) is in running state; queued entries leave opRunning false. 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.
  • Line 2: status badges only (no per-card action buttons — actions moved to the selection bar, see below).
  • Line 3: drill-in triggers —
    • ↳ logs · <container> — opens the side panel and lazy- fetches journald via GET /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. The panel uses a column-flex layout so the <pre> log surface fills the full remaining panel height; scroll happens inside the <pre>, not the side panel body.
    • Plain navigation links (config repo, forge profile, dashboardLinks extras) now live in the icon-only nav strip on Line 1 — see above. The agent's config link goes to the repo root; the deployed sha shows separately on Line 1 as the deployed:<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).

Topology tree

Container rows render as a forest, not a flat list — each agent sits indented under its declared parent. tabs.js::buildAgentTree walks ContainerView.parent for every container in the snapshot and produces a render order with per-row depth + sibling-position info:

  • Top-level rows are agents with parent = null OR a parent that doesn't appear in the container map (orphans get hoisted to root so they're still visible).
  • Within each level children sort alphabetically by name; roots likewise.
  • Cycle safety: any container not reached during the root-walk is appended at the end as a root, so no agent ever silently disappears from the list when the topology JSON is malformed.
  • The pre-topology rendering shape (every container at depth 0, flat list) collapses to the same visual today when no parent field is set — bit-identical fallback path.

The per-row prefix column (.tree-prefix) is DOM-painted, not text-glyph-painted. Each indent lane is its own positioned <span> so CSS can draw full-height vertical bars that bridge the gap between sibling rows; using text box-drawing characters (├─, └─, ) only paints one text-line tall and leaves visible breaks between the taller-than-one-line container cards. The bars come in two flavours: continuation (the ancestor's subtree extends below this row → vertical line top→bottom) or blank (ancestor was the last sibling at its level → no line needed). The joint at the row's own depth column is (more siblings below) or (last sibling at this depth — vertical stops at the row's icon midline).

Indent + lane geometry. Each depth level shifts the row right by 1.8em (the lane width). The per-depth ladders are hardcoded for six levels — enough for any plausible hive topology, and the typed attr() function from CSS Values 5 that would collapse this to one rule is still partial-support (Chromium-only as of 2026). The .tree-prefix span sits absolutely positioned with left: -<depth>*1.8em so its right edge meets the row content (the icon) and its leftmost lane lines up with top-level rows' icons at x = 0. Each .tree-lane is flex: 0 0 1.8em so all lanes have equal width. Continuation bars are drawn at lane center (left: 0.6em, border-left: 1px solid currentColor, top: 0; bottom: 0) and extend through .containers { gap: 0.4em } into the next sibling's prefix (bottom: -0.4em on the prefix span itself) so adjacent ancestor lines visually merge into one unbroken vertical line. The horizontal stub at a row's own joint lands at the icon midline so the L/T meets the icon edge cleanly. When every container has parent = null (pre-topology state) the [data-depth] attribute is absent on every row and these rules are no-ops — the layout reads exactly like the legacy flat list.

Selection bar

Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / DESTR0Y / PURG3) used to live on each container row; the operator picked the bulk-bar model instead. Clicking an agent's icon toggles its selection (an in-memory Set<name>); Esc or the bar's ✕ clear button drops everything. The selection persists across tab switches in-memory — the bar just hides on non-SW4RM tabs since other tabs don't show the agent cards needed to cross-reference.

When one or more agents are selected (via icon click), a sticky frosted-mauve bar slides up from the bottom of the viewport (#selection-bar, position: fixed; bottom: 0). It shows:

  • Count + names — "N agents selected · name1, name2, …"
  • Bulk action buttons — only enabled when ALL selected agents support the action; disabled with a tooltip naming the blockers when the selection is mixed:
    • ↺ R3ST4RT — running agents only
    • ■ ST0P — running agents only
    • ▶ ST4RT — stopped agents only
    • ↻ R3BU1LD — always available
    • DESTR0Y / PURG3 — sub-agents only (disabled if manager selected)
    • ⇡ M0V3 → ROOT — promote selected agents to top-level (parent = null); disabled when all selected are already at root. Backend topology::set_parent refuses moves it can't satisfy (e.g. moving the manager) and the refusal surfaces in the failure roll-up.
    • ⇢ M0V3 → [select] — inline picker available for any selection size. The dropdown lists every container that isn't IN the selection itself nor a descendant of any selected agent (client-side BFS cycle prevention across the whole batch; the backend re-checks per-agent). On submit POSTs to /api/topology/set-parent (form-encoded child=<name>&new_parent=<target>) once per selected agent, which writes topology.json and re-emits a container snapshot so the tree repaints without a page reload.
  • ✕ clear button + Esc key clear the entire selection.

Stale selections (agents destroyed while selected) are pruned on every render before the bar appears.

Approval card

Each pending approval renders as a card (assets/tabs.js:: renderApprovals) with three stacked sections:

  • identity header — glyph, #id, agent, kind chip, (for apply_commit) the short proposal sha as <code>, and a right-aligned requested <N> ago relative time from ApprovalView.requested_at — amber once the request has been pending ≥ 1h so a stale approval stands out.
  • 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/<agent> (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:<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. Fires ApprovalResolved on 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 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 /api/rebuild-queue/{id}/cancel — drop a Queued entry. Refuses Running / terminal-state entries (in-flight rebuilds can't be safely interrupted). Always 200; body is {"cancelled": true} on a successful flip or {"cancelled": false} when the entry was not in Queued state.

  • POST /api/agent/{name}/mark-all-read — ack all pending broker messages for {name}. Backfills delivered_at for rows not yet delivered and sets acked_at = now. Returns { "marked": N }. Agent name validated against [a-z0-9_-], 1-63 chars; 400 on bad input.

  • 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 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=<host-or-container-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/<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 / 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<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/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).

  • GET /api/schedules — list all schedules (active and recently cancelled) for the SYST3M scheduled-prompts panel.

  • POST /api/schedules — operator-direct schedule create: { targets, body, first_fire_at_unix, interval_seconds?, description? }. Agent-initiated schedules go through the approval queue instead (manager MCP request_schedule_prompt).

  • PATCH /api/schedules/{id} — partial edit. JSON body { body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove? }. Missing key = "leave alone"; explicit null on description / interval_seconds clears the field (so a recurring schedule flips to one-shot when interval_seconds is sent as null). targets_add is replace-on-conflict: re-adding a previously-cancelled target drops the tombstone and the target starts fresh (operator intent on re-add = "this target is active again"). targets_remove delegates to the same path as cancel_targets — tombstones preserve audit, parent schedule auto-cancels when no active targets remain. Refuses cancelled rows; returns the updated WireSchedule on success.

  • POST /api/schedules/{id}/cancel — cancel a schedule. Body { targets?: ["name", …] } cancels just those recipients; absent or empty body cancels the whole schedule.

  • POST /api/schedules/{id}/fire-now — out-of-band manual pulse. Fires the schedule body once immediately to every active target. Recurring schedules: next_fire_at_unix is untouched; the regular cadence continues. One-shots: the schedule is consumed (cancelled) after the manual fan-out. Per-target last_result is annotated as a manual fire so the audit trail distinguishes scheduled fires from operator- triggered ones.

  • POST /meta-updatenix 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<i64> 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<QueueEntry>) — 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 (<header class="agent-header">): frosted glass — backdrop-filter: blur lets scrolled terminal rows show through. Three flex columns:

  • Agent icon (<img class="agent-icon">): fixed-size square identity anchor — width: 5em; height: 5em with explicit pixel sizing so the <img>'s intrinsic (large) dimensions don't push the parent flex container open via align-items: stretch-driven height feedback. 5em ≈ header content area (header min-height: 6em minus 2 × 0.5em padding). align-self: flex-start keeps the icon stuck to the top so a state-row line-wrap doesn't drag it down with it. Falls back to the dimmed hyperhive mark on load error.
  • Main column (.agent-header-main): two rows.
    • Row 1 (.agent-header-title-row): title (<h2 id="title">) + meta-nav (<nav id="meta-links">). Meta-nav renders backend-supplied StateSnapshot.links as icon-only anchors — always 📊 stats (kind = Container); 🖥 screen when VNC is enabled; ⬡ forge (profile) + ↳ config (agent-configs mirror) when the agent has a forge account; any hyperhive.dashboardLinks extras (kind = External). A ↑ dashboard link is prepended by the JS so the host dashboard is one click away. GET /api/agent/{name}/links is the single source of truth. Each NavLink.kind resolves differently in the frontend: Container → same-origin path (the agent page is itself container-local); Forgehttp://<host>:3000<url>; External → already absolute. All anchors are built via el() — agent-declared icon / label / url strings never reach innerHTML (XSS-safe by construction).
    • Row 2 (.agent-state-row): alive badge + state badge + model chip
      • ctx badge + cost badge + last-turn chip + cancel button.
      • Alive badge: ● alive (green) / ⊘ rate limited (red) / ◌ needs login / ◌ logging in / ○ offline / … connecting. Driven by LiveEvent::StatusChanged.
      • State badge: 💤 idle / 🧠 thinking / 📦 compacting / ○ offline / … booting + age suffix. Driven by LiveEvent::TurnStateChanged ({ state, since_unix }).
      • Model chip: model · <name>. Driven by LiveEvent::ModelChanged.
      • Ctx badge: ctx · 142k — last inference's prompt size. Tooltip shows % of window when context_window_tokens is 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 — cost signal, not size signal).
      • Both driven by LiveEvent::TokenUsageChanged { ctx, cost } at turn-end.
      • ■ cancel turn (visible while thinking) → POST /api/cancel.
  • Right cluster (.agent-header-pills): flyout pills + overflow.
    • 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.
    • Overflow button (): always visible. Opens a frosted popover (#overflow-menu, positioned outside the header to escape any stacking context) with four rows: ↑ dashboard (link), ↻ rebuild container (POST confirm, same action as the dashboard R3BU1LD button), ↻ new claude session (POST confirm → POST /api/new-session; next turn drops --continue), 🔓 logout (POST confirm → POST /api/logout; SIGINTs any in-flight turn, wipes OAuth credential files, flips the agent to needs_login — session history preserved). All destructive actions require one extra click to acknowledge — rare ops shouldn't live in the primary state strip. The popover's display rules are scoped to :not([hidden]) so the [hidden] HTML attribute's UA display: none isn't overridden by the author CSS's display: flex — the popover stays hidden until JS removes the attribute.

/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, and qualified_label — the hive-qualified agent name (name@domain form when HYPERHIVE_HIVE_DOMAIN is set, otherwise just name). The frontend uses qualified_label to set the browser tab title so two tabs from different hives are distinguishable; the header <h2 id="title"> stays short.

Main content (<main class="agent-main">): fills the viewport and scrolls behind the fixed header + footer.

  • #status overlay: empty when online; shows the login form / OAuth URL when status is needs_login_*. The OAuth code input is type="password" with a 👁 reveal toggle that flips it back to text on press so the operator can sanity-check the paste before submit — avoids accidental on-screen token exposure to shoulder-surfers or screenshots. autocomplete="one-time-code" is the semantic value for OAuth codes (per WHATWG): browsers may silently ignore autocomplete="off" on type="password", but one-time-code is honoured and suppresses the "save password for this site?" prompt that would otherwise fire on submit.
  • Terminal-wrap: live event tail (sticky-bottom auto-scroll + ↓ N new pill when not at bottom). The pill is anchored in .agent-main, not in log.parentElement = .terminal-wrap: .terminal-wrap applies backdrop-filter: blur for the frost effect, which creates a CSS stacking context — anchoring the pill inside that context would trap its z-index below the fixed composer in the root stacking context, and it'd never float. .agent-main has no backdrop-filter (no stacking-context creators), so the pill's z-index reaches the root and properly composites above the composer. Geometry is unchanged — .agent-main and .terminal-wrap both inset: 0 fill the same area.

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. A ✓ mark all read button appears in the flyout header when the inbox is non-empty; clicking it confirms then POSTs cross-origin to the core dashboard's POST /api/agent/{name}/mark-all-read — all pending messages for this agent are acked, the harness won't receive wake-prompts for them. A { marked: N } pill surfaces the count. The displayed message list stays put (it shows the most-recent N regardless of ack state); the unread badge on the next turn-start will reflect zero. 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).

Ask → operator inline-answer binding. When the agent emits mcp__hyperhive__ask(to: "operator", ...), the tool_use renderer mounts an empty slot (<div class="ask-answer-inline-slot">) right under the ↳ ask → operator row in the terminal scrollback and pushes a reference into pendingAskBinds. The broker assigns the question id asynchronously, so the slot waits — and the next /api/loose-ends refresh runs reconcileAskBinds(), which walks the slot list and pairs each unbound slot with the first unclaimed pending operator-bound question whose question text matches the slot's stashed _askQuestion. On match the slot mounts the buildAnswerForm (same form shape as the loose-ends flyout — POSTs to the core's /answer-question/{id} cross-origin). Slots stay in the array after binding so the reconciler can flip them to a neutral [resolved] tag when the question later disappears from the pending list. Disappearance can mean answered, cancelled by the asker, or TTL-expired — the neutral label avoids mis-asserting "✓" on the cancel / expire paths; full resolution state is visible via the side-panel history. A defensive prune walks the slot list each tick and drops any whose DOM node has been removed (e.g. via a future "clear single row" affordance), so stale references don't accumulate. Slots whose question never arrives (e.g. the agent cancelled the ask, or the question is older than the loose-ends retention window) stay empty — the operator can still answer via the side panel, no regression.

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/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 <details> 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 <details> ▸ ← 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).
  • /cancelPOST /api/cancel → host shellouts pkill -INT claude, emits a Note. Also surfaces as a ■ cancel turn button in the state row while state=thinking.
  • /compactPOST /api/compact → host spawns turn::compact_session in the background; output streams into the live panel.
  • /model <name>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-sessionPOST /api/new-session (confirms first). Arms a one-shot on the Bus; next turn runs without --continue, dropping the resume session entirely.
  • /logoutPOST /api/logout (confirms first). Wipes OAuth credential files, parks the agent in needs_login. Session history (~/.claude/projects/) is preserved.

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 emit LiveEvent::StatusChanged to flip the badge to/from needs_login_in_progress.

  • POST /api/cancel — SIGINT the in-flight claude turn. Emits a LiveEvent::Note.

  • 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). Flips state to Compacting via Bus::set_state, which emits TurnStateChanged.

  • POST /api/model (model=<name>) — switch the model for future turns. Bus::set_model emits ModelChanged.

  • POST /api/new-session — arm a one-shot for the next turn to drop --continue. Emits a LiveEvent::Note.

  • POST /api/logout — three-step teardown that re-uses the existing wait_for_login resumption path:

    1. SIGINT any running claude (matches /api/cancel's pattern — idempotent no-op when nothing is running) so the credential wipe doesn't race a mid-API-call turn.
    2. Delete only the OAuth credential files (.credentials.json + mcp-needs-auth-cache.json under ~/.claude/). Preserves session history files (projects/<hash>/*.jsonl), sessions, shell-snapshots, plans, settings, telemetry, and the dir itself, so claude --continue keeps working after a fresh login. Wholesale remove_dir_all of ~/.claude/ was the previous shape and broke session continuity; the narrowed allow-list is the fix.
    3. Flip LoginState::NeedsLogin + emit a LiveEvent::Note describing exactly what was wiped, then emit needs_login_idle. The turn-loop's next iteration parks into wait_for_login, which snapshots the credential dir (now missing the wiped files) and resumes when a fresh credentials file appears via the dashboard's /login/code flow (the same mtime-resumption path manual re-login uses).

    Always returns 200 with a body describing what happened — per-file errors are folded into the response + the Note so the operator sees them in the live panel rather than as an HTTP error. Missing files (already logged out) are treated as idempotent.

  • GET /events/history — replay buffer for the terminal.

  • GET /screen — VNC viewer page (minimal RFB-over-WebSocket renderer — deliberately thin, just enough to display the desktop + forward pointer + keyboard. A production-grade viewer would vendor noVNC; this file ships the minimal in-tree variant). Only accessible when hyperhive.gui.enable = true in the agent's agent.nix; the harness shows a 🖥 screen link in the state row when gui_vnc_port is present. Toolbar: ⤢ fit CSS-downscales the canvas to the window via relayoutCanvas() setting explicit pixel dimensions on the canvas — not CSS max-width/max-height, because a flex item's automatic minimum size (min-width: auto resolves to the canvas's intrinsic framebuffer resolution) silently clamps max-* back up, making fit mode a no-op that just centred + clipped the oversized canvas. The fit-mode rules pin the canvas with flex: none; min-width: 0; min-height: 0 so the JS-set size sticks. ⤡ match size sends an RFB SetDesktopSize request so the server (weston) changes its real output resolution to the window dimensions; enabled once the server advertises the ExtendedDesktopSize pseudo-encoding (-308 rect in the header). Fit-mode state persists in localStorage (screen-fit); default is on. Pointer coordinates are rescaled in sendPointer so clicks land on the right pixel regardless of CSS scale.

  • GET /screen/ws — raw RFB byte relay: proxies WebSocket frames to the weston VNC server at 127.0.0.1:<vnc_port>. Transparent to any RFB variant. VNC port comes from /etc/hyperhive/gui.json (written by the weston startup script in weston-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_limited is 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 from Bus::record_turn_usage at turn-end; ctx is the last inference's usage (current context size), cost is the cumulative across every inference (the result event'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.