14 issue-ref cookies removed from docs/web-ui.md. Most were attribution refs to closed issues that landed the feature being described — the prose around them already describes the current behaviour, the cookie was just a 'this was issue NNN' breadcrumb. Scrubbed: - #753 (manager port hash 8100-8999) - #233 (linkify XSS) - #448 (SSE multiplexing SharedWorker) - #515 (worker-death self-heal) - #451 (side-panel drag-to-resize) - #635 (matrix.gui.enable defaults) - #609 + #15 (gateway re-root) → 'lives in docs/gateway.md' - #66 (ctx badge thresholds) - #541 (journald panel-body column-flex) - #447 (rebuild-queue cancel) - #559 (mark all read) - #474 (schedules PATCH partial edit) - #467 (schedules fire-now) - #589 (hive-qualified label phase A) docs/web-ui.md: 14 → 0 issue-ref cookies. Self-contained read.
63 KiB
Web UI
Two web surfaces share the same skeleton: the dashboard (port 7000)
and the per-agent UIs (every container — including the manager —
hashes into :8100-8999 via lifecycle::agent_web_port's FNV-1a).
Both are SPAs — GET / returns a static shell, /api/state
returns JSON, JS renders. No full-page reloads.
Shape (shared by both)
GET /→index.htmlfrom the bundled frontend dist (seefrontend/). Both binaries' routers declare their dynamic endpoints first and thenfallback_service(ServeDir::new(...))pointed atHIVE_STATIC_DIR— anything not matched by an API or action route is served from the dist. Dashboard dist lives at${frontend}/dashboard; per-agent dist is the mergedhyperhive.frontend.mergedDist(default agent dist + per-agentextraFilesoverlay).GET /static/*→ bundled CSS + JS produced by esbuild (frontend/packages/{dashboard,agent}/build.mjs). Both pages pull the shared terminal pane + Catppuccin palette + typography from@hive/shared(washive-fr0nt); the CSS bundle inlinesbase.css+terminal.cssvia esbuild's@importresolution.terminal.jsexports{ create, linkify }as ES module members (no morewindow.HiveTerminalglobal outside the back-compat shim the IIFE bodies still use). The dashboard's#msgflowand the per-agent#livelog are both backed by this terminal — sticky-bottom auto-scroll, "↓ N new" pill, history backfill, SSE plumbing all live there. Each page registers a kind→renderer map; unknown kinds fall through to a JSON-dump note row. Barehttp(s)://URLs in row text are turned into clickable new-tab links bylinkify(text-node based, noinnerHTML— XSS-safe); markdown bodies get the same treatment viamarked's autolink (npm dep, replacing the vendored UMD bundle), with the rendered<a>s rewritten totarget="_blank".GET /api/state→ JSON snapshot the JS app renders into the DOM. Includes a top-levelseq(the dashboard event channel's high-water mark at the moment the snapshot was assembled); clients use it to dedupe their buffered SSE traffic against the snapshot (drop frames withseq <= snapshot.seq).GET /dashboard/stream(dashboard) /GET /events/stream(per-agent) →text/event-streamSSE for live updates. The dashboard stream carries brokerSent/Delivered(mirrored by a forwarder task from the broker's intra-process channel) plus mutation events (approval_added/approval_resolved,question_added/question_resolved,transient_set/transient_cleared). Each frame carries aseq. The matching backfill endpoint isGET /dashboard/history(last ~200 broker messages wrapped in{ seq, events }) on the dashboard andGET /events/history(last 2000LiveEvents also wrapped in{ seq, events }) on the agent. SSE multiplexing: the dashboard uses aSharedWorker(stream-worker.js) to hold one upstreamEventSourceper URL. All same-origin tabs share this worker — a second dashboard tab joins the existing connection rather than opening a duplicate. The worker fans SSE events out to each subscribed tab viaMessagePort; on bfcache restore the page re-subscribes (gets a syntheticopenevent immediately if the upstream is already connected). Falls back gracefully whenSharedWorkeris unavailable (e.g. some private-mode browsers). Worker-death self-heal: Firefox kills "idle" SharedWorkers under memory pressure with no client-side signal — the port silently goes no-op. The worker now pings every connected port every 30s; the client bumps a last-activity timestamp on every message (incl. pings, which carry no URL — bumped before the URL filter). A visibility-gated watchdog polls every 15s and, if the page is visible AND has active subs AND hasn't heard from the worker in >90s (three missed pings), presumes the worker dead and re-subscribes on a freshSharedWorkerport (same code path bfcache-restore uses). Recovery is per-tab; pings are invisible on the healthy path.
Shared terminal pane
Both surfaces' scrollable log streams (#msgflow on the dashboard,
#live on the per-agent page) are backed by the shared terminal
factory in @hive/shared/terminal.js. The factory wires up
sticky-bottom auto-scroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a kind → renderer map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row
shapes the renderers call:
api.row(cls, text)— single-line row with an inlinelinkifypass over the text.api.details(cls, summary, body)— collapsible<details>with a<pre>body (used by long tool-results and stack traces).api.detailsDiff(cls, summary, body)— same shape, splits the body on newlines and tags each line asdiff-add/diff-del/diff-ctxso the renderer's diff bodies get coloured without emitting raw HTML.
Sticky-bottom + snap animation. stickToBottom is the
operator's intent: true means "keep snapping to bottom on every
mutation", false means "I scrolled up, leave me alone". The flag
flips when a scroll event lands further than NEAR_BOTTOM_PX = 48
from the bottom. New rows then either snap to bottom (when sticky)
or bump the unseen-count and surface the "↓ N new" pill. The snap
is a brief 140ms ease-out (SCROLL_ANIM_MS) — the browser's
default behavior: 'smooth' ~500ms reads as "still smooth, but
visibly slow"; 140ms feels snap-y while still reading as motion
rather than a jump. Distances under SCROLL_SNAP_PX = 24
short-circuit to instant — animating a 12px nudge would just be
jitter. Each new snap cancels the previous requestAnimationFrame
so a burst of mutations coalesces into one ride to the latest
bottom; the per-frame step re-reads scrollHeight - clientHeight
so mutations landing mid-animation extend the destination smoothly
rather than land short.
Mid-animation scroll-event guard. The scroll handler's
isNearBottom check would flip stickToBottom false mid-snap as
the smooth animation eases through positions that are technically
"not near bottom yet", which would strand the operator partway. A
smoothScrollingUntil timestamp gates the scroll handler — set to
the animation end + ~80ms headroom, re-armed on each fresh snap.
Programmatic scrollTop writes (the animation's per-frame update)
fire scroll events that the gate swallows.
Post-append MutationObserver. Renderers commonly call
api.row(cls, text) to create the row shell then append more
children (badges, multi-line bodies, tool result panes) after the
factory returned. The initial sticky-snap fires off the row's
empty shape; the renderer's later appends grow the row past the
visible bottom. A MutationObserver on the log subtree fires once
per microtask after each batch of synchronous mutations and snaps
again when stickToBottom is true. Programmatic scrollTop
writes don't re-trigger the observer (scroll isn't a DOM
mutation), so no feedback loop. The pre-append
nearBottomBeforeAppend snapshot is still useful — it keeps the
initial visual lag to one frame instead of one microtask + frame.
Backfill + SSE. Cold load fetches historyUrl (replay), then
subscribes to streamUrl (live tail). Both endpoints return
{ seq, events } so the client can dedupe — events with
seq <= snapshot.seq from the SSE stream are dropped silently
(the snapshot already covers them). History rows render with a
.no-anim class so they don't stagger in like live events. The
optional streamFactory(url) callback lets the dashboard hand
the factory a SharedWorker-backed EventSource facade (so
multiple tabs share one upstream connection — see SSE
multiplexing above); when omitted, the factory falls back to a
plain new EventSource(url).
linkify (text-node based). Bare http(s):// URLs in row
text get wrapped in <a target="_blank" rel="noopener noreferrer">
inside a fresh text node, so the autolinker never touches
innerHTML and untrusted row content can't smuggle markup. The
trailing-punctuation strip keeps .,;: outside the link surface.
Markdown bodies go through marked separately and get the same
target rewrite.
The JS app handles all form[data-async] submissions via a delegated
listener: read data-confirm, swap the button to a spinner, POST
application/x-www-form-urlencoded, re-enable the button on success
(refreshState may keep the form mounted, so we don't rely on a
re-render), call refreshState(). State shapes live in
dashboard.rs::StateSnapshot and web_ui.rs::StateSnapshot — when
adding state fields, plumb through the snapshot struct and the
relevant assets/tabs.js render function.
Focus preservation: refreshState checks whether
document.activeElement sits inside one of the managed sections
and, if so, skips the refresh (defers 2s). The operator never has
the form yanked out from under them mid-type; the update lands as
soon as they blur.
Atomic section repaint: every managed-section renderer goes
through paintAtomic(liveRoot, build): the builder appends into
a fresh DocumentFragment (off-DOM) and the commit is one
replaceChildren call. The naive root.innerHTML = ''; root.append(...)
shape was visibly flashing empty on every poll cycle — on async
paths the await yield gave the browser a paint opportunity between
the clear and the re-append, and on complex builds (many el()
allocations) layout could escape the per-task budget even on the
synchronous path. The fragment approach keeps the intermediate
empty state invisible. Builders receive the fragment as their
root, so existing renderer code carries over unchanged; early
returns inside the builder still commit whatever was appended
before they returned.
<details> open-state preservation: any collapsible element
tagged with data-restore-key="<stable-key>" survives the
refresh. snapshotOpenDetails() walks managed sections before
render, restoreOpenDetails() re-applies after. Long-content
drill-ins (file previews, diffs, journald logs) now open in the
side panel (see below) rather than expanding inline, so the
only restore-keyed <details> left is the answered-questions
history list.
Side panel (dashboard): long content opens in a drawer that
swipes in from the right — a singleton #side-panel with a
titled header, a close button, and a scrollable body. Closes on
the button, a backdrop click, or Escape. Panel.open(title, node) swaps the body; the JS builders for file previews,
approval diffs, and journald logs all render into it. The
drawer width is drag-to-resize: a thin 6px hit-strip on
the left edge captures pointer events, resizes the drawer in
real-time (pointer capture keeps dragging even if the cursor
outpaces the handle), and persists the chosen width to
localStorage (key hyperhive:side-panel-width) so it
survives page reload. Width is clamped to CSS min-width: 320px
/ max-width: 96vw; the viewport-resize handler re-clamps
persisted values after a window shrink. File
previews are type-aware:
- Markdown (
.md/.markdown) — arendered/plaintabbed view:rendered(default) is the vendoredmarkedbundle (GET /static/marked.js),plainis the raw source. - SVG (
.svg) — arendered/sourcetabbed view;renderedshows the image via an<img>data:URI (the browser's secure static mode, so an untrusted SVG can't run scripts),sourceshows the raw markup. - Raster images (
.png/.jpg/.gif/.webp/.bmp/.ico/.avif) — render as an<img>pointed at/api/state-file, which serves them as binary with their real content-type (text files stay UTF-8-lossytext/plain). - Everything else — raw text in a
<pre>.
Both bind their listeners with SO_REUSEADDR via
tokio::net::TcpSocket plus a retry loop on AddrInUse (12 tries,
exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself.
Per-agent relative paths
The per-agent UI uses document-relative paths everywhere for
assets, API calls, form actions, and the screen WebSocket. Bare
references like static/app.js, api/state, events/stream,
screen/ws resolve against document.baseURI — the page's URL
without its last path segment.
That makes the page work under any prefix the agent ends up mounted at without rebuilding the dist. The cases that matter:
| served at | api/state resolves to |
|---|---|
/ (own port, today's shape) |
/api/state |
/agent/iris/ (gateway-prefixed) |
/agent/iris/api/state |
/agent/iris/stats (subpage, no trailing slash) |
/agent/iris/api/state |
The gateway upstream config strips the prefix before forwarding to
the per-agent server, so the agent's Rust routes (api/state,
events/stream, screen/ws, login/start, …) keep their absolute
paths server-side. Only the browser-facing URLs are gated on the
mount prefix.
Subpages (stats, screen) are served without a trailing slash so
the relative-path resolution stays correct: static/app.js from
/stats becomes /static/app.js (last segment stats gets
replaced), not /stats/static/app.js. Adding a trailing slash to
those routes would break the resolution; either keep them
slash-less or use <base href> injection at serve time.
Dashboard layout
The dashboard (/) has a fixed chrome header at the top and a
<main> that shows exactly one tab pane at a time. The URL hash
(#swarm, #call, #system, #schedules) drives which pane is
active; hash changes don't reload the page. FL0W is a separate
full-page terminal at /flow.html — its tab-strip entry is a
cross-page link (◆ FL0W ◆ →), not a pane swap.
Chrome header (fixed, overlays the active tab pane):
- Tab strip:
◆ SW4RM ◆,◆ Y3R C4LL ◆,◆ SYST3M ◆,◆ SCH3DUL3S ◆,◆ M4TR1X ◆ →(optional page link, see below), and◆ FL0W ◆ →(page link). Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S (active schedules); FL0W pill mirrors the operator inbox length (hidden when zero). The M4TR1X → entry is hidden whenservices.hyperhive.matrix.gui.enableis off (defaults tomatrix.enable) so operators without the matrix GUI on don't see a dead link — tabs.js gates thehiddenattribute onstate.matrix_gui_enabledfrom/api/state. - Notification controls:
🔔 enable notificationswhen permission ungranted;🔕 mute / 🔔 unmutetoggle once granted. Always visible in the chrome regardless of active tab. - Banner-thin (
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░) — sits below the tab strip.
SW4RM tab
C0NTAINERS — live containers rendered as a depth-first
tree using ContainerView.parent (populated by topology.rs).
Each container's row is prefixed with ASCII tree glyphs (├─,
└─, │ continuation columns) showing the agent
parent/child hierarchy. When every container has parent = null
(flat topology) the tree collapses to a plain list with no
glyphs. Children are sorted alphabetically within each parent;
roots likewise. Cycles in the parent graph are tolerated —
orphaned containers (not reachable from any root) are appended
as roots so no agent disappears. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(port_conflicts from /api/state): the operator must rename
one of them and rebuild. lifecycle::{spawn,rebuild} also
preflight this and refuse with a clear error message naming the
conflicting agent.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale.
Y3R C4LL tab
Things blocked on operator decision — approvals and questions share a tab because they're the same concept ("something is waiting on you").
P3NDING APPR0VALS — the queue (see "Approval card" below). The R3QU3ST SP4WN form lives at the top of this section.
M1ND H4S QU3STI0NS — pending operator-targeted ask
questions (amber pulsing border). Free-text fallback always
rendered alongside any option list; multi=true renders options
as checkboxes; submit merges selections + free text
comma-joined. Each row has a ✗ CANC3L button. Questions with
a ttl_seconds show a ⏳ MM:SS chip; the host-side watchdog
auto-cancels with [expired] when the deadline fires.
SYST3M tab
Passive / rare-interaction state.
M3T4 1NPUTS — inputs in meta/flake.lock the operator can
selectively nix flake update, rendered as an indented tree:
every fetched input at every depth (hyperhive,
hyperhive/nixpkgs, agent-<n>, agent-<n>/mcp-<x>, …), each
shown once at its shallowest path. read_meta_inputs walks the
lock graph with a visited set — follows aliases and rev-less
nodes are skipped. A select all / select none
control sits above the tree. Checking inputs + submitting bumps
the lock in /meta/ and rebuilds the selected agents in
sequence; each outcome reaches the manager as a rebuilt
system event. POST /meta-update. While a lock-bump ripple runs,
the panel shows a pulsing "⏳ meta-update running" banner and the
update button is disabled (snapshot field meta_update_running,
live event meta_update_running).
R3BU1LD QU3U3 — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (⏸ queued / ▶ running / ✔ done /
✖ failed / ⊘ cancelled), kind glyph + verb (↻ rebuild,
◆ meta_update, ✨ spawn, 🗑 destroy), agent name, source
chip (manual | meta_update | auto_update | crash_recover | approval
— green for operator-approved config changes), timing, and an
optional reason / error. Meta-update cascade rebuilds nest under
their parent entry (parent_id grouping; rqe-child CSS class).
Dedup: re-enqueueing a still-queued op for the same agent
collapses into the existing entry. Running entries tick elapsed
seconds live, and when the worker has annotated the current phase
a cyan ↳ <step> sub-line appears under the main row showing the
in-flight step name (e.g. ↳ meta prepare_deploy → ↳ nixos-container update
→ ↳ finalize deploy). Terminal transitions clear step on the
backend so Done / Failed rows don't render stale labels.
Queued entries carry a ✗ cancel button on the right edge;
running / done / failed / cancelled entries don't show it — the
backend refuses cancellation for non-Queued rows anyway
(POST /api/rebuild-queue/{id}/cancel). Successful
cancel flips the row to ⊘ cancelled via the next
rebuild_queue_changed snapshot.
Cold-loaded from /api/state.rebuild_queue; live updates via
rebuild_queue_changed snapshot event.
K3PT ST4T3 — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: ⊕ R3V1V3 (queues a
Spawn approval; existing state is reused), PURG3 (wipes
state + applied dirs; POST /purge-tombstone/{name}).
SCH3DUL3S tab
Anything that fires at a future time. Operator-set schedules are created inline in the table (last row); agent self-paced reminders surface at the bottom as a sibling list — they share enough conceptual ground to live together.
N3W SCH3DUL3 / QU3U3D SCH3DUL3S — operator-managed
scheduled prompts. Single-table layout: each schedule is
one <tr>; columns are
# | src | next | every | owner | body | …agents… | actions.
Agent columns are dynamic — operator + manager + every
live container + any extra name that appears as a target on
some schedule but isn't a current container (same
buildTargetChips membership rule the new/edit forms use,
so table and forms agree on what's addressable). Column
headers tilt -45° via CSS so each column reads as a narrow
~28px strip; per-agent cells render as:
- active target →
<button>✓</button>that cancels just that one target on click - cancelled target → muted
✕glyph (no button — re-adding goes through the edit form's targets multi-select) - not a target → empty cell
Per-schedule action column: a ↯ fire now button sends an
out-of-band manual pulse to every active target (recurring
schedules keep their cadence; one-shots are consumed after
the manual fire), an ✎ edit button expands an inline edit
form as a colspan'd row directly under the schedule's row
(body / description / interval / next-fire / targets all
editable; targets are a multi-select diff'd against the
original active set so unchecked-was-active = targets_remove,
checked-not-originally-active = targets_add; submit PATCHes
/api/schedules/{id}), and a ✕ button cancels the whole
schedule (POST /api/schedules/{id}/cancel).
The table's last row is a permanent inline creation row:
inputs live directly in table cells (targets as checkboxes,
body textarea that expands on focus, datetime-local pre-filled
to 5 minutes from now, mini d/h/m/s number inputs (blank or
all-zero = one-shot), description). Click + to POST to
/api/schedules as JSON (or ⌫ to clear the half-filled row);
carry-state preserves partially-typed inputs across re-renders. The tab pill shows the count of active
schedules (at least one live target not yet cancelled).
Refreshed on tab activation and after each submit/cancel. Backed by
GET /api/schedules. No backend changes for the table layout
— it renders entirely from existing schedulesState +
containersState.
QU3U3D R3M1ND3RS — reminders agents have scheduled for
themselves (via the remind tool) but not yet delivered.
Each row shows the owner, due time, and message; a CANC3L
button hard-deletes (POST /cancel-reminder/{id}) and a
R3TRY button re-arms one whose delivery failed
(POST /retry-reminder/{id}). Backed by GET /api/reminders.
Lives in the SCH3DUL3S tab alongside operator schedules so the
operator has one place for everything time-fired.
M4TR1X page (/matrix/, optional)
A static matrix web client (default pkgs.fluffychat-web rebuilt
with --base-href /matrix/, swappable via
services.hyperhive.matrix.gui.package) served by the hive-gateway
nginx container at /matrix/ when
services.hyperhive.matrix.gui.enable is on (defaults to
matrix.enable). c0re signals availability via the
HIVE_MATRIX_GUI_ENABLED env var → state.matrix_gui_enabled in
/api/state; the gateway does the actual static serving.
The operator opens /matrix/ from the M4TR1X → strip entry, logs
in once with the in-host tuwunel homeserver URL
(http://localhost:8008 or whatever the matrix module exposes).
The unified nginx-front re-root to
https://matrix.${hyperhive.domain} + .well-known/matrix/client
auto-discovery lives in docs/gateway.md (atlas's lane).
FL0W page (/flow.html)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Reuses the same <header class="dashboard-chrome"> chrome
as the dashboard so the tab strip remains visible; SW4RM / Y3R
C4LL / SYST3M / SCH3DUL3S are cross-page links back to /#<tab>,
and the FL0W entry is marked active (aria-current="page").
0PER4T0R 1NB0X — recent messages addressed to operator,
derived client-side from the dashboard event stream. Cold load
seeds from /dashboard/history's 200-message backfill; subsequent
sent events with to == "operator" are appended live. Cap 50,
newest-first.
MESS4GE FL0W — live broker tail wrapped in a .terminal-wrap.
Cold load backfills the last ~200 messages from /dashboard/history;
live frames arrive on /dashboard/stream. Each row is one broker
event — sent or delivered — with from → to: body. The row is
a flex-wrap: wrap container holding ts / arrow / from / sep / to
chips inline; the body wraps to its own full-width line below
the chips (flex: 1 1 100%) so the body always gets the full row
width down to the content edge — long timestamps + agent names
used to push the body ~30ch in and force awkward narrow-column
wraps. min-width: 0 keeps word-break: break-word effective so
the body doesn't force the row wider than its container. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: @name picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
to confirm; @* broadcasts). POST /op-send drops
{from:"operator", to, body} into the broker; the resulting SSE
frame re-renders both the terminal row and the inbox section.
Manager is addressed as @manager (the broker recipient string),
not @hm1nd (the container name).
Container row
A full-height square agent icon (5em, capped) on the left. The
icon is the selection toggle: click (or Enter/Space) adds/removes
the agent from the selection set; aria-pressed reflects the state;
the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)". The <img> points at <url>/icon; load failure
falls back to the dimmed hyperhive mark (/favicon.svg). The card
body sits to the right with three stacked lines
(assets/tabs.js::renderContainers).
Icon layout + load strategy: the <img> is absolutely
positioned (inset: 0) inside the .container-icon wrapper —
the wrapper is the flex child and sizes itself via width: 5em +
aspect-ratio: 1, the <img> is out of flow so its load state
(pending, loaded, broken) can never contribute intrinsic size or
reflow the row. Without that, the row would briefly grow as the
image's natural dimensions arrived, then snap back on
object-fit: contain. The load itself is fire-and-forget: the
dashboard doesn't pre-check whether the agent is reachable, it
just lets the <img> try and listens for an error event. On
failure the handler swaps the src to /favicon.svg (served by
the dashboard itself, always reachable) and adds the
icon-unreachable class for the dimmed look. When the container
is known stopped up front (ContainerView.running = false) the
fallback fires immediately, skipping the doomed <url>/icon
fetch entirely.
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
icon-only nav strip populated async from the agent backend
(
📊 stats,🖥 screenwhen GUI is enabled,⬡ forge profile,↳ agent-configs mirror, plus any agent-declareddashboardLinksextras). The dashboard JS fetchesGET /api/agent/{name}/links, a same-origin passthrough proxy that forwards the agent's own link list; the agent backend is the single source of truth. The frontend resolves eachAgentLink.kind(container→http://host:<container.port>,forge→http://host:3000,external→ already absolute). When the container is stopped (ContainerView.running = false), the host clears live-only fields before emitting the state, so the dashboard never renders stale data: the badge chain is replaced by a single muted■ not runningbadge, the nav-strip fetch is skipped (the agent web server is down), and the self-reported status text is suppressed. The agent icon goes straight to the dimmed/favicon.svgfallback instead of attempting a doomed load from the container's URL. Static fields —needs_update,deployed_sha,pending_reminders,parent,configlink — remain visible regardless of run state. When the container is running, status badges follow —⊘ rate limited(red, while the harness is parked after a 429),needs login,needs update— in-flight◐ pending-state…pill (replaces buttons during operator-initiated start / stop / restart / rebuild / destroy). Additionally, when a rebuild-queue entry for this agent isqueuedorrunningbut no operator-initiated transient is set, the card surfaces abuilding…/meta-updating…badge sourced fromrebuildQueueState— so the SW4RM tab shows the same rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3. The row visual splits queued vs running: a queued entry shows only the pending-state pill (no row tint, so a long queue doesn't paint half the tab amber); a running entry keeps the amber row tint AND draws a rotating amber ring around the agent icon, so it's obvious at a glance which container is actually moving. Pending-state derivation: the pill is sourced from two separate stores in priority order. (1) The operator-initiated transient (transientsState) is set on the dashboard the moment the operator clicks start / stop / restart / rebuild / destroy / spawn — covers the create-and-start window where the container literally isn't up yet, before any backend state event has fired. (2) If no transient is set, the rebuild-queue entry for this agent is consulted (rebuildQueueState); this covers worker-driven ops — meta-update cascades, crash-recover rebuilds, approval-driven rebuilds — that the operator didn't click.ContainerStateChangedcarries neither signal, so the dashboard reads from the two snapshots directly. TheopRunningflag (driving thepending-runningrow class + spinner) is true when (1) is set OR (2) is inrunningstate; queued entries leaveopRunningfalse. Container name + port, and actx · Nkchip showing the agent's last-turn context size (fromContainerView.ctx_tokens, read from the turn-stats sqlite on eachbuild_allsweep; absent until the first turn). The chip colour (green / yellow / red) is keyed off the model's real context window:build_allresolves the last turn's model against the host's per-modelcontextWindowTokensconfig and exposes it asContainerView.context_window_tokens; the badge goes yellow ≥ 50% and red ≥ 75% of that window (the harness compaction watermarks). When the window can't be resolved the badge falls back to fixed 100k / 150k thresholds. - Line 2: status badges only (no per-card action buttons — actions moved to the selection bar, see below).
- Line 3: drill-in triggers —
↳ logs · <container>— opens the side panel and lazy- fetches journald viaGET /api/journal/{name}?unit=&lines=(journalctl -M <container> -b --no-pager --output=short-iso). A unit dropdown (harness service / full machine journal) and a refresh button live in the panel. The panel uses a column-flex layout so the<pre>log surface fills the full remaining panel height; scroll happens inside the<pre>, not the side panel body.- Plain navigation links (config repo, forge profile,
dashboardLinksextras) now live in the icon-only nav strip on Line 1 — see above. The agent'sconfiglink goes to the repo root; the deployed sha shows separately on Line 1 as thedeployed:<sha>chip, since the agent harness can't know its own deployed commit.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale. Banner pulses on each broker SSE event
(pulseBanner with a 4s grace timer).
Topology tree
Container rows render as a forest, not a flat list — each agent
sits indented under its declared parent. tabs.js::buildAgentTree
walks ContainerView.parent for every container in the snapshot
and produces a render order with per-row depth + sibling-position
info:
- Top-level rows are agents with
parent = nullOR a parent that doesn't appear in the container map (orphans get hoisted to root so they're still visible). - Within each level children sort alphabetically by name; roots likewise.
- Cycle safety: any container not reached during the root-walk is appended at the end as a root, so no agent ever silently disappears from the list when the topology JSON is malformed.
- The pre-topology rendering shape (every container at depth 0, flat list) collapses to the same visual today when no parent field is set — bit-identical fallback path.
The per-row prefix column (.tree-prefix) is DOM-painted, not
text-glyph-painted. Each indent lane is its own positioned
<span> so CSS can draw full-height vertical bars that bridge the
gap between sibling rows; using text box-drawing characters
(├─, └─, │ ) only paints one text-line tall and leaves
visible breaks between the taller-than-one-line container cards.
The bars come in two flavours: continuation (the ancestor's
subtree extends below this row → vertical line top→bottom) or
blank (ancestor was the last sibling at its level → no line
needed). The joint at the row's own depth column is ├ (more
siblings below) or └ (last sibling at this depth — vertical
stops at the row's icon midline).
Indent + lane geometry. Each depth level shifts the row right
by 1.8em (the lane width). The per-depth ladders are hardcoded
for six levels — enough for any plausible hive topology, and the
typed attr() function from CSS Values 5 that would collapse
this to one rule is still partial-support (Chromium-only as of
2026). The .tree-prefix span sits absolutely positioned with
left: -<depth>*1.8em so its right edge meets the row content
(the icon) and its leftmost lane lines up with top-level rows'
icons at x = 0. Each .tree-lane is flex: 0 0 1.8em so all
lanes have equal width. Continuation bars are drawn at lane
center (left: 0.6em, border-left: 1px solid currentColor,
top: 0; bottom: 0) and extend through .containers { gap: 0.4em }
into the next sibling's prefix (bottom: -0.4em on the prefix
span itself) so adjacent ancestor lines visually merge into one
unbroken vertical line. The horizontal stub at a row's own joint
lands at the icon midline so the L/T meets the icon edge cleanly.
When every container has parent = null (pre-topology state) the
[data-depth] attribute is absent on every row and these rules
are no-ops — the layout reads exactly like the legacy flat list.
Selection bar
Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
DESTR0Y / PURG3) used to live on each container row; the
operator picked the bulk-bar model instead. Clicking an agent's
icon toggles its selection (an in-memory Set<name>); Esc or
the bar's ✕ clear button drops everything. The selection
persists across tab switches in-memory — the bar just hides on
non-SW4RM tabs since other tabs don't show the agent cards needed
to cross-reference.
When one or more agents are selected (via icon click), a sticky
frosted-mauve bar slides up from the bottom of the viewport
(#selection-bar, position: fixed; bottom: 0). It shows:
- Count + names — "N agents selected · name1, name2, …"
- Bulk action buttons — only enabled when ALL selected agents
support the action; disabled with a tooltip naming the blockers
when the selection is mixed:
↺ R3ST4RT— running agents only■ ST0P— running agents only▶ ST4RT— stopped agents only↻ R3BU1LD— always availableDESTR0Y/PURG3— sub-agents only (disabled if manager selected)⇡ M0V3 → ROOT— promote selected agents to top-level (parent = null); disabled when all selected are already at root. Backendtopology::set_parentrefuses moves it can't satisfy (e.g. moving the manager) and the refusal surfaces in the failure roll-up.⇢ M0V3 → [select]— inline picker available for any selection size. The dropdown lists every container that isn't IN the selection itself nor a descendant of any selected agent (client-side BFS cycle prevention across the whole batch; the backend re-checks per-agent). On submit POSTs to/api/topology/set-parent(form-encodedchild=<name>&new_parent=<target>) once per selected agent, which writestopology.jsonand re-emits a container snapshot so the tree repaints without a page reload.
✕ clearbutton +Esckey clear the entire selection.
Stale selections (agents destroyed while selected) are pruned on every render before the bar appears.
Approval card
Each pending approval renders as a card (assets/tabs.js:: renderApprovals) with three stacked sections:
- identity header — glyph,
#id, agent, kind chip, (forapply_commit) the short proposal sha as<code>, and a right-alignedrequested <N> agorelative time fromApprovalView.requested_at— amber once the request has been pending ≥ 1h so a stale approval stands out. - what-changed body — the manager's description, then
drill-in triggers:
↳ view diffopens the diff in the side panel;↳ commit on forge ↗deep-links the proposal commit intoagent-configs/<agent>(shown only whenforge_present). Spawn approvals show a one-line "container will be created" note instead. - decision actions —
◆ APPR0VEandDENY. Deny pops aprompt()for an optional reason carried to the manager asHelperEvent::ApprovalResolved.note.
The diff panel has a 3-way base toggle — vs applied (the
running tree, served instantly from the diff already on the
approval), vs last-approved, vs previous proposal — the
latter two fetched on click from GET /api/approval-diff/{id} ?base=approved|previous. Each line is classified client-side
(+ / - / @@ / --- / +++ → add / del / hunk / file).
A pending · N / history · N tab pair switches the section
between the live queue and the last 30 resolved approvals.
Browser notifications
Pure frontend (Notification API). Three signals trigger them:
- new pending approval (per id, delta on
/api/state) - new pending operator question (per id)
- new broker message sent
to: "operator"(live via SSE)
First /api/state after page load seeds "seen" sets without
firing — only items that arrive while the page is open count.
Per-event tags (hyperhive:approval:<id>, hyperhive:question:<id>,
hyperhive:msg:<at>:<rand>) so distinct events stack in the OS
notification center instead of overwriting each other.
console.debug logs at every block point (unsupported,
permission ungranted, muted) for in-browser debugging. Click
focuses the dashboard tab. localStorage-backed mute toggle
silences without revoking the OS permission. Requires a secure
context (HTTPS or localhost); on other origins the controls hide
themselves. Browsers typically suppress notifications while the
originating tab is focused — that's a browser-level decision,
not ours.
Dashboard endpoints
-
POST /approve/{id}— approve a pending approval. FiresApprovalResolvedon the dashboard event channel; client updates derived approvals state from the event. -
POST /deny/{id}(note=<reason>, optional) — deny a pending approval with an optional operator-supplied reason. The reason travels to the manager asHelperEvent::ApprovalResolved.noteand also rides on the dashboard'sApprovalResolvedevent. Dashboard prompts viawindow.prompt()on click. -
POST /{rebuild,kill,restart,start,destroy}/{name}— lifecycle.destroyacceptspurge=onto also wipe state dirs. -
POST /purge-tombstone/{name}— wipe a tombstone's state dirs. -
POST /answer-question/{id}— answer a pending operator question. -
POST /cancel-question/{id}— cancel a pending question with the sentinel[cancelled]. Same code path as a real answer. -
POST /request-spawn— queue a Spawn approval. -
POST /update-all— rebuild every stale container. -
POST /api/rebuild-queue/{id}/cancel— drop aQueuedentry. RefusesRunning/ terminal-state entries (in-flight rebuilds can't be safely interrupted). Always 200; body is{"cancelled": true}on a successful flip or{"cancelled": false}when the entry was not inQueuedstate. -
POST /api/agent/{name}/mark-all-read— ack all pending broker messages for{name}. Backfillsdelivered_atfor rows not yet delivered and setsacked_at = now. Returns{ "marked": N }. Agent name validated against[a-z0-9_-], 1-63 chars; 400 on bad input. -
POST /op-send(to=<name>,body=<text>) — drop an operator-authored message into<name>'s inbox.to=*fans out to every registered agent. Returns 200; the brokerSentevent re-renders both the message-flow terminal and the operator inbox without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W. -
GET /api/journal/{name}?unit=&lines=— journalctl viewer for a managed container; rendered in the side panel. -
GET /api/approval-diff/{id}?base=applied|approved|previous— on-demand unified diff for anApplyCommitapproval against the chosen base (running tree / last approved proposal / previous queued proposal). Raw diff text, classified client-side.GET /static/marked.jsserves the vendoredmarkedbundle the side panel uses for markdown previews. -
GET /api/state-file?path=<host-or-container-path>— bounded text read of a file under the per-agentstate/subtree or the shared/var/lib/hyperhive/shared/. Accepts the container-view forms (/agents/<n>/state/...,/shared/...) and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses/agents/<n>/claude/configsubtrees, truncates bodies at 1 MiB. Click-time backing for the inline path-link preview.Detection of which tokens are path links is done server-side at broker-message ingest, not client-side: the broker forwarder calls
scan_validated_paths(body)— same allow-list helper the read endpoint uses — and attaches the verified file tokens to the event asfile_refs: Vec<String>. The client trusts that list and linkifies only those tokens, so directories, missing files, and forbidden subtrees never become anchors. No probe endpoint, no client-side regex heuristics. Historical messages get the same treatment on/dashboard/historybackfill. -
GET /api/reminders— list pending reminders for the dashboard's queued-reminders panel. -
POST /cancel-reminder/{id}— hard-delete a pending reminder. -
POST /retry-reminder/{id}— re-arm a reminder whose delivery failed (clears the failure state so the scheduler retries). -
GET /api/schedules— list all schedules (active and recently cancelled) for the SYST3M scheduled-prompts panel. -
POST /api/schedules— operator-direct schedule create:{ targets, body, first_fire_at_unix, interval_seconds?, description? }. Agent-initiated schedules go through the approval queue instead (manager MCPrequest_schedule_prompt). -
PATCH /api/schedules/{id}— partial edit. JSON body{ body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove? }. Missing key = "leave alone"; explicitnullondescription/interval_secondsclears the field (so a recurring schedule flips to one-shot wheninterval_secondsis sent asnull).targets_addis replace-on-conflict: re-adding a previously-cancelled target drops the tombstone and the target starts fresh (operator intent on re-add = "this target is active again").targets_removedelegates to the same path ascancel_targets— tombstones preserve audit, parent schedule auto-cancels when no active targets remain. Refuses cancelled rows; returns the updatedWireScheduleon success. -
POST /api/schedules/{id}/cancel— cancel a schedule. Body{ targets?: ["name", …] }cancels just those recipients; absent or empty body cancels the whole schedule. -
POST /api/schedules/{id}/fire-now— out-of-band manual pulse. Fires the schedule body once immediately to every active target. Recurring schedules:next_fire_at_unixis untouched; the regular cadence continues. One-shots: the schedule is consumed (cancelled) after the manual fan-out. Per-targetlast_resultis annotated as a manual fire so the audit trail distinguishes scheduled fires from operator- triggered ones. -
POST /meta-update—nix flake updatethe selectedmeta/flake.lockinputs, then rebuild the affected agents. -
GET /dashboard/stream— unified live event channel: brokersent/delivered, plus the mutation events listed below. Each frame carriesseq. -
GET /dashboard/history— last ~200 broker messages (wrapped as{ seq, events }) for the message-flow terminal's backfill on page load.
Dashboard event channel
Wire vocabulary on /dashboard/stream (kind tag is in the JSON
payload):
sent/delivered— broker traffic, mirrored from the intra-process channel by a forwarder task. Both carryid: i64(the broker row id) andin_reply_to: Option<i64>for thread rendering. The dashboard message-flow terminal renders reply rows with a↳ replytag that scroll-highlights the parent row on click. Used by the message-flow terminal renderer and the operator-inbox derived state.approval_added(id, agent, approval_kind, sha_short, diff, description) /approval_resolved(id, agent, approval_kind, sha_short, status, resolved_at, note, description) — pending queue + history mutations. Client mutates a derived store and re-renders only the approvals section.question_added(id, asker, question, options, multi, asked_at, deadline_at, target) /question_resolved(id, answer, answerer, answered_at, cancelled, target) — both operator-targeted and peer (agent-to-agent) threads fire these. The dashboard's questions pane surfaces both, with filter chips (all / @operator / @peer / per-participant) and an0V3RR1D3button on peer rows so the operator can answer when an agent is stuck. The ttl watchdog firesquestion_resolvedwithanswerer = "ttl-watchdog"on expiry.transient_set(name, transient_kind, since_unix) /transient_cleared(name) — lifecycle action spinners. The client ticks the elapsed-seconds badge offsince_unixclient-side, no polling.container_state_changed(container: ContainerView) /container_removed(name) — per-row container mutations, emitted byCoordinator::rescan_containers_and_emitfrom every mutation site (actions::approvepost-spawn,actions::destroy, the lifecycle_action wrapper,auto_update::rebuild_agent) and from the 10scrash_watchpoll. Client upserts/removes by name; the pending overlay is read fromtransientsStatesince the payload doesn't carry it.rebuild_queue_changed(seq, queue:Vec<QueueEntry>) — full snapshot of the rebuild queue on every mutation (enqueue, state transition, dedup collapse, terminal-history trim). Same snapshot-over-diff rationale astombstones_changed/meta_inputs_changed: the list is small and the client'sparent_idgrouping is most naturally re-derived from the full list. Cold-loaded from/api/state.rebuild_queue.
/api/state is only fetched on cold-load and on the few
forms that mutate non-event-derived state (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — approvals, questions,
transients, containers, operator inbox, message flow —
derives from /dashboard/stream after the initial snapshot,
maintaining its own client-side store and applying events on
top. The 5s periodic poll is gone.
Generalised form helpers: form[data-confirm="…"] pops
confirm() before submit; form[data-prompt="…"] pops
prompt() and stashes the answer in a hidden input named by
data-prompt-field (default note).
Per-agent page
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: 5emwith explicit pixel sizing so the<img>'s intrinsic (large) dimensions don't push the parent flex container open viaalign-items: stretch-driven height feedback. 5em ≈ header content area (headermin-height: 6emminus2 × 0.5empadding).align-self: flex-startkeeps 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-suppliedStateSnapshot.linksas icon-only anchors — always📊 stats(kind = Container);🖥 screenwhen VNC is enabled;⬡ forge(profile) +↳ config(agent-configs mirror) when the agent has a forge account; anyhyperhive.dashboardLinksextras (kind = External). A↑ dashboardlink is prepended by the JS so the host dashboard is one click away.GET /api/agent/{name}/linksis the single source of truth. EachNavLink.kindresolves 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 viael()— agent-declared icon / label / url strings never reachinnerHTML(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 byLiveEvent::StatusChanged. - State badge:
💤 idle/🧠 thinking/📦 compacting/○ offline/… booting+ age suffix. Driven byLiveEvent::TurnStateChanged ({ state, since_unix }). - Model chip:
model · <name>. Driven byLiveEvent::ModelChanged. - Ctx badge:
ctx · 142k— last inference's prompt size. Tooltip shows % of window whencontext_window_tokensis 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.
- Row 1 (
- 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 rows:↑ 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 toneeds_login— session history preserved). All destructive actions require one extra click to acknowledge — rare ops shouldn't live in the primary state strip. The popover's display rules are scoped to:not([hidden])so the[hidden]HTML attribute's UAdisplay: noneisn't overridden by the author CSS'sdisplay: flex— the popover stays hidden until JS removes the attribute.
- Inbox pill (
/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.
#statusoverlay: empty when online; shows the login form / OAuth URL whenstatusisneeds_login_*. The OAuth code input istype="password"with a👁 revealtoggle that flips it back totexton 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 ignoreautocomplete="off"ontype="password", butone-time-codeis 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 newpill when not at bottom). The pill is anchored in.agent-main, not inlog.parentElement = .terminal-wrap:.terminal-wrapappliesbackdrop-filter: blurfor the frost effect, which creates a CSS stacking context — anchoring the pill inside that context would trap itsz-indexbelow the fixed composer in the root stacking context, and it'd never float..agent-mainhas no backdrop-filter (no stacking-context creators), so the pill'sz-indexreaches the root and properly composites above the composer. Geometry is unchanged —.agent-mainand.terminal-wrapbothinset: 0fill 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/historyon page load and replays the last 2000 events (oldest first, with.no-animso 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:
Streamtool_use→Write/Edit: collapsed<details>with a +/- diff body (-lines frominput.old_string,+lines frominput.new_stringor every line ofinput.content). Summary carries the path + line counts.- others (
Read /path,Bash $ cmd,mcp__hyperhive__send → operator: "...", etc.): flat one-line per-tool format.
Streamtool_resultshort → flat← ...; long → collapsed<details>▸ ← Nl · headline(click to expand full body).Streamthinking→ text content if claude provided one, otherwise the bare· thinking …indicator.Streamsystem init,result,rate_limit_eventare dropped — too noisy.Note→· text.TurnEnd→✓ turn ok/✗ turn fail — note, triggers arefreshState().
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 shelloutspkill -INT claude, emits a Note. Also surfaces as a■ cancel turnbutton in the state row while state=thinking./compact—POST /api/compact→ host spawnsturn::compact_sessionin the background; output streams into the live panel./model <name>—POST /api/modelflippingBus::set_model. Takes effect on the next turn; persisted to/state/hyperhive-modelso 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 inneeds_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 emitLiveEvent::StatusChangedto flip the badge to/fromneeds_login_in_progress.POST /api/cancel— SIGINT the in-flight claude turn. Emits aLiveEvent::Note.POST /api/compact— run/compacton the persistent session (same MCP config + system prompt + allowed tools as a normal turn — only the stdin payload differs). Flips state toCompactingviaBus::set_state, which emitsTurnStateChanged.POST /api/model(model=<name>) — switch the model for future turns.Bus::set_modelemitsModelChanged.POST /api/new-session— arm a one-shot for the next turn to drop--continue. Emits aLiveEvent::Note.POST /api/logout— SIGINT any in-flight turn, wipe OAuth credential files (.credentials.json+mcp-needs-auth-cache.jsonunder~/.claude/), flipLoginState::NeedsLogin. Session history (~/.claude/projects/) is preserved. Returns 200 with a plain-text wipe summary. Emitsneeds_login_idlestatus viawait_for_loginentry.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 whenhyperhive.gui.enable = truein the agent'sagent.nix; the harness shows a 🖥 screen link in the state row whengui_vnc_portis present. Toolbar:⤢ fitCSS-downscales the canvas to the window viarelayoutCanvas()setting explicit pixel dimensions on the canvas — not CSSmax-width/max-height, because a flex item's automatic minimum size (min-width: autoresolves to the canvas's intrinsic framebuffer resolution) silently clampsmax-*back up, making fit mode a no-op that just centred + clipped the oversized canvas. The fit-mode rules pin the canvas withflex: none; min-width: 0; min-height: 0so the JS-set size sticks.⤡ match sizesends an RFBSetDesktopSizerequest so the server (weston) changes its real output resolution to the window dimensions; enabled once the server advertises theExtendedDesktopSizepseudo-encoding (-308rect in the header). Fit-mode state persists inlocalStorage(screen-fit); default is on. Pointer coordinates are rescaled insendPointerso 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 at127.0.0.1:<vnc_port>. Transparent to any RFB variant. VNC port comes from/etc/hyperhive/gui.json(written by the weston startup script inweston-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_limitedis 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 fromBus::record_turn_usageat turn-end;ctxis the last inference's usage (current context size),costis the cumulative across every inference (theresultevent'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 (vendored from a CDN). 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), and
doughnuts for tool / wake-source / result mix. A summary chip
row carries window totals. stats.rs opens the sqlite db
read-only and degrades to an empty snapshot on any error — the
page is decorative, never authoritative.