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:
- Agent icon (
<img class="agent-icon">): fixed-size square identity anchor —width: 5em; height: 5emwith explicit pixel sizing so the<img>'s intrinsic (large) dimensions don't push the parent flex container open viaalign-items: stretch-driven height feedback. 5em ≈ header content area (headermin-height: 6emminus2 × 0.5empadding).align-self: flex-startkeeps 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 (<h2 id="title">) + meta-nav (<nav id="meta-links">). Meta-nav renders backend-suppliedStateSnapshot.linksas icon-only anchors — always📊 stats(kind = Container);🖥 screenwhen VNC is enabled;⬡ forge(profile) +↳ config(agent-configs mirror) when the agent has a forge account; anyhyperhive.dashboardLinksextras (kind = External). A↑ dashboardlink is prepended by the JS so the host dashboard is one click away. Links come fromStateSnapshot.links(served byGET /api/state); the same set also appears inDashboardState.links(GET /api/dashboard-state) for the dashboard card's icon strip. Both are produced byagent_links()in hive-ag3nt — the single source of truth. EachNavLink.kindresolves differently in the frontend:Container→ same-origin path (the agent page is itself container-local);Forge→http://<host>:3000<url>;External→ already absolute. All anchors are built viael()— agent-declared icon / label / url strings never reachinnerHTML(XSS-safe by construction). - Row 2 (
.agent-state-row): alive badge + state badge + model chip- ctx badge + cost badge + last-turn chip + cancel button.
- Alive badge:
● alive(green) /⊘ rate limited(red) /◌ needs login/◌ logging in/○ offline/… connecting. Driven byLiveEvent::StatusChanged. - State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting+ age suffix. Driven byLiveEvent::TurnStateChanged ({ state, since_unix }). - Model chip:
model · <name>. Driven byLiveEvent::ModelChanged. - Ctx badge:
ctx · 142k— last inference's prompt size. Tooltip shows % of window whencontext_window_tokensis known. - Cost badge:
cost · 1.3M— cumulative tokens billed across every inference in the last turn (tool-heavy turns rebill the cached prefix per call — cost signal, not size signal). - Both driven by
LiveEvent::TokenUsageChanged { ctx, cost }at turn-end. ■ cancel turn(visible while thinking) →POST /api/cancel.
- Row 1 (
- Right cluster (
.agent-header-pills): flyout pills + overflow.- Inbox pill (
📬 inbox · N): hidden when empty; click opens the inbox flyout in the side panel. - Loose-ends pill (
🪢 loose ends · N): hidden when empty; click opens the loose-ends flyout. - Overflow button (
⋯): always visible. Opens a frosted popover (#overflow-menu, positioned outside the header to escape any stacking context) with four management rows followed by a model quick-picker section:↑ dashboard(link),↻ rebuild container(POST confirm, same action as the dashboard R3BU1LD button),↻ new claude session(POST confirm →POST /api/new-session; next turn drops--continue),🔓 logout(POST confirm →POST /api/logout; SIGINTs any in-flight turn, wipes OAuth credential files, flips the agent toneeds_login— session history preserved). All destructive actions require one extra click to acknowledge — rare ops shouldn't live in the primary state strip. Below a separator, a model quick-picker section labelledmodelrenders one button per model in the operator-configured list. The list is driven bystate.available_models(sourced from theHIVE_AVAILABLE_MODELSenv var, injected by theservices.hyperhive.availableModelsNixOS option; defaults to["haiku", "sonnet", "opus"]when unset). Well-known aliases get a parenthetical description (haiku (fast),sonnet (balanced),opus (powerful)); operator-declared custom names show as-is. Clicking a button POSTs/api/modelwith the alias (same path as the/model <name>slash command). The button for the currently-active model is highlighted via theactiveclass;renderModelChipkeeps the picker state in sync with livemodel_changedevents so it stays accurate when the model is changed from another session. Clicking the already-active model closes the menu without an extra POST. The popover's display rules are scoped to:not([hidden])so the[hidden]HTML attribute's UAdisplay: noneisn't overridden by the author CSS'sdisplay: flex— the popover stays hidden until JS removes the attribute.
- Inbox pill (
/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.
#statusoverlay: empty when online; shows the login form / OAuth URL whenstatusisneeds_login_*. The OAuth code input istype="password"with a👁 revealtoggle that flips it back totexton press so the operator can sanity-check the paste before submit — avoids accidental on-screen token exposure to shoulder-surfers or screenshots.autocomplete="one-time-code"is the semantic value for OAuth codes (per WHATWG): browsers may silently ignoreautocomplete="off"ontype="password", butone-time-codeis honoured and suppresses the "save password for this site?" prompt that would otherwise fire on submit.- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
↓ N newpill when not at bottom). The pill is anchored in.agent-main, not inlog.parentElement = .terminal-wrap:.terminal-wrapappliesbackdrop-filter: blurfor the frost effect, which creates a CSS stacking context — anchoring the pill inside that context would trap itsz-indexbelow the fixed composer in the root stacking context, and it'd never float..agent-mainhas no backdrop-filter (no stacking-context creators), so the pill'sz-indexreaches the root and properly composites above the composer. Geometry is unchanged —.agent-mainand.terminal-wrapbothinset: 0fill the same area.
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:
- fetches
GET /events/historyon page load and replays the last 2000 events (oldest first, with.no-animso 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:
Streamtool_use→Write/Edit: collapsed<details>with a +/- diff body (-lines frominput.old_string,+lines frominput.new_stringor every line ofinput.content). Summary carries the path + line counts.- others (
Read /path,Bash $ cmd,mcp__hyperhive__send → operator: "...", etc.): flat one-line per-tool format.
Streamtool_resultshort → flat← ...; long → collapsed<details>▸ ← Nl · headline(click to expand full body).Streamthinking→ text content if claude provided one, otherwise the bare· thinking …indicator.Streamsystem init,result,rate_limit_eventare dropped — too noisy.Note→· text.TurnEnd→✓ turn ok/✗ turn fail — note, triggers arefreshState().
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 shelloutspkill -INT claude, emits a Note. Also surfaces as a■ cancel turnbutton in the state row while state=thinking./compact—POST /api/compact→ host spawnsturn::compact_sessionin the background; output streams into the live panel./model <name>—POST /api/modelflippingBus::set_model. Takes effect on the next turn; persisted to/state/hyperhive-modelso the override survives harness restart / rebuild./new-session—POST /api/new-session(confirms first). Arms a one-shot on the Bus; next turn runs without--continue, dropping the resume session entirely./logout—POST /api/logout(confirms first). Wipes OAuth credential files, parks the agent inneeds_login. Session history (~/.claude/projects/) is preserved.
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).
-
POST /send— operator-injected message into this agent's inbox. -
POST /login/{start,code,cancel}— claude OAuth login flow. Start/cancel emitLiveEvent::StatusChangedto flip the badge to/fromneeds_login_in_progress. -
POST /api/cancel— SIGINT the in-flight claude turn. Emits aLiveEvent::Note. -
POST /api/compact— run/compacton the persistent session (same MCP config + system prompt + allowed tools as a normal turn — only the stdin payload differs). Flips state toCompactingviaBus::set_state, which emitsTurnStateChanged. -
POST /api/model(model=<name>) — switch the model for future turns.Bus::set_modelemitsModelChanged. -
POST /api/new-session— arm a one-shot for the next turn to drop--continue. Emits aLiveEvent::Note. -
POST /api/logout— three-step teardown that re-uses the existingwait_for_loginresumption path:- SIGINT any running claude (matches
/api/cancel's pattern — idempotent no-op when nothing is running) so the credential wipe doesn't race a mid-API-call turn. - Delete only the OAuth credential files
(
.credentials.json+mcp-needs-auth-cache.jsonunder~/.claude/). Preserves session history files (projects/<hash>/*.jsonl), sessions, shell-snapshots, plans, settings, telemetry, and the dir itself, soclaude --continuekeeps working after a fresh login. Wholesaleremove_dir_allof~/.claude/was the previous shape and broke session continuity; the narrowed allow-list is the fix. - Flip
LoginState::NeedsLogin+ emit aLiveEvent::Notedescribing exactly what was wiped, then emitneeds_login_idle. The turn-loop's next iteration parks intowait_for_login, which snapshots the credential dir (now missing the wiped files) and resumes when a fresh credentials file appears via the dashboard's/login/codeflow (the same mtime-resumption path manual re-login uses).
Always returns 200 with a body describing what happened — per-file errors are folded into the response + the Note so the operator sees them in the live panel rather than as an HTTP error. Missing files (already logged out) are treated as idempotent.
- SIGINT any running claude (matches
-
GET /api/state— cold-load snapshot (StateSnapshot) consumed byapp.json page load and whilestatus === 'needs_login_in_progress'. Includesturn_state,context_window_tokens,qualified_label,available_models,links, and other fields described inline throughout this document. All subsequent state updates arrive via SSE. -
GET /api/dashboard-state— lean snapshot of agent-owned fields fetched once per running agent by the dashboard's container row to get fresh values without relying on hive-c0re's periodic file-reads. Returns{ status_text?, status_set_at?, ctx_tokens?, context_window_tokens, rate_limited, links }. Only called when the container is running; skipped (muted badges) when stopped. Also accessible via the gateway at/agent/<name>/api/dashboard-state. -
GET /api/loose-ends— loose-ends snapshot consumed by the inbox flyout (renderLooseEnds). Returns pending questions the agent asked or owes, plus pending reminders. Also callsreconcileAskBinds()to wire inline answer forms to openquestion_askedevents. -
GET /api/stats?window=24h|7d|30d— time-bucketed turn analyticsSnapshotconsumed by the/statspage. -
GET /events/history— replay buffer for the terminal. -
GET /screen— VNC viewer page (minimal RFB-over-WebSocket renderer — deliberately thin, just enough to display the desktop + forward pointer + keyboard. A production-grade viewer would vendor noVNC; this file ships the minimal in-tree variant). Only accessible whenhyperhive.gui.enable = truein the agent'sagent.nix; the harness shows a 🖥 screen link in the state row whengui_vnc_portis present. Toolbar:⤢ fitCSS-downscales the canvas to the window viarelayoutCanvas()setting explicit pixel dimensions on the canvas — not CSSmax-width/max-height, because a flex item's automatic minimum size (min-width: autoresolves to the canvas's intrinsic framebuffer resolution) silently clampsmax-*back up, making fit mode a no-op that just centred + clipped the oversized canvas. The fit-mode rules pin the canvas withflex: none; min-width: 0; min-height: 0so the JS-set size sticks.⤡ match sizesends an RFBSetDesktopSizerequest so the server (weston) changes its real output resolution to the window dimensions; enabled once the server advertises theExtendedDesktopSizepseudo-encoding (-308rect in the header). Fit-mode state persists inlocalStorage(screen-fit); default is on. Pointer coordinates are rescaled insendPointerso clicks land on the right pixel regardless of CSS scale. -
GET /screen/ws— raw RFB byte relay: proxies WebSocket frames to the weston VNC server at127.0.0.1:<vnc_port>. Transparent to any RFB variant. VNC port comes from/etc/hyperhive/gui.json(written by the weston startup script inweston-vnc.nix).
Bus events (new vocabulary on /events/stream):
status_changed { status }—online/rate_limited/needs_login_idle/needs_login_in_progress. Drives the alive-badge.rate_limitedis set when the harness detects a 429 response and cleared when the retry sleep expires.model_changed { model }— drives the model chip.token_usage_changed { ctx: TokenUsage, cost: TokenUsage }— drives the ctx + cost badges. Emitted fromBus::record_turn_usageat turn-end;ctxis the last inference's usage (current context size),costis the cumulative across every inference (theresultevent's totals).turn_state_changed { state, since_unix }— drives the state badge (idle/thinking/compacting).
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.