# Web UI Two web surfaces share the same skeleton: the dashboard (port 7000) and the per-agent UIs (manager on :8000, sub-agents on a hashed :8100-8999). Both are SPAs — `GET /` returns a static shell, `/api/state` returns JSON, JS renders. No full-page reloads. ## Shape (shared by both) - `GET /` → `assets/index.html` (placeholders for state-driven sections, shipped via `include_str!` so the binary has no runtime file dependency). - `GET /static/*.css` + `GET /static/*.js` → static assets. Both pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the shared `window.HiveTerminal.create` runtime. 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. - `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. 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/app.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. **`
` 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. Markdown file previews (`.md` / `.markdown`) render through the vendored `marked` bundle (`GET /static/marked.js`) into a `.md` block; other files stay raw 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.

## Dashboard sections (top to bottom)

1. **Notification row** — `🔔 enable notifications` button when
   permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted;
   inline "unsupported / blocked" message when applicable. Sits
   under the banner.
2. **C0NTAINERS** — live containers with their action surface.
   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.
3. **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}`).
4. **M3T4 1NPUTS** — inputs in `meta/flake.lock` the operator can
   selectively `nix flake update` (hyperhive first, then
   `agent-` rows). 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`.
5. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
   questions, i.e. rows with `target IS NULL` (peer-to-peer
   questions live in the same table but never surface here)
   (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 that resolves the question
   with `[cancelled]`. Questions with a `ttl_seconds` show a
   `⏳ MM:SS` chip; the host-side watchdog auto-cancels with
   `[expired]` when the deadline fires.
6. **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`.
7. **P3NDING APPR0VALS** — the queue (see "Approval card"
   below). The R3QU3ST SP4WN form lives at the top of this
   section since submitting it immediately queues an approval
   that lands directly below.
8. **0PER4T0R 1NB0X** — recent messages addressed to `operator`,
   derived client-side from the dashboard event stream (no longer
   a snapshot field). Cold load seeds from
   `/dashboard/history`'s 200-message backfill; subsequent
   `sent` events with `to == "operator"` are appended live. Cap
   50, newest-first.
9. **MESS4GE FL0W** — live broker tail wrapped in a
   `.terminal-wrap` (same chrome as the per-agent terminal).
   Cold load backfills the last ~200 messages from
   `/dashboard/history`; live frames arrive on
   `/dashboard/stream` and dispatch through
   `HiveTerminal.create`. Each row is one broker event —
   `sent` or `delivered` — with `from → to: body`; per-agent
   thinking / tool calls / claude chatter stay out of this
   view, only what passes through hive-c0re's broker. Sticky-
   bottom auto-scroll + "↓ N new" pill match the per-agent
   page. Below the stream sits a terminal-style compose box:
   `@name` picks the recipient (sticky across sends via
   localStorage; auto-complete from the live container list,
   Tab/Enter to confirm; `@*` broadcasts to every registered
   agent), starting a message with `@ body` retargets
   in one stroke, plain text sends to the sticky recipient.
   `POST /op-send` drops `{from:"operator", to, body}` into
   the broker and returns 200; the resulting SSE frame
   re-renders both the terminal row and the inbox section
   (no `/api/state` refetch). Manager is addressed as
   `@manager` (the broker recipient string), not `@hm1nd`
   (the container name); the auto-complete swaps automatically.

### Container row

A full-height **square agent icon** on the left (the agent's
`/icon`, painted as a background-image div so its load state can
never reflow the row), and the card body on the right with three
stacked lines (`assets/app.js::renderContainers`):

- Line 1: agent name (link → new tab), m1nd/ag3nt chip, status
  badges — `⊘ rate limited` (red, while the harness is parked
  after a 429), `needs login`, `needs update` — in-flight `◐
  pending-state…` pill (replaces buttons during start / stop /
  restart / rebuild / destroy), 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).
- Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3`
  on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running,
  `▶ ST4RT` when stopped. Buttons dim + disable while a transient
  lifecycle action is in flight.
- 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.
  - `↳ config repo ↗` — link to the agent's applied config repo
    on the bundled forge (`agent-configs/`), opened in a
    new tab. Shown only when `forge_present`. Replaces the old
    one-file `agent.nix` viewer — the forge shows the full repo
    with history. Mutating config still requires
    `request_apply_commit` + approval.

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

### Approval card

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

- **identity header** — glyph, `#id`, agent, kind chip, and (for
  `apply_commit`) the short proposal sha as ``.
- **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 /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).
- `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.

`/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

Layout, top to bottom:

- Banner (gradient shimmer while state=thinking).
- Title with `↑ DASHB04RD` back-link (new tab) + `↻ R3BU1LD`.
- Meta links row: `📊 stats →`, `🖥 screen →` (shown when
  `gui_enabled`), and `⬡ forge ↗` (shown when `forge_present` —
  links to the agent's Forgejo profile at `:3000/