diff --git a/docs/web-ui.md b/docs/web-ui.md index 318204c1..b6663633 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -6,36 +6,1319 @@ 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. -This doc has been split for readability. Pick the section you need: +## Shape (shared by both) -- **[Shape (shared by both)](web-ui/shape.md)** — shared SPA - skeleton, SSE multiplexing, terminal pane, listener bind, - per-agent relative paths, `data-async` form pattern, side panel, - atomic repaint. -- **[Dashboard layout](web-ui/dashboard.md)** — tab contents - (SW4RM, Y3R C4LL, SYST3M, SCH3DUL3S, S3TT1NGS), container row, - topology tree, selection bar, approval card, browser - notifications, dashboard endpoints + event channel. -- **[Per-agent page](web-ui/agent.md)** — header, main terminal, - composer, side panel + inbox, live view, slash commands, - per-agent endpoints, stats page. +- `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. -## Reading paths +### Shared terminal pane -- **"How does the dashboard SPA stay live without polling?"** → - [`web-ui/shape.md`](web-ui/shape.md) (SSE multiplexing, - Worker-death self-heal, atomic repaint). -- **"What does a container row contain?"** → - [`web-ui/dashboard.md`](web-ui/dashboard.md) (Container row, - Topology tree, Selection bar). -- **"What endpoints does the dashboard expose?"** → - [`web-ui/dashboard.md`](web-ui/dashboard.md) (Dashboard - endpoints, Dashboard event channel). -- **"How does the per-agent terminal render tool calls?"** → - [`web-ui/agent.md`](web-ui/agent.md) (Live view). -- **"What slash commands does the agent accept?"** → - [`web-ui/agent.md`](web-ui/agent.md) (Terminal-embedded prompt). -- **"What are the per-agent HTTP endpoints?"** → - [`web-ui/agent.md`](web-ui/agent.md) (Per-agent endpoints). - - \ No newline at end of file +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.
+  - `↳ build logs · ` — opens the side panel and fetches
+    the last 10 build-log headers via
+    `GET /api/build-logs/{agent}` (status chip + kind + age +
+    truncated cmdline per row). Clicking a row lazy-fetches its
+    full stdout+stderr from `GET /api/build-logs/id/{id}` and
+    expands it inline as a scrollable `
`. A refresh button
+    re-fetches the header list. Backed by the `build_logs.sqlite`
+    store that `lifecycle::run` and `lifecycle::prebuild_toplevel`
+    write into.
+  - 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 (`