Per damocles (capture-side owner): "argv[0] basename" undersells the normalisation and misleads — the head is the basename of the first *real* command, looking past `cd repo &&` prefixes, env-assignments, and prefix-runners (sudo/env), so `cd /repo && cargo build` records `cargo`, not `cd`. Match the persistence.md phrasing.
393 lines
21 KiB
Markdown
393 lines
21 KiB
Markdown
# 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.
|
||
- **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.
|
||
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 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/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:
|
||
|
||
- `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.
|
||
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
||
`<details>` `▸ ← Nl · headline` (click to expand full body).
|
||
- `Stream` `thinking` → text content if claude provided one,
|
||
otherwise the bare `· thinking …` indicator.
|
||
- `Stream` `system init`, `result`, `rate_limit_event` are
|
||
dropped — too noisy.
|
||
- `Note` → `· text`.
|
||
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, triggers a
|
||
`refreshState()`.
|
||
|
||
## 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` → host spawns
|
||
`turn::compact_session` in the background; output streams into
|
||
the live panel.
|
||
- `/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.
|
||
- `/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
|
||
|
||
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 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/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`, `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/stats?window=24h|7d|30d` — time-bucketed turn analytics
|
||
`Snapshot` consumed by the `/stats` page.
|
||
- `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. Toolbar: `⤢ 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
|
||
`/etc/hyperhive/gui.json` (written by the weston startup
|
||
script in `weston-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_limited` is 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 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). 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.
|