hyperhive/docs/web-ui/dashboard.md
iris c091d8a4b5 docs(web-ui): align dashboard endpoint list with the /api FE routes
The 'Dashboard endpoints' list was half-migrated: newer routes were
documented under /api/* but the older ones (approve, deny, the
rebuild/kill/restart/start/destroy lifecycle group, purge-tombstone,
answer-question, cancel-question, request-spawn, update-all, op-send)
were still listed bare. The dashboard FE calls all of them under /api/
since the route migration, so the doc was stale. Prefix the bare entries
(plus the two inline references) to match what the FE actually requests.
Documentation-only; no behaviour change.
2026-06-22 19:09:53 +02:00

69 KiB
Raw Blame History

Dashboard layout

Part of Web UI. See also: Shape (shared) · Per-agent page

The dashboard is served at /dashboard.html (with the home page at /). It 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, #permissions, #schedules, #peers, #settings) drives which pane is active; hash changes don't reload the page. FL0W, L0GS, and the optional M4TR1X client are separate pages reachable from the H0M3 hub at /, not from the dashboard tab strip.

Chrome header (fixed, overlays the active tab pane):

  • ← home back-link: top-left of the chrome, links to the H0M3 hub at /. Every surface links back to the hub rather than to each other.
  • Tab strip: ◆ SW4RM ◆, ◆ Y3R C4LL ◆, ◆ P3RM1SS10NS ◆, ◆ SCH3DUL3S ◆. In-page tabs only — the SYST3M panels moved to the standalone C0R3 page (/core.html), and FL0W / L0GS / ST4TS / S3TT1NGS / M4TR1X live on their own pages too, all reachable from the H0M3 hub (not the tab strip). Peer hives render as a headline under SW4RM rather than a tab. Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions + unread operator messages), and SCH3DUL3S (active schedules); P33RS and S3TT1NGS have no count.
  • Banner-thin (░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░) — sits below the tab strip.
  • Server-warnings banner — a generic, sticky top-of-page strip shown on every page (dashboard + the stand-alone FL0W / L0GS / H0M3 pages), injected at the top of <body> by renderServerWarnings in common.js. Driven by state.server_warnings — a list of { kind, level, message } from hive-c0re's host_stats::server_warnings — and coloured by level (warn amber / crit red). The backend owns the threshold + message, so adding a new system warning needs no frontend change. The only producer today is the host disk-pressure check (a statvfs probe of /nix: ≥85% used → warn, ≥95% → crit, e.g. ⚠ host nix store N% full (G GiB free) — garbage-collect …). Hidden when there are no warnings.
  • Browser tab titlehive / c0re by default; updated to <swarm> / <hive> once hive_name / swarm_name arrive in the state snapshot. When there are pending approvals or unanswered questions, a (N) prefix is prepended — (3) pr1ma / hive-c0re — so the operator can see the call count in an unfocused browser tab without opening the dashboard. The prefix is set on the initial /api/state cold-load and updated live by approval_added / approval_resolved / question_added / question_resolved SSE events; it's preserved when hive_name / swarm_name later replace the raw title.

The FL0W and L0GS pages use a slim header (a ← home back-link + the page title) rather than the dashboard tab strip — they're standalone surfaces, not tab panes.

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. A pending · N / history · N tab pair switches between the live queue and the last 30 resolved approvals (see "Approval card" for the history row shape).

M1ND H4S QU3STI0NS — pending ask calls waiting on the operator, with amber pulsing border. Anatomy of each card:

  • Filter chipsall · N, @operator · N, @peer · N, plus one chip per participant name (@asker · N / @target · N). Every chip shows its own count so the operator can see the distribution at a glance. Clicking a chip narrows the visible list; selection persists in localStorage so a tab switch doesn't lose the filter.
  • Question card — timestamp · asker → target · body text (with file-path links). Operator-targeted questions (target = null) show ▸ ANSW3R; peer-targeted questions (target = agent) show ⤿ 0V3RR1D3 so the operator can unblock an agent-to-agent exchange. Questions with a ttl_seconds show a ⏳ MM:SS live countdown chip; the host-side watchdog resolves with answerer = "ttl-watchdog" on expiry.
  • Answer form — free-text textarea (Enter = submit, Shift+Enter = newline) + optional option list (radio for single-select, checkboxes for multi=true). Submit merges selected options + free text comma-joined into a single answer field. ✗ CANC3L is a separate form so the submit merge handler doesn't interfere.
  • ◆ answ3red (N) — collapsible <details> below the pending list; shows the last 20 resolved questions with their answers.

0PER4T0R 1NB0X — messages agents have sent to to="operator" but the operator hasn't read yet. Cold-loaded from /api/operator-inbox on tab activation + page load; appended live from the broker sent stream (deduped on row id). Each row shows sender · timestamp · body (with file-path linkification). A ✓ mark all read button on the right acks all rows via POST /api/agent/operator/mark-all-read (reuses the existing mark-read endpoint). Unread count folds into the Y3R C4LL tab pill so messages are visible from any tab even while inactive. Backed by GET /api/operator-inbox{ messages: [...] } (id, from, body, at, in_reply_to, file_refs).

C0R3 page (/core.html)

Passive / rare-interaction state. No longer a dashboard tab — it's a standalone page reached from the Core tile on the H0M3 hub (served at /core.html), with the same minimal chrome as /logs.html: a ← home back-link + a createTabStrip sub-tab nav (R3BU1LD QU3U3 default, then M3T4 1NPUTS, K3PT ST4T3, C0NT41N3R L04D). The page is its own esbuild bundle (core.js) that cold-loads /api/state and subscribes to /dashboard/stream for the same live events as the dashboard (rebuild_queue_changed, meta_inputs_changed, meta_update_running, tombstones_changed). The dashboard keeps the rebuild-queue state (it drives the "building…" badges on SW4RM agent cards) but no longer renders these panels.

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. All timing labels stay live: running entries tick elapsed seconds every second; queued and terminal ("done N ago" / "failed N ago") labels tick every 30s so keyed rows never show stale timestamps as they persist across rebuild_queue_changed snapshots. 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.

Below the queue, a live build-log panel (#rebuild-live-log, renderRebuildLiveLog) streams the currently-running rebuild's output inline — collapsible, with a live/ok/fail badge and a ↓ raw download. It's keyed to the running entry's build_log_id and opens one EventSource to GET /api/build-logs/id/{id}/stream (the same stream the L0GS page BUILD tab uses; the stream replays accumulated output on connect). It lives in its own container outside #rebuild-queue-section so the queue's per-row re-render (rows rebuild as the step advances) never tears down the open stream; it hides when nothing is building and each row keeps its logs → link out to the full L0GS history.

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 /api/purge-tombstone/{name}).

C0NT41N3R L04D — live CPU + memory per agent container, read straight from cgroup v2 on the host (cpu.stat, memory.current, memory.peak, memory.max under /sys/fs/cgroup/machine.slice/machine-h\x2d<name>.scope/). CPU is a host-normalised percentage (0..100 across all cores) sampled over a short (~200 ms) two-read interval; memory shows current + peak with a bar against the memory.max quota. Backed by GET /api/container-resources (container_stats.rs), which reads the files read-only (world-readable; no hive-priv) and skips agents whose scope dir is absent (= not running). Pull-only: core.js polls every 5 s only while the C0NT41N3R L04D sub-tab is active (CPU needs a fresh sample each refresh), and stops on sub-tab change. Disk size (disk_bytes) rides the same row but is fed by a separate ~5 min background du sampler (state dir + container writable rootfs, shared nix store excluded via du -x), so the 5 s poll stays cheap cgroup-only reads; the row carries the last-sampled value (null until the first sample). Network is intentionally omitted — agents share the host netns, so there is no per-container net counter (per-agent network needs the netns-isolation roadmap in docs/network.md).

M4TR1X ACC0UNTS page (/matrix-accounts.html)

Operator surface to provision / log in a per-agent external matrix account and store its access token, without editing the agent's config repo. Standalone page reached from the Matrix accounts tile on the H0M3 hub, same minimal chrome as /core.html (a ← home back-link + title). Its own esbuild bundle (matrix-accounts.js); no SSE — it reads /api/state once for the agent picker and otherwise works off two purpose-built endpoints.

An agent picker (populated from state.containers, the live roster) drives a list of that agent's accounts — name, homeserver, user id, and a status dot — read from GET /api/matrix-accounts?agent=<name>{ accounts: [ { name, homeserver, token_present, live, user_id } ], as_of_unix }. token_present is whether a token is stored; live, homeserver, and user_id are backfilled from the matrix daemon's matrix-accounts.json snapshot — a host-visible file the daemon force-rewrites every ~30s (a heartbeat), so as_of_unix (the snapshot mtime) advances while the daemon is alive and a stalled value genuinely means "stopped publishing", not just "old snapshot". An account with a token but absent from the snapshot reports live: false.

The status dot renders these states:

  • greenlive and the container is running: online.
  • dim greenlive but as_of_unix hasn't advanced in > ~90s (3 missed heartbeats) while the container is not down: the daemon stopped publishing, so the snapshot's live is no longer trustworthy (likely dead/wedged). Labelled "online · no heartbeat".
  • amberlive but the container is down (a stopped container ⟹ a dead daemon, so the snapshot is stale); also the token_present && !live "provisioned but offline" case.
  • grey — no token (not provisioned).

The container-down cross-reference (/api/state) takes precedence over the age check. as_of_unix is tooltipped ("live as of N ago") throughout so freshness is always legible. When live is absent (an older backend without the snapshot) the dot falls back to a token-present rendering.

The provision form (account name, homeserver, login method) posts POST /api/matrix-account-login (x-www-form-urlencoded, operator-auth): fields agent, account, homeserver, mode=password|token, user_id?, password?, token?200 { ok, user_id } on success. Failures come back as RFC 9457 application/problem+json ({ type, title, status, detail }) with the human-readable message in detail and the status code reflecting the cause (400 for a validation error, 500 for a login / whoami / internal failure); the page reads detail for display. The host coordinator performs the login (password) or validates the token (whoami) and writes the bearer to the agent's matrixAccounts.<account>.tokenFile via the same privileged write path as the hive-internal matrix-token; the token is never echoed back, and the page clears the secret inputs on submit regardless of outcome. The account list reflects what is provisioned (an account with a stored token), so a config-declared-but-unprovisioned account appears only once it has been provisioned through the form.

P3RM1SS10NS tab

Per-agent permission configuration. Two sections, each rendered as a column-driven checkbox matrix: rows are agents, columns are the permission names fetched from the backend. The column list is authoritative — adding a new tool-group or capability to the backend requires no UI change; the new column appears automatically.

The snapshot carries agents (the full manageable roster — live containers agents with an explicit entry) and effective (per-agent explicit-or-role-default values) alongside the explicit assignments map. Rows come from agents so agents on defaults always appear (not just those with an explicit entry), and checkboxes reflect the effective values so a default agent shows the groups it actually runs with rather than blank — which also means saving it won't silently strip those defaults. The (default) badge keys off absence from assignments (no explicit entry).

Fetches fire on tab activation (not page-load) to avoid unnecessary work when the operator never visits this tab. Live mutations from the rebuild-queue worker are also pushed via the capabilities_changed / tool_groups_changed SSE events (same payload shape as the GET endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes without requiring navigation. Tab-activation re-fetches remain as a safety net for reconnect windows.

C4P4B1L1T13S — per-agent capability grants. Capabilities unlock gated MCP tools and system-level access beyond the default agent surface. A saving POST queues a rebuild for the affected agent so the new HIVE_CAPABILITIES env var takes effect in the next session.

The current capabilities are:

Name Effect
manage_root_agent allows the set_status / lifecycle tools on the root manager
read_host_journal unlocks get_host_journal to read journald from inside a container
query_agent_state allows get_loose_ends(agent: "<name>") calls targeting other agents

Each row is one agent. Columns are the capability names returned by GET /api/capabilities as caps: Vec<String>. Checking or unchecking boxes only stages the change in-browser; nothing is written until the page-level save all button (described below) is clicked. The checkboxes reflect the effective set (explicit grant or role default), so a default-perms agent shows its real grants rather than blank; absent agents in the assignment map have no extra capabilities.

T00L GR0UPS — per-agent tool-group permissions. Tool groups are named buckets of MCP tools; each agent starts with a role default (agents: messaging, meta, inbox, execution; manager: all groups). Checking / unchecking stages which groups are active for the agent; the page-level save all button (below) commits it. Columns come from GET /api/tool-groups. A rebuild is queued so HIVE_TOOL_GROUPS takes effect.

The current tool groups are: messaging, meta, inbox, lifecycle, approvals, scheduling, diagnostics, execution, web_tools. All listed in ToolGroup::ALL in hive-sh4re. The web_tools group is special: it carries no MCP tools; instead it adds Claude's built-in WebFetch and WebSearch to --tools / --allowedTools for that agent session.

Both tables share the same visual shape: .cap-table-wrap / .tg-table-wrap outer scroll container, thead with a label column (.cap-agent-col / .tg-agent-col) + one column per permission (.cap-col / .tg-group-col). Each tbody row is one agent: a name cell and its checkbox cells, where each checkbox carries data-baseline (its render-time state) and the row carries data-agent for dirty-tracking.

Saving — one button for the whole page. There are no per-row save buttons. A single page-level .perm-save-bar with a save all (N agents) button sits at the bottom of the pane, enabled only when some checkbox diverges from its baseline. Clicking it diffs every checkbox across both matrices and POSTs one batch to POST /api/permissions as { changes: [ { agent, tool_groups?, capabilities? } ] } — only the perm-types that actually changed for each agent are included (an omitted field leaves that file untouched; an included array fully replaces it). The backend coalesces an agent's capabilities + tool-groups into a single rebuild, so changing both for one agent is one rebuild, not two. The batch is atomic: it validates every change first and on any error rejects the whole POST ({error}, nothing applied); a clean 200 (ok) flips the bar to a queued→rebuilding state and re-fetches both tables. Live capabilities_changed / tool_groups_changed events re-render the matrices unless the section has unsaved edits, so an in-progress edit set isn't clobbered.

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 next column cell (.sched-due) carries a data-due-at Unix timestamp attribute; a shared 1s ticker rewrites it in-place showing fmtDuration while in the future and overdue X ago once the fire time has passed — same zero-re-render pattern as the reminder due-at labels and the question TTL chip.

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. The due-time label (.reminder-due) carries a data-due-at Unix timestamp attribute; a shared 1s ticker rewrites it in-place — showing in Xm Ys while the reminder is in the future and overdue X ago once the deadline passes — without triggering a full re-render of the list.

ST4TS tab

Hive-wide turn statistics, aggregated across every agent's hyperhive-turn-stats.sqlite for the selected window. Distinct from each agent's own /stats page (which carries the per-agent trend charts): ST4TS is the swarm-level rollup.

  • Window selector (1h30d) re-fetches on change.
  • Summary chips: active agents, turns, total/input/output/cache-read tokens, and a labelled est cost.
  • Busiest agents table — one row per agent (most turns first): turns, input / output / cache-read tokens, est cost.
  • Model mix — turns per model across the swarm, as CSS bars.
  • Favorite tools — most-run normalised bash-command heads across the swarm (top 10, as CSS bars), aggregated from each agent's bash_commands table (written by the hive-bash-mcp capture). The header + list stay hidden until at least one agent has recorded a command, so the section never shows an empty block on a fresh hive.

Backed by GET /api/stats-hive?window=<w> in hive-c0re (hive_stats.rs): for every name from Coordinator::kept_state_names() it opens agent_harness_dir(name)/hyperhive-turn-stats.sqlite read-only (with a 500 ms busy_timeout, since turn_stats is rollback-journal) and rolls the rows up — missing / unreadable / zero-turn dbs are skipped so one bad db never fails the endpoint. This is a pull surface (no SSE): the data is fetched on tab activation and on window change. Rendered with plain tables + CSS bars — the dashboard bundle ships no chart library.

The cost figure is a deliberately rough estimate from a per-model price table (est_cost_usd); it drifts with list pricing and is labelled accordingly. The table is operator-tunable via the services.hyperhive.modelPrices nix option — each key is a model-family short name (matched case-insensitively as a substring of the model id, longest match wins) mapping to { input, output, cache_read, cache_write } USD-per-million-token prices. Models not covered fall back to hive-c0re's built-in estimate.

P33RS tab

Peer hives in this swarm. The tab is hidden when the state.peer_hives array from /api/state is empty (i.e. no services.hyperhive.swarm.peers are configured). When at least one peer is present the hidden attribute is removed and the tab becomes active.

P33R H1V3S — each peer renders as a card row: a hexagon icon (), the peer's DNS domain as the primary name, and the peer dashboard HTTPS URL as a clickable secondary link. Clicking the URL opens the peer hive's dashboard in a new tab.

Backend wiring

The host daemon reads services.hyperhive.swarm.peers from the nix config (an attrset keyed by peer domain), serialises each entry as { name, url } into state.peer_hives: Vec<PeerHiveView>, and includes the field in the /api/state snapshot. tabs.js reads state.peer_hives on every refreshState call and calls renderPeerHives(peers), which rebuilds the #peers-section div from scratch.

The name field is the peer's DNS domain (the attrset key); url is https://{domain}/. Both are derived from the env var HYPERHIVE_PEERS (a JSON array of { domain, cert_fingerprint } objects) that the nix module writes into the c0re container environment. cert_fingerprint is null for CA-trusted (e.g. Let's Encrypt) peers and non-null to pin a self-signed cert. parse_peer_hives() in dashboard.rs converts each entry to the PeerHiveView { name: domain, url: "https://domain/" } shape the frontend reads.

S3TT1NGS tab

Operator-local preferences. State lives in the browser's localStorage — preferences do NOT sync between devices and do NOT survive a profile wipe. Today the tab holds one section (browser notifications); future preferences (theme, density, etc.) land here as sibling <h3> blocks under the same <section id="tab-pane-settings">.

◇ browser notifications🔔 enable notifications button when permission ungranted; 🔕 mute / 🔔 unmute toggle once granted (mute silences the dispatch without revoking the OS-level permission). On unsupported origins (non-secure context, or browsers without the Notification API) the controls hide and a single status line explains why. See ### Browser notifications below for the dispatch model + the three signals the dashboard emits OS notifications on.

The FL0W page does NOT host this pane — settings live only on the dashboard's S3TT1NGS tab (reach it via the FL0W page's ← home back-link → Dashboard). Notifications still fire on the FL0W page when they're enabled here, because NOTIF.show() in common.js depends on Notification.permission + the hyperhive.notify.muted localStorage key, not on the buttons existing in the page DOM.

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 Matrix tile on the H0M3 hub, 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). Slim chrome: a ← home back-link to the H0M3 hub, the FL0W title, and the agent-filter select (see below). No dashboard tab strip — FL0W is a standalone surface reached from the hub.

The operator inbox is not on this page — it lives on the dashboard's Y3R C4LL tab (◆ 1NB0X ◆ section, with per-message and mark-all read). FL0W stays the pure event firehose.

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. When a sent and delivered event for the same message arrive within 3 seconds (immediate delivery to a live recipient), the row is upgraded in place (arrow becomes green ✓, title reads "sent + delivered") instead of rendering two near-identical lines — genuine delivery latency (recipient was busy) still appears as a second row. Each row carries data-from / data-to attributes; an agent filter select in the FL0W header narrows the timeline to messages involving the chosen agent (matched on from OR to), with non-matching rows hidden (.flow-hidden class). The selection persists in localStorage across reloads; new rows pick up the active filter at render time. The dropdown populates from the live container list and stays current on add/remove; a saved selection survives even if that agent isn't currently listed.

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 /api/op-send drops {from:"operator", to, body} into the broker; the resulting SSE frame re-renders the terminal row. Manager is addressed as @root.

H0M3 page (/)

The H0M3 hub is the primary landing page (served at / by default). A responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled), Forge (when enabled) — each pointing to their respective surfaces. The page is a pure portal with no tab-bar or SSE subscriptions. Typography + colours inherit from the shared theme (Catppuccin Mocha via common.css + theme.css). Optional tiles are hidden until home.js confirms their availability: Matrix is hidden until home.js confirms matrix_gui_enabled (same gating as the dashboard's M4TR1X tab); Forge is hidden until home.js confirms state.forge_present and fills the href from state.forge_public_url (the gateway-served public URL when services.hyperhive.forge.behindGateway=true) or falls back to the direct :3000 port. Operators without matrix or forge enabled never see dead links. home.js also fills the swarm/hive identity line at the top. Dashboard is now served at /dashboard.html (route swap completed in #1464 step 2); the home page at / replaces the old dashboard root. All dashboard sub-pages include a ← Home back-link for navigation.

L0GS page (/logs.html)

A dedicated log-viewer page (not a tab pane — a separate HTML page), reachable from the Logs tile on the H0M3 hub. Minimal chrome: a ← home back link and a four-item sub-tab strip. Tab routing is hash-based (#build, #agent, #system, #audit); default is #build.

BUILD sub-tab — all-agents build log history. Fetches GET /api/build-logs?limit=30 on load and on ↻ refresh. Renders a scrollable list of build entries; each row is a collapsible button showing status badge (live / ok / fail), agent name, elapsed duration, build kind, age, and the invocation command line. Expanding a row fetches the full stdout+stderr via GET /api/build-logs/id/{id}.

A live in-progress build shows a live badge with an elapsed-time chip that ticks every second (updated by a setInterval on the row; cleared when the build finishes or the stream errors). Expanding a live row streams its output via GET /api/build-logs/stream/{id} (newline-delimited JSON frames) with sticky-bottom auto-scroll: the stream scrolls to keep the latest output visible as long as the operator hasn't scrolled up manually; once the operator scrolls up, new lines append silently at the bottom without jumping.

The build list auto-refreshes when a rebuild_queue_changed SSE event fires while the BUILD tab is active (2s debounce to let the backend commit the new row). The ↻ refresh button triggers an immediate re-fetch.

AGENT sub-tab — per-container journald viewer. Two selects: agent name (populated from GET /api/state) and unit filter (hive-ag3nt.service / (full machine journal)). Fetches GET /api/journal/{name}?unit=<unit>&lines=500 on selection change or ↻ refresh. Output rendered as a <pre> block. A ?agent=<name> and/or ?unit=<svc> URL param pre-selects the agent + unit on page load — the per-agent menu's journal logs → entry uses this to deep-link directly to a specific agent's journal. A "fetched N ago" chip appears after the ↻ refresh button following each successful fetch and ticks every 30 s.

SYSTEM sub-tab — host-side service logs. Unit selector (currently only hive-c0re.service). Fetches GET /api/journal-host?unit=hive-c0re.service&lines=500 on activation and on ↻ refresh. Rendered as a <pre> block. A "fetched N ago" chip ticks every 30 s. Available to the operator unconditionally (not capability-gated — the endpoint lives on the hive-c0re dashboard, behind the gateway).

AUDIT sub-tab — operator-visible trail of agent-initiated privileged actions (e.g. infra-container restarts via infra_admin). Lazy-fetched on tab show (like SYSTEM) from GET /api/audit-log, which returns { entries, total }entries newest-first, server-clamped to the latest 500; total drives a "latest 500 of N" count so the clamp is never silent. Rendered as a filterable table (when / agent / action / target / outcome / detail); the filter box is a client-side substring match over the cached rows. The outcome badge colours ok green and err red, with an err whose detail starts denied: (a capability refusal) shown amber and labelled denied so it reads apart from an execution failure. ts_unix is unix seconds; a 30 s ticker keeps the relative "ago" column honest while the tab is in view. The backing audit_log store records every privileged-action attempt (ok / err / denied). New entries live-append without a refresh: an audit_entry_added event on /dashboard/stream (the flattened row) is prepended to the table and the "latest N of M" count bumped, de-duped by id against the cold fetch.

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 plus live agent-owned state, all populated async from a single GET /api/dashboard-state call to the agent's own backend. The response (DashboardState) carries: links (nav strip entries — 📊 stats, 🖥 screen when GUI is enabled, ⬡ forge profile, ↳ agent-configs mirror, plus any agent-declared dashboardLinks extras), status_text / status_set_at (agent self-reported status — the (set N ago) chip is stamped data-set-at and ticks every 30s to stay fresh across the long-lived keyed row cache), rate_limited, ctx_tokens / context_window_tokens (context-window badge data). The agent backend is the single source of truth for all of these. The dashboard resolves each AgentLink.kind against a per-agent base URL depending 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): base URL is /agent/<name> (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): base URL is http://<host>:<container.port> (direct TCP fallback). Forge links resolve against http://<host>:3000, external links are already absolute. The same base URL drives the primary agent-name link + favicon fetch, so the whole row routes through the gateway as a unit. When the container is stopped (ContainerView.running = false), the async dashboard-state fetch is skipped entirely (the agent web server is down), so the badge chain is replaced by a single muted ■ not running badge, the nav strip is empty, and status text / rate-limited / ctx badges are 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… / starting… / stopping… badge (per queue kind — e.g. a start entry shows starting… / start queued, stop and graceful_stop both show stopping… / stop queued) sourced from rebuildQueueState — so the SW4RM tab shows the same progress visible on the C0R3 page'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. A ctx · Nk chip showing the agent's last-turn context size, populated from DashboardState.ctx_tokens (absent until the agent has completed at least one turn). The chip colour (green / yellow / red) is keyed off DashboardState.context_window_tokens (the real context window for the model the agent last ran on, authoritative from the agent side); the badge goes yellow ≥ 50% and red ≥ 75% of that window, matching the harness compaction watermarks. When the window value is absent 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 or the per-agent menu, see below).

Per-agent overflow menu — a button appears on the right edge of each container row. Clicking it opens a small dropdown with per-agent actions and navigation links. Contents:

  • ↺ R3ST4RT (running agents only) / ■ ST0P (running only) / ▶ ST4RT (stopped only) — single-agent run-state toggles. Identical to the bulk actions on the selection bar but operate on one agent without requiring a selection click.
  • ↻ R3BU1LD — always available; queues a rebuild for this agent.
  • journal logs → — opens /logs.html#agent?agent=<name> so the operator lands directly in the AGENT log tab pre-filtered to this container, without having to pick an agent from the dropdown.
  • DESTR0Y / PURG3 — destructive, each prompts for confirmation via the themed dialog (see Themed dialogs below).
  • deployed:<sha> ↗ — present when the agent has a deployed_sha and the forge is reachable; links the deployed commit on the forge agent-configs mirror.

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

Build-queue summary banner — when the rebuild queue has any queued / running entries, a compact amber banner sits above the container list: ◐ build queue — N running · M queued — view queue → (the link goes to the C0R3 page's R3BU1LD QU3U3). It replaces the old per-transient spinner list; the actual running step for each agent is already shown on its card (transient + in-flight-queue badges), so the top of the tab only needs the at-a-glance summary.

Themed dialogs

All confirmations, prompts, and transient error notices use an in-app themed dialog system (assets/modal.js) rather than the browser's native confirm() / prompt() / alert() chrome, so they match the Catppuccin palette and can't be styled away by the OS. Three primitives, all built on the openDialog core:

  • themedConfirm({ message, danger, confirmLabel, checkboxes }) — a modal confirm that resolves to null on cancel or an object of checkbox states on confirm. Destructive actions pass danger: true (the confirm button turns red and the cancel button takes focus). Backdrop click and Esc both cancel.
  • themedPrompt(...) — a modal with a text input, resolving to the entered string or null.
  • themedToast({ type, ... }) — a non-blocking toast (top-right, info / error / ok) for transient validation + action failures, so an error doesn't trap the operator behind a modal. Single-action errors auto-dismiss; bulk / partial-failure summaries are sticky (click to dismiss) so they aren't missed.

Every destructive run-state action (ST0P, R3ST4RT, R3BU1LD, DESTR0Y, PURG3, M0V3) routes through themedConfirm, on both the per-agent menu and the bulk selection bar.

Graceful stop — the ■ ST0P confirm dialog (per-agent and bulk) carries a stop gracefully — let the agent finish its turn and flush state before the container stops checkbox. When ticked, the action POSTs /kill/<name>?graceful=1 (the bulk path appends the flag per-agent); unticked is the instant hard stop (/kill/<name> with no query). The backend enqueues a GracefulStop rebuild-queue transient: the harness runs one stop-checkpoint turn (so the agent can flush /state) and then exits, with a 3-minute timeout that falls back to a hard stop. The quiescing progress surfaces through the same rebuild-queue transient + build log the card already reads for a rebuild. (The hivectl --graceful CLI flag enqueues the same GracefulStop, so the dashboard and CLI paths behave identically.)

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. The chip ticks live every second via a data-requested-at attribute + client-side interval (no re-render). Turns amber once the request has been pending ≥ 1h so a stale approval stands out; the .stale class flips precisely at the 3600s boundary rather than at the next renderApprovals call.
  • 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)

The toggle controls live in the S3TT1NGS tab (#settings); see that section above for the user-facing shape. Dispatch logic lives in common.js::NOTIF.

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. The localStorage key hyperhive.notify.muted ("1" = muted, absent = unmuted) backs the toggle and silences dispatch 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 /api/approve/{id} — approve a pending approval. Fires ApprovalResolved on the dashboard event channel; client updates derived approvals state from the event.

  • POST /api/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 the themed themedPrompt() dialog on click — a resizable <textarea> where Enter submits and Shift+Enter inserts a newline (so multi-sentence rejection notes are possible).

  • POST /api/{rebuild,kill,restart,start,destroy}/{name} — lifecycle. destroy accepts purge=on to also wipe state dirs.

  • POST /api/purge-tombstone/{name} — wipe a tombstone's state dirs.

  • POST /api/answer-question/{id} — answer a pending operator question.

  • POST /api/cancel-question/{id} — cancel a pending question with the sentinel [cancelled]. Same code path as a real answer.

  • POST /api/request-spawn — queue a Spawn approval.

  • POST /api/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.

  • GET /api/operator-inbox — list unread messages addressed to to="operator" (broker rows with acked_at = NULL). Cold-loaded for the Y3R C4LL tab's ◆ 1NB0X ◆ section on page load + tab activation; live updates fed from the broker sent stream. Returns { messages: [{ id, from, body, at, in_reply_to, file_refs }, …] }, newest-first. Reuses /api/agent/operator/mark-all-read to ack (filters are identical so every listed row is exactly what mark-read clears).

  • POST /api/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 the message-flow terminal without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W.

  • GET /api/build-logs/{agent}?limit=N — most-recent build log headers for one agent, newest first. Returns Vec<BuildLogHeader> (JSON): id, agent, kind, cmdline, started_at, finished_at, status ("ok" / "fail" / null while in-progress). limit defaults to 10, server-side cap at 50. Agent name validated ([a-z0-9_-], 1-63 chars).

  • GET /api/build-logs/id/{id} — full build log by id. Returns BuildLogFull (JSON): all header fields plus stdout and stderr as plain text (newline-terminated lines, utf-8). HTTP 404 when the row is missing (vacuum-reaped or stale id).

  • GET /api/journal/{name}?unit=&lines= — journalctl viewer for a managed container; rendered in the side panel.

  • GET /api/audit-log — agent-initiated privileged-action audit trail. Returns { entries, total }: entries is a Vec<AuditEntry> (id, ts_unix in seconds, agent, action, target, outcome "ok"/"err", detail nullable), newest first, server-clamped to 500; total is the full row count for a "latest 500 of N" header. Backs the LOGS page AUDIT sub-tab.

  • 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.

  • GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d> — hive-wide turn-stats rollup for the ST4TS tab. Aggregates every agent's hyperhive-turn-stats.sqlite read-only (skips missing / unreadable / zero-turn dbs); returns swarm totals, a busiest-first per-agent rollup, swarm model mix, and a labelled est_cost_usd. Window defaults to 24h.

  • GET /api/container-resources — live per-agent-container CPU + memory from cgroup v2 (C0R3 C0NT41N3R L04D panel). Returns one row per running agent (name, cpu_pct, mem_current_bytes, mem_peak_bytes, mem_max_bytes, disk_bytes); samples CPU over ~200 ms so the call briefly awaits. Skips non-running agents (no scope dir). No network field — agents share the host netns. disk_bytes is the last value from a separate slow sampler (not this hot path): a background du -sxb of the agent's state dir + container writable rootfs every ~5 min, -x excluding the shared read-only nix store. null until the first sample lands.

  • 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/tool-groups — returns { groups: Vec<String>, assignments: BTreeMap<String, Vec<String>>, descriptions: BTreeMap<String, String> }. groups is the ordered list of all known tool-group names (drives the column headers in the P3RM1SS10NS tab); assignments is the per-agent override map (absent agents use the role default); descriptions maps each group name to a short human-readable string surfaced as a column-header tooltip on hover.

  • POST /api/tool-groups/{agent} — body { groups: ["name", …] }. Writes the tool-group set for {agent} to /var/lib/hyperhive/meta/tool-groups.json and queues a rebuild so HIVE_TOOL_GROUPS takes effect. Agent name validated; guard_agent_name applied.

  • GET /api/capabilities — returns { caps: Vec<String>, assignments: BTreeMap<String, Vec<String>>, descriptions: BTreeMap<String, String> }. caps is the ordered list of all known capability names; assignments is the per-agent grant map (absent agents have no extra capabilities); descriptions maps each capability name to a short human-readable string surfaced as a column-header tooltip on hover.

  • POST /api/capabilities/{agent} — body { caps: ["name", …] }. Writes the capability set for {agent} to /var/lib/hyperhive/meta/capabilities.json and queues a rebuild so HIVE_CAPABILITIES takes effect. Agent name validated; unknown capability strings are rejected (400). guard_agent_name applied.

  • POST /api/permissions — batch perm apply for the save-all permissions button. Body { changes: [{ agent, tool_groups?: ["name", …], capabilities?: ["name", …] }] }. Sparse per agent: an omitted field leaves that perm-type untouched, an empty array clears it, a populated array fully replaces it (same replace semantics as the per-agent endpoints above). Each affected agent gets ONE combined PermChange queue entry, so changing both an agent's tool-groups and capabilities triggers a single rebuild, not two. Atomic: every change is validated first (agent names via guard_agent_name, group + capability names) and on any validation error nothing is written or enqueued (non-2xx { error }); rows with both fields omitted are skipped, not errors. Returns 200 "ok" on success.

  • GET /api/schedules — list all schedules (active and recently cancelled) for the SCH3DUL3S 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.
  • schedules_changed (seq, schedules: Vec<WireSchedule>) — full snapshot of all scheduled prompts. Emitted after every operator mutation via the /api/schedules surface (new / edit / cancel / fire-now) and after the worker fires or rearms a row. Same snapshot-shape rationale as rebuild_queue_changed. The SCH3DUL3S tab subscribes and re-renders schedulesState on receipt; tab activation still re-fetches as a safety net for approval-path inserts and disconnect windows.
  • reminders_changed (seq, reminders: Vec<PendingReminder>) — full snapshot of all pending reminders. Emitted after every reminder mutation: agent remind calls (agent_server), operator cancel / retry (/api/system/reminders/*), cancel_loose_end with Reminder kind, and the scheduler tick after each delivery batch (reminder_scheduler). The SCH3DUL3S tab's reminders section subscribes and calls renderReminders on receipt, so the list updates live without polling.
  • capabilities_changed (seq, caps: Vec<str>, descriptions: map, assignments: BTreeMap<String, Vec<String>>) — full snapshot of capability grants. Emitted from the rebuild-queue worker after a PermChange / Capabilities entry commits the JSON file. Payload matches GET /api/capabilities shape so renderCapabilities can be called directly. P3RM1SS10NS tab subscribes; activation re-fetch still runs as a safety net.
  • tool_groups_changed (seq, groups: Vec<str>, descriptions: map, assignments: BTreeMap<String, Vec<String>>) — full snapshot of tool-group assignments. Emitted from the rebuild-queue worker after a PermChange / ToolGroups entry commits the JSON file. Same shape as GET /api/tool-groups; P3RM1SS10NS tab subscribes.

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