692 lines
35 KiB
Markdown
692 lines
35 KiB
Markdown
# Web UI
|
|
|
|
Two web surfaces share the same skeleton: the dashboard (port 7000)
|
|
and the per-agent UIs (manager on :8000, sub-agents on a hashed
|
|
:8100-8999). Both are SPAs — `GET /` returns a static shell,
|
|
`/api/state` returns JSON, JS renders. No full-page reloads.
|
|
|
|
## Shape (shared by both)
|
|
|
|
- `GET /` → `index.html` from the bundled frontend dist (see
|
|
`frontend/`). Both binaries' routers declare their dynamic
|
|
endpoints first and then `fallback_service(ServeDir::new(...))`
|
|
pointed at `HIVE_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 merged
|
|
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
|
|
`extraFiles` overlay).
|
|
- `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` (was `hive-fr0nt`); the CSS bundle inlines
|
|
`base.css` + `terminal.css` via esbuild's `@import` resolution.
|
|
`terminal.js` exports `{ create, linkify }` as ES module
|
|
members (no more `window.HiveTerminal` global outside the
|
|
back-compat shim the IIFE bodies still use). The dashboard's
|
|
`#msgflow` and the per-agent `#live` log 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. Bare `http(s)://` URLs in row text are
|
|
turned into clickable new-tab links by `linkify` (text-node
|
|
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
|
same treatment via `marked`'s autolink (npm dep, replacing the
|
|
vendored UMD bundle), with the rendered `<a>`s rewritten to
|
|
`target="_blank"` (issue #233).
|
|
- `GET /api/state` → JSON snapshot the JS app renders into the
|
|
DOM. Includes a top-level `seq` (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 with `seq <= snapshot.seq`).
|
|
- `GET /dashboard/stream` (dashboard) / `GET /events/stream`
|
|
(per-agent) → `text/event-stream` SSE for live updates. The
|
|
dashboard stream carries broker `Sent` / `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 a `seq`. The
|
|
matching backfill endpoint is `GET /dashboard/history` (last
|
|
~200 broker messages wrapped in `{ seq, events }`) on the
|
|
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
|
also wrapped in `{ seq, events }`) on the agent.
|
|
|
|
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/app.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.
|
|
|
|
**`<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. File
|
|
previews are type-aware:
|
|
|
|
- **Markdown** (`.md` / `.markdown`) — a `rendered` / `plain`
|
|
tabbed view: `rendered` (default) is the vendored `marked`
|
|
bundle (`GET /static/marked.js`), `plain` is the raw source.
|
|
- **SVG** (`.svg`) — a `rendered` / `source` tabbed view;
|
|
`rendered` shows the image via an `<img>` `data:` URI (the
|
|
browser's secure static mode, so an untrusted SVG can't run
|
|
scripts), `source` shows 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-lossy `text/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.
|
|
|
|
## 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`) 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 ◆`, and
|
|
`◆ FL0W ◆ →` (page link). Count pills on SW4RM (container count)
|
|
and Y3R C4LL (pending approvals + questions); FL0W pill mirrors
|
|
the operator inbox length (hidden when zero).
|
|
- **Notification controls**: `🔔 enable notifications` when
|
|
permission ungranted; `🔕 mute / 🔔 unmute` toggle 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 (issue #275). 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`),
|
|
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. Cold-loaded from
|
|
`/api/state.rebuild_queue`; live updates via `rebuild_queue_changed`
|
|
snapshot event.
|
|
|
|
**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`.
|
|
|
|
**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}`).
|
|
|
|
### 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 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`. 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** on the left (the agent's
|
|
`/icon`, an `<img>` absolutely positioned inside a wrapper div so
|
|
its load state can never reflow the row), and the card body on
|
|
the right with three stacked lines
|
|
(`assets/app.js::renderContainers`). The `<img>` points straight
|
|
at `<url>/icon`; if it actually fails to load (container stopped
|
|
or mid-transient, web server not answering) the `error` handler
|
|
falls it back to the dimmed hyperhive mark (`/favicon.svg`)
|
|
instead of an empty box — a real load-failure fallback, not a
|
|
guess from container state.
|
|
|
|
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
|
|
**icon-only nav strip** populated async from the agent backend
|
|
(`📊 stats`, `🖥 screen` when GUI is enabled, `⬡ forge profile`,
|
|
`↳ agent-configs mirror`, plus any agent-declared
|
|
`dashboardLinks` extras — issue #262). The dashboard JS fetches
|
|
`GET /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 each
|
|
`AgentLink.kind` (`container` → `http://host:<container.port>`,
|
|
`forge` → `http://host:3000`, `external` → already absolute).
|
|
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 is `queued` or `running` but no
|
|
operator-initiated transient is set, the card surfaces a `building…` /
|
|
`meta-updating…` badge sourced from `rebuildQueueState` (#398) —
|
|
so the SW4RM tab shows the same rebuild progress visible on the
|
|
SYST3M tab's R3BU1LD QU3U3. Container name + port, and a
|
|
`ctx · Nk` chip showing the agent's last-turn context size
|
|
(from `ContainerView.ctx_tokens`, read from the turn-stats
|
|
sqlite on each `build_all` sweep; absent until the first turn).
|
|
The chip colour (green / yellow / red) is keyed off the model's
|
|
real context window: `build_all` resolves the last turn's model
|
|
against the host's per-model `contextWindowTokens` config and
|
|
exposes it as `ContainerView.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. (issue #66)
|
|
- Line 2: action buttons — `↻ R3BU1LD` always, `DESTR0Y` + `PURG3`
|
|
on sub-agents, `↺ R3ST4RT` + (sub-agents) `■ ST0P` when running,
|
|
`▶ ST4RT` when stopped. Buttons dim + disable while a transient
|
|
lifecycle action is in flight.
|
|
- Line 3: drill-in triggers —
|
|
- `↳ logs · <container>` — opens the side panel and lazy-
|
|
fetches journald via `GET /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.
|
|
- Plain navigation links (config repo, forge profile,
|
|
`dashboardLinks` extras) now live in the icon-only nav strip
|
|
on Line 1 — see above (issue #262). The agent's `config` link
|
|
goes to the repo root; the deployed sha shows separately on
|
|
Line 1 as the `deployed:<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).
|
|
|
|
### Approval card
|
|
|
|
Each pending approval renders as a card (`assets/app.js::
|
|
renderApprovals`) with three stacked sections:
|
|
|
|
- **identity header** — glyph, `#id`, agent, kind chip, (for
|
|
`apply_commit`) the short proposal sha as `<code>`, and a
|
|
right-aligned `requested <N> ago` relative time from
|
|
`ApprovalView.requested_at` — amber once the request has been
|
|
pending ≥ 1h so a stale approval stands out (issue #272).
|
|
- **what-changed body** — the manager's description, then
|
|
drill-in triggers: `↳ view diff` opens the diff in the side
|
|
panel; `↳ commit on forge ↗` deep-links the proposal commit
|
|
into `agent-configs/<agent>` (shown only when `forge_present`).
|
|
Spawn approvals show a one-line "container will be created"
|
|
note instead.
|
|
- **decision actions** — `◆ APPR0VE` and `DENY`. Deny pops a
|
|
`prompt()` for an optional reason carried to the manager as
|
|
`HelperEvent::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. Fires
|
|
`ApprovalResolved` on 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 as `HelperEvent::ApprovalResolved.note`
|
|
and also rides on the dashboard's `ApprovalResolved` event.
|
|
Dashboard prompts via `window.prompt()` on click.
|
|
- `POST /{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
|
|
`destroy` accepts `purge=on` to 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 /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 broker
|
|
`Sent` event 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 an `ApplyCommit` approval against
|
|
the chosen base (running tree / last approved proposal /
|
|
previous queued proposal). Raw diff text, classified
|
|
client-side. `GET /static/marked.js` serves the vendored
|
|
`marked` bundle 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-agent `state/` 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` / `config` subtrees, 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 as `file_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/history` backfill.
|
|
- `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).
|
|
- `POST /meta-update` — `nix flake update` the selected
|
|
`meta/flake.lock` inputs, then rebuild the affected agents.
|
|
- `GET /dashboard/stream` — unified live event channel:
|
|
broker `sent` / `delivered`, plus the mutation events listed
|
|
below. Each frame carries `seq`.
|
|
- `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 carry `id: i64`
|
|
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
|
rendering. The dashboard message-flow terminal renders reply
|
|
rows with a `↳ reply` tag 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
|
|
an `0V3RR1D3` button on peer rows so the operator can
|
|
answer when an agent is stuck. The ttl watchdog fires
|
|
`question_resolved` with `answerer = "ttl-watchdog"` on
|
|
expiry.
|
|
- `transient_set` (name, transient_kind, since_unix) /
|
|
`transient_cleared` (name) — lifecycle action spinners. The
|
|
client ticks the elapsed-seconds badge off `since_unix`
|
|
client-side, no polling.
|
|
- `container_state_changed` (container: ContainerView) /
|
|
`container_removed` (name) — per-row container mutations,
|
|
emitted by `Coordinator::rescan_containers_and_emit` from
|
|
every mutation site (`actions::approve` post-spawn,
|
|
`actions::destroy`, the lifecycle_action wrapper,
|
|
`auto_update::rebuild_agent`) and from the 10s
|
|
`crash_watch` poll. Client upserts/removes by name; the
|
|
pending overlay is read from `transientsState` since 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 as `tombstones_changed` /
|
|
`meta_inputs_changed`: the list is small and the client's
|
|
`parent_id` grouping 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 (#394 redesign):
|
|
|
|
- **Agent icon** (`<img class="agent-icon">`): full-height square
|
|
identity anchor (6em, `height: 100%; aspect-ratio: 1`). 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. `GET /api/agent/{name}/links`
|
|
is the single source of truth.
|
|
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
|
|
+ ctx badge + cost badge + last-turn chip + cancel button.
|
|
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
|
|
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
|
|
Driven by `LiveEvent::StatusChanged`.
|
|
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` /
|
|
`○ offline` / `… booting` + age suffix. Driven by
|
|
`LiveEvent::TurnStateChanged ({ state, since_unix })`.
|
|
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`.
|
|
- Ctx badge: `ctx · 142k` — last inference's prompt size.
|
|
Tooltip shows % of window when `context_window_tokens` is known.
|
|
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every
|
|
inference in the last turn (tool-heavy turns rebill the cached
|
|
prefix per call — cost signal, not size signal).
|
|
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at
|
|
turn-end.
|
|
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`.
|
|
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow.
|
|
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens
|
|
the inbox flyout in the side panel.
|
|
- **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty;
|
|
click opens the loose-ends flyout.
|
|
- **Overflow button** (`⋯`): always visible. Opens a frosted popover
|
|
(`#overflow-menu`, positioned outside the header to escape any
|
|
stacking context) with three 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`). Both destructive
|
|
actions require one extra click to acknowledge (#394 — rare ops
|
|
shouldn't live in the primary state strip).
|
|
|
|
`/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.
|
|
|
|
**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_*`.
|
|
- Terminal-wrap: live event tail (sticky-bottom auto-scroll +
|
|
`↓ N new` pill when not at bottom).
|
|
|
|
**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. 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`).
|
|
|
|
### Live view
|
|
|
|
Each agent runs an `events::Bus`: a `tokio::sync::broadcast<LiveEvent>`
|
|
plus a sqlite-backed history at `/state/hyperhive-events.sqlite`.
|
|
The harness emits `TurnStart { from, body, unread }`,
|
|
`Stream(value)` (one per parsed stream-json line), `Note`,
|
|
`TurnEnd { ok, note }`. The web UI:
|
|
|
|
- fetches `GET /events/history` on page load and replays the last
|
|
2000 events (oldest first, with `.no-anim` so they don't
|
|
stagger);
|
|
- then subscribes to `GET /events/stream` (SSE) for live tail;
|
|
- shows a granular state badge above the terminal, driven
|
|
authoritatively from `/api/state.turn_state`. SSE turn_start /
|
|
turn_end still flip the badge instantly between renders;
|
|
- sticky-bottom auto-scroll: scrolling up parks the view; new rows
|
|
surface a "↓ N new" pill instead of yanking;
|
|
- terminal-themed: phosphor mauve glow, Crust bg,
|
|
backdrop-filter blur, row fade-in slide-up.
|
|
|
|
Per-stream rendering:
|
|
|
|
- `Stream` `tool_use` →
|
|
- `Write` / `Edit`: collapsed `<details>` with a +/- diff body
|
|
(`-` lines from `input.old_string`, `+` lines from
|
|
`input.new_string` or every line of `input.content`).
|
|
Summary carries the path + line counts.
|
|
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
|
|
operator: "..."`, etc.): flat one-line per-tool format.
|
|
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
|
`<details>` `▸ ← Nl · headline` (click to expand full body).
|
|
- `Stream` `thinking` → text content if claude provided one,
|
|
otherwise the bare `· thinking …` indicator.
|
|
- `Stream` `system init`, `result`, `rate_limit_event` are
|
|
dropped — too noisy.
|
|
- `Note` → `· text`.
|
|
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note`, triggers a
|
|
`refreshState()`.
|
|
|
|
### Terminal-embedded prompt
|
|
|
|
The operator input lives *inside* the terminal-wrap as a
|
|
prompt-style textarea below the live tail: multi-line (Enter
|
|
sends, Shift+Enter newlines), tab-completes slash commands.
|
|
|
|
Slash commands today:
|
|
|
|
- `/help` — list commands locally.
|
|
- `/clear` — wipe the local terminal view (server history kept).
|
|
- `/cancel` — `POST /api/cancel` → host shellouts `pkill -INT
|
|
claude`, emits a Note. Also surfaces as a `■ cancel turn`
|
|
button in the state row while state=thinking.
|
|
- `/compact` — `POST /api/compact` → host spawns
|
|
`turn::compact_session` in the background; output streams into
|
|
the live panel.
|
|
- `/model <name>` — `POST /api/model` flipping `Bus::set_model`.
|
|
Takes effect on the next turn; persisted to
|
|
`/state/hyperhive-model` so the override survives harness
|
|
restart / rebuild.
|
|
- `/new-session` — `POST /api/new-session` (confirms first).
|
|
Arms a one-shot on the Bus; next turn runs without
|
|
`--continue`, dropping the resume session entirely.
|
|
|
|
Unknown `/foo` shows an error row instead of being silently sent.
|
|
|
|
### Per-agent endpoints
|
|
|
|
All POSTs return 200 (no 303 redirects). The matching mutations
|
|
fire `LiveEvent` variants on the per-agent bus, so the client
|
|
doesn't refetch `/api/state` on submit — the SSE stream
|
|
delivers the new state faster anyway. Only the login flow still
|
|
polls (session output streams in updates that aren't event-
|
|
shaped).
|
|
|
|
- `POST /send` — operator-injected message into this agent's inbox.
|
|
- `POST /login/{start,code,cancel}` — claude OAuth login flow.
|
|
Start/cancel emit `LiveEvent::StatusChanged` to flip the
|
|
badge to/from `needs_login_in_progress`.
|
|
- `POST /api/cancel` — SIGINT the in-flight claude turn. Emits a
|
|
`LiveEvent::Note`.
|
|
- `POST /api/compact` — run `/compact` on the persistent session
|
|
(same MCP config + system prompt + allowed tools as a normal
|
|
turn — only the stdin payload differs). Flips state to
|
|
`Compacting` via `Bus::set_state`, which emits
|
|
`TurnStateChanged`.
|
|
- `POST /api/model` (`model=<name>`) — switch the model for
|
|
future turns. `Bus::set_model` emits `ModelChanged`.
|
|
- `POST /api/new-session` — arm a one-shot for the next turn to
|
|
drop `--continue`. Emits a `LiveEvent::Note`.
|
|
- `GET /events/history` — replay buffer for the terminal.
|
|
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
|
|
renderer). Only accessible when `hyperhive.gui.enable = true`
|
|
in the agent's `agent.nix`; the harness shows a 🖥 screen link
|
|
in the state row when `gui_vnc_port` is present. Toolbar:
|
|
`⤢ fit` CSS-downscales the canvas to the window; `⤡ 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 (issue #133).
|
|
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
|
|
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
|
|
Transparent to any RFB variant. VNC port comes from
|
|
`/etc/hyperhive/gui.json` (written by the weston startup
|
|
script in `weston-vnc.nix`).
|
|
|
|
Bus events (new vocabulary on `/events/stream`):
|
|
|
|
- `status_changed { status }` — `online` / `rate_limited` /
|
|
`needs_login_idle` / `needs_login_in_progress`. Drives the
|
|
alive-badge. `rate_limited` is set when the harness detects a
|
|
429 response and cleared when the retry sleep expires.
|
|
- `model_changed { model }` — drives the model chip.
|
|
- `token_usage_changed { ctx: TokenUsage, cost: TokenUsage }`
|
|
— drives the ctx + cost badges. Emitted from
|
|
`Bus::record_turn_usage` at turn-end; `ctx` is the last
|
|
inference's usage (current context size), `cost` is the
|
|
cumulative across every inference (the `result` event's
|
|
totals).
|
|
- `turn_state_changed { state, since_unix }` — drives the
|
|
state badge (`idle`/`thinking`/`compacting`).
|
|
|
|
### Stats page
|
|
|
|
`GET /stats` is a separate per-agent page (served by the
|
|
harness, linked from the per-agent page's `📊 stats →` and from
|
|
each dashboard container row). Turn analytics, read-only, from
|
|
`/state/hyperhive-turn-stats.sqlite`. `GET /api/stats?window=
|
|
24h|7d|30d` returns a time-bucketed `Snapshot`; the page renders
|
|
it with Chart.js (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.
|