hyperhive/docs/web-ui/dashboard.md
iris cc8f58fb24 feat(stats): make ST4TS model price table operator-tunable
The hive-wide cost estimate on the dashboard's ST4TS tab used a
hard-coded model->price table in hive_stats.rs. Anthropic list pricing
drifts, so move the table to a nix option operators can keep current
without a code change.

- New `services.hyperhive.modelPrices` option: attrset of model-family
  short name -> { input, output, cache_read, cache_write } USD per
  million tokens. Passed to `hive-c0re serve --model-prices <json>`.
- hive_stats: `Prices` is now public + Deserialize; add `PriceTable`
  type and `resolve_prices` (longest case-insensitive substring key
  wins) with the old hard-coded table preserved as `builtin_prices`
  fallback for any model not covered.
- Coordinator holds the parsed table (hive-c0re-local, not injected
  into containers, so not part of HiveEnv); `/api/stats-hive` reads it.
- Docs: dashboard.md ST4TS cost note updated; option self-documents
  via nixosOptionsDoc.

Closes #1434
2026-06-08 20:02:31 +02:00

1062 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Dashboard layout
> Part of [Web UI](../web-ui.md). See also:
> [Shape (shared)](shape.md) · [Per-agent page](agent.md)
The dashboard is served at `/dashboard.html` (with the home page at `/`).
It 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`,
`#permissions`, `#schedules`, `#peers`, `#settings`) drives which pane is
active; hash changes don't reload the page. FL0W, L0GS, and the optional
M4TR1X client are separate pages reachable from the H0M3 hub at `/`, not
from the dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **← home back-link**: top-left of the chrome, links to the H0M3 hub
at `/`. Every surface links back to the hub rather than to each other.
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ SYST3M ◆`,
`◆ P3RM1SS10NS ◆`, `◆ SCH3DUL3S ◆`, `◆ ST4TS ◆`,
`◆ P33RS ◆` (hidden when `swarm.peers` is empty), and `◆ S3TT1NGS ◆`.
In-page tabs only — FL0W / L0GS / M4TR1X live on their own pages,
reachable from the H0M3 hub (not the tab strip). Count pills on SW4RM
(container count), Y3R C4LL (pending approvals + questions + unread
operator messages), and SCH3DUL3S (active schedules); P33RS and
S3TT1NGS have no count.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
— sits below the tab strip.
- **Browser tab title** — `hive / c0re` by default; updated to
`<swarm> / <hive>` once `hive_name` / `swarm_name` arrive in the
state snapshot. When there are pending approvals or unanswered
questions, a `(N)` prefix is prepended — `(3) pr1ma / hive-c0re`
— so the operator can see the call count in an unfocused browser
tab without opening the dashboard. The prefix is set on the
initial `/api/state` cold-load and updated live by
`approval_added` / `approval_resolved` / `question_added` /
`question_resolved` SSE events; it's preserved when
`hive_name` / `swarm_name` later replace the raw title.
The FL0W and L0GS pages use a slim header (a `← home` back-link + the
page title) rather than the dashboard tab strip — they're standalone
surfaces, not tab panes.
## 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. A
`pending · N` / `history · N` tab pair switches between the live
queue and the last 30 resolved approvals (see "Approval card" for
the history row shape).
**M1ND H4S QU3STI0NS** — pending `ask` calls waiting on the
operator, with amber pulsing border. Anatomy of each card:
- **Filter chips** — `all · N`, `@operator · N`, `@peer · N`,
plus one chip per participant name (`@asker · N` / `@target · N`).
Every chip shows its own count so the operator can see the
distribution at a glance. Clicking a chip narrows the visible
list; selection persists in localStorage so a tab switch doesn't
lose the filter.
- **Question card** — timestamp · asker → target · body text
(with file-path links). Operator-targeted questions (`target =
null`) show `▸ ANSW3R`; peer-targeted questions (`target =
agent`) show `⤿ 0V3RR1D3` so the operator can unblock an
agent-to-agent exchange. Questions with a `ttl_seconds` show a
`⏳ MM:SS` live countdown chip; the host-side watchdog resolves
with `answerer = "ttl-watchdog"` on expiry.
- **Answer form** — free-text textarea (Enter = submit,
Shift+Enter = newline) + optional option list (radio for
single-select, checkboxes for `multi=true`). Submit merges
selected options + free text comma-joined into a single
`answer` field. `✗ CANC3L` is a separate form so the submit
merge handler doesn't interfere.
- **◆ answ3red (N)** — collapsible `<details>` below the pending
list; shows the last 20 resolved questions with their answers.
**0PER4T0R 1NB0X** — messages agents have sent to `to="operator"` but
the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on
tab activation + page load; appended live from the broker `sent` stream
(deduped on row id). Each row shows sender · timestamp · body (with
file-path linkification). A `✓ mark all read` button on the right acks
all rows via `POST /api/agent/operator/mark-all-read` (reuses the existing
mark-read endpoint). Unread count folds into the Y3R C4LL tab pill so
messages are visible from any tab even while inactive. Backed by
`GET /api/operator-inbox``{ messages: [...] }` (id, from, body, at,
in_reply_to, file_refs).
## 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. All timing labels stay live:
running entries tick elapsed seconds every second; queued and
terminal ("done N ago" / "failed N ago") labels tick every 30s so
keyed rows never show stale timestamps as they persist across
`rebuild_queue_changed` snapshots. 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}`).
**C0NT41N3R L04D** — live CPU + memory per agent container, read
straight from cgroup v2 on the host (`cpu.stat`, `memory.current`,
`memory.peak`, `memory.max` under
`/sys/fs/cgroup/machine.slice/machine-h\x2d<name>.scope/`). CPU is a
host-normalised percentage (0..100 across all cores) sampled over a
short (~200 ms) two-read interval; memory shows current + peak with a
bar against the `memory.max` quota. Backed by
`GET /api/container-resources` (`container_stats.rs`), which reads the
files read-only (world-readable; no `hive-priv`) and skips agents whose
scope dir is absent (= not running). Pull-only: `tabs.js` polls every
5 s **only while the SYST3M tab is active** (CPU needs a fresh sample
each refresh), and stops on tab change. Network is intentionally
omitted — agents share the host netns, so there is no per-container net
counter (per-agent network needs the netns-isolation roadmap in
`docs/network.md`).
## P3RM1SS10NS tab
Per-agent permission configuration. Two sections, each rendered as a
column-driven checkbox matrix: rows are agents, columns are the
permission names fetched from the backend. The column list is
authoritative — adding a new tool-group or capability to the backend
requires no UI change; the new column appears automatically.
Fetches fire on tab activation (not page-load) to avoid unnecessary
work when the operator never visits this tab. Live mutations from the
rebuild-queue worker are also pushed via the `capabilities_changed` /
`tool_groups_changed` SSE events (same payload shape as the GET
endpoints), so an open P3RM1SS10NS tab reflects worker-applied changes
without requiring navigation. Tab-activation re-fetches remain as a
safety net for reconnect windows.
**C4P4B1L1T13S** — per-agent capability grants. Capabilities unlock
gated MCP tools and system-level access beyond the default agent
surface. A saving POST queues a rebuild for the affected agent so the
new `HIVE_CAPABILITIES` env var takes effect in the next session.
The current capabilities are:
| Name | Effect |
|------|--------|
| `manage_root_agent` | allows the `set_status` / lifecycle tools on the root manager |
| `read_host_journal` | unlocks `get_host_journal` to read journald from inside a container |
| `query_agent_state` | allows `get_loose_ends(agent: "<name>")` calls targeting other agents |
Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
boxes changes only the in-browser state; the `S4V3` button on the
right edge POSTs the full capability set for that agent to
`POST /api/capabilities/{agent}` as `{ caps: ["name", …] }` and
queues a rebuild. Absent agents in the assignment map have no extra
capabilities.
**T00L GR0UPS** — per-agent tool-group permissions. Tool groups are
named buckets of MCP tools; each agent starts with a role default
(agents: `messaging`, `meta`, `inbox`, `execution`; manager: all
groups). Checking / unchecking and saving changes which groups are
active for the agent. Backed by `GET /api/tool-groups` (columns) and
`POST /api/tool-groups/{agent}` (save). A rebuild is queued after
each save so `HIVE_TOOL_GROUPS` takes effect.
The current tool groups are: `messaging`, `meta`, `inbox`, `lifecycle`,
`approvals`, `scheduling`, `diagnostics`, `execution`, `web_tools`. All
listed in `ToolGroup::ALL` in `hive-sh4re`. The `web_tools` group is
special: it carries no MCP tools; instead it adds Claude's built-in
`WebFetch` and `WebSearch` to `--tools` / `--allowedTools` for that
agent session.
Both tables share the same visual shape: `.cap-table-wrap` /
`.tg-table-wrap` outer scroll container, `thead` with a label column
(`.cap-agent-col` / `.tg-agent-col`) + one column per permission
(`.cap-col` / `.tg-group-col`) + a save column (`.cap-save-col` /
`.tg-save-col`). Each tbody row is one agent: a name cell, checkbox
cells, and the `S4V3` button.
## 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 `next` column cell (`.sched-due`) carries a `data-due-at`
Unix timestamp attribute; a shared 1s ticker rewrites it
in-place showing `fmtDuration` while in the future and
`overdue X ago` once the fire time has passed — same
zero-re-render pattern as the reminder due-at labels and the
question TTL chip.
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. The due-time
label (`.reminder-due`) carries a `data-due-at` Unix timestamp
attribute; a shared 1s ticker rewrites it in-place — showing
`in Xm Ys` while the reminder is in the future and
`overdue X ago` once the deadline passes — without triggering a
full re-render of the list.
## ST4TS tab
Hive-wide turn statistics, aggregated across every agent's
`hyperhive-turn-stats.sqlite` for the selected window. Distinct from
each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup.
- **Window selector** (`1h``30d`) re-fetches on change.
- **Summary chips**: active agents, turns, total/input/output/cache-read
tokens, and a labelled **est cost**.
- **Busiest agents** table — one row per agent (most turns first):
turns, input / output / cache-read tokens, est cost.
- **Model mix** — turns per model across the swarm, as CSS bars.
Backed by `GET /api/stats-hive?window=<w>` in `hive-c0re`
(`hive_stats.rs`): for every name from
`Coordinator::kept_state_names()` it opens
`agent_harness_dir(name)/hyperhive-turn-stats.sqlite` read-only (with a
500 ms `busy_timeout`, since `turn_stats` is rollback-journal) and rolls
the rows up — missing / unreadable / zero-turn dbs are skipped so one
bad db never fails the endpoint. This is a **pull** surface (no SSE):
the data is fetched on tab activation and on window change. Rendered
with plain tables + CSS bars — the dashboard bundle ships no chart
library.
The cost figure is a deliberately rough estimate from a per-model
price table (`est_cost_usd`); it drifts with list pricing and is
labelled accordingly. The table is operator-tunable via the
`services.hyperhive.modelPrices` nix option — each key is a
model-family short name (matched case-insensitively as a substring of
the model id, longest match wins) mapping to
`{ input, output, cache_read, cache_write }` USD-per-million-token
prices. Models not covered fall back to hive-c0re's built-in estimate.
## P33RS tab
Peer hives in this swarm. The tab is hidden when the
`state.peer_hives` array from `/api/state` is empty (i.e. no
`services.hyperhive.swarm.peers` are configured). When at least
one peer is present the `hidden` attribute is removed and the tab
becomes active.
**P33R H1V3S** — each peer renders as a card row: a hexagon icon
(`⬡`), the peer's DNS domain as the primary name, and the peer
dashboard HTTPS URL as a clickable secondary link. Clicking the
URL opens the peer hive's dashboard in a new tab.
### Backend wiring
The host daemon reads `services.hyperhive.swarm.peers` from the
nix config (an attrset keyed by peer domain), serialises each
entry as `{ name, url }` into `state.peer_hives: Vec<PeerHiveView>`,
and includes the field in the `/api/state` snapshot. `tabs.js`
reads `state.peer_hives` on every `refreshState` call and calls
`renderPeerHives(peers)`, which rebuilds the `#peers-section`
div from scratch.
The `name` field is the peer's DNS domain (the attrset key); `url`
is `https://{domain}/`. Both are derived from the env var
`HYPERHIVE_PEERS` (a JSON array of `{ domain, cert_fingerprint }`
objects) that the nix module writes into the c0re container
environment. `cert_fingerprint` is null for CA-trusted (e.g.
Let's Encrypt) peers and non-null to pin a self-signed cert.
`parse_peer_hives()` in `dashboard.rs` converts each entry to the
`PeerHiveView { name: domain, url: "https://domain/" }` shape the
frontend reads.
## S3TT1NGS tab
Operator-local preferences. State lives in the browser's
`localStorage` — preferences do NOT sync between devices and
do NOT survive a profile wipe. Today the tab holds one section
(browser notifications); future preferences (theme, density,
etc.) land here as sibling `<h3>` blocks
under the same `<section id="tab-pane-settings">`.
**◇ browser notifications** — `🔔 enable notifications` button when
permission ungranted; `🔕 mute / 🔔 unmute` toggle once granted
(mute silences the dispatch without revoking the OS-level
permission). On unsupported origins (non-secure context, or
browsers without the `Notification` API) the controls hide and a
single status line explains why. See `### Browser notifications`
below for the dispatch model + the three signals the dashboard
emits OS notifications on.
The FL0W page does NOT host this pane — settings live only on the
dashboard's S3TT1NGS tab (reach it via the FL0W page's `← home`
back-link → Dashboard). Notifications still fire on the FL0W page when
they're enabled here, because `NOTIF.show()` in
`common.js` depends on `Notification.permission` + the
`hyperhive.notify.muted` localStorage key, not on the buttons
existing in the page DOM.
## 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 Matrix tile on the H0M3 hub, 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). Slim chrome: a `← home` back-link to the H0M3 hub, the FL0W
title, and the agent-filter select (see below). No dashboard tab
strip — FL0W is a standalone surface reached from the hub.
The operator inbox is **not** on this page — it lives on the
dashboard's Y3R C4LL tab (◆ 1NB0X ◆ section, with per-message and
mark-all read). FL0W stays the pure event firehose.
**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`. When a `sent`
and `delivered` event for the same message arrive within 3 seconds
(immediate delivery to a live recipient), the row is upgraded in place
(arrow becomes green ✓, title reads "sent + delivered") instead of
rendering two near-identical lines — genuine delivery latency (recipient
was busy) still appears as a second row. Each row carries `data-from` /
`data-to` attributes; an **agent filter select in the FL0W header** narrows
the timeline to messages involving the chosen agent (matched on `from` OR
`to`), with non-matching rows hidden (`.flow-hidden` class). The selection
persists in localStorage across reloads; new rows pick up the active filter
at render time. The dropdown populates from the live container list and
stays current on add/remove; a saved selection survives even if that agent
isn't currently listed.
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 the terminal row. Manager is addressed as `@root`.
## H0M3 page (`/`)
The H0M3 hub is the primary landing page (served at `/` by default). A
responsive grid of link tiles — Dashboard, Flow, Logs, Matrix (when enabled)
— each pointing to their respective surfaces. The page is a pure portal with
no tab-bar or SSE subscriptions. Typography + colours inherit from the shared
theme (Catppuccin Mocha via `common.css` + `theme.css`). The Matrix tile is
hidden until `home.js` confirms `matrix_gui_enabled` (same gating as the
dashboard's M4TR1X tab); `home.js` also fills the swarm/hive identity line
at the top. Dashboard is now served at `/dashboard.html` (route swap completed
in #1464 step 2); the home page at `/` replaces the old dashboard root. All
dashboard sub-pages include a `← Home` back-link for navigation.
## L0GS page (`/logs.html`)
A dedicated log-viewer page (not a tab pane — a separate HTML page),
reachable from the Logs tile on the H0M3 hub. Minimal chrome:
a `← home` back link and a three-item sub-tab strip. Tab
routing is hash-based (`#build`, `#agent`, `#system`); default is
`#build`.
**BUILD sub-tab** — all-agents build log history. Fetches
`GET /api/build-logs?limit=30` on load and on `↻ refresh`. Renders
a scrollable list of build entries; each row is a collapsible button
showing status badge (`live` / `ok` / `fail`), agent name, elapsed
duration, build kind, age, and the invocation command line.
Expanding a row fetches the full stdout+stderr via
`GET /api/build-logs/id/{id}`.
A live in-progress build shows a `live` badge with an elapsed-time
chip that ticks every second (updated by a `setInterval` on the
row; cleared when the build finishes or the stream errors). Expanding
a live row streams its output via `GET /api/build-logs/stream/{id}`
(newline-delimited JSON frames) with **sticky-bottom auto-scroll**:
the stream scrolls to keep the latest output visible as long as the
operator hasn't scrolled up manually; once the operator scrolls up,
new lines append silently at the bottom without jumping.
The build list auto-refreshes when a `rebuild_queue_changed` SSE
event fires while the BUILD tab is active (2s debounce to let the
backend commit the new row). The `↻ refresh` button triggers an
immediate re-fetch.
**AGENT sub-tab** — per-container journald viewer. Two selects: agent
name (populated from `GET /api/state`) and unit filter
(`hive-ag3nt.service` / `(full machine journal)`). Fetches
`GET /api/journal/{name}?unit=<unit>&lines=500` on selection change
or `↻ refresh`. Output rendered as a `<pre>` block. A `?agent=<name>`
and/or `?unit=<svc>` URL param pre-selects the agent + unit on page
load — the per-agent `⋮` menu's **journal logs →** entry uses this to
deep-link directly to a specific agent's journal. A "fetched N ago"
chip appears after the `↻ refresh` button following each successful
fetch and ticks every 30 s.
**SYSTEM sub-tab** — host-side service logs. Unit selector (currently
only `hive-c0re.service`). Fetches
`GET /api/journal-host?unit=hive-c0re.service&lines=500` on activation
and on `↻ refresh`. Rendered as a `<pre>` block. A "fetched N ago"
chip ticks every 30 s. Available to the operator unconditionally (not
capability-gated — the endpoint lives on the hive-c0re dashboard,
behind the gateway).
## 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** plus live agent-owned state, all populated
async from a single `GET /api/dashboard-state` call to the
agent's own backend. The response (`DashboardState`) carries:
`links` (nav strip entries — `📊 stats`, `🖥 screen` when GUI is
enabled, `⬡ forge profile`, `↳ agent-configs mirror`, plus any
agent-declared `dashboardLinks` extras), `status_text` /
`status_set_at` (agent self-reported status — the `(set N ago)`
chip is stamped `data-set-at` and ticks every 30s to stay fresh
across the long-lived keyed row cache), `rate_limited`,
`ctx_tokens` / `context_window_tokens` (context-window badge
data). The agent backend is the single source of truth for all
of these. The dashboard resolves each `AgentLink.kind` against a
per-agent base URL depending on whether hive-gateway is in front
(`StateSnapshot.gateway_enabled`, sourced from the
`HIVE_GATEWAY_ENABLED` env the c0re NixOS module sets when
`services.hyperhive.gateway.enable = true`). Gateway-on (default):
base URL is `/agent/<name>` (same origin, gateway proxies to the
per-agent harness — TCP or unix-domain depending on the agent's
`HIVE_WEB_SOCKET` opt-in, see
`docs/gateway.md::Per-agent unix-socket upstream`).
Gateway-off (legacy / local dev): base URL is
`http://<host>:<container.port>` (direct TCP fallback). Forge
links resolve against `http://<host>:3000`, external links are
already absolute. The same base URL drives the primary agent-name
link + favicon fetch, so the whole row routes through the gateway
as a unit.
**When the container is stopped** (`ContainerView.running = false`),
the async `dashboard-state` fetch is skipped entirely (the agent
web server is down), so the badge chain is replaced by a single
muted `■ not running` badge, the nav strip is empty, and status
text / rate-limited / ctx badges are suppressed. The agent icon
goes straight to the dimmed `/favicon.svg` fallback instead of
attempting a doomed load from the container's URL. Static fields
`needs_update`, `deployed_sha`, `pending_reminders`, `parent`,
`config` link — 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 is `queued` or `running` but no
operator-initiated transient is set, the card surfaces a
`building…` / `meta-updating…` badge sourced from
`rebuildQueueState` — 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. `ContainerStateChanged` carries neither signal, so the
dashboard reads from the two snapshots directly. The
`opRunning` flag (driving the `pending-running` row class +
spinner) is true when (1) is set OR (2) is in `running` state;
queued entries leave `opRunning` false.
A `ctx · Nk` chip showing the agent's last-turn context size,
populated from `DashboardState.ctx_tokens` (absent until the
agent has completed at least one turn). The chip colour (green /
yellow / red) is keyed off `DashboardState.context_window_tokens`
(the real context window for the model the agent last ran on,
authoritative from the agent side); the badge goes yellow ≥ 50%
and red ≥ 75% of that window, matching the harness compaction
watermarks. When the window value is absent 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** or the **per-agent `⋮` menu**, see
below).
**Per-agent `⋮` overflow menu** — a `⋮` button appears on the right
edge of each container row. Clicking it opens a small dropdown with
per-agent actions and navigation links. Contents:
- `↺ R3ST4RT` (running agents only) / `■ ST0P` (running only) /
`▶ ST4RT` (stopped only) — single-agent run-state toggles.
Identical to the bulk actions on the selection bar but operate
on one agent without requiring a selection click.
- `↻ R3BU1LD` — always available; queues a rebuild for this agent.
- `journal logs →` — opens `/logs.html#agent?agent=<name>` so the
operator lands directly in the AGENT log tab pre-filtered to this
container, without having to pick an agent from the dropdown.
- `DESTR0Y` / `PURG3` — destructive, each prompts for confirmation.
- `deployed:<sha> ↗` — present when the agent has a `deployed_sha`
and the forge is reachable; links the deployed commit on the forge
agent-configs mirror.
`↻ 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 = null` OR 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 available
- `DESTR0Y` / `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.
Backend `topology::set_parent` refuses 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-encoded `child=<name>&new_parent=<target>`)
once per selected agent, which writes `topology.json` and re-emits
a container snapshot so the tree repaints without a page reload.
- **`✕ clear`** button + `Esc` key 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, (for
`apply_commit`) the short proposal sha as `<code>`, and a
right-aligned `requested <N> ago` relative time from
`ApprovalView.requested_at`. The chip ticks live every second
via a `data-requested-at` attribute + client-side interval (no
re-render). Turns amber once the request has been pending ≥ 1h
so a stale approval stands out; the `.stale` class flips
precisely at the 3600s boundary rather than at the next
`renderApprovals` call.
- **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)
The toggle controls live in the `S3TT1NGS` tab (`#settings`); see
that section above for the user-facing shape. Dispatch logic lives
in `common.js::NOTIF`.
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. The localStorage key
`hyperhive.notify.muted` (`"1"` = muted, absent = unmuted) backs
the toggle and silences dispatch 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 /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
Refuses `Running` / 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 in `Queued` state.
- `POST /api/agent/{name}/mark-all-read` — ack all pending broker
messages for `{name}`. Backfills `delivered_at` for rows
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
- `GET /api/operator-inbox` — list unread messages addressed to
`to="operator"` (broker rows with `acked_at = NULL`). Cold-loaded
for the Y3R C4LL tab's ◆ 1NB0X ◆ section on page load + tab activation;
live updates fed from the broker `sent` stream. Returns
`{ messages: [{ id, from, body, at, in_reply_to, file_refs }, …] }`,
newest-first. Reuses `/api/agent/operator/mark-all-read` to ack (filters
are identical so every listed row is exactly what mark-read clears).
- `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 the message-flow terminal without a
snapshot refetch. Used by the compose textbox under MESS4GE FL0W.
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
headers for one agent, newest first. Returns
`Vec<BuildLogHeader>` (JSON): `id`, `agent`, `kind`, `cmdline`,
`started_at`, `finished_at`, `status` (`"ok"` / `"fail"` /
`null` while in-progress). `limit` defaults to 10, server-side
cap at 50. Agent name validated (`[a-z0-9_-]`, 1-63 chars).
- `GET /api/build-logs/id/{id}` — full build log by id. Returns
`BuildLogFull` (JSON): all header fields plus `stdout` and
`stderr` as plain text (newline-terminated lines, utf-8). HTTP
404 when the row is missing (vacuum-reaped or stale id).
- `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.
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d>` — hive-wide
turn-stats rollup for the ST4TS tab. Aggregates every agent's
`hyperhive-turn-stats.sqlite` read-only (skips missing / unreadable /
zero-turn dbs); returns swarm totals, a busiest-first per-agent
rollup, swarm model mix, and a labelled `est_cost_usd`. Window
defaults to `24h`.
- `GET /api/container-resources` — live per-agent-container CPU +
memory from cgroup v2 (SYST3M C0NT41N3R L04D panel). Returns one
row per running agent (`name`, `cpu_pct`, `mem_current_bytes`,
`mem_peak_bytes`, `mem_max_bytes`); samples CPU over ~200 ms so the
call briefly awaits. Skips non-running agents (no scope dir). No
network field — agents share the host netns.
- `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/tool-groups` — returns `{ groups: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `groups` is the
ordered list of all known tool-group names (drives the column
headers in the P3RM1SS10NS tab); `assignments` is the per-agent
override map (absent agents use the role default); `descriptions`
maps each group name to a short human-readable string surfaced as
a column-header tooltip on hover.
- `POST /api/tool-groups/{agent}` — body `{ groups: ["name", …] }`.
Writes the tool-group set for `{agent}` to
`/var/lib/hyperhive/meta/tool-groups.json` and queues a rebuild so
`HIVE_TOOL_GROUPS` takes effect. Agent name validated;
`guard_agent_name` applied.
- `GET /api/capabilities` — returns `{ caps: Vec<String>,
assignments: BTreeMap<String, Vec<String>>,
descriptions: BTreeMap<String, String> }`. `caps` is the
ordered list of all known capability names; `assignments` is the
per-agent grant map (absent agents have no extra capabilities);
`descriptions` maps each capability name to a short human-readable
string surfaced as a column-header tooltip on hover.
- `POST /api/capabilities/{agent}` — body `{ caps: ["name", …] }`.
Writes the capability set for `{agent}` to
`/var/lib/hyperhive/meta/capabilities.json` and queues a rebuild so
`HIVE_CAPABILITIES` takes effect. Agent name validated;
unknown capability strings are rejected (400). `guard_agent_name`
applied.
- `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 MCP `request_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"; explicit `null` on
`description` / `interval_seconds` clears the field (so a
recurring schedule flips to one-shot when `interval_seconds`
is sent as `null`). `targets_add` is 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_remove` delegates
to the same path as `cancel_targets` — tombstones preserve
audit, parent schedule auto-cancels when no active targets
remain. Refuses cancelled rows; returns the updated
`WireSchedule` on 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_unix`
is untouched; the regular cadence continues. One-shots: the
schedule is consumed (cancelled) after the manual fan-out.
Per-target `last_result` is annotated as a manual fire so
the audit trail distinguishes scheduled fires from operator-
triggered ones.
- `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`.
- `schedules_changed` (seq, schedules: `Vec<WireSchedule>`) —
full snapshot of all scheduled prompts. Emitted after every
operator mutation via the `/api/schedules` surface (new /
edit / cancel / fire-now) and after the worker fires or
rearms a row. Same snapshot-shape rationale as
`rebuild_queue_changed`. The SCH3DUL3S tab subscribes and
re-renders `schedulesState` on receipt; tab activation still
re-fetches as a safety net for approval-path inserts and
disconnect windows.
- `reminders_changed` (seq, reminders: `Vec<PendingReminder>`) —
full snapshot of all pending reminders. Emitted after every
reminder mutation: agent `remind` calls (`agent_server`),
operator cancel / retry (`/api/system/reminders/*`), `cancel_loose_end`
with Reminder kind, and the scheduler tick after each delivery
batch (`reminder_scheduler`). The SCH3DUL3S tab's reminders section
subscribes and calls `renderReminders` on receipt, so the list
updates live without polling.
- `capabilities_changed` (seq, caps: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
capability grants. Emitted from the rebuild-queue worker after a
`PermChange` / Capabilities entry commits the JSON file. Payload
matches `GET /api/capabilities` shape so `renderCapabilities` can
be called directly. P3RM1SS10NS tab subscribes; activation
re-fetch still runs as a safety net.
- `tool_groups_changed` (seq, groups: `Vec<str>`, descriptions: map,
assignments: `BTreeMap<String, Vec<String>>`) — full snapshot of
tool-group assignments. Emitted from the rebuild-queue worker after
a `PermChange` / ToolGroups entry commits the JSON file. Same
shape as `GET /api/tool-groups`; P3RM1SS10NS tab subscribes.
`/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`).