mara: 'i merged this without realizing you still did not delete the old code. pls follow up with ripping out the old code.' - index.html: dropped the old static header/main/composer/overflow- menu markup and app.js's <script> tag. Now just <div id=preact-root> + <script src=static/main.js>. main.css's <link> lands after agent.css's — cascade order argus flagged matters for LoginFlow's .login-card / MetaNav's popover to win against agent.css's legacy rules, confirmed explicitly rather than assumed at this exact step. - build.mjs: app.js dropped from the esbuild entryPoints (stats.js keeps its own bundle, unaffected — separate page, separate script). - frontend/packages/agent/src/app.js deleted (1717 lines). screen.html has its own inline <script>, untouched — never depended on app.js. - docs/web-ui/agent.md: rewrote the Header section to describe the real Preact component tree and the badges+pills-together layout (deferred from the earlier commits on this PR specifically so it wouldn't describe a hybrid state — this is that promised follow-up). Touched up the one other app.js-specific mention in the endpoints section. Left the Main/composer/side-panel/live-view/slash-command sections alone — behavior there is unchanged, verified faithfully ported throughout this PR's earlier commits. Verified against the REAL dist/index.html (not the dev-preview harness) — a scratch mock server serving the actual built output end-to-end, screenshotted clean. tsc --noEmit clean, build clean, both pre-push lints clean.
25 KiB
Per-agent page
Part of Web UI. See also: Shape (shared) · Dashboard layout
Three fixed-position layers frame a full-viewport terminal: a header, scrollable main content, and a footer composer — plus a slide-in side panel for flyouts and long content.
Header
Preact component tree (Header.tsx + StatusChips.tsx +
MetaNav.tsx + OverflowMenu.tsx + HeaderPill.tsx, wired together
in Root.tsx) — see frontend/packages/agent/src/components/. This
section describes the rendered result, not the DOM ids the pre-Preact
page used (there are none any more — every element is component
output, not something a selector reaches by id).
Fixed-overlay header (<header class="agent-header">): frosted
glass — backdrop-filter: blur lets scrolled terminal rows show
through. Measures its own rendered height via ResizeObserver
(Header.tsx) and writes it to a CSS custom property the content
below reads for its offset — a fixed 6em guess used to be baked into
agent.css, which silently broke (content overlapping the header) the
moment any row of badges/pills wrapped onto an extra line at some
viewport width; measuring instead of guessing closes that bug class
structurally rather than for one specific trigger. Two 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. Falls back to the dimmed hyperhive mark (/favicon.svg) on load error (/icon404s when the agent has nohyperhive.iconoverride — there is no server-side default image). - Main column (
.agent-header-main,Header.tsx): two rows — title (◆ <label> ◆) and, when set, the "swarm / hive" identity line. Nothing else lives here; both rows are short, fixed-shape text that doesn't wrap in practice, so this column's height stays stable regardless of how many badges or pills are showing (a design correction mid-review — the first pass put the variable-width content here instead, which is exactly the kind of thing theResizeObservermeasurement above exists to catch even when a layout choice reintroduces it). - Pills cluster (
.agent-header-pills, right-aligned): status badges (StatusChips.tsx) and flyout triggers together in one row — a deliberate choice (not the historical default) so the header reads as one identity zone + one status/actions zone rather than several separate clusters:- Alive badge:
● alive(green) /⊘ rate limited(red) /◌ needs login/◌ logging in/○ offline/… connecting. - State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting+ age suffix. - Model badge (
model · <name> ▾): a real picker — click opens aDropdownofstate.available_models, selecting one POSTs/api/modelimmediately (same endpoint the/model <name>slash command uses). No longer buried in the overflow menu — the design guide's own named anti-example (control disconnected from display) this rewrite exists to fix. - Effort badge (
effort · <level> ▾): same shape,/api/effort, shown whenstate.available_effortsis non-empty. - Ctx / cost badges:
ctx · 142k(last inference's prompt size, tooltip shows % of context window) /cost · 1.3M(cumulative tokens billed across every inference in the last turn). - Pause badge (
⏸ pause/▶ resume): toggles via the same hive-c0re-owned/api/pause/<name>//api/resume/<name>endpoints the dashboard uses (pausing needs a write this unprivileged process can't make directly). - Inbox / todos pills (
📬 inbox · N/📋 todos · N): hidden when empty; click opens the matching flyout in the side panel. - Links badge (
🔗): opens a popover listing this agent'sagent_links()-sourced navigation (stats, screen when VNC is enabled, forge profile + config-repo mirror when the agent has a forge account, anyhyperhive.dashboardLinksextras) as real<a>elements — not aDropdown-style command list, so ctrl/middle-click and "copy link address" keep working. EachAgentLink.kindresolves differently:container→ same-origin path;forge→state.forge_public_url + url, and the link is dropped entirely when that's unset (never guessed from<host>:3000);external→ already absolute. Same source (GET /api/state'slinksfield) also feedsDashboardState.linksfor the dashboard card's icon strip —agent_links()in hive-agent is the single source of truth for both. - Overflow badge (
⋯): aDropdownwith two rows —↑ dashboard(link) and↻ rebuild container(select-twice confirm, same action as the dashboard R3BU1LD button). Everything else that used to live here (model/effort pickers, new-session, logout) has a better home now: pickers are real badges above, and/new-session//logoutare typed slash commands with their own type-twice confirm (see below) — a modal doesn't fit a text-input flow, and burying rare-but-important actions in one flat menu was the design guide's own named anti-example. - No header cancel-turn button any more —
/cancel(slash command, below) is the only path; the turn-loop state badge already showsthinkingas the discoverability cue.
- Alive badge:
Values throughout come from GET /api/state's cold-load snapshot,
kept in sync afterwards by the SSE stream (see Live view below) —
context_window_tokens for the ctx badge tooltip, qualified_label
(the hive-qualified name@domain form, used for the browser tab title
so two tabs from different hives are distinguishable; the header's own
◆ <label> ◆ stays short).
Main content
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.
Footer / composer
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
Side panel (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and todos 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). Todos flyout ("loose-ends v2"): the harness-local
todos other subsystems push at this agent (GET /api/todos) — matrix,
forge, and bash are the built-in producers, but any user-configured MCP
server can push its own via the same in-agent socket. Each row shows the
producing subsystem, an
optional source label, a summary, and age. A checkbox per row plus a
select-all / select-none / ✓ mark done bulk row above the list POSTs
the checked ids to POST /api/todos/mark-done, which dismisses them
from the harness-local store (same effect as cancel_loose_end(kind: "todo"), just from the web UI instead of the agent's own tool calls).
The todos flyout is the only per-agent flyout — there is no separate
"loose-ends" or "tasks" list. There is also no inline answer form for
ask tool calls in this terminal: an ask renders like any other
tool call, and the operator answers a pending question from the
dashboard's Y3R C4LL tab instead (see
terminal-rendering.md).
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); - 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; - terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up.
The backfill/live-tail dedupe, sticky-bottom auto-scroll, and "↓ N new" pill are the shared terminal-pane mechanics described in Shape — this page's log is one instance of that same factory.
Per-stream rendering (see docs/terminal-rendering.md for
the full row taxonomy and dispatch logic):
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 with per-tool salient-arg extraction (fmtToolUse).
Streamtool_resultshort → flat← ...; long → collapsed<details>▸ ← Nl · headline(click to expand full body).Streamthinking→.thinkingrow with a💭 thinking …indicator.Streamsystem→ handled by subtype:plugin_installandcompact_boundaryemit muted notes;commands_changedemits an expandable details row listing slash commands;thinking_tokensupdates a single in-place🧠counter;init,result, andrate_limit_eventare dropped (noise / used elsewhere); other subtypes → muted⚙ <subtype>note.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→ sets the deferredBus::request_compact()flag and returns immediately. The harness runs the/compactat the next turn boundary (end of the in-flight turn, orturn::run_pending_compactwhen idle); output streams into the live panel. Works mid-turn, not only when idle./model <name>—POST /api/modelflippingBus::set_model. Takes effect on the next turn; persisted to/state/hyperhive-modelso the override survives harness restart / rebuild./effort <level>—POST /api/effortsetting the claude effort level (low/medium/high/xhigh/max). Takes effect on the next turn. The overflow menu surfaces an effort picker that calls the same endpoint; both stay in sync viaStateSnapshot.effort./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
Successful POSTs return 200 (no 303 redirects). Error responses
use semantic status codes: 400 for missing/invalid input
(body required, unknown model name, invalid effort level),
409 for retryable state conflicts (turn in flight when
/compact is called), 500 only for genuine server/transport
failures. 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/effort(effort=<level>) — switch the claude effort level for future sessions. Validated server-side againstEFFORT_LEVELS(low/medium/high/xhigh/max) — unknown values are rejected rather than forwarded toclaude --effort. Persists viaBus::set_effort, which emitsEffortChanged. Applies on the next session start (no mid-session swap). -
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. ⚠️ Deleting the whole~/.claude/directory instead breaks session continuity — logout must stay narrowed to just the credential files. - 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 by the frontend (useAgentState.ts) on page load and, on a poll, whilestatus === 'needs_login_in_progress'. Includesturn_state,context_window_tokens,qualified_label,available_models,effort,available_efforts,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/todos— harness-local todos snapshot (loose-ends v2) consumed by the todos flyout (refreshTodos/buildTodosList). See the todos-flyout paragraph above for the producer/subsystem model and the bulk mark-done row. -
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 /icon— agent's icon asimage/svg+xml. Returns/etc/hyperhive/icon.svg(set viahyperhive.iconinagent.nix) when present, otherwise 404 — there is no server-side default. Consumers (dashboard container row, this page's own header icon) hit/iconoptimistically and fall back client-side on load failure to the frontend-bundled/favicon.svgrather than probing first. -
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). -
GET|POST /extra/<name>/…— extra web proxies declared viahyperhive.extraWebProxiesinagent.nix(serialised to theHIVE_EXTRA_WEB_PROXIESenv var as a JSON object{"<name>": "<upstream_url>"}). Each entry mounts a transparent reverse-proxy at/extra/<name>/that forwards every request (method, headers, body) to the configured upstream, strips hop-by-hop headers on both sides, and buffers the full response body (MVP — SSE connections will appear as one large response rather than streaming). The/extra/namespace ensures user-declared proxies can never conflict with native agent endpoints. Upstream values are either anhttp(s)://URL (forwarded viareqwest) or a Unix domain socket, spelledunix:<path>(e.g.unix:/run/myapp/http.sock) — dialed directly with a raw HTTP/1.1 client per request, sincereqwesthas no UDS transport. Implemented inweb_ui/proxy.rs.
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.effort_changed { effort }— drives the effort picker chip. Emitted fromBus::set_effortonPOST /api/effort; applies on the next session start.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-daemon 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/stats.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.