Add CapabilitiesChanged and ToolGroupsChanged DashboardEvent variants
so the P3RM1SS10NS tab reflects perm changes without the operator
navigating away and back.
Backend:
- DashboardEvent::CapabilitiesChanged { seq, caps, descriptions,
assignments } — same payload shape as GET /api/capabilities
- DashboardEvent::ToolGroupsChanged { seq, groups, descriptions,
assignments } — same payload shape as GET /api/tool-groups
- Coordinator::emit_capabilities_snapshot() and
emit_tool_groups_snapshot() — read from the JSON files and broadcast
- rebuild_queue.rs PermChange worker: emit after each successful
commit_capabilities / commit_tool_groups call
Frontend:
- applyCapabilitiesChanged(ev): calls renderCapabilities(root, ev)
- applyToolGroupsChanged(ev): calls renderToolGroups(root, ev)
- Both registered in MUTATION_HANDLERS
- activateTab comment updated (SSE now covers perm changes)
Docs: dashboard.md and CLAUDE.md updated.
This completes SSE coverage for all dashboard sections: SW4RM,
Y3R C4LL, SYST3M, SCH3DUL3S/reminders, and P3RM1SS10NS all
derive live updates from /dashboard/stream.
45 KiB
Dashboard layout
Part of Web UI. See also: Shape (shared) · Per-agent page
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, #permissions, #schedules, #peers, #settings)
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 ◆,◆ P3RM1SS10NS ◆,◆ SCH3DUL3S ◆,◆ P33RS ◆(hidden whenswarm.peersis empty),◆ M4TR1X ◆ →(optional page link, see below),◆ FL0W ◆ →(page link), and◆ S3TT1NGS ◆. Count pills on SW4RM (container count), Y3R C4LL (pending approvals + questions), and SCH3DUL3S (active schedules); FL0W pill mirrors the operator inbox length (hidden when zero); P33RS and S3TT1NGS have no count. The M4TR1X → entry is hidden whenservices.hyperhive.matrix.gui.enableis off (defaults tomatrix.enable) so operators without the matrix GUI on don't see a dead link — tabs.js gates thehiddenattribute onstate.matrix_gui_enabledfrom/api/state. - Banner-thin (
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░) — sits below the tab strip.
The FL0W page reuses the same chrome strip but its ◆ S3TT1NGS ◆ →
entry is a cross-page link back to the dashboard
(/#settings) since the settings pane only lives there.
SW4RM tab
C0NTAINERS — live containers rendered as a depth-first
tree using ContainerView.parent (populated by topology.rs).
Each container's row is prefixed with ASCII tree glyphs (├─,
└─, │ continuation columns) showing the agent
parent/child hierarchy. When every container has parent = null
(flat topology) the tree collapses to a plain list with no
glyphs. Children are sorted alphabetically within each parent;
roots likewise. Cycles in the parent graph are tolerated —
orphaned containers (not reachable from any root) are appended
as roots so no agent disappears. Pulsing red banner at the top
of this section if any two sub-agents hash to the same port
(port_conflicts from /api/state): the operator must rename
one of them and rebuild. lifecycle::{spawn,rebuild} also
preflight this and refuse with a clear error message naming the
conflicting agent.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale.
Y3R C4LL tab
Things blocked on operator decision — approvals and questions share a tab because they're the same concept ("something is waiting on you").
P3NDING APPR0VALS — the queue (see "Approval card" below). The R3QU3ST SP4WN form lives at the top of this section.
M1ND H4S QU3STI0NS — pending operator-targeted ask
questions (amber pulsing border). Free-text fallback always
rendered alongside any option list; multi=true renders options
as checkboxes; submit merges selections + free text
comma-joined. Each row has a ✗ CANC3L button. Questions with
a ttl_seconds show a ⏳ MM:SS chip; the host-side watchdog
auto-cancels with [expired] when the deadline fires.
SYST3M tab
Passive / rare-interaction state.
M3T4 1NPUTS — inputs in meta/flake.lock the operator can
selectively nix flake update, rendered as an indented tree:
every fetched input at every depth (hyperhive,
hyperhive/nixpkgs, agent-<n>, agent-<n>/mcp-<x>, …), each
shown once at its shallowest path. read_meta_inputs walks the
lock graph with a visited set — follows aliases and rev-less
nodes are skipped. A select all / select none
control sits above the tree. Checking inputs + submitting bumps
the lock in /meta/ and rebuilds the selected agents in
sequence; each outcome reaches the manager as a rebuilt
system event. POST /meta-update. While a lock-bump ripple runs,
the panel shows a pulsing "⏳ meta-update running" banner and the
update button is disabled (snapshot field meta_update_running,
live event meta_update_running).
R3BU1LD QU3U3 — pending and recently-completed container
operations: rebuilds, meta-update cascades, and first-spawns.
One operation runs at a time; the worker drains FIFO. Each row
shows a state glyph (⏸ queued / ▶ running / ✔ done /
✖ failed / ⊘ cancelled), kind glyph + verb (↻ rebuild,
◆ meta_update, ✨ spawn, 🗑 destroy), agent name, source
chip (manual | meta_update | auto_update | crash_recover | approval
— green for operator-approved config changes), timing, and an
optional reason / error. Meta-update cascade rebuilds nest under
their parent entry (parent_id grouping; rqe-child CSS class).
Dedup: re-enqueueing a still-queued op for the same agent
collapses into the existing entry. Running entries tick elapsed
seconds live, and when the worker has annotated the current phase
a cyan ↳ <step> sub-line appears under the main row showing the
in-flight step name (e.g. ↳ meta prepare_deploy → ↳ nixos-container update
→ ↳ finalize deploy). Terminal transitions clear step on the
backend so Done / Failed rows don't render stale labels.
Queued entries carry a ✗ cancel button on the right edge;
running / done / failed / cancelled entries don't show it — the
backend refuses cancellation for non-Queued rows anyway
(POST /api/rebuild-queue/{id}/cancel). Successful
cancel flips the row to ⊘ cancelled via the next
rebuild_queue_changed snapshot.
Cold-loaded from /api/state.rebuild_queue; live updates via
rebuild_queue_changed snapshot event.
K3PT ST4T3 — destroyed-but-state-kept tombstones (size +
age + claude-creds badge). Two actions: ⊕ R3V1V3 (queues a
Spawn approval; existing state is reused), PURG3 (wipes
state + applied dirs; POST /purge-tombstone/{name}).
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.
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 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.
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,
inbox-pill threshold, 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 — its tab-strip
◆ S3TT1NGS ◆ → entry is a cross-page link to the dashboard's
#settings route. 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 M4TR1X → strip entry, logs
in once with the in-host tuwunel homeserver URL
(http://localhost:8008 or whatever the matrix module exposes).
The unified nginx-front re-root to
https://matrix.${hyperhive.domain} + .well-known/matrix/client
auto-discovery lives in docs/gateway.md (atlas's lane).
FL0W page (/flow.html)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Reuses the same <header class="dashboard-chrome"> chrome
as the dashboard so the tab strip remains visible; SW4RM / Y3R
C4LL / SYST3M / SCH3DUL3S are cross-page links back to /#<tab>,
and the FL0W entry is marked active (aria-current="page").
0PER4T0R 1NB0X — recent messages addressed to operator,
derived client-side from the dashboard event stream. Cold load
seeds from /dashboard/history's 200-message backfill; subsequent
sent events with to == "operator" are appended live. Cap 50,
newest-first.
MESS4GE FL0W — live broker tail wrapped in a .terminal-wrap.
Cold load backfills the last ~200 messages from /dashboard/history;
live frames arrive on /dashboard/stream. Each row is one broker
event — sent or delivered — with from → to: body. The row is
a flex-wrap: wrap container holding ts / arrow / from / sep / to
chips inline; the body wraps to its own full-width line below
the chips (flex: 1 1 100%) so the body always gets the full row
width down to the content edge — long timestamps + agent names
used to push the body ~30ch in and force awkward narrow-column
wraps. min-width: 0 keeps word-break: break-word effective so
the body doesn't force the row wider than its container. Sticky-
bottom auto-scroll + "↓ N new" pill. Below the stream sits a
terminal-style compose box: @name picks the recipient (sticky via
localStorage; auto-complete from the live container list, Tab/Enter
to confirm; @* broadcasts). POST /op-send drops
{from:"operator", to, body} into the broker; the resulting SSE
frame re-renders both the terminal row and the inbox section.
Manager is addressed as @root.
L0GS page (/logs.html)
A dedicated log-viewer page (not a tab pane — a separate HTML page),
linked from the SETTINGS tab's ◆ L0GS ◆ → entry. Minimal chrome:
a ← dashboard 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 and a growing … duration chip; when complete, the row
re-renders with the final status. status === null = still running.
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 refresh. Output
rendered as a <pre> block.
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. 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-statecall to the agent's own backend. The response (DashboardState) carries:links(nav strip entries —📊 stats,🖥 screenwhen GUI is enabled,⬡ forge profile,↳ agent-configs mirror, plus any agent-declareddashboardLinksextras),status_text/status_set_at(agent self-reported status),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 eachAgentLink.kindagainst a per-agent base URL depending on whether hive-gateway is in front (StateSnapshot.gateway_enabled, sourced from theHIVE_GATEWAY_ENABLEDenv the c0re NixOS module sets whenservices.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'sHIVE_WEB_SOCKETopt-in, seedocs/gateway.md::Per-agent unix-socket upstream). Gateway-off (legacy / local dev): base URL ishttp://<host>:<container.port>(direct TCP fallback). Forge links resolve againsthttp://<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 asyncdashboard-statefetch is skipped entirely (the agent web server is down), so the badge chain is replaced by a single muted■ not runningbadge, the nav strip is empty, and status text / rate-limited / ctx badges are suppressed. The agent icon goes straight to the dimmed/favicon.svgfallback instead of attempting a doomed load from the container's URL. Static fields —needs_update,deployed_sha,pending_reminders,parent,configlink — remain visible regardless of run state. When the container is running, status badges follow —⊘ rate limited(red, while the harness is parked after a 429),needs login,needs update— in-flight◐ pending-state…pill (replaces buttons during operator-initiated start / stop / restart / rebuild / destroy). Additionally, when a rebuild-queue entry for this agent isqueuedorrunningbut no operator-initiated transient is set, the card surfaces abuilding…/meta-updating…badge sourced fromrebuildQueueState— so the SW4RM tab shows the same rebuild progress visible on the SYST3M tab's R3BU1LD QU3U3. The row visual splits queued vs running: a queued entry shows only the pending-state pill (no row tint, so a long queue doesn't paint half the tab amber); a running entry keeps the amber row tint AND draws a rotating amber ring around the agent icon, so it's obvious at a glance which container is actually moving. Pending-state derivation: the pill is sourced from two separate stores in priority order. (1) The operator-initiated transient (transientsState) is set on the dashboard the moment the operator clicks start / stop / restart / rebuild / destroy / spawn — covers the create-and-start window where the container literally isn't up yet, before any backend state event has fired. (2) If no transient is set, the rebuild-queue entry for this agent is consulted (rebuildQueueState); this covers worker-driven ops — meta-update cascades, crash-recover rebuilds, approval-driven rebuilds — that the operator didn't click.ContainerStateChangedcarries neither signal, so the dashboard reads from the two snapshots directly. TheopRunningflag (driving thepending-runningrow class + spinner) is true when (1) is set OR (2) is inrunningstate; queued entries leaveopRunningfalse. Actx · Nkchip showing the agent's last-turn context size, populated fromDashboardState.ctx_tokens(absent until the agent has completed at least one turn). The chip colour (green / yellow / red) is keyed offDashboardState.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, see below).
- Line 3: drill-in triggers —
↳ logs · <container>— opens the side panel and lazy- fetches journald viaGET /api/journal/{name}?unit=&lines=(journalctl -M <container> -b --no-pager --output=short-iso). A unit dropdown (harness service / full machine journal) and a refresh button live in the panel. The panel uses a column-flex layout so the<pre>log surface fills the full remaining panel height; scroll happens inside the<pre>, not the side panel body.↳ build logs · <agent>— opens the side panel and fetches the last 10 build-log headers viaGET /api/build-logs/{agent}(status chip + kind + age + truncated cmdline per row). Clicking a row lazy-fetches its full stdout+stderr fromGET /api/build-logs/id/{id}and expands it inline as a scrollable<pre>. A refresh button re-fetches the header list. Backed by thebuild_logs.sqlitestore thatlifecycle::runandlifecycle::prebuild_toplevelwrite into.- Plain navigation links (config repo, forge profile,
dashboardLinksextras) now live in the icon-only nav strip on Line 1 — see above. The agent'sconfiglink goes to the repo root; the deployed sha shows separately on Line 1 as thedeployed:<sha>chip, since the agent harness can't know its own deployed commit.
↻ UPD4TE 4LL button appears above the containers list when any
agent is stale. Banner pulses on each broker SSE event
(pulseBanner with a 4s grace timer).
Topology tree
Container rows render as a forest, not a flat list — each agent
sits indented under its declared parent. tabs.js::buildAgentTree
walks ContainerView.parent for every container in the snapshot
and produces a render order with per-row depth + sibling-position
info:
- Top-level rows are agents with
parent = nullOR a parent that doesn't appear in the container map (orphans get hoisted to root so they're still visible). - Within each level children sort alphabetically by name; roots likewise.
- Cycle safety: any container not reached during the root-walk is appended at the end as a root, so no agent ever silently disappears from the list when the topology JSON is malformed.
- The pre-topology rendering shape (every container at depth 0, flat list) collapses to the same visual today when no parent field is set — bit-identical fallback path.
The per-row prefix column (.tree-prefix) is DOM-painted, not
text-glyph-painted. Each indent lane is its own positioned
<span> so CSS can draw full-height vertical bars that bridge the
gap between sibling rows; using text box-drawing characters
(├─, └─, │ ) only paints one text-line tall and leaves
visible breaks between the taller-than-one-line container cards.
The bars come in two flavours: continuation (the ancestor's
subtree extends below this row → vertical line top→bottom) or
blank (ancestor was the last sibling at its level → no line
needed). The joint at the row's own depth column is ├ (more
siblings below) or └ (last sibling at this depth — vertical
stops at the row's icon midline).
Indent + lane geometry. Each depth level shifts the row right
by 1.8em (the lane width). The per-depth ladders are hardcoded
for six levels — enough for any plausible hive topology, and the
typed attr() function from CSS Values 5 that would collapse
this to one rule is still partial-support (Chromium-only as of
2026). The .tree-prefix span sits absolutely positioned with
left: -<depth>*1.8em so its right edge meets the row content
(the icon) and its leftmost lane lines up with top-level rows'
icons at x = 0. Each .tree-lane is flex: 0 0 1.8em so all
lanes have equal width. Continuation bars are drawn at lane
center (left: 0.6em, border-left: 1px solid currentColor,
top: 0; bottom: 0) and extend through .containers { gap: 0.4em }
into the next sibling's prefix (bottom: -0.4em on the prefix
span itself) so adjacent ancestor lines visually merge into one
unbroken vertical line. The horizontal stub at a row's own joint
lands at the icon midline so the L/T meets the icon edge cleanly.
When every container has parent = null (pre-topology state) the
[data-depth] attribute is absent on every row and these rules
are no-ops — the layout reads exactly like the legacy flat list.
Selection bar
Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
DESTR0Y / PURG3) used to live on each container row; the
operator picked the bulk-bar model instead. Clicking an agent's
icon toggles its selection (an in-memory Set<name>); Esc or
the bar's ✕ clear button drops everything. The selection
persists across tab switches in-memory — the bar just hides on
non-SW4RM tabs since other tabs don't show the agent cards needed
to cross-reference.
When one or more agents are selected (via icon click), a sticky
frosted-mauve bar slides up from the bottom of the viewport
(#selection-bar, position: fixed; bottom: 0). It shows:
- Count + names — "N agents selected · name1, name2, …"
- Bulk action buttons — only enabled when ALL selected agents
support the action; disabled with a tooltip naming the blockers
when the selection is mixed:
↺ R3ST4RT— running agents only■ ST0P— running agents only▶ ST4RT— stopped agents only↻ R3BU1LD— always availableDESTR0Y/PURG3— sub-agents only (disabled if manager selected)⇡ M0V3 → ROOT— promote selected agents to top-level (parent = null); disabled when all selected are already at root. Backendtopology::set_parentrefuses moves it can't satisfy (e.g. moving the manager) and the refusal surfaces in the failure roll-up.⇢ M0V3 → [select]— inline picker available for any selection size. The dropdown lists every container that isn't IN the selection itself nor a descendant of any selected agent (client-side BFS cycle prevention across the whole batch; the backend re-checks per-agent). On submit POSTs to/api/topology/set-parent(form-encodedchild=<name>&new_parent=<target>) once per selected agent, which writestopology.jsonand re-emits a container snapshot so the tree repaints without a page reload.
✕ clearbutton +Esckey clear the entire selection.
Stale selections (agents destroyed while selected) are pruned on every render before the bar appears.
Approval card
Each pending approval renders as a card (assets/tabs.js:: renderApprovals) with three stacked sections:
- identity header — glyph,
#id, agent, kind chip, (forapply_commit) the short proposal sha as<code>, and a right-alignedrequested <N> agorelative time fromApprovalView.requested_at— amber once the request has been pending ≥ 1h so a stale approval stands out. - what-changed body — the manager's description, then
drill-in triggers:
↳ view diffopens the diff in the side panel;↳ commit on forge ↗deep-links the proposal commit intoagent-configs/<agent>(shown only whenforge_present). Spawn approvals show a one-line "container will be created" note instead. - decision actions —
◆ APPR0VEandDENY. Deny pops aprompt()for an optional reason carried to the manager asHelperEvent::ApprovalResolved.note.
The diff panel has a 3-way base toggle — vs applied (the
running tree, served instantly from the diff already on the
approval), vs last-approved, vs previous proposal — the
latter two fetched on click from GET /api/approval-diff/{id} ?base=approved|previous. Each line is classified client-side
(+ / - / @@ / --- / +++ → add / del / hunk / file).
A pending · N / history · N tab pair switches the section
between the live queue and the last 30 resolved approvals.
Browser notifications
Pure frontend (Notification API). Three signals trigger them:
- new pending approval (per id, delta on
/api/state) - new pending operator question (per id)
- new broker message sent
to: "operator"(live via SSE)
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. FiresApprovalResolvedon the dashboard event channel; client updates derived approvals state from the event. -
POST /deny/{id}(note=<reason>, optional) — deny a pending approval with an optional operator-supplied reason. The reason travels to the manager asHelperEvent::ApprovalResolved.noteand also rides on the dashboard'sApprovalResolvedevent. Dashboard prompts viawindow.prompt()on click. -
POST /{rebuild,kill,restart,start,destroy}/{name}— lifecycle.destroyacceptspurge=onto also wipe state dirs. -
POST /purge-tombstone/{name}— wipe a tombstone's state dirs. -
POST /answer-question/{id}— answer a pending operator question. -
POST /cancel-question/{id}— cancel a pending question with the sentinel[cancelled]. Same code path as a real answer. -
POST /request-spawn— queue a Spawn approval. -
POST /update-all— rebuild every stale container. -
POST /api/rebuild-queue/{id}/cancel— drop aQueuedentry. RefusesRunning/ terminal-state entries (in-flight rebuilds can't be safely interrupted). Always 200; body is{"cancelled": true}on a successful flip or{"cancelled": false}when the entry was not inQueuedstate. -
POST /api/agent/{name}/mark-all-read— ack all pending broker messages for{name}. Backfillsdelivered_atfor rows not yet delivered and setsacked_at = now. Returns{ "marked": N }. Agent name validated against[a-z0-9_-], 1-63 chars; 400 on bad input. -
POST /op-send(to=<name>,body=<text>) — drop an operator-authored message into<name>'s inbox.to=*fans out to every registered agent. Returns 200; the brokerSentevent re-renders both the message-flow terminal and the operator inbox without a snapshot refetch. Used by the compose textbox under MESS4GE FL0W. -
GET /api/build-logs/{agent}?limit=N— most-recent build log headers for one agent, newest first. ReturnsVec<BuildLogHeader>(JSON):id,agent,kind,cmdline,started_at,finished_at,status("ok"/"fail"/nullwhile in-progress).limitdefaults 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. ReturnsBuildLogFull(JSON): all header fields plusstdoutandstderras 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 anApplyCommitapproval against the chosen base (running tree / last approved proposal / previous queued proposal). Raw diff text, classified client-side.GET /static/marked.jsserves the vendoredmarkedbundle the side panel uses for markdown previews. -
GET /api/state-file?path=<host-or-container-path>— bounded text read of a file under the per-agentstate/subtree or the shared/var/lib/hyperhive/shared/. Accepts the container-view forms (/agents/<n>/state/...,/shared/...) and the host form. Canonicalises + verifies the path stays inside the allow-list, refuses anything but a regular file, refuses/agents/<n>/claude/configsubtrees, truncates bodies at 1 MiB. Click-time backing for the inline path-link preview.Detection of which tokens are path links is done server-side at broker-message ingest, not client-side: the broker forwarder calls
scan_validated_paths(body)— same allow-list helper the read endpoint uses — and attaches the verified file tokens to the event asfile_refs: Vec<String>. The client trusts that list and linkifies only those tokens, so directories, missing files, and forbidden subtrees never become anchors. No probe endpoint, no client-side regex heuristics. Historical messages get the same treatment on/dashboard/historybackfill. -
GET /api/reminders— list pending reminders for the dashboard's queued-reminders panel. -
POST /cancel-reminder/{id}— hard-delete a pending reminder. -
POST /retry-reminder/{id}— re-arm a reminder whose delivery failed (clears the failure state so the scheduler retries). -
GET /api/tool-groups— returns{ groups: Vec<String>, assignments: BTreeMap<String, Vec<String>> }.groupsis the ordered list of all known tool-group names (drives the column headers in the P3RM1SS10NS tab);assignmentsis the per-agent override map (absent agents use the role default). -
POST /api/tool-groups/{agent}— body{ groups: ["name", …] }. Writes the tool-group set for{agent}to/var/lib/hyperhive/meta/tool-groups.jsonand queues a rebuild soHIVE_TOOL_GROUPStakes effect. Agent name validated;guard_agent_nameapplied. -
GET /api/capabilities— returns{ caps: Vec<String>, assignments: BTreeMap<String, Vec<String>> }.capsis the ordered list of all known capability names;assignmentsis the per-agent grant map (absent agents have no extra capabilities). -
POST /api/capabilities/{agent}— body{ caps: ["name", …] }. Writes the capability set for{agent}to/var/lib/hyperhive/meta/capabilities.jsonand queues a rebuild soHIVE_CAPABILITIEStakes effect. Agent name validated; unknown capability strings are rejected (400).guard_agent_nameapplied. -
GET /api/schedules— list all schedules (active and recently cancelled) for the SYST3M scheduled-prompts panel. -
POST /api/schedules— operator-direct schedule create:{ targets, body, first_fire_at_unix, interval_seconds?, description? }. Agent-initiated schedules go through the approval queue instead (manager MCPrequest_schedule_prompt). -
PATCH /api/schedules/{id}— partial edit. JSON body{ body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove? }. Missing key = "leave alone"; explicitnullondescription/interval_secondsclears the field (so a recurring schedule flips to one-shot wheninterval_secondsis sent asnull).targets_addis replace-on-conflict: re-adding a previously-cancelled target drops the tombstone and the target starts fresh (operator intent on re-add = "this target is active again").targets_removedelegates to the same path ascancel_targets— tombstones preserve audit, parent schedule auto-cancels when no active targets remain. Refuses cancelled rows; returns the updatedWireScheduleon success. -
POST /api/schedules/{id}/cancel— cancel a schedule. Body{ targets?: ["name", …] }cancels just those recipients; absent or empty body cancels the whole schedule. -
POST /api/schedules/{id}/fire-now— out-of-band manual pulse. Fires the schedule body once immediately to every active target. Recurring schedules:next_fire_at_unixis untouched; the regular cadence continues. One-shots: the schedule is consumed (cancelled) after the manual fan-out. Per-targetlast_resultis annotated as a manual fire so the audit trail distinguishes scheduled fires from operator- triggered ones. -
POST /meta-update—nix flake updatethe selectedmeta/flake.lockinputs, then rebuild the affected agents. -
GET /dashboard/stream— unified live event channel: brokersent/delivered, plus the mutation events listed below. Each frame carriesseq. -
GET /dashboard/history— last ~200 broker messages (wrapped as{ seq, events }) for the message-flow terminal's backfill on page load.
Dashboard event channel
Wire vocabulary on /dashboard/stream (kind tag is in the JSON
payload):
sent/delivered— broker traffic, mirrored from the intra-process channel by a forwarder task. Both carryid: i64(the broker row id) andin_reply_to: Option<i64>for thread rendering. The dashboard message-flow terminal renders reply rows with a↳ replytag that scroll-highlights the parent row on click. Used by the message-flow terminal renderer and the operator-inbox derived state.approval_added(id, agent, approval_kind, sha_short, diff, description) /approval_resolved(id, agent, approval_kind, sha_short, status, resolved_at, note, description) — pending queue + history mutations. Client mutates a derived store and re-renders only the approvals section.question_added(id, asker, question, options, multi, asked_at, deadline_at, target) /question_resolved(id, answer, answerer, answered_at, cancelled, target) — both operator-targeted and peer (agent-to-agent) threads fire these. The dashboard's questions pane surfaces both, with filter chips (all / @operator / @peer / per-participant) and an0V3RR1D3button on peer rows so the operator can answer when an agent is stuck. The ttl watchdog firesquestion_resolvedwithanswerer = "ttl-watchdog"on expiry.transient_set(name, transient_kind, since_unix) /transient_cleared(name) — lifecycle action spinners. The client ticks the elapsed-seconds badge offsince_unixclient-side, no polling.container_state_changed(container: ContainerView) /container_removed(name) — per-row container mutations, emitted byCoordinator::rescan_containers_and_emitfrom every mutation site (actions::approvepost-spawn,actions::destroy, the lifecycle_action wrapper,auto_update::rebuild_agent) and from the 10scrash_watchpoll. Client upserts/removes by name; the pending overlay is read fromtransientsStatesince the payload doesn't carry it.rebuild_queue_changed(seq, queue:Vec<QueueEntry>) — full snapshot of the rebuild queue on every mutation (enqueue, state transition, dedup collapse, terminal-history trim). Same snapshot-over-diff rationale astombstones_changed/meta_inputs_changed: the list is small and the client'sparent_idgrouping is most naturally re-derived from the full list. Cold-loaded from/api/state.rebuild_queue.schedules_changed(seq, schedules:Vec<WireSchedule>) — full snapshot of all scheduled prompts. Emitted after every operator mutation via the/api/schedulessurface (new / edit / cancel / fire-now) and after the worker fires or rearms a row. Same snapshot-shape rationale asrebuild_queue_changed. The SCH3DUL3S tab subscribes and re-rendersschedulesStateon 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: agentremindcalls (agent_server), operator cancel / retry (/api/system/reminders/*),cancel_loose_endwith Reminder kind, and the scheduler tick after each delivery batch (reminder_scheduler). The SCH3DUL3S tab's reminders section subscribes and callsrenderReminderson 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 aPermChange/ Capabilities entry commits the JSON file. Payload matchesGET /api/capabilitiesshape sorenderCapabilitiescan 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 aPermChange/ ToolGroups entry commits the JSON file. Same shape asGET /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).