From c7360cf0bb9bd050d040d6a5683acc4930fe49a6 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 21:34:17 +0200 Subject: [PATCH] docs(#727): split docs/web-ui.md into shape / dashboard / agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/web-ui.md (1315 lines) split into three sub-files: - docs/web-ui/shape.md — shared SPA skeleton, SSE multiplexing, Worker-death self-heal, terminal pane, listener bind, relative paths, atomic repaint, side panel - docs/web-ui/dashboard.md — SW4RM/Y3R/SYST3M/SCH3DUL3S/S3TT1NGS tabs, container row, topology tree, selection bar, approval card, dashboard endpoints + event channel - docs/web-ui/agent.md — header, terminal, composer, inbox, live view, slash commands, per-agent endpoints, stats page docs/web-ui.md replaced with a thin index linking all three. Section anchors in docs (gateway.md, gotchas.md), Rust doc comments (hive-ag3nt/src/web_ui.rs), and nix/templates/weston-vnc.nix updated to point at the correct sub-file. README and CLAUDE.md file-map updated with sub-file links. Inline // comments in frontend source left unchanged (they reference the index which redirects to the right sub-file). --- CLAUDE.md | 13 +- README.md | 2 +- docs/gateway.md | 2 +- docs/gotchas.md | 2 +- docs/web-ui.md | 1340 +--------------------------------- docs/web-ui/agent.md | 336 +++++++++ docs/web-ui/dashboard.md | 713 ++++++++++++++++++ docs/web-ui/shape.md | 270 +++++++ hive-ag3nt/src/web_ui.rs | 12 +- nix/templates/weston-vnc.nix | 2 +- 10 files changed, 1373 insertions(+), 1319 deletions(-) create mode 100644 docs/web-ui/agent.md create mode 100644 docs/web-ui/dashboard.md create mode 100644 docs/web-ui/shape.md diff --git a/CLAUDE.md b/CLAUDE.md index ec067ede..82a42aef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -298,7 +298,13 @@ nix/ docs/ conventions.md naming, identity=socket, async forms, commit style gotchas.md NixOS / nspawn quirks and lessons learned - web-ui.md dashboard + per-agent page layouts and endpoints + web-ui.md index → web-ui/shape.md (shared skeleton, SSE, + terminal, listener bind, relative paths, atomic + repaint, side panel), web-ui/dashboard.md (tab + contents, container row, topology, selection bar, + approval card, endpoints + event channel), + web-ui/agent.md (header, terminal, composer, + inbox, live view, per-agent endpoints, stats) turn-loop.md claude invocation, wake prompt, MCP tool surface approvals.md approval flow, manager policy, helper events persistence.md sqlite dbs, retention, state dir layout @@ -324,7 +330,10 @@ Pick the doc that matches your task. None depend on the others — read them à la carte. - **"What does the dashboard look like?"** → - [`docs/web-ui.md`](docs/web-ui.md). + [`docs/web-ui.md`](docs/web-ui.md) (index; sub-pages: + [`shape`](docs/web-ui/shape.md), + [`dashboard`](docs/web-ui/dashboard.md), + [`agent`](docs/web-ui/agent.md)). - **"How does the per-agent terminal classify + colour events?"** → [`docs/terminal-rendering.md`](docs/terminal-rendering.md). - **"How does claude get its prompt and what tools does it have?"** → diff --git a/README.md b/README.md index 62c5e29e..0c2c5208 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Depth lives in [`docs/`](docs/) — pick the one matching your task: | reading path | doc | | --- | --- | -| dashboard layout + endpoints | [`docs/web-ui.md`](docs/web-ui.md) | +| dashboard layout + endpoints | [`docs/web-ui.md`](docs/web-ui.md) ([shape](docs/web-ui/shape.md) · [dashboard](docs/web-ui/dashboard.md) · [agent](docs/web-ui/agent.md)) | | claude turn loop + MCP tools | [`docs/turn-loop.md`](docs/turn-loop.md) | | config-edit + approval state machine | [`docs/approvals.md`](docs/approvals.md) | | what survives destroy / purge / restart | [`docs/persistence.md`](docs/persistence.md) | diff --git a/docs/gateway.md b/docs/gateway.md index e6e00aa1..7ecd39f3 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -125,7 +125,7 @@ flip together: the primary agent-name link, the favicon fetch `/api/agent//links`. `forge`-kind nav-strip links still resolve against `http://:3000` (separate sub-domain transition tracked by `forge.behindGateway`); `external`-kind links are -already absolute. See `docs/web-ui.md::Container row` for the +already absolute. See `docs/web-ui/dashboard.md::Container row` for the frontend-side derivation. ## Self-signed TLS (`selfSignedTls`) diff --git a/docs/gotchas.md b/docs/gotchas.md index b061d6dd..8fa69869 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -256,7 +256,7 @@ derivation's `nativeBuildInputs`. `nix/templates/weston-vnc.nix` adds an optional Weston Wayland compositor with the VNC backend, surfaced as `hyperhive.gui.enable = true` per-agent. The harness's -`/screen/ws` WebSocket relay (`docs/web-ui.md::Per-agent endpoints`) +`/screen/ws` WebSocket relay (`docs/web-ui/agent.md::Per-agent endpoints`) connects to the compositor at `127.0.0.1:`. - **Port allocation**: deterministic FNV-1a of the agent name diff --git a/docs/web-ui.md b/docs/web-ui.md index c7d4ba6e..318204c1 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -6,1310 +6,36 @@ 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 ``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 `LiveEvent`s - 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 `
` with - a `
` 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 ``
-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.
-
-**`
` open-state preservation:** any collapsible element -tagged with `data-restore-key=""` survives the -refresh. `snapshotOpenDetails()` walks managed sections before -render, `restoreOpenDetails()` re-applies after. Long-content -drill-ins (file previews, diffs, journald logs) now open in the -**side panel** (see below) rather than expanding inline, so the -only restore-keyed `
` left is the answered-questions -history list. - -**Side panel (dashboard):** long content opens in a drawer that -swipes in from the right — a singleton `#side-panel` with a -titled header, a close button, and a scrollable body. Closes on -the button, a backdrop click, or `Escape`. `Panel.open(title, -node)` swaps the body; the JS builders for file previews, -approval diffs, and journald logs all render into it. **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 `` `data:` URI (the - browser's secure static mode, so an untrusted SVG can't run - scripts), `source` shows the raw markup. -- **Raster images** (`.png` / `.jpg` / `.gif` / `.webp` / - `.bmp` / `.ico` / `.avif`) — render as an `` pointed at - `/api/state-file`, which serves them as binary with their - real content-type (text files stay UTF-8-lossy `text/plain`). -- **Everything else** — raw text in a `
`.
-
-### 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//` bind-mount,
-`.bound` marker filtering, `agent-sockets.json` consumer on the
-gateway side) live in [`docs/gateway.md::Per-agent unix-socket
-upstream`](gateway.md). 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 `` injection at serve time.
-
-## Dashboard layout
-
-The dashboard (`/`) has a fixed chrome header at the top and a
-`
` that shows exactly one tab pane at a time. The URL hash -(`#swarm`, `#call`, `#system`, `#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), - `◆ FL0W ◆ →` (page link), and `◆ S3TT1NGS ◆`. 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); S3TT1NGS has no count. 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`. -- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) - — sits below the tab strip. - -The FL0W page reuses the same chrome strip but its `◆ S3TT1NGS ◆ →` -entry is a cross-page link back to the dashboard -(`/#settings`) since the settings pane only lives there. - -### SW4RM tab - -**C0NTAINERS** — live containers rendered as a depth-first -tree using `ContainerView.parent` (populated by `topology.rs`). -Each container's row is prefixed with ASCII tree glyphs (`├─`, -`└─`, `│ ` continuation columns) showing the agent -parent/child hierarchy. When every container has `parent = null` -(flat topology) the tree collapses to a plain list with no -glyphs. Children are sorted alphabetically within each parent; -roots likewise. Cycles in the parent graph are tolerated — -orphaned containers (not reachable from any root) are appended -as roots so no agent disappears. Pulsing red banner at the top -of this section if any two sub-agents hash to the same port -(`port_conflicts` from `/api/state`): the operator must rename -one of them and rebuild. `lifecycle::{spawn,rebuild}` also -preflight this and refuse with a clear error message naming the -conflicting agent. - -`↻ UPD4TE 4LL` button appears above the containers list when any -agent is stale. - -### Y3R C4LL tab - -Things blocked on operator decision — approvals and questions -share a tab because they're the same concept ("something is -waiting on you"). - -**P3NDING APPR0VALS** — the queue (see "Approval card" below). -The R3QU3ST SP4WN form lives at the top of this section. - -**M1ND H4S QU3STI0NS** — pending operator-targeted `ask` -questions (amber pulsing border). Free-text fallback always -rendered alongside any option list; `multi=true` renders options -as checkboxes; submit merges selections + free text -comma-joined. Each row has a `✗ CANC3L` button. Questions with -a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog -auto-cancels with `[expired]` when the deadline fires. - -### SYST3M tab - -Passive / rare-interaction state. - -**M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can -selectively `nix flake update`, rendered as an indented tree: -every fetched input at every depth (`hyperhive`, -`hyperhive/nixpkgs`, `agent-`, `agent-/mcp-`, …), each -shown once at its shallowest path. `read_meta_inputs` walks the -lock graph with a `visited` set — `follows` aliases and rev-less -nodes are skipped. 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 `↳ ` 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 ``; 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** → `` 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. - -### 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, -inbox-pill threshold, etc.) land here as sibling `

` blocks -under the same `
`. - -**◇ 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 — its tab-strip -`◆ S3TT1NGS ◆ →` entry is a cross-page link to the dashboard's -`#settings` route. 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 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 `
` chrome -as the dashboard so the tab strip remains visible; SW4RM / Y3R -C4LL / SYST3M / SCH3DUL3S are cross-page links back to `/#`, -and the FL0W entry is marked active (`aria-current="page"`). - -**0PER4T0R 1NB0X** — recent messages addressed to `operator`, -derived client-side from the dashboard event stream. Cold load -seeds from `/dashboard/history`'s 200-message backfill; subsequent -`sent` events with `to == "operator"` are appended live. Cap 50, -newest-first. - -**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`. -Cold load backfills the last ~200 messages from `/dashboard/history`; -live frames arrive on `/dashboard/stream`. Each row is one broker -event — `sent` or `delivered` — with `from → to: body`. 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 `` points at `/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 `` 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 `` 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 `` 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 `/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//` (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): - `container` → `http://:/` (direct TCP - fallback). Forge links resolve against `http://:3000`, - external links are already absolute. The same flag drives the - primary agent-name link + favicon fetch (`/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 · ` — opens the side panel and lazy- - fetches journald via `GET /api/journal/{name}?unit=&lines=` - (`journalctl -M -b --no-pager --output=short-iso`). - A unit dropdown (harness service / full machine journal) and - a refresh button live in the panel. The panel uses a column-flex - layout so the `
` log surface fills the full remaining panel
-    height; scroll happens inside the `
`, 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:` 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
-`` 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: -*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`); `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=&new_parent=`)
-    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 ``, and a
-  right-aligned `requested  ago` relative time from
-  `ApprovalView.requested_at` — amber once the request has been
-  pending ≥ 1h so a stale approval stands out.
-- **what-changed body** — the manager's description, then
-  drill-in triggers: `↳ view diff` opens the diff in the side
-  panel; `↳ commit on forge ↗` deep-links the proposal commit
-  into `agent-configs/` (shown only when `forge_present`).
-  Spawn approvals show a one-line "container will be created"
-  note instead.
-- **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a
-  `prompt()` for an optional reason carried to the manager as
-  `HelperEvent::ApprovalResolved.note`.
-
-The diff panel has a 3-way base toggle — **vs applied** (the
-running tree, served instantly from the diff already on the
-approval), **vs last-approved**, **vs previous proposal** — the
-latter two fetched on click from `GET /api/approval-diff/{id}
-?base=approved|previous`. Each line is classified client-side
-(`+` / `-` / `@@` / `--- ` / `+++ ` → add / del / hunk / file).
-
-A `pending · N` / `history · N` tab pair switches the section
-between the live queue and the last 30 resolved approvals.
-
-### Browser notifications
-
-Pure frontend (`Notification` API). Three signals trigger them:
-
-- new pending approval (per id, delta on `/api/state`)
-- new pending operator question (per id)
-- new broker message sent `to: "operator"` (live via SSE)
-
-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:`, `hyperhive:question:`,
-`hyperhive:msg::`) so distinct events stack in the OS
-notification center instead of overwriting each other.
-`console.debug` logs at every block point (unsupported,
-permission ungranted, muted) for in-browser debugging. Click
-focuses the dashboard tab. 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 /approve/{id}` — approve a pending approval. Fires
-  `ApprovalResolved` on the dashboard event channel; client
-  updates derived approvals state from the event.
-- `POST /deny/{id}` (`note=`, optional) — deny a pending
-  approval with an optional operator-supplied reason. The reason
-  travels to the manager as `HelperEvent::ApprovalResolved.note`
-  and also rides on the dashboard's `ApprovalResolved` event.
-  Dashboard prompts via `window.prompt()` on click.
-- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
-  `destroy` accepts `purge=on` to also wipe state dirs.
-- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
-- `POST /answer-question/{id}` — answer a pending operator question.
-- `POST /cancel-question/{id}` — cancel a pending question with
-  the sentinel `[cancelled]`. Same code path as a real answer.
-- `POST /request-spawn` — queue a Spawn approval.
-- `POST /update-all` — rebuild every stale container.
-- `POST /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=`, `body=`) — drop an
-  operator-authored message into ``'s inbox. `to=*` fans
-  out to every registered agent. Returns 200; the broker
-  `Sent` event re-renders both the message-flow terminal and
-  the operator inbox without a snapshot refetch. Used by the
-  compose textbox under MESS4GE FL0W.
-- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
-  headers for one agent, newest first. Returns
-  `Vec` (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/approval-diff/{id}?base=applied|approved|previous` —
-  on-demand unified diff for an `ApplyCommit` approval against
-  the chosen base (running tree / last approved proposal /
-  previous queued proposal). Raw diff text, classified
-  client-side. `GET /static/marked.js` serves the vendored
-  `marked` bundle the side panel uses for markdown previews.
-- `GET /api/state-file?path=` — bounded
-  text read of a file under the per-agent `state/` subtree or
-  the shared `/var/lib/hyperhive/shared/`. Accepts the
-  container-view forms (`/agents//state/...`, `/shared/...`)
-  and the host form. Canonicalises + verifies the path stays
-  inside the allow-list, refuses anything but a regular file,
-  refuses `/agents//claude` / `config` subtrees, truncates
-  bodies at 1 MiB. Click-time backing for the inline path-link
-  preview.
-
-  Detection of which tokens *are* path links is done
-  **server-side at broker-message ingest**, not client-side:
-  the broker forwarder calls `scan_validated_paths(body)` —
-  same allow-list helper the read endpoint uses — and attaches
-  the verified file tokens to the event as `file_refs: Vec`.
-  The client trusts that list and linkifies only those tokens,
-  so directories, missing files, and forbidden subtrees never
-  become anchors. No probe endpoint, no client-side regex
-  heuristics. Historical messages get the same treatment on
-  `/dashboard/history` backfill.
-- `GET /api/reminders` — list pending reminders for the
-  dashboard's queued-reminders panel.
-- `POST /cancel-reminder/{id}` — hard-delete a pending reminder.
-- `POST /retry-reminder/{id}` — re-arm a reminder whose delivery
-  failed (clears the failure state so the scheduler retries).
-- `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-update` — `nix flake update` the selected
-  `meta/flake.lock` inputs, then rebuild the affected agents.
-- `GET /dashboard/stream` — unified live event channel:
-  broker `sent` / `delivered`, plus the mutation events listed
-  below. Each frame carries `seq`.
-- `GET /dashboard/history` — last ~200 broker messages
-  (wrapped as `{ seq, events }`) for the message-flow
-  terminal's backfill on page load.
-
-### Dashboard event channel
-
-Wire vocabulary on `/dashboard/stream` (kind tag is in the JSON
-payload):
-
-- `sent` / `delivered` — broker traffic, mirrored from the
-  intra-process channel by a forwarder task. Both carry `id: i64`
-  (the broker row id) and `in_reply_to: Option` for thread
-  rendering. The dashboard message-flow terminal renders reply
-  rows with a `↳ reply` tag that scroll-highlights the parent
-  row on click. Used by the message-flow terminal renderer and
-  the operator-inbox derived state.
-- `approval_added` (id, agent, approval_kind, sha_short, diff,
-  description) / `approval_resolved` (id, agent, approval_kind,
-  sha_short, status, resolved_at, note, description) — pending
-  queue + history mutations. Client mutates a derived store and
-  re-renders only the approvals section.
-- `question_added` (id, asker, question, options, multi,
-  asked_at, deadline_at, target) / `question_resolved` (id,
-  answer, answerer, answered_at, cancelled, target) — both
-  operator-targeted and peer (agent-to-agent) threads fire
-  these. The dashboard's questions pane surfaces both, with
-  filter chips (all / @operator / @peer / per-participant) and
-  an `0V3RR1D3` button on peer rows so the operator can
-  answer when an agent is stuck. The ttl watchdog fires
-  `question_resolved` with `answerer = "ttl-watchdog"` on
-  expiry.
-- `transient_set` (name, transient_kind, since_unix) /
-  `transient_cleared` (name) — lifecycle action spinners. The
-  client ticks the elapsed-seconds badge off `since_unix`
-  client-side, no polling.
-- `container_state_changed` (container: ContainerView) /
-  `container_removed` (name) — per-row container mutations,
-  emitted by `Coordinator::rescan_containers_and_emit` from
-  every mutation site (`actions::approve` post-spawn,
-  `actions::destroy`, the lifecycle_action wrapper,
-  `auto_update::rebuild_agent`) and from the 10s
-  `crash_watch` poll. Client upserts/removes by name; the
-  pending overlay is read from `transientsState` since the
-  payload doesn't carry it.
-- `rebuild_queue_changed` (seq, queue: `Vec`) —
-  full snapshot of the rebuild queue on every mutation (enqueue,
-  state transition, dedup collapse, terminal-history trim).
-  Same snapshot-over-diff rationale as `tombstones_changed` /
-  `meta_inputs_changed`: the list is small and the client's
-  `parent_id` grouping is most naturally re-derived from the
-  full list. Cold-loaded from `/api/state.rebuild_queue`.
-
-`/api/state` is **only fetched on cold-load and on the few
-forms that mutate non-event-derived state** (PURG3 +
-meta-update, since tombstones + meta_inputs aren't event-
-shaped yet). Every other section — approvals, questions,
-transients, containers, operator inbox, message flow —
-derives from `/dashboard/stream` after the initial snapshot,
-maintaining its own client-side store and applying events on
-top. The 5s periodic poll is gone.
-
-Generalised form helpers: `form[data-confirm="…"]` pops
-`confirm()` before submit; `form[data-prompt="…"]` pops
-`prompt()` and stashes the answer in a hidden input named by
-`data-prompt-field` (default `note`).
-
-## Per-agent page
-
-Three fixed-position layers frame a full-viewport terminal:
-
-**Fixed-overlay header** (`
`): frosted -glass — `backdrop-filter: blur` lets scrolled terminal rows show -through. Three flex columns: - -- **Agent icon** (``): fixed-size square - identity anchor — `width: 5em; height: 5em` with explicit pixel - sizing so the ``'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 (`

`) + - meta-nav (`