docs(dashboard): fix misplaced CR3D3NTIALS content, dedupe, trim history

This commit is contained in:
iris 2026-08-15 11:53:15 +02:00 committed by mara
commit 785b59a4bb

View file

@ -212,8 +212,8 @@ Three sub-tabs: **R3BU1LD QU3U3** (default), **M3T4 1NPUTS**,
`/api/state` and subscribes to `/api/dashboard/stream` for
`rebuild_queue_changed`, `meta_inputs_changed`, `meta_update_running`.
The SW4RM tab (`/dashboard.html`) does **not** read this endpoint at
all — mara, on review: "swarm.js should not need to pull in the jobq
to do its job." Its per-agent pending badges are transient-only, and
all, by design — the swarm view stays independent of job-queue
internals. Its per-agent pending badges are transient-only, and
its queue-summary banner reads the much narrower `GET /api/jobq/rollup`
instead (see Container row, below) — a handful of pre-tallied counts,
not the graph.
@ -239,23 +239,21 @@ array order, unchanged from the wire). A row shows a state glyph (`⏸`
pending / `▶` running / `◐` finishing — own work done, a sub-node
still running / `✔` done / `✖` failed / `⊘` cancelled / `·` skipped)
and each step's own label/agent. A `Node`-kind dep on a sibling shows
as a plain "waits on: `<label>`" text line under the row — an earlier
gutter-rail version was reverted (it broke visually whenever a nested
subtree sat between the two related rows; text has no such gap).
as a plain "waits on: `<label>`" text line under the row rather than a
gutter rail — a rail breaks visually whenever a nested subtree sits
between the two related rows, since a text line needs no continuous
vertical space to draw.
`builds.js` mounts the element with `cancellable` set, which turns on
a per-node cancel button (`✕`) on any non-terminal row — the button
dispatches `hive-jobq-graph-cancel`, and the page does the actual
`POST /api/rebuild-queue/{id}/cancel`, matching the wire event as its
own domain concept (the component knows nothing about that endpoint).
**Still no source chip, kind label, timing, or build-log deep-link on
rows** — no equivalent of the old `DagView`'s `source`/`reason`/
`created_at`, which were `NodeKind::Dag`-specific fields the generic
wire doesn't carry; per mara's original steer ("dont feel constrained
by what the ui does currently"), rows present what the endpoint
actually gives rather than reconstructing the old per-row chrome.
Settled entries render their **full step tree**, not just a bare
summary — unlike the old `DagView` projection, this wire does not
filter `Done` nodes out.
**Rows carry no source chip, kind label, timing, or build-log
deep-link** — the generic graph wire doesn't carry those fields, and
rows are meant to present exactly what the endpoint provides rather
than reconstruct chrome the backend no longer sends. Settled entries
render their **full step tree**, not just a bare summary — the wire
does not filter `Done` nodes out.
**State filter (hyperhive#2606).** A row of per-state checkboxes above
the tree — one per lifecycle state, matching the row glyphs — lets the
@ -359,24 +357,6 @@ The status dot renders these states:
!live` "provisioned but offline" case.
- **grey** — no token (not provisioned).
### GITHUB tab
Provision a single per-agent GitHub personal access token (see
[`docs/github.md`](../github.md) for the injection + `gh`/git-push
mechanics). No login flow — the operator pastes an existing PAT for a
dedicated bot account, with a security-warning banner (dedicated account +
minimally-scoped token) and a link to
[github.com/settings/tokens](https://github.com/settings/tokens).
Status reads `GET /api/github-account?agent=<name>`
`{ present: bool }` — whether the agent's `github-token` file exists.
There's no live/heartbeat concept for a static PAT, so this is just a
"token stored ✓" / "not set" line, unlike MATRIX's status-dot taxonomy.
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
`token`) → `200 { ok: true }` on success, or the same `error_response`
shape `/api/matrix-account-login` uses on failure. The token is never
echoed back in either direction.
The container-down cross-reference (`/api/state`) takes precedence over
the age check. `as_of_unix` is tooltipped ("live as of N ago") throughout
so freshness is always legible. When `live` is absent (an older backend
@ -398,6 +378,24 @@ regardless of outcome. The account list reflects what is *provisioned*
(an account with a stored token), so a config-declared-but-unprovisioned
account appears only once it has been provisioned through the form.
### GITHUB tab
Provision a single per-agent GitHub personal access token (see
[`docs/github.md`](../github.md) for the injection + `gh`/git-push
mechanics). No login flow — the operator pastes an existing PAT for a
dedicated bot account, with a security-warning banner (dedicated account +
minimally-scoped token) and a link to
[github.com/settings/tokens](https://github.com/settings/tokens).
Status reads `GET /api/github-account?agent=<name>`
`{ present: bool }` — whether the agent's `github-token` file exists.
There's no live/heartbeat concept for a static PAT, so this is just a
"token stored ✓" / "not set" line, unlike MATRIX's status-dot taxonomy.
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
`token`) → `200 { ok: true }` on success, or the same `error_response`
shape `/api/matrix-account-login` uses on failure. The token is never
echoed back in either direction.
### FORGES tab
Store a **label + base URL + access token** for an external Forgejo/Gitea/
@ -467,10 +465,8 @@ The current capabilities are:
Each row is one agent. Columns are the capability names returned by
`GET /api/capabilities` as `caps: Vec<String>`. Checking or unchecking
boxes only stages the change in-browser; nothing is written until the
page-level **save all** button (described below) is clicked. The
checkboxes reflect the *effective* set (explicit grant or role default),
so a default-perms agent shows its real grants rather than blank; absent
agents in the assignment map have no extra capabilities.
page-level **save all** button (described below) is clicked. Row
values follow the `effective`/`assignments` rule described above.
**T00L GR0UPS** — per-agent tool-group permissions. Tool groups are
named buckets of MCP tools; each agent starts with a role default
@ -752,9 +748,8 @@ 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
chips (`flex: 1 1 100%`) so long timestamps and agent names never push the
body into a narrow trailing column. `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
@ -783,9 +778,8 @@ latter — never guessed from the operator's browser hostname + a container
port, which is only right by accident off plain localhost. Operators without
matrix or forge enabled — or with forge on but no public URL configured —
never see a dead or wrong link. `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.
line at the top. All dashboard sub-pages include a `← Home` back-link for
navigation.
## L0GS page (`/logs.html`)
@ -862,122 +856,127 @@ 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 `state.forge_public_url` (sourced from
`services.hyperhive.forge.publicUrl`) and are omitted entirely when
that's unset — never guessed from `<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
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.
**Which single badge (hyperhive#3139):** `ContainerView.failed`
(systemd `ActiveState=failed` — the unit exhausted its bounded
restarts and gave up on its own) draws a red `✖ gave up` badge;
otherwise a plain muted `■ not running` — a container an operator
stopped deliberately. Both states read `running: false`; `failed` is
the orthogonal fact (a fifth one alongside `paused`/`needs_update`/
`needs_login`, same "independent flags, no state machine" shape —
see `ContainerView`'s own doc comment) that tells them apart. An
older backend without the field serves `failed: undefined`, which
reads falsy — degrades cleanly to the single `not running` badge.
When the container is running, status badges follow — `⊘ rate
limited` (red, while the harness is parked after a 429), `needs
login`, `needs update` — plus **one `◐ pending-state…` pill per
active transient** (replaces buttons during operator-initiated
start / stop / restart / rebuild / destroy). An agent can carry
**several transients at once** (mara: "show all running nodes that
name the agent") — e.g. a lease-exempt `prebuild` running alongside
a `stop_for_update` on the same agent — and each renders as its own
independent badge rather than being collapsed into one label,
matching the existing multi-badge convention this line already uses
for `paused`/`needs_update`/model/ctx.
Any pending badge means the row is actually **running** something
right now — there is no separate queued-but-not-started row state
to visually distinguish it from (see **Pending-badge derivation**
below), so every row carrying ≥1 badge keeps the amber row tint AND
draws a **rotating amber ring** around the agent icon.
**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 `state.forge_public_url` (sourced from
`services.hyperhive.forge.publicUrl`) and are omitted entirely when
that's unset — never guessed from `<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.
**Pending-badge derivation:** transients only (`transientsState`,
keyed `agent -> Map<kind, since_unix>`) — a transient is **derived
from a job-queue node currently `Running`** against that agent, not
declared per request, so its label follows the operation as it
progresses (a rebuild reads `stop_for_update`, then `swap`, then
`reconcile` rather than one constant `rebuilding` for its whole
life). Two consequences for anything rendering it:
**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
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.
- The label vocabulary is **open** — it is the node's own wire tag
(`NodeKind::as_str`, the same strings the graph wire's node
labels carry),
not a fixed set. Treat it as an opaque display string; do not
switch on specific values. `restarting` in particular no longer
exists, because no node kind is unique to a restart.
- It is **not** exclusively operator-initiated, and **not** limited
to rebuild-shaped work — `running_transients()` on the backend is
a status-only test (any `Running` node whose payload names a
non-empty agent lights a pill), so work the operator never
clicked (a meta-update cascade, a crash-recover rebuild, a
lease-exempt `prebuild`) lights the same mechanism.
**Line 2** — status badges only (no per-card action buttons — actions
moved to the **selection bar** or the **per-agent `⋮` menu**, see
below).
Ops with no queue node behind them (destroy, migration) supply
their own label directly via `TransientSet`/`TransientCleared`
events carrying no backing node at all.
### Status badges
**Queued (not-yet-started) work shows nothing on the card.** An
earlier version of this page had a job-queue-backed fallback badge
for the `Pending` case (`queuedOpsByAgent()`, reading a `GET
/api/jobq/graph` fetch); removed per mara, on review of
hyperhive#3028's PR: *"swarm.js should not need to pull in the jobq
to do its job,"* followed by *"remove the per agent pending stuff -
only show what is running."* Per-agent badges never came back — only
the queue-summary banner below did, once a narrow enough endpoint
existed for it to read instead of the full graph.
An **active model badge** (`model · <name>`, blue) appears when the
container is running and the harness has persisted a model name
(`harness/hyperhive-model`). Read by hive-c0re's `ContainerView`
(`read_active_model`); absent until the agent has completed at least
one turn and stale values are suppressed for stopped containers.
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).
**Which single badge (hyperhive#3139):** `ContainerView.failed`
(systemd `ActiveState=failed` — the unit exhausted its bounded
restarts and gave up on its own) draws a red `✖ gave up` badge;
otherwise a plain muted `■ not running` — a container an operator
stopped deliberately. Both states read `running: false`; `failed` is
the orthogonal fact (a fifth one alongside `paused`/`needs_update`/
`needs_login`, same "independent flags, no state machine" shape —
see `ContainerView`'s own doc comment) that tells them apart. An
older backend without the field serves `failed: undefined`, which
reads falsy — degrades cleanly to the single `not running` badge.
When the container is running, status badges follow — `⊘ rate
limited` (red, while the harness is parked after a 429), `needs
login`, `needs update` — plus **one `◐ pending-state…` pill per
active transient** (replaces buttons during operator-initiated
start / stop / restart / rebuild / destroy). An agent can carry
**several transients at once** — e.g. a lease-exempt `prebuild`
running alongside a `stop_for_update` on the same agent — and each
renders as its own independent badge rather than being collapsed
into one label, matching the existing multi-badge convention this
line already uses for `paused`/`needs_update`/model/ctx.
Any pending badge means the row is actually **running** something
right now — there is no separate queued-but-not-started row state
to visually distinguish it from (see **Pending-badge derivation**
below), so every row carrying ≥1 badge keeps the amber row tint AND
draws a **rotating amber ring** around the agent icon.
**Pending-badge derivation:** transients only (`transientsState`,
keyed `agent -> Map<kind, since_unix>`) — a transient is **derived
from a job-queue node currently `Running`** against that agent, not
declared per request, so its label follows the operation as it
progresses (a rebuild reads `stop_for_update`, then `swap`, then
`reconcile` rather than one constant `rebuilding` for its whole
life). Two consequences for anything rendering it:
- The label vocabulary is **open** — it is the node's own wire tag
(`NodeKind::as_str`, the same strings the graph wire's node
labels carry), not a fixed set. Treat it as an opaque display
string; do not switch on specific values. `restarting` in
particular no longer exists, because no node kind is unique to a
restart.
- It is **not** exclusively operator-initiated, and **not** limited
to rebuild-shaped work — `running_transients()` on the backend is
a status-only test (any `Running` node whose payload names a
non-empty agent lights a pill), so work the operator never
clicked (a meta-update cascade, a crash-recover rebuild, a
lease-exempt `prebuild`) lights the same mechanism.
Ops with no queue node behind them (destroy, migration) supply
their own label directly via `TransientSet`/`TransientCleared`
events carrying no backing node at all.
**Queued (not-yet-started) work shows nothing on the card.** Only
running work gets a per-agent pending badge — by design, there is no
fallback badge for work that's merely `Pending` in the queue, since
the swarm view stays independent of job-queue internals. The queue-
summary banner below is the only queued-work indicator on this tab,
and it reads the narrow `/api/jobq/rollup` endpoint rather than the
full graph.
**Active model badge** (`model · <name>`, blue) appears when the
container is running and the harness has persisted a model name
(`harness/hyperhive-model`). Read by hive-c0re's `ContainerView`
(`read_active_model`); absent until the agent has completed at least
one turn and stale values are suppressed for stopped containers.
**`ctx · Nk` chip** shows 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.
**Per-agent `⋮` overflow menu** — a `⋮` button appears on the right
edge of each container row. Clicking it opens a small dropdown with
@ -1011,19 +1010,11 @@ fixed order, zero counts included — rather than the full
`/api/jobq/graph` tree: `running` sums the `Running` and `Finishing`
entries' `roots` (`Finishing` = own work done, subtree still going,
still in flight), `queued` reads the `Pending` entry's `roots`.
`roots` specifically, not `nodes` — the banner has always meant *N
whole operations*, not raw steps (one rebuild is ~7 nodes but 1
root); `nodes` exists on the same endpoint for a consumer that wants
`roots` specifically, not `nodes` — the banner means *N whole
operations*, not raw steps (one rebuild is ~7 nodes but 1 root);
`nodes` exists on the same endpoint for a consumer that wants
step-level counts instead, unused here.
This banner went through two prior shapes before landing here, both
per mara review comments on hyperhive#3028's PR: a client-side
derivation over the full graph (*"swarm.js should not need to pull in
the jobq to do its job"*), then removed entirely rather than keep
that interim fetch (*"dont replace one legacy thing with another"*).
Restored once the dedicated rollup endpoint (hyperhive#3033) existed
for it to read directly instead.
### Themed dialogs
All confirmations, prompts, and transient error notices use an
@ -1065,67 +1056,35 @@ the dashboard and CLI paths behave identically.)
### Topology tree
Container rows render as a forest, not a flat list — each agent
sits indented under its declared parent. `swarm.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.
See **SW4RM tab** above for the parent/child derivation, sibling
sort order, and cycle-safety rules (`swarm.js::buildAgentTree` walks
`ContainerView.parent`) — this section covers only how the tree is
*drawn*.
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.
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. Plain text box-drawing characters (`├─`, `└─`, `│ `)
would only paint one text-line tall and leave visible breaks between
the taller-than-one-line container cards, so the bars are drawn as
CSS borders instead: a continuation bar runs the full height of an
ancestor's still-open subtree, and the joint at a row's own depth is
`├` (more siblings below) or `└` (last sibling — the line stops at
the row's icon midline). Exact lane widths and positioning live in
`swarm.js`'s tree-prefix rendering and its paired CSS rules — not
reproduced here since they're tuned in pixel units and will drift.
When every container is at depth 0 (no `parent` set) these rules are
all no-ops and the layout reads like a plain 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.
Bulk actions (`R3ST4RT` / `ST0P` / `ST4RT` / `R3BU1LD` / `DESTR0Y` /
`PURG3`) live here rather than as per-card buttons — see **Container
row** above. 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