Per-agent page

Part of Web UI. See also: Shape (shared) · Dashboard layout

Three fixed-position layers frame a full-viewport terminal:

Fixed-overlay header (<header class="agent-header">): frosted glass — backdrop-filter: blur lets scrolled terminal rows show through. Three flex columns:

/api/state is fetched once on cold load (+ while status === 'needs_login_in_progress'); all other updates arrive via SSE. Snapshot includes context_window_tokens for the ctx badge tooltip, and qualified_label — the hive-qualified agent name (name@domain form when HYPERHIVE_HIVE_DOMAIN is set, otherwise just name). The frontend uses qualified_label to set the browser tab title so two tabs from different hives are distinguishable; the header <h2 id="title"> stays short.

Main content (<main class="agent-main">): fills the viewport and scrolls behind the fixed header + footer.

Fixed-overlay footer (<footer class="agent-composer">): frosted glass, symmetric with the header. Contains the operator-input textarea (#term-input) — multi-line, Enter sends, Shift+Enter newlines, Tab-completes slash commands (see "Terminal-embedded prompt" below).

Side panel (slide-in from right): singleton shared with the dashboard's side panel shape. Carries inbox and loose-ends flyouts (opened via the header pills) as well as long content (file previews, diffs, journald logs). Inbox flyout: last 30 messages addressed to this agent (AgentRequest::Recent { limit: 30 }); reply messages indented with ↳ reply · in amber. A ✓ mark all read button appears in the flyout header when the inbox is non-empty; clicking it confirms then POSTs cross-origin to the core dashboard's POST /api/agent/{name}/mark-all-read — all pending messages for this agent are acked, the harness won't receive wake-prompts for them. A { marked: N } pill surfaces the count. The displayed message list stays put (it shows the most-recent N regardless of ack state); the unread badge on the next turn-start will reflect zero. Loose-ends flyout: questions, approvals, and reminders pending against this agent (GET /api/loose-ends); question rows carry an inline answer form that POSTs cross-origin to the core dashboard's /answer-question/{id} so the operator answers as operator (see docs/boundary.md).

Ask → operator inline-answer binding. When the agent emits mcp__hyperhive__ask(to: "operator", ...), the tool_use renderer mounts an empty slot (<div class="ask-answer-inline-slot">) right under the ↳ ask → operator row in the terminal scrollback and pushes a reference into pendingAskBinds. The broker assigns the question id asynchronously, so the slot waits — and the next /api/loose-ends refresh runs reconcileAskBinds(), which walks the slot list and pairs each unbound slot with the first unclaimed pending operator-bound question whose question text matches the slot's stashed _askQuestion. On match the slot mounts the buildAnswerForm (same form shape as the loose-ends flyout — POSTs to the core's /answer-question/{id} cross-origin). Slots stay in the array after binding so the reconciler can flip them to a neutral [resolved] tag when the question later disappears from the pending list. Disappearance can mean answered, cancelled by the asker, or TTL-expired — the neutral label avoids mis-asserting "✓" on the cancel / expire paths; full resolution state is visible via the side-panel history. A defensive prune walks the slot list each tick and drops any whose DOM node has been removed (e.g. via a future "clear single row" affordance), so stale references don't accumulate. Slots whose question never arrives (e.g. the agent cancelled the ask, or the question is older than the loose-ends retention window) stay empty — the operator can still answer via the side panel, no regression.

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:

Per-stream rendering:

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:

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

Per-agent endpoints

All POSTs return 200 (no 303 redirects). The matching mutations fire LiveEvent variants on the per-agent bus, so the client doesn't refetch /api/state on submit — the SSE stream delivers the new state faster anyway. Only the login flow still polls (session output streams in updates that aren't event- shaped).

Bus events (new vocabulary on /events/stream):

Stats page

GET /stats is a separate per-agent page (served by the harness, linked from the per-agent page's 📊 stats → and from each dashboard container row). Turn analytics, read-only, from /state/hyperhive-turn-stats.sqlite. GET /api/stats?window= 24h|7d|30d returns a time-bucketed Snapshot; the page renders it with Chart.js (bundled into stats.js via esbuild — no CDN dependency). Charts: turns, duration (p50 · p95 · avg), context tokens, token cost per bucket, a turns-by-model stacked bar (model choice drives token cost, so it sits directly under the cost chart), doughnuts for tool / wake-source / result mix, and a result-trend stacked bar — per-bucket result_counts so error / rate-limit / compaction outcomes are visible over time (the doughnut shows only the window total). A favorite tools doughnut shows the most-run shell commands — normalised bash_commands heads written per bash task by the hive-bash-mcp capture: the basename of the first real command, looking past cd repo && prefixes, env-assignments, and prefix-runners like sudo / env (so cd /repo && cargo build records cargo, not cd). Read via bash_breakdown; the card stays hidden until the agent has run a bash command (a missing bash_commands table degrades to an empty list), so it never renders an empty chart. A summary chip row carries window totals, plus two token-efficiency chips derived from the bucket sums: cache hit-rate (cache_read over all input-side tokens) and tokens/turn. When reminder_stats is present (fetched via ReminderRollup RPC and merged into the snapshot in web_ui.rs::api_stats) three more chips appear: reminders scheduled / delivered / pending for the window. stats.rs opens the sqlite db read-only and degrades to an empty snapshot on any error — the page is decorative, never authoritative.