hyperhive/docs/web-ui/agent.md
iris 668ccc2278 agent: remove rebuild button, move dashboard link into the links menu
mara (#3704): 'remove rebuild button, move link to dashboards into
links menu.'

The overflow (⋯) menu existed for exactly two items: the dashboard
back-link and a rebuild-container action. Rebuild is gone outright —
the dashboard's own R3BU1LD button already covers it, this was just a
rarely-used shortcut not worth its own menu. The dashboard link moves
into MetaNav's links popover (now the first item, above stats/forge/
config/extras) instead. With both gone, OverflowMenu had nothing left
to justify existing as a separate component — deleted along with its
CSS and the now-unused rebuildAction.ts (only consumer).

MetaNav gained a dashboardBase prop (Root.tsx already computes this
via resolveDashboardBase for InboxPanel/pause — reused, not
duplicated) and renders the dashboard link as a real <a>, same
treatment as every other item in that popover — no dangling
window.open()-only affordance.

Updated docs/web-ui/agent.md's Header section and the couple of
now-stale OverflowMenu references in index.html's/MetaNav.css's own
comments.

Verified: header now shows a single trailing icon-badge (was two),
popover opens with dashboard first then the agent_links() set.
tsc --noEmit clean, build clean, both pre-push lints clean.
2026-08-28 23:40:56 +02:00

25 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: a header, scrollable main content, and a footer composer — plus a slide-in side panel for flyouts and long content.

Header

Preact component tree (Header.tsx + StatusChips.tsx + MetaNav.tsx + OverflowMenu.tsx + HeaderPill.tsx, wired together in Root.tsx) — see frontend/packages/agent/src/components/. This section describes the rendered result, not the DOM ids the pre-Preact page used (there are none any more — every element is component output, not something a selector reaches by id).

