hyperhive/docs/web-ui/agent.md
iris 2c317c1106 docs(web-ui/agent): update inbox flyout to reflect unread-only filter
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.
2026-07-02 14:15:24 +02:00

23 KiB
Raw Blame History

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: 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); Forgehttp://<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. 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: 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/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.
  • 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).
  • /cancelPOST /api/cancel → host shellouts pkill -INT claude, emits a Note. Also surfaces as a ■ cancel turn button in the state row while state=thinking.
  • /compactPOST /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-sessionPOST /api/new-session (confirms first). Arms a one-shot on the Bus; next turn runs without --continue, dropping the resume session entirely.
  • /logoutPOST /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|all — time-bucketed turn analytics Snapshot consumed by the /stats page. all ranges 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 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).

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). 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.