readme: manager mcp surface picks up update; operator-surface recap mentions /model + last-turn + model chip + the three collapsibles (inbox / journald / agent.nix). web-ui.md: details-restore-key story under shape; port-conflict banner mention on containers; agent.nix viewer alongside journald; notifications use per-event tags + console.debug log on block/show; deny endpoint takes note=<reason>; data-prompt / data-prompt-field generalisation noted. conventions.md: data-prompt and snapshot/restoreOpenDetails added to the async-forms section. persistence.md: operator_questions row picks up deadline_at (ttl) column with a migration note. todo.md: new 'Bugs' section captures the manager-question not-rendering issue with three suspect paths to chase. claude.md scratchpad rewritten as a clean handoff for the compaction + the upcoming config-git overhaul. flags the two-repo (proposed/ + applied/) split as the thing to reconsider.
252 lines
12 KiB
Markdown
252 lines
12 KiB
Markdown
# 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.
|
|
|
|
**`<details>` open-state preservation:** any collapsible element
|
|
tagged with `data-restore-key="<stable-key>"` survives the
|
|
refresh. `snapshotOpenDetails()` walks managed sections before
|
|
render, `restoreOpenDetails()` re-applies after. Used today for
|
|
the journald viewer (`journal:<container>`), the agent-config
|
|
viewer (`agent-config:<name>`), and approval diff blocks
|
|
(`approval-diff:<id>`). Setting `.open = true` programmatically
|
|
also fires the `toggle` event, so any lazy-fetch wired to it
|
|
re-runs cleanly on restore.
|
|
|
|
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. **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 two collapsible `<details>` blocks:
|
|
- `↳ logs · <container>` — 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.
|
|
- `↳ agent.nix · <name>` — lazy-fetches the applied config
|
|
file via `GET /api/agent-config/{name}` (read-only mirror of
|
|
`/var/lib/hyperhive/applied/<name>/agent.nix`). Mutating
|
|
this 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).
|
|
|
|
### 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:<id>`, `hyperhive:question:<id>`,
|
|
`hyperhive:msg:<at>:<rand>`) 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.
|
|
- `POST /deny/{id}` (`note=<reason>`, optional) — deny a pending
|
|
approval with an optional operator-supplied reason. The reason
|
|
travels to the manager as `HelperEvent::ApprovalResolved.note`.
|
|
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.
|
|
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
|
|
a managed container.
|
|
- `GET /api/agent-config/{name}` — read-only view of the applied
|
|
`agent.nix`.
|
|
|
|
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`.
|
|
- 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 }`. (Separate from
|
|
`AgentRequest::Recv { wait_seconds }` which the harness uses
|
|
internally to long-poll the broker.)
|
|
- 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).
|
|
- `/cancel` — `POST /api/cancel` → host shellouts `pkill -INT
|
|
claude`, emits a Note. Also surfaces as a `■ cancel turn`
|
|
button in the state row while state=thinking.
|
|
- `/compact` — `POST /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.
|