Fixed-overlay header (<header class="agent-header">): frosted glass — backdrop-filter: blur lets scrolled terminal rows show through. Measures its own rendered height via ResizeObserver (Header.tsx) and writes it to a CSS custom property the content below reads for its offset — a fixed 6em guess used to be baked into agent.css, which silently broke (content overlapping the header) the moment any row of badges/pills wrapped onto an extra line at some viewport width; measuring instead of guessing closes that bug class structurally rather than for one specific trigger. Two columns:

  • Agent icon (<img class="agent-icon">): fixed-size square identity anchor — width: 5em; height: 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. Falls back to the dimmed hyperhive mark (/favicon.svg) on load error (/icon 404s when the agent has no hyperhive.icon override — there is no server-side default image).
  • Main column (.agent-header-main, Header.tsx): two rows — title (◆ <label> ◆) and, when set, the "swarm / hive" identity line. Nothing else lives here; both rows are short, fixed-shape text that doesn't wrap in practice, so this column's height stays stable regardless of how many badges or pills are showing (a design correction mid-review — the first pass put the variable-width content here instead, which is exactly the kind of thing the ResizeObserver measurement above exists to catch even when a layout choice reintroduces it).
  • Pills cluster (.agent-header-pills, right-aligned): status badges (StatusChips.tsx) and flyout triggers together in one row — a deliberate choice (not the historical default) so the header reads as one identity zone + one status/actions zone rather than several separate clusters:
    • Alive badge: ● alive (green) / ⊘ rate limited (red) / ◌ needs login / ◌ logging in / ○ offline / … connecting.
    • State badge: 💤 idle / 🧠 thinking / 📦 compacting / ○ offline / … booting + age suffix.
    • Model badge (model · <name> ▾): a real picker — click opens a Dropdown of state.available_models, selecting one POSTs /api/model immediately (same endpoint the /model <name> slash command uses). No longer buried in the overflow menu — the design guide's own named anti-example (control disconnected from display) this rewrite exists to fix.
    • Effort badge (effort · <level> ▾): same shape, /api/effort, shown when state.available_efforts is non-empty.
    • Ctx / cost badges: ctx · 142k (last inference's prompt size, tooltip shows % of context window) / cost · 1.3M (cumulative tokens billed across every inference in the last turn).
    • Pause badge (⏸ pause / ▶ resume): toggles via the same hive-c0re-owned /api/pause/<name> / /api/resume/<name> endpoints the dashboard uses (pausing needs a write this unprivileged process can't make directly).
    • Inbox / todos pills (📬 inbox · N / 📋 todos · N): hidden when empty; click opens the matching flyout in the side panel.
    • Links badge (🔗): opens a popover listing ↑ dashboard (back-link to the host dashboard, ${dashboardBase}dashboard.html) followed by this agent's agent_links()-sourced navigation (stats, screen when VNC is enabled, forge profile + config-repo mirror when the agent has a forge account, any hyperhive.dashboardLinks extras) — all as real <a> elements, not a Dropdown-style command list, so ctrl/middle-click and "copy link address" keep working. Each AgentLink.kind resolves differently: container → same-origin path; forgestate.forge_public_url + url, and the link is dropped entirely when that's unset (never guessed from <host>:3000); external → already absolute. Same source (GET /api/state's links field) also feeds DashboardState.links for the dashboard card's icon strip — agent_links() in hive-agent is the single source of truth for both. There is no separate overflow () menu any more — it used to hold exactly this dashboard link plus a rebuild-container action (mara, hyperhive#3704: "remove rebuild button, move link to dashboards into links menu") — rebuild had no real discoverability need of its own (the dashboard's own R3BU1LD button already covers it) so it's gone outright, and the dashboard link moved here, leaving nothing to justify a separate menu. Everything else that used to live in the old overflow menu (model/effort pickers, new-session, logout) already had a better home before this: pickers are real badges above, and /new-session / /logout are typed slash commands with their own type-twice confirm (see below) — a modal doesn't fit a text-input flow, and burying rare-but-important actions in one flat menu was the design guide's own named anti-example.
    • No header cancel-turn button any more — /cancel (slash command, below) is the only path; the turn-loop state badge already shows thinking as the discoverability cue.

Values throughout come from GET /api/state's cold-load snapshot, kept in sync afterwards by the SSE stream (see Live view below) — context_window_tokens for the ctx badge tooltip, qualified_label (the hive-qualified name@domain form, used for the browser tab title so two tabs from different hives are distinguishable; the header's own ◆ <label> ◆ stays short).

Main content

Main content (<main class="agent-main">): fills the viewport and scrolls behind the fixed header + footer.

  • #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

Side panel (slide-in from right): singleton shared with the dashboard's side panel shape. Carries inbox and todos flyouts (opened via the header pills) as well as long content (file previews, diffs, journald logs). Inbox flyout: unread messages addressed to this agent (acked_at IS NULL, newest-first, up to 30); reply messages indented with ↳ reply · in amber. A ✓ mark all read button appears in the flyout header when the inbox is non-empty; clicking it confirms then POSTs cross-origin to the core dashboard's POST /api/agent/{name}/mark-all-read — all pending messages for this agent are acked, the harness won't receive wake-prompts for them. A { marked: N } pill surfaces the count. After the drain the inbox list empties on reload (the filter is acked_at IS NULL, so drained messages disappear). Todos flyout ("loose-ends v2"): the harness-local todos other subsystems push at this agent (GET /api/todos) — matrix, forge, and bash are the built-in producers, but any user-configured MCP server can push its own via the same in-agent socket. Each row shows the producing subsystem, an optional source label, a summary, and age. A checkbox per row plus a select-all / select-none / ✓ mark done bulk row above the list POSTs the checked ids to POST /api/todos/mark-done, which dismisses them from the harness-local store (same effect as cancel_loose_end(kind: "todo"), just from the web UI instead of the agent's own tool calls).

The todos flyout is the only per-agent flyout — there is no separate "loose-ends" or "tasks" list. There is also no inline answer form for ask tool calls in this terminal: an ask renders like any other tool call, and the operator answers a pending question from the dashboard's Y3R C4LL tab instead (see terminal-rendering.md).

Live view

Each agent runs an events::Bus: a tokio::sync::broadcast<LiveEvent> plus a sqlite-backed history at /state/hyperhive-events.sqlite. The harness emits TurnStart { from, body, unread }, Stream(value) (one per parsed stream-json line), Note, TurnEnd { ok, note }. Each event also carries a ts (unix seconds) — a flattened sibling of the event tag on both the live SSE frame and the replayed history rows. The web UI:

  • fetches GET /events/history on page load and replays the last 2000 events (oldest first);
  • then subscribes to GET /events/stream (SSE) for live tail;
  • shows a granular state badge above the terminal, driven authoritatively from /api/state.turn_state. SSE turn_start / turn_end still flip the badge instantly between renders;
  • terminal-themed: phosphor mauve glow, Crust bg, backdrop-filter blur, row fade-in slide-up.

The backfill/live-tail dedupe, sticky-bottom auto-scroll, and "↓ N new" pill are the shared terminal-pane mechanics described in Shape — this page's log is one instance of that same factory.

Per-stream rendering (see docs/terminal-rendering.md for the full row taxonomy and dispatch logic):

  • 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), 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. ⚠️ Deleting the whole ~/.claude/ directory instead breaks session continuity — logout must stay narrowed to just the credential files.
    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 the frontend (useAgentState.ts) on page load and, on a poll, 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/todos — harness-local todos snapshot (loose-ends v2) consumed by the todos flyout (refreshTodos / buildTodosList). See the todos-flyout paragraph above for the producer/subsystem model and the bulk mark-done row.

  • GET /api/stats?window=24h|7d|30d|all — time-bucketed turn 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 404 — there is no server-side default. Consumers (dashboard container row, this page's own header icon) hit /icon optimistically and fall back client-side on load failure to the frontend-bundled /favicon.svg rather than probing first.

  • GET /events/history — replay buffer for the terminal.

  • GET /screen — VNC viewer page (minimal RFB-over-WebSocket renderer — deliberately thin, just enough to display the desktop + forward pointer + keyboard. A production-grade viewer would vendor noVNC; this file ships the minimal in-tree variant). Only accessible 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.