The broker's recent_for was fixed to filter acked_at IS NULL (commit
5264828091), so the agent inbox flyout
now shows only unread messages and empties after 'mark all read'.
The agent.md prose still described the old behaviour ('shows the
most-recent N regardless of ack state; the list stays put after drain').
Update it to match: unread-only, list empties on reload after drain.
23 KiB
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. - Tasks pill (
⚙ tasks · N): hidden when empty; click opens the running-bash-tasks flyout (GET /api/bash-tasks— the in-flight Pending/Running tasks from the in-containerbash-tasks/dir). Unlike loose-ends (refreshed on turn_end), tasks start + finish asynchronously between turns and/api/stateisn't polled while online, so the pill polls the endpoint on a light interval (≈4s). Snapshot only (no SSE push yet). - 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: unread messages addressed to
this agent (acked_at IS NULL, newest-first, up to 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.
After the drain the inbox list empties on reload (the filter is
acked_at IS NULL, so drained messages disappear). 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). Tasks flyout: in-flight bash
tasks (GET /api/bash-tasks); each row shows status (▶ running /
◷ queued), the task id, elapsed time, and a truncated one-line
command preview. Read-only — kill/inspect lives in the harness, not
the page.
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 }. Each event also carries a ts (unix
seconds) — a flattened sibling of the event tag on both the live
SSE frame and the replayed history rows. 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.TurnStart→◆ TURN ← <from>with the wake-prompt body; a muted· HH:MM:SStime suffix from the eventts.TurnEnd→✓ turn ok/✗ turn fail — note, with a· HH:MM:SS · <duration>suffix (duration = end − the paired turn-start), triggers arefreshState(). The time suffix is guarded on a numericts, so rows degrade cleanly when absent.
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|all— time-bucketed turn analyticsSnapshotconsumed by the/statspage.allranges from the earliest recorded turn with an adaptive bucket width. -
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. A← agentback-link in the page header returns to the main per-agent page. Controls:⤢ 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 theHIVE_GUI_VNC_PORTenv var (a fixed port set on the harness service whenhyperhive.gui.enable; seeweston-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). A ← live back-link in the page
header returns to the main per-agent page. Turn analytics, read-only, from
/state/hyperhive-turn-stats.sqlite. GET /api/stats?window= 24h|7d|30d|all 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. When the
per-session capture has data, a first-turn ctx chip shows the
input tokens of the most recent fresh claude session's first turn —
a proxy for system-prompt + CLAUDE.md sprawl (a fresh session's first
turn pays the full static prefix uncached). It's derived in stats.rs
(first_turn_ctx: the first turn, ORDER BY started_at LIMIT 1, of
the latest sessions row in the window) and is omitted from the JSON
until the sessions / turn_stats.session_id capture has rows — so
the chip stays hidden on older dbs (inert-until-capture).
stats.rs opens the sqlite db read-only and degrades to an
empty snapshot on any error — the page is decorative, never
authoritative.