hyperhive/docs/web-ui/agent.md

27 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. 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 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, reminders, pending inbox messages, and unread matrix notifications pending against this agent (GET /api/loose-ends); question rows carry an inline answer form that POSTs cross-origin to the core dashboard's POST /api/answer-question/{id} so the operator answers as operator (see docs/boundary.md). Pending inbox messages (✉ inbox — N pending message(s)) and unread matrix rooms appear informational only (not cancellable from the flyout — drain with recv / mark_read in-turn). 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 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 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).
  • /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 → 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-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

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