hyperhive/docs/web-ui/agent.md
iris da3fc9bf95 move terminal-verbosity toggle from agent overflow menu to /settings.html
Per mara: 'i wanted you to put this in .../settings.html' — the toggle
belongs with the other operator-local browser preferences, not buried
in each agent's own overflow menu.

Extracted the get/set + localStorage key into @hive/shared/prefs.js so
settings.html (writer) and every per-agent app.js (reader, via
HiveTerminal.create's expandDetails option) agree on the exact same key
without two independently-typed copies that could drift. Removed the
now-unused overflow-menu toggle + its agent.css rules from the agent
page. Docs moved from docs/web-ui/agent.md's overflow-button section to
docs/web-ui/dashboard.md's S3TT1NGS section, next to the existing
browser-notifications preference.
2026-08-02 18:34:40 +02:00

489 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Per-agent page
> Part of [Web UI](../web-ui.md). See also:
> [Shape (shared)](shape.md) · [Dashboard layout](dashboard.md)
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: 5em` with explicit pixel
sizing so the `<img>`'s intrinsic (large) dimensions don't push
the parent flex container open via `align-items: stretch`-driven
height feedback. 5em ≈ header content area (header `min-height: 6em`
minus `2 × 0.5em` padding). `align-self: flex-start` keeps 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-supplied `StateSnapshot.links` as icon-only anchors — always
`📊 stats` (`kind = Container`); `🖥 screen` when VNC is enabled;
`⬡ forge` (profile) + `↳ config` (agent-configs mirror) when the
agent has a forge account; any `hyperhive.dashboardLinks` extras
(`kind = External`). A `↑ dashboard` link is prepended by the JS
so the host dashboard is one click away. Links come from
`StateSnapshot.links` (served by `GET /api/state`); the same set
also appears in `DashboardState.links` (`GET /api/dashboard-state`)
for the dashboard card's icon strip. Both are produced by
`agent_links()` in hive-ag3nt — the single source of truth.
Each `NavLink.kind` resolves
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 via `el()` — agent-declared icon /
label / url strings never reach `innerHTML` (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 by `LiveEvent::StatusChanged`.
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
`○ offline` / `… booting` + age suffix. Driven by
`LiveEvent::TurnStateChanged ({ state, since_unix })`.
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
- Ctx badge: `ctx · 142k` — last inference's prompt size.
Tooltip shows % of window when `context_window_tokens` is 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`.
- **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-container `bash-tasks/` dir). Unlike
loose-ends (refreshed on turn_end), tasks start + finish asynchronously
between turns and `/api/state` isn'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 to `needs_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 labelled
`model` renders one button per model in the operator-configured
list. The list is driven by `state.available_models` (sourced from
the `HIVE_AVAILABLE_MODELS` env var, injected by the
`services.hyperhive.availableModels` NixOS 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/model` with the alias (same path as
the `/model <name>` slash command). The button for the
currently-active model is highlighted via the `active` class;
`renderModelChip` keeps the picker state in sync with live
`model_changed` events so it stays accurate when the model is
changed from another session. Clicking the already-active model
closes the menu without an extra POST.
A second separator + **effort quick-picker** section labelled
`effort` appears when the backend declares effort levels in
`state.available_efforts`. One button per level (e.g. `low`,
`medium`, `high`, `xhigh`, `max`); clicking POSTs `/api/effort` (same endpoint as
the `/effort <level>` slash command). The active level's button is
highlighted; `renderEffortChip` keeps the picker in sync with
`StateSnapshot.effort` from the cold-load snapshot.
The popover's display rules are scoped to `:not([hidden])` so the
`[hidden]` HTML attribute's UA `display: none` isn't overridden by
the author CSS's `display: flex` — the popover stays hidden until
JS removes the attribute.
`/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.
- `#status` overlay: empty when online; shows the login form / OAuth
URL when `status` is `needs_login_*`. The OAuth code input is
`type="password"` with a `👁 reveal` toggle that flips it back to
`text` on 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 ignore `autocomplete="off"` on `type="password"`, but
`one-time-code` is 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 new` pill when not at bottom). The pill is **anchored in
`.agent-main`**, not in `log.parentElement = .terminal-wrap`:
`.terminal-wrap` applies `backdrop-filter: blur` for the frost
effect, which creates a CSS stacking context — anchoring the
pill inside that context would trap its `z-index` below the
fixed composer in the root stacking context, and it'd never
float. `.agent-main` has no backdrop-filter (no stacking-context
creators), so the pill's `z-index` reaches the root and properly
composites above the composer. Geometry is unchanged —
`.agent-main` and `.terminal-wrap` both `inset: 0` fill 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 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 (matrix, forge, bash) push at this agent
(`GET /api/todos`) — 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).
Older per-agent flyouts this doc used to describe (a "loose-ends"
list of questions/approvals/reminders backed by a since-removed
`GET /api/loose-ends`, and a read-only "tasks" list of in-flight bash
tasks backed by a since-removed `GET /api/bash-tasks`) no longer
exist — todos superseded both. One open gap from that migration:
the "Ask → operator inline-answer binding" described below relies on
that removed `/api/loose-ends` endpoint and is currently non-functional
(hyperhive#2922) — the operator can still answer a pending question via
the main dashboard's own question surfacing, just not inline in this
agent's terminal.
**Ask → operator inline-answer binding** (currently non-functional,
see the todos-flyout paragraph above + hyperhive#2922 — described here
for the intended design, which is what a fix would restore). 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 `POST /api/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/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 (see [`docs/terminal-rendering.md`](../terminal-rendering.md) for
the full row taxonomy and dispatch logic):
- `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 with
per-tool salient-arg extraction (`fmtToolUse`).
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
`<details>` `▸ ← Nl · headline` (click to expand full body).
- `Stream` `thinking` → `.thinking` row with a `💭 thinking …`
indicator.
- `Stream` `system` → handled by subtype: `plugin_install` and
`compact_boundary` emit muted notes; `commands_changed` emits an
expandable details row listing slash commands; `thinking_tokens`
updates a single in-place `🧠` counter; `init`, `result`, and
`rate_limit_event` are dropped (noise / used elsewhere); other
subtypes → muted `⚙ <subtype>` note.
- `Note` → `· text`.
- `TurnStart` → `◆ TURN ← <from>` with the wake-prompt body; a
muted `· HH:MM:SS` time suffix from the event `ts`.
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, with a
`· HH:MM:SS · <duration>` suffix (duration = end the paired
turn-start), triggers a `refreshState()`. The time suffix is
guarded on a numeric `ts`, 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 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` → sets the deferred
`Bus::request_compact()` flag and returns immediately. The harness
runs the `/compact` at the next turn boundary (end of the in-flight
turn, or `turn::run_pending_compact` when idle); output streams into
the live panel. Works mid-turn, not only when idle.
- `/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.
- `/effort <level>` — `POST /api/effort` setting 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 via `StateSnapshot.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 in `needs_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, hive-c0re busy on `GET /api/loose-ends`),
**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 emit `LiveEvent::StatusChanged` to flip the
badge to/from `needs_login_in_progress`.
- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a
`LiveEvent::Note`.
- `POST /api/compact` — run `/compact` on the persistent session
(same MCP config + system prompt + allowed tools as a normal
turn — only the stdin payload differs). Flips state to
`Compacting` via `Bus::set_state`, which emits
`TurnStateChanged`.
- `POST /api/model` (`model=<name>`) — switch the model for
future turns. `Bus::set_model` emits `ModelChanged`.
- `POST /api/effort` (`effort=<level>`) — switch the claude
effort level for future sessions. Validated server-side against
`EFFORT_LEVELS` (`low`/`medium`/`high`/`xhigh`/`max`) — unknown values are
rejected rather than forwarded to `claude --effort`. Persists via
`Bus::set_effort`, which emits `EffortChanged`. 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 a `LiveEvent::Note`.
- `POST /api/logout` — three-step teardown that re-uses the
existing `wait_for_login` resumption path:
1. 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.
2. Delete only the OAuth credential files
(`.credentials.json` + `mcp-needs-auth-cache.json` under
`~/.claude/`). **Preserves** session history files
(`projects/<hash>/*.jsonl`), sessions, shell-snapshots,
plans, settings, telemetry, and the dir itself, so
`claude --continue` keeps working after a fresh login.
Wholesale `remove_dir_all` of `~/.claude/` was the previous
shape and broke session continuity; the narrowed allow-list
is the fix.
3. Flip `LoginState::NeedsLogin` + emit a `LiveEvent::Note`
describing exactly what was wiped, then emit
`needs_login_idle`. The turn-loop's next iteration parks
into `wait_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/code`
flow (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.
- `GET /api/state` — cold-load snapshot (`StateSnapshot`) consumed by
`app.js` on page load and while `status === 'needs_login_in_progress'`.
Includes `turn_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/loose-ends` — loose-ends snapshot consumed by the inbox
flyout (`renderLooseEnds`). Returns pending questions the agent asked
or owes, plus pending reminders. Also calls `reconcileAskBinds()` to
wire inline answer forms to open `question_asked` events.
- `GET /api/bash-tasks` — in-flight bash-task snapshot; returns only
`Pending` and `Running` tasks (completed/killed tasks are excluded).
Consumed by the running-bash-tasks flyout.
- `GET /api/stats?window=24h|7d|30d|all` — time-bucketed turn analytics
`Snapshot` consumed by the `/stats` page. `all` ranges from the
earliest recorded turn with an adaptive bucket width.
- `GET /icon` — agent's icon as `image/svg+xml`. Returns
`/etc/hyperhive/icon.svg` (set via `hyperhive.icon` in `agent.nix`)
when present, otherwise the bundled default hyperhive logo. Always
returns an image — consumers (dashboard container row, per-agent
favicon) can hit `/icon` unconditionally without probing for a
custom config.
- `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 when `hyperhive.gui.enable = true` in the agent's
`agent.nix`; the harness shows a 🖥 screen link in the state row
when `gui_vnc_port` is present. A `← agent` back-link in the page
header returns to the main per-agent page. Controls: `⤢ fit` CSS-downscales
the canvas to the window via `relayoutCanvas()` setting explicit
pixel dimensions on the canvas — *not* CSS `max-width/max-height`,
because a flex item's automatic minimum size (`min-width: auto`
resolves to the canvas's intrinsic framebuffer resolution) silently
clamps `max-*` back up, making fit mode a no-op that just centred +
clipped the oversized canvas. The fit-mode rules pin the canvas
with `flex: none; min-width: 0; min-height: 0` so the JS-set size
sticks. `⤡ match size` sends an RFB `SetDesktopSize` request so the
server (weston) changes its real output resolution to the window
dimensions; enabled once the server advertises the
`ExtendedDesktopSize` pseudo-encoding (`-308` rect in the header).
Fit-mode state persists in `localStorage` (`screen-fit`); default
is on. Pointer coordinates are rescaled in `sendPointer` so 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 at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from the
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
- `GET|POST /extra/<name>/…` — **extra web proxies** declared via
`hyperhive.extraWebProxies` in `agent.nix` (serialised to the
`HIVE_EXTRA_WEB_PROXIES` env 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 an
`http(s)://` URL (forwarded via `reqwest`) or a Unix domain socket,
spelled `unix:<path>` (e.g. `unix:/run/myapp/http.sock`) — dialed
directly with a raw HTTP/1.1 client per request, since `reqwest` has
no UDS transport. Implemented in `web_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_limited` is 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 from `Bus::set_effort` on `POST /api/effort`; applies on
the next session start.
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
— drives the ctx + cost badges. Emitted from
`Bus::record_turn_usage` at turn-end; `ctx` is the last
inference's usage (current context size), `cost` is the
cumulative across every inference (the `result` event'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.