# Web UI Two web surfaces share the same skeleton: the dashboard (port 7000) and the per-agent UIs (every container — including the manager — hashes into :8100-8999 via `lifecycle::agent_web_port`'s FNV-1a, since #753). Both are SPAs — `GET /` returns a static shell, `/api/state` returns JSON, JS renders. No full-page reloads. ## Shape (shared by both) - `GET /` → `index.html` from the bundled frontend dist (see `frontend/`). Both binaries' routers declare their dynamic endpoints first and then `fallback_service(ServeDir::new(...))` pointed at `HIVE_STATIC_DIR` — anything not matched by an API or action route is served from the dist. Dashboard dist lives at `${frontend}/dashboard`; per-agent dist is the merged `hyperhive.frontend.mergedDist` (default agent dist + per-agent `extraFiles` overlay). - `GET /static/*` → bundled CSS + JS produced by esbuild (`frontend/packages/{dashboard,agent}/build.mjs`). Both pages pull the shared terminal pane + Catppuccin palette + typography from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines `base.css` + `terminal.css` via esbuild's `@import` resolution. `terminal.js` exports `{ create, linkify }` as ES module members (no more `window.HiveTerminal` global outside the back-compat shim the IIFE bodies still use). The dashboard's `#msgflow` and the per-agent `#live` log are both backed by this terminal — sticky-bottom auto-scroll, "↓ N new" pill, history backfill, SSE plumbing all live there. Each page registers a kind→renderer map; unknown kinds fall through to a JSON-dump note row. Bare `http(s)://` URLs in row text are turned into clickable new-tab links by `linkify` (text-node based, no `innerHTML` — XSS-safe); markdown bodies get the same treatment via `marked`'s autolink (npm dep, replacing the vendored UMD bundle), with the rendered ``s rewritten to `target="_blank"` (issue #233). - `GET /api/state` → JSON snapshot the JS app renders into the DOM. Includes a top-level `seq` (the dashboard event channel's high-water mark at the moment the snapshot was assembled); clients use it to dedupe their buffered SSE traffic against the snapshot (drop frames with `seq <= snapshot.seq`). - `GET /dashboard/stream` (dashboard) / `GET /events/stream` (per-agent) → `text/event-stream` SSE for live updates. The dashboard stream carries broker `Sent` / `Delivered` (mirrored by a forwarder task from the broker's intra-process channel) plus mutation events (`approval_added` / `approval_resolved`, `question_added` / `question_resolved`, `transient_set` / `transient_cleared`). Each frame carries a `seq`. The matching backfill endpoint is `GET /dashboard/history` (last ~200 broker messages wrapped in `{ seq, events }`) on the dashboard and `GET /events/history` (last 2000 `LiveEvent`s also wrapped in `{ seq, events }`) on the agent. **SSE multiplexing** (#448): 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** (#515): 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. 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** (#451): 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 `
`.

Both bind their listeners with `SO_REUSEADDR` via
`tokio::net::TcpSocket` plus a retry loop on `AddrInUse` (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.

### 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), and `◆ FL0W ◆ →` (page link). Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S (active schedules); FL0W pill mirrors the operator inbox length (hidden when zero). The M4TR1X → entry is hidden when `services.hyperhive.matrix.gui.enable` is off (defaults to `matrix.enable`) so operators without the matrix GUI on don't see a dead link — tabs.js gates the `hidden` attribute on `state.matrix_gui_enabled` from `/api/state`. - **Notification controls**: `🔔 enable notifications` when permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted. Always visible in the chrome regardless of active tab. - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) — sits below the tab strip. ### SW4RM tab **C0NTAINERS** — live containers rendered as a depth-first tree using `ContainerView.parent` (populated by `topology.rs`). Each container's row is prefixed with ASCII tree glyphs (`├─`, `└─`, `│ ` continuation columns) showing the agent parent/child hierarchy. When every container has `parent = null` (flat topology) the tree collapses to a plain list with no glyphs. Children are sorted alphabetically within each parent; roots likewise. Cycles in the parent graph are tolerated — orphaned containers (not reachable from any root) are appended as roots so no agent disappears. Pulsing red banner at the top of this section if any two sub-agents hash to the same port (`port_conflicts` from `/api/state`): the operator must rename one of them and rebuild. `lifecycle::{spawn,rebuild}` also preflight this and refuse with a clear error message naming the conflicting agent. `↻ UPD4TE 4LL` button appears above the containers list when any agent is stale. ### Y3R C4LL tab Things blocked on operator decision — approvals and questions share a tab because they're the same concept ("something is waiting on you"). **P3NDING APPR0VALS** — the queue (see "Approval card" below). The R3QU3ST SP4WN form lives at the top of this section. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask` questions (amber pulsing border). Free-text fallback always rendered alongside any option list; `multi=true` renders options as checkboxes; submit merges selections + free text comma-joined. Each row has a `✗ CANC3L` button. Questions with a `ttl_seconds` show a `⏳ MM:SS` chip; the host-side watchdog auto-cancels with `[expired]` when the deadline fires. ### SYST3M tab Passive / rare-interaction state. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can selectively `nix flake update`, rendered as an indented tree: every fetched input at every depth (`hyperhive`, `hyperhive/nixpkgs`, `agent-`, `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. ### 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`, #635). 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 is tracked in #609 (atlas's lane, post-#15). ### 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`. 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` (`container` → `http://host:`, `forge` → `http://host:3000`, `external` → already absolute). **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. (issue #66) - 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 (#541); 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)

First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
Per-event tags (`hyperhive:approval:`, `hyperhive:question:`,
`hyperhive:msg::`) so distinct events stack in the OS
notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.

### Dashboard endpoints

- `POST /approve/{id}` — approve a pending approval. Fires
  `ApprovalResolved` on the dashboard event channel; client
  updates derived approvals state from the event.
- `POST /deny/{id}` (`note=`, optional) — deny a pending
  approval with an optional operator-supplied reason. The reason
  travels to the manager as `HelperEvent::ApprovalResolved.note`
  and also rides on the dashboard's `ApprovalResolved` event.
  Dashboard prompts via `window.prompt()` on click.
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
  `destroy` accepts `purge=on` to also wipe state dirs.
- `POST /purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /answer-question/{id}` — answer a pending operator question.
- `POST /cancel-question/{id}` — cancel a pending question with
  the sentinel `[cancelled]`. Same code path as a real answer.
- `POST /request-spawn` — queue a Spawn approval.
- `POST /update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
  (#447). 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}` (#559). 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/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 (#474). 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 (#467). 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 (`