hyperhive/docs/web-ui.md
müde 62d1a74929 docs sync + revert auto-unfree removal
revert the earlier 'operator must set allowUnfree' move:
per-agent containers evaluate their own nixpkgs and the operator's
host-level allowUnfree doesn't propagate in. restoring the scoped
allowUnfreePredicate inside both the claude-unstable overlay and
harness-base.nix; documented in README + gotchas as 'nothing to
set on the operator side'.

docs:
- claude.md file map adds crash_watch.rs, kick_agent on coordinator,
  /api/model + journald viewer + bind-with-retry references.
- scratchpad rewritten to reflect the recent run.
- web-ui.md: notification row + browser notifications section,
  state row (badge + model chip + last-turn chip + cancel button),
  per-agent inbox, /model slash, /cancel-question + journald
  endpoints, focus-preservation on refresh.
- turn-loop.md: --model is read from Bus::model() per turn (runtime
  override via /model); recv(wait_seconds) up to 180s with the
  rationale; ask_operator gains ttl_seconds; new TurnState section;
  kick_agent inbox-on-startup hint.
- approvals.md: ttl/cancel resolution paths for operator questions.
- persistence.md: /state/hyperhive-model file.
- gotchas.md: web UI port collision policy (rename, don't probe);
  bind retry + SO_REUSEADDR shape; auto-unfree restored.
- todo.md: cleaned up empty sections and stale entries; /model
  shipped, dropped from the list.
2026-05-15 21:26:13 +02:00

9.5 KiB

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.
  • GET /api/state → JSON snapshot the JS app renders into the DOM.
  • GET /events/stream (per-agent) / GET /messages/stream (dashboard) → text/event-stream SSE for live updates.

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.

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.
  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. M1ND H4S QU3STI0NS — pending ask_operator 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 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.
  5. 0PER4T0R 1NB0X — recent messages addressed to operator (last 50, from the broker).
  6. P3NDING APPR0VALS — the queue. The R3QU3ST SP4WN form lives at the top of this section since submitting it immediately queues an approval that lands directly below.
  7. MESS4GE FL0W — live broker SSE tail.

Container row

Two-line layout (assets/app.js::renderContainers):

  • Line 1: agent name (link → new tab), m1nd/ag3nt chip, needs login / needs update warning badges, in-flight ◐ pending-state… pill (replaces buttons during start / stop / restart / rebuild / destroy), container name + port.
  • 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.
  • Plus a collapsible ↳ logs · <container> <details> block. Expanding lazy-fetches journald output via GET /api/journal/{name}?unit=...&lines=... (journalctl -M <container> -b --no-pager --output=short-iso). A unit dropdown switches between the harness service (default) and the full machine journal; refresh button re-fetches.

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

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. tag: "hyperhive" collapses bursts; 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.

Dashboard endpoints

  • POST /{approve,deny}/{id} — approve/deny a pending approval.
  • POST /{rebuild,kill,restart,start,destroy}/{name} — lifecycle.
  • 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.
  • GET /api/journal/{name}?unit=&lines= — journalctl viewer for a managed container.

Per-agent page

Layout, top to bottom:

  • Banner (gradient shimmer while state=thinking).
  • Title with ↑ DASHB04RD back-link (new tab) + ↻ R3BU1LD.
  • Status section (online / needs login / login-in-progress).
  • State row: state badge + model chip + last-turn timing + cancel-turn button.
    • State badge: 💤 idle / 🧠 thinking / 📦 compacting / ○ offline / … booting, with an age suffix (12s, 2m 14s). Driven from /api/state.turn_state + turn_state_since; SSE turn_start/turn_end still flip it instantly between polls. Authoritative source is the harness's Bus::state_snapshot().
    • Model chip: model · <name> (e.g. model · haiku).
    • Last-turn chip: last turn 12.3s appears after the first turn ends, computed from the state-since deltas.
    • ■ cancel turn button: visible only while state=thinking, POSTs /api/cancel.
  • Inbox <details> block (collapsed): inbox · N — last 30 messages addressed to this agent, fetched via AgentRequest::Recent { limit: 30 }.
  • Terminal-wrap: live event tail (sticky-bottom auto-scroll + ↓ N new pill when not at bottom) followed by an operator-input textarea acting as a prompt.

Live view

Each agent runs an events::Bus: a tokio::sync::broadcast<LiveEvent> plus a sqlite-backed history at /state/hyperhive-events.sqlite. The harness emits TurnStart { from, body, unread }, Stream(value) (one per parsed stream-json line), Note, TurnEnd { ok, note }. The web UI:

  • fetches GET /events/history on page load and replays the last 2000 events (oldest first, with .no-anim so they don't stagger);
  • then subscribes to GET /events/stream (SSE) for live tail;
  • shows a granular state badge above the terminal, driven authoritatively from /api/state.turn_state. SSE turn_start / turn_end still flip the badge instantly between renders;
  • sticky-bottom auto-scroll: scrolling up parks the view; new rows surface a "↓ N new" pill instead of yanking;
  • terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up.

Per-stream rendering:

  • Stream tool_use
    • Write / Edit: collapsed <details> with a +/- diff body (- lines from input.old_string, + lines from input.new_string or every line of input.content). Summary carries the path + line counts.
    • others (Read /path, Bash $ cmd, mcp__hyperhive__send → operator: "...", etc.): flat one-line per-tool format.
  • Stream tool_result short → flat ← ...; long → collapsed <details> ▸ ← Nl · headline (click to expand full body).
  • Stream thinking → text content if claude provided one, otherwise the bare · thinking … indicator.
  • Stream system init, result, rate_limit_event are dropped — too noisy.
  • Note· text.
  • TurnEnd✓ turn ok / ✗ turn fail — note, triggers a refreshState().

Terminal-embedded prompt

The operator input lives inside the terminal-wrap as a prompt-style textarea below the live tail: multi-line (Enter sends, Shift+Enter newlines), tab-completes slash commands.

Slash commands today:

  • /help — list commands locally.
  • /clear — wipe the local terminal view (server history kept).
  • /cancelPOST /api/cancel → host shellouts pkill -INT claude, emits a Note. Also surfaces as a ■ cancel turn button in the state row while state=thinking.
  • /compactPOST /api/compact → host spawns turn::compact_session in the background; output streams into the live panel.
  • /model <name>POST /api/model flipping Bus::set_model. Takes effect on the next turn; persisted to /state/hyperhive-model so the override survives harness restart / rebuild.

Unknown /foo shows an error row instead of being silently sent.

Per-agent endpoints

  • POST /send — operator-injected message into this agent's inbox.
  • POST /login/{start,code,cancel} — claude OAuth login flow.
  • POST /api/cancel — SIGINT the in-flight claude turn.
  • POST /api/compact — run /compact on the persistent session.
  • POST /api/model (model=<name>) — switch the model for future turns.
  • GET /events/history — replay buffer for the terminal.