diff --git a/frontend/packages/dashboard/src/swarm.js b/frontend/packages/dashboard/src/swarm.js
new file mode 100644
index 00000000..ce2014d0
--- /dev/null
+++ b/frontend/packages/dashboard/src/swarm.js
@@ -0,0 +1,1217 @@
+// SW4RM (containers) domain — extracted from tabs.js.
+// Agent topology, container-row rendering, selection bar, peer-hives block,
+// and all live-update apply handlers for container-state, rebuild-queue, and
+// transient ops. See docs/web-ui.md::Container row for the rendering contract.
+
+import {
+ $, el, form, fmtAgeSecs,
+} from './common.js';
+import { themedConfirm, themedToast } from './modal.js';
+import {
+ containersState, questionsState,
+} from './state.js';
+
+// Context-window badge thresholds. Preferred source is each container's
+// `context_window_tokens` from /api/state (the real window for the model
+// it last ran on) — thresholds are then 75% / 50% of it, matching the
+// harness compaction watermarks (compact at 75%, auto-reset at 50%). The
+// fixed token constants are the fallback for when that field is absent
+// (agent has no turns yet, or no per-model config matched the model).
+const CTX_WARN_FRACTION = 0.75; // >= this share of the window -> red
+const CTX_CAUTION_FRACTION = 0.50; // >= this share of the window -> yellow
+const CTX_WARN_TOKENS = 150_000; // fallback red threshold (~= 75% of 200k)
+const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (~= 50% of 200k)
+
+// ─── module-level state ─────────────────────────────────────────────────────
+
+let rebuildQueueState = [];
+
+// Keyed container row cache. Maps agent name -> { el:
, fingerprint }.
+// Allows renderContainers to skip rebuilding rows whose displayed state
+// hasn't changed — prevents full-wipe flicker + avoids redundant async
+// dashboard-state fetches on every SSE event.
+const containerRowCache = new Map();
+
+// Derived transient state — cold-loaded from /api/state.transients,
+// then mutated live by `transient_set` / `transient_cleared`. Keyed
+// by agent name so add/remove are O(1). `since_unix` is wall-clock so
+// the elapsed-seconds badge ticks without polling.
+const transientsState = new Map();
+
+// In-memory set of selected agent logical names backing the sticky
+// #selection-bar. See docs/web-ui.md::Selection bar for the
+// interaction model (icon-click toggle, Esc/clear button drop,
+// tab-gated visibility).
+const selectionState = new Set();
+
+let openAgentMenu = null; // currently open dropdown element, or null
+
+// ─── rebuild queue ──────────────────────────────────────────────────────────
+
+export function syncRebuildQueueFromSnapshot(s) {
+ rebuildQueueState = (s.rebuild_queue || []).slice();
+}
+export function applyRebuildQueueChanged(ev) {
+ rebuildQueueState = (ev.queue || []).slice();
+ // Re-render the SW4RM tab so newly-queued / newly-running
+ // rebuild-queue ops light up the right card with a building...
+ // badge, and finished ops fall back to the regular state badges.
+ // See docs/web-ui.md::Container row for the badge taxonomy.
+ renderContainersFromState();
+}
+// Map from agent name -> highest-priority in-flight queue entry
+// (`running` beats `queued`). Used by the container row renderer
+// to surface "building..." / "meta-updating..." badges on the
+// SW4RM tab when an op is still in the rebuild queue but no
+// operator-initiated transient is set.
+function inFlightOpsByAgent() {
+ const out = new Map();
+ for (const e of rebuildQueueState) {
+ if (e.state !== 'queued' && e.state !== 'running') continue;
+ // spawn ops target an agent that doesn't exist yet as a
+ // container — the transient store already drives the
+ // pending row for that case. Skip here to avoid double-
+ // surfacing if the spawn op happens to land in the queue
+ // while the row exists transiently.
+ if (e.kind === 'spawn') continue;
+ const cur = out.get(e.agent);
+ // Prefer running over queued; otherwise keep the first match.
+ if (!cur || (cur.state === 'queued' && e.state === 'running')) {
+ out.set(e.agent, e);
+ }
+ }
+ return out;
+}
+
+// ─── transients ─────────────────────────────────────────────────────────────
+
+export function syncTransientsFromSnapshot(s) {
+ transientsState.clear();
+ for (const t of s.transients || []) {
+ // Snapshot ships `secs` (server-computed); reconstruct an
+ // approximate since_unix so the live ticker keeps progressing
+ // without surprising jumps when the next snapshot lands.
+ const nowUnix = Math.floor(Date.now() / 1000);
+ transientsState.set(t.name, {
+ kind: t.kind,
+ since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
+ });
+ }
+}
+export function applyTransientSet(ev) {
+ transientsState.set(ev.name, {
+ kind: ev.transient_kind,
+ since_unix: ev.since_unix,
+ });
+ renderContainersFromState();
+}
+export function applyTransientCleared(ev) {
+ if (transientsState.delete(ev.name)) renderContainersFromState();
+}
+// Re-render using the last cached snapshot (containers come from
+// /api/state, transients overlay from the derived map). The snapshot
+// is stashed on window.__hyperhive_state by refreshState; on cold
+// load before the first snapshot we just skip.
+export function renderContainersFromState() {
+ const s = window.__hyperhive_state;
+ if (s) renderContainers(s);
+}
+
+// ─── container-state apply ──────────────────────────────────────────────────
+
+export function applyContainerStateChanged(ev) {
+ if (!ev.container || !ev.container.name) return;
+ containersState.set(ev.container.name, ev.container);
+ renderContainersFromState();
+}
+export function applyContainerRemoved(ev) {
+ if (containersState.delete(ev.name)) renderContainersFromState();
+}
+
+// ─── selection ──────────────────────────────────────────────────────────────
+
+function toggleSelection(name) {
+ if (selectionState.has(name)) selectionState.delete(name);
+ else selectionState.add(name);
+ renderContainersFromState();
+}
+function clearSelection() {
+ if (selectionState.size === 0) return;
+ selectionState.clear();
+ renderContainersFromState();
+}
+// Esc clears the current selection (operator escape hatch — mirrors
+// the side-panel close pattern). Ignored when an editable element
+// has focus so typing in compose / answer / journal-search isn't
+// intercepted.
+document.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape') return;
+ if (!selectionState.size) return;
+ const a = document.activeElement;
+ if (a && (a.isContentEditable
+ || a.tagName === 'INPUT'
+ || a.tagName === 'TEXTAREA'
+ || a.tagName === 'SELECT')) return;
+ e.preventDefault();
+ clearSelection();
+});
+document.addEventListener('click', (e) => {
+ if (e.target && e.target.closest('#selection-clear')) {
+ clearSelection();
+ }
+});
+
+// ─── per-agent context menu ──────────────────────────────────────────────────
+// Three-dot (⋮) button on each agent card for quick single-agent
+// lifecycle actions without needing to select first. State-aware:
+// restart/stop only shown when running, start only shown when
+// stopped; rebuild + destroy/purge always shown.
+// The button is CSS-invisible until the row is hovered (or menu is
+// open) so it doesn't clutter quiet rows.
+
+function closeAllAgentMenus() {
+ if (!openAgentMenu) return;
+ openAgentMenu.hidden = true;
+ const wrap = openAgentMenu.closest('.agent-menu');
+ if (wrap) {
+ wrap.classList.remove('open');
+ const btn = wrap.querySelector('.agent-menu-btn');
+ if (btn) btn.setAttribute('aria-expanded', 'false');
+ }
+ openAgentMenu = null;
+}
+
+// Close on any click outside an agent-menu element.
+document.addEventListener('click', (e) => {
+ if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
+}, true);
+// Close on Escape. stopImmediatePropagation so the selection-clear
+// handler on the same element doesn't also fire when a menu is open.
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape' && openAgentMenu) {
+ closeAllAgentMenus();
+ e.stopImmediatePropagation();
+ }
+}, true);
+
+// Single-agent POST helper shared by all menu items.
+async function agentMenuPost(actionPath, name, body, graceful) {
+ const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
+ try {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
+ body: body ? new URLSearchParams(body) : undefined,
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok) {
+ const text = await resp.text().catch(() => '');
+ themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
+ }
+ } catch (err) {
+ themedToast('action failed: ' + err, { type: 'error' });
+ }
+}
+
+function buildAgentMenu(c, forgeBase) {
+ const wrap = el('div', { class: 'agent-menu' });
+ const btn = el('button', {
+ type: 'button',
+ class: 'agent-menu-btn',
+ title: `actions for ${c.name}`,
+ 'aria-label': `actions for ${c.name}`,
+ 'aria-haspopup': 'menu',
+ 'aria-expanded': 'false',
+ }, '⋮');
+
+ const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
+
+ function menuItem(label, opts) {
+ const li = el('li', { role: 'presentation' });
+ const item = el('button', {
+ type: 'button',
+ class: 'agent-menu-item',
+ role: 'menuitem',
+ }, label);
+ item.addEventListener('click', async () => {
+ closeAllAgentMenus();
+ let graceful = false;
+ if (opts.confirm) {
+ const r = await themedConfirm({
+ message: opts.confirm,
+ danger: true,
+ confirmLabel: opts.confirmLabel || 'confirm',
+ checkboxes: opts.graceful
+ ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
+ : [],
+ });
+ if (!r) return;
+ graceful = !!r.graceful;
+ }
+ await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
+ });
+ li.append(item);
+ return li;
+ }
+
+ function menuSep() {
+ return el('li', { class: 'agent-menu-sep', role: 'separator' });
+ }
+
+ // Navigation link item (opens in same tab by default).
+ function menuLink(label, href, title) {
+ const li = el('li', { role: 'presentation' });
+ const a = el('a', {
+ class: 'agent-menu-item',
+ href,
+ role: 'menuitem',
+ title: title || '',
+ }, label);
+ a.addEventListener('click', () => closeAllAgentMenus());
+ li.append(a);
+ return li;
+ }
+
+ // Show only actions that are applicable in the current state.
+ if (c.running) {
+ dropdown.append(
+ menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }),
+ menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
+ );
+ } else {
+ dropdown.append(
+ menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
+ );
+ }
+ dropdown.append(
+ menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
+ menuSep(),
+ // Deep-link to the AGENT log tab pre-filtered to this container.
+ // The ?agent= param is read by logs.js on load and pre-selects this
+ // agent's journal without extra clicks.
+ menuLink('journal logs →',
+ `/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
+ `view ${c.name} journal logs`),
+ );
+
+ dropdown.append(
+ menuSep(),
+ menuItem('DESTR0Y', {
+ action: '/api/destroy/',
+ confirm: `destroy ${c.name}? container removed; state + creds kept.`,
+ }),
+ menuItem('PURG3', {
+ action: '/api/destroy/',
+ body: { purge: 'on' },
+ confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
+ }),
+ );
+
+ if (c.deployed_sha && forgeBase) {
+ const li = el('li', { role: 'presentation' });
+ const a = el('a', {
+ class: 'agent-menu-item',
+ href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
+ target: '_blank',
+ rel: 'noopener',
+ role: 'menuitem',
+ title: 'deployed config commit on forge',
+ }, `deployed:${c.deployed_sha} ↗`);
+ li.append(a);
+ dropdown.append(menuSep(), li);
+ }
+
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ const wasHidden = dropdown.hidden;
+ closeAllAgentMenus();
+ if (wasHidden) {
+ dropdown.hidden = false;
+ btn.setAttribute('aria-expanded', 'true');
+ wrap.classList.add('open');
+ openAgentMenu = dropdown;
+ }
+ });
+
+ wrap.append(btn, dropdown);
+ return wrap;
+}
+
+// ─── port conflicts ──────────────────────────────────────────────────────────
+
+// Re-derive port conflicts from the live containers map. Mirrors the
+// server-side `build_port_conflicts` so the banner reacts to event
+// updates instead of waiting for a /api/state refetch.
+function derivePortConflicts(containers) {
+ const byPort = new Map();
+ for (const c of containers) {
+ if (!byPort.has(c.port)) byPort.set(c.port, []);
+ byPort.get(c.port).push(c.name);
+ }
+ const out = [];
+ for (const [port, agents] of byPort) {
+ if (agents.length > 1) {
+ agents.sort();
+ out.push({ port, agents });
+ }
+ }
+ out.sort((a, b) => a.port - b.port);
+ return out;
+}
+
+// ─── topology tree ───────────────────────────────────────────────────────────
+// See docs/web-ui.md::Topology tree for the rendering contract
+// (forest walk, alphabetical sort, orphan + cycle handling).
+
+function buildAgentTree(containers) {
+ // Close any open context menu before replacing the DOM tree — the
+ // previous dropdown element would otherwise be a stale reference.
+ closeAllAgentMenus();
+ const byName = new Map();
+ for (const c of containers) byName.set(c.name, c);
+ const children = new Map(); // parent_name -> [child_name, ...]
+ const roots = [];
+ for (const c of containers) {
+ const p = c.parent || null;
+ if (p == null || !byName.has(p)) {
+ roots.push(c.name);
+ } else {
+ const list = children.get(p) || [];
+ list.push(c.name);
+ children.set(p, list);
+ }
+ }
+ roots.sort();
+ for (const list of children.values()) list.sort();
+ const out = [];
+ const visited = new Set();
+ function visit(name, depth, ancestorIsLast, isLast) {
+ if (visited.has(name)) return;
+ visited.add(name);
+ const c = byName.get(name);
+ if (!c) return;
+ out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
+ const kids = children.get(name) || [];
+ kids.forEach((kid, i) =>
+ visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
+ }
+ roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
+ // Cycle safety: anything not reached lands at root level so no
+ // agent silently disappears when a config is malformed.
+ for (const c of containers) {
+ if (!visited.has(c.name)) visit(c.name, 0, [], true);
+ }
+ return out;
+}
+// Builds the .tree-prefix DOM for a row at the given depth.
+// See docs/web-ui.md::Topology tree for why this is DOM-painted
+// (one positioned per lane) rather than text-glyph-painted.
+function treePrefixDom({ depth, ancestorIsLast, isLast }) {
+ if (depth === 0) return null;
+ const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
+ // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
+ // continuation column — top-level rows are separated visually as
+ // top-level rows already).
+ for (let d = 1; d < depth; d++) {
+ const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
+ prefix.append(el('span', { class: cls }));
+ }
+ const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
+ prefix.append(el('span', { class: jointCls }));
+ return prefix;
+}
+
+// ─── container row ───────────────────────────────────────────────────────────
+
+// Serialise the visible state of a container row into a stable string
+// for change-detection. Includes everything that affects what the row
+// renders — container fields, derived pending/selection state, tree
+// position, and link-base context. The async dashboard-state (nav
+// strip, ctx badge, status text) is intentionally excluded: it
+// populates in-place and is preserved when a row is reused.
+function containerRowFingerprint(c, node, pending, opRunning, selected,
+ askerCount, targetCount, gatewayLinks, hostname) {
+ return JSON.stringify({
+ running: c.running,
+ needs_login: c.needs_login,
+ needs_update: c.needs_update,
+ pending_reminders: c.pending_reminders,
+ active_model: c.active_model,
+ port: c.port,
+ pending,
+ opRunning,
+ selected,
+ askerCount,
+ targetCount,
+ depth: node.depth,
+ isLast: node.isLast,
+ ancestorIsLast: node.ancestorIsLast,
+ gatewayLinks,
+ hostname,
+ });
+}
+
+// Build a single container-row from scratch. Extracted so
+// renderContainers can call this only for rows whose fingerprint
+// changed (keyed cache), skipping the build + async dashboard-state
+// fetch for stable rows.
+function buildContainerLi(c, node, opts) {
+ const {
+ pending, opRunning, selected,
+ askerCount, targetCount, agentQCount,
+ url, containerBase, forgeBase, s,
+ } = opts;
+ const li = el('li', {
+ class: 'container-row'
+ + (pending ? ' pending' : '')
+ + (opRunning ? ' pending-running' : '')
+ + (selected ? ' selected' : ''),
+ });
+ // Topology: depth contributes left-padding; the glyph string in
+ // the .tree-prefix span draws the ├─ / └─ joint + continuation
+ // lines (`│ `) for ancestors whose subtree extends below this
+ // row. Both are CSS-driven from the data attributes so the
+ // legacy flat layout (every container at depth 0) is bit-
+ // identical to today's render — no glyph, no indent.
+ if (node.depth > 0) li.dataset.depth = String(node.depth);
+ const prefix = treePrefixDom(node);
+ if (prefix) li.prepend(prefix);
+
+ // Agent icon: 5em square wrapper with an absolutely-positioned
+ //
+ fire-and-forget load with /favicon.svg fallback. The
+ // wrapper doubles as the selection toggle (click / keyboard).
+ // See docs/web-ui.md::Container row for the layout + load-strategy
+ // rationale.
+ const iconImg = el('img', { class: 'container-icon-img', alt: '' });
+ const icon = el('div', {
+ class: 'container-icon',
+ role: 'button',
+ tabindex: '0',
+ 'aria-pressed': selected ? 'true' : 'false',
+ title: selected
+ ? `deselect ${c.name} (or press Esc to clear all)`
+ : `select ${c.name} for bulk actions`,
+ }, iconImg);
+ icon.addEventListener('click', (e) => {
+ e.preventDefault();
+ toggleSelection(c.name);
+ });
+ icon.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ toggleSelection(c.name);
+ }
+ });
+ if (c.running) {
+ iconImg.src = `${url}icon`;
+ iconImg.addEventListener('error', () => {
+ if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
+ iconImg.dataset.fallback = '1';
+ icon.classList.add('icon-unreachable');
+ iconImg.src = '/favicon.svg';
+ });
+ } else {
+ // Container stopped — skip the doomed `${url}icon` fetch and
+ // go straight to the dimmed hyperhive mark. Avoids a noisy
+ // failed request in the console + the brief broken-image flash.
+ icon.classList.add('icon-unreachable');
+ iconImg.src = '/favicon.svg';
+ }
+ // Card body: the three stacked content lines, right of the icon.
+ const body = el('div', { class: 'card-body' });
+
+ // -- identity -------------------------------------------------------
+ const head = el('div', { class: 'head' });
+ head.append(
+ el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
+ );
+ // Icon-only nav strip — populated async from the agent's own
+ // `GET /api/dashboard-state` (via gateway when enabled, direct
+ // TCP otherwise). The agent is the single source of truth for its
+ // link list: stats / screen (GUI agents only — c0re's disk-based
+ // fallback cannot detect this) / forge profile / agent-configs /
+ // extras. DOM-built — link strings come from the agent's process
+ // and must never reach the HTML parser.
+ const navStrip = el('span', { class: 'nav-strip' });
+ head.append(navStrip);
+ if (c.running) {
+ // Fetch the lean dashboard-state snapshot from the agent directly.
+ // Populates: nav strip links (including the screen link that
+ // c0re's disk-based build cannot detect), rate_limited badge,
+ // ctx-window badge, and self-reported status text.
+ // Fails gracefully when the agent is starting up or the gateway
+ // is not yet routing to it — badges simply don't appear.
+ // Only runs when the row is first built (fingerprint changed) —
+ // reused rows keep their previously-fetched nav strip + badges.
+ fetch(`${containerBase}/api/dashboard-state`)
+ .then((r) => (r.ok ? r.json() : null))
+ .then((ds) => {
+ if (!ds) return;
+ // -- nav strip -----------------------------------------------
+ if (Array.isArray(ds.links)) {
+ for (const lnk of ds.links) {
+ const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
+ : lnk.kind === 'external' ? (lnk.url || '')
+ : /* container */ containerBase + '/' + (lnk.url || '');
+ const a = el('a', {
+ class: 'nav-link',
+ href,
+ target: '_blank',
+ rel: 'noopener',
+ title: lnk.label || '',
+ });
+ // Plain text — agent-controlled strings stay out of innerHTML.
+ a.textContent = lnk.icon || lnk.label || '';
+ navStrip.append(a);
+ }
+ }
+ // -- agent-owned status badges --------------------------------
+ // rate_limited: only show when no other critical badge is
+ // already shown (pending / not-running already handled sync).
+ if (ds.rate_limited) {
+ head.append(el('span',
+ { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
+ '⊘ rate limited'));
+ }
+ // ctx-window badge
+ if (ds.ctx_tokens != null) {
+ const k = Math.round(ds.ctx_tokens / 1000);
+ const win = ds.context_window_tokens;
+ const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
+ const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
+ const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
+ : ds.ctx_tokens >= caution ? 'badge-ctx-caution'
+ : 'badge-ctx-ok';
+ const title = win != null
+ ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
+ + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
+ : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
+ head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
+ }
+ // -- agent status text (self-reported via set_status) ---------
+ if (ds.status_text) {
+ const nowUnix = Math.floor(Date.now() / 1000);
+ const ageStr = ds.status_set_at != null
+ ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
+ // Stamp data-set-at so the 30s ticker below keeps the age
+ // label current even when the row is reused from the cache.
+ const ageAttrs = ds.status_set_at != null
+ ? { class: 'status-age', 'data-set-at': String(ds.status_set_at) }
+ : { class: 'status-age' };
+ // Icon + message are wrapped together in `.status-msg` so the
+ // CSS can clamp *just the message* to two lines; the age span is
+ // a separate flex sibling that is never clipped, so the "(set N
+ // ago)" stamp stays visible even when the status text is long.
+ body.append(el('div', {
+ class: 'agent-status',
+ title: `agent self-reported status${ageStr}`,
+ },
+ el('span', { class: 'status-msg' },
+ el('span', { class: 'status-icon' }, '◈ '),
+ ds.status_text,
+ ),
+ el('span', ageAttrs, ageStr),
+ ));
+ }
+ })
+ .catch(() => { /* graceful: agent starting / gateway miss -> no data */ });
+ }
+ // Status / runtime badges. Pending transients always win
+ // (start / stop / restart / rebuild is in progress). Otherwise,
+ // when the container is stopped, surface a single `■ not running`
+ // badge. `needs_login` is still c0re-owned (reads auth sentinel
+ // files on the host). rate_limited / ctx / status_text are
+ // agent-owned and rendered by the async dashboard-state fetch above.
+ if (pending) {
+ head.append(el('span', { class: 'pending-state' },
+ el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
+ } else if (!c.running) {
+ head.append(el('span',
+ { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
+ '■ not running'));
+ } else if (c.needs_login) {
+ head.append(el('a',
+ { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
+ 'needs login →'));
+ }
+ if (c.needs_update) {
+ head.append(form(
+ '/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
+ 'rebuild ' + c.name + '? hot-reloads the container.',
+ {}, { noRefresh: true },
+ ));
+ }
+
+ if (c.active_model) {
+ head.append(el('span',
+ { class: 'badge badge-model', title: `active claude model: ${c.active_model}` },
+ c.active_model));
+ }
+
+ if (c.pending_reminders && c.pending_reminders > 0) {
+ head.append(el('span',
+ {
+ class: 'badge badge-reminder',
+ title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
+ },
+ `⏰ ${c.pending_reminders}`));
+ }
+ // Pending questions where this agent is the asker (awaiting an
+ // answer) or the target (owes a reply). Derived live from
+ // questionsState so the badge updates instantly on QuestionAdded /
+ // QuestionResolved without a separate backend field.
+ if (agentQCount > 0) {
+ const parts = [];
+ if (askerCount > 0) parts.push(`${askerCount} asked`);
+ if (targetCount > 0) parts.push(`${targetCount} to answer`);
+ head.append(el('span',
+ {
+ class: 'badge badge-loose-ends',
+ title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
+ },
+ `❓ ${agentQCount}`));
+ }
+ body.append(head);
+
+ // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
+ // DESTR0Y / PURG3) moved to the selection bar — see
+ // docs/web-ui.md::Selection bar. The contextual `needs update ↻`
+ // chip in the head row stays — it's a state-hint, not an
+ // action button.
+
+ li.append(icon, body, buildAgentMenu(c, forgeBase));
+ return li;
+}
+
+// ─── containers render ───────────────────────────────────────────────────────
+
+export function renderContainers(s) {
+ const root = $('containers-section');
+ // #containers-section only exists on /dashboard.html. tabs.js is the
+ // bundle for that page only (flow.html loads flow.js instead).
+ // Belt-and-suspenders for any future page that adds tabs.js
+ // without a #containers-section — matches the
+ // no-op-when-target-absent convention the other renderers
+ // (renderQuestions, renderApprovals, etc.) follow.
+ if (!root) return;
+
+ // Containers come from the derived map (event-driven) rather than
+ // `s.containers`; `s` still supplies hostname (for the web-ui
+ // link) and tombstones/meta_inputs (not event-derived yet). The
+ // tree builder handles the ordering — we don't pre-sort here.
+ const containers = Array.from(containersState.values());
+ const portConflicts = derivePortConflicts(containers);
+ const anyStale = containers.some((c) => c.needs_update);
+
+ // Preserve the keyed container list across the header-section
+ // rebuild. We wipe the banners/buttons above the list on every
+ // render (simple), but recycle elements for unchanged rows
+ // (keyed) to avoid full-DOM thrash and redundant async fetches.
+ const existingUl = root.querySelector('ul.containers');
+ root.replaceChildren();
+
+ // Port-hash collisions: rename one of the listed agents and
+ // rebuild. The banner sits above the agent list so it's the
+ // first thing the operator sees when something's wedged.
+ if (portConflicts.length) {
+ const banner = el('div', { class: 'port-conflict' },
+ el('strong', {}, '⚠ port collision'), ' — ');
+ const groups = portConflicts.map((c) =>
+ `:${c.port} (${c.agents.join(' + ')})`).join('; ');
+ banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
+ root.append(banner);
+ }
+
+ if (anyStale) {
+ root.append(form(
+ '/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
+ 'rebuild every stale container?',
+ {}, { noRefresh: true },
+ ));
+ }
+
+ // Queue-summary banner: when the rebuild queue has active work,
+ // show one compact at-a-glance line + a link to the full queue on the
+ // C0R3 page. Replaces the old per-transient spinner list — the actual
+ // running step is already visible per-agent on each card (transient +
+ // in-flight-queue badges), so the top of the tab only needs the summary.
+ const activeQueue = rebuildQueueState.filter(
+ (e) => e.state === 'queued' || e.state === 'running',
+ );
+ if (activeQueue.length) {
+ const running = activeQueue.filter((e) => e.state === 'running').length;
+ const queued = activeQueue.length - running;
+ const parts = [];
+ if (running) parts.push(`${running} running`);
+ if (queued) parts.push(`${queued} queued`);
+ root.append(el('div', { class: 'queue-summary' },
+ el('span', { class: 'glyph spinner' }, '◐'), ' ',
+ el('strong', {}, 'build queue'), ' — ',
+ parts.join(' · '), ' ',
+ el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'),
+ ));
+ }
+
+ if (!containers.length && !transientsState.size) {
+ root.append(el('p', { class: 'empty' }, 'no managed containers'));
+ return;
+ }
+
+ // Drop stale selections (agent destroyed while selected). Defensive —
+ // the action bar would otherwise loop POST against a gone agent.
+ const liveNames = new Set(containers.map((c) => c.name));
+ for (const n of Array.from(selectionState)) {
+ if (!liveNames.has(n)) selectionState.delete(n);
+ }
+
+ const hostname = (s && s.hostname) || window.location.hostname;
+ // When hive-gateway is in front of the dashboard, build same-origin
+ // `/agent//` URLs instead of the direct `http://:/`
+ // TCP fallback — the gateway proxies the prefix to the per-agent
+ // harness (unix-domain via `agent-sockets.json`, or a computed TCP
+ // loopback port while the socket marker is absent). See
+ // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
+ const gatewayLinks = !!(s && s.gateway_enabled);
+ // Forge public URL: prefer state.forge_public_url (set by the NixOS
+ // module when forge.behindGateway=true), fall back to
+ // ":3000" for gateway-off / local-dev deploys.
+ const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
+ const ul = existingUl ?? el('ul', { class: 'containers' });
+ const tree = buildAgentTree(containers);
+ // In-flight rebuild / meta-update / destroy ops per agent name —
+ // see docs/web-ui.md::Container row for the building... badge
+ // rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
+ // gap when no operator transient is set).
+ const inFlight = inFlightOpsByAgent();
+
+ // Build the ordered list of elements, reusing cached rows
+ // whose displayed state hasn't changed.
+ const orderedLis = [];
+ for (const node of tree) {
+ const c = node.container;
+ const url = gatewayLinks
+ ? `/agent/${encodeURIComponent(c.name)}/`
+ : `http://${hostname}:${c.port}/`;
+ // Container nav-strip base: gateway prefix or direct TCP.
+ const containerBase = gatewayLinks
+ ? `/agent/${encodeURIComponent(c.name)}`
+ : `http://${hostname}:${c.port}`;
+ // Pending-state derivation + queued-vs-running split — see
+ // docs/web-ui.md::Container row for the transient -> in-flight
+ // queue priority order and the opRunning rationale.
+ const transientKind = transientsState.get(c.name)?.kind || null;
+ const op = !transientKind ? inFlight.get(c.name) : null;
+ const pending = transientKind
+ || (op && (op.state === 'running'
+ ? (op.kind === 'meta_update' ? 'meta-updating'
+ : op.kind === 'destroy' ? 'destroying'
+ : op.kind === 'restart' ? 'restarting'
+ : op.kind === 'start' ? 'starting'
+ : op.kind === 'stop' ? 'stopping'
+ : op.kind === 'graceful_stop' ? 'stopping'
+ : 'rebuilding')
+ : (op.kind === 'meta_update' ? 'meta-update queued'
+ : op.kind === 'destroy' ? 'destroy queued'
+ : op.kind === 'restart' ? 'restart queued'
+ : op.kind === 'start' ? 'start queued'
+ : op.kind === 'stop' ? 'stop queued'
+ : op.kind === 'graceful_stop' ? 'stop queued'
+ : 'rebuild queued')));
+ const opRunning = transientKind != null
+ || (op != null && op.state === 'running');
+ const selected = selectionState.has(c.name);
+ // Pending questions where this agent is the asker (awaiting an
+ // answer) or the target (owes a reply). Derived live from
+ // questionsState so the badge updates instantly on QuestionAdded /
+ // QuestionResolved without a separate backend field.
+ const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
+ const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
+ const agentQCount = questionsState.pending.filter(
+ (q) => q.asker === c.name || q.target === c.name,
+ ).length;
+
+ const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
+ askerCount, targetCount, gatewayLinks, hostname);
+ const cached = containerRowCache.get(c.name);
+
+ let li;
+ if (cached && cached.fingerprint === fp) {
+ // Row unchanged — reuse the existing DOM node. The async
+ // dashboard-state (nav strip, ctx badge, status text) stays
+ // intact from the previous build, avoiding a redundant fetch.
+ li = cached.el;
+ } else {
+ li = buildContainerLi(c, node, {
+ pending, opRunning, selected,
+ askerCount, targetCount, agentQCount,
+ url, containerBase, forgeBase, s,
+ });
+ containerRowCache.set(c.name, { el: li, fingerprint: fp });
+ }
+ orderedLis.push(li);
+ }
+
+ // Remove cache entries for agents that no longer exist.
+ for (const [name, entry] of containerRowCache) {
+ if (!liveNames.has(name)) {
+ entry.el.remove();
+ containerRowCache.delete(name);
+ }
+ }
+
+ // Apply correct DOM order without a full wipe. insertBefore is a
+ // no-op when the node is already at position i, so stable sections
+ // of the list cause zero layout work.
+ for (let i = 0; i < orderedLis.length; i++) {
+ if (ul.children[i] !== orderedLis[i]) {
+ ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
+ }
+ }
+ // Trim any excess children (defensive — shouldn't happen after the
+ // cache-removal pass above, but keeps the ul length exact).
+ while (ul.children.length > orderedLis.length) ul.lastChild.remove();
+
+ root.append(ul);
+ renderSelectionBar(containers);
+}
+
+// ─── selection bar ───────────────────────────────────────────────────────────
+// Sticky-bottom strip; visible when >=1 agent selected on the SW4RM
+// tab. See docs/web-ui.md::Selection bar for the interaction model
+// and the per-action availability rules (disabled-with-tooltip for
+// actions that don't apply to the full selection). Actions POST per
+// agent in a loop (endpoints are individually idempotent /
+// event-covered, so no new bulk backend wire is needed).
+export function renderSelectionBar(containers) {
+ const bar = $('selection-bar');
+ if (!bar) return;
+ const countSpan = $('selection-count');
+ const namesSpan = $('selection-names');
+ const actions = $('selection-actions');
+ if (!countSpan || !namesSpan || !actions) return;
+
+ const selected = containers.filter((c) => selectionState.has(c.name));
+ // Tab-gate: bar only renders on SW4RM (the only tab with agent
+ // cards to cross-reference). Selection state lives in-memory and
+ // the bar reappears on return to SW4RM if still non-empty.
+ const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm';
+ if (!selected.length || !onSwarmTab) {
+ bar.hidden = true;
+ document.body.classList.remove('has-selection');
+ return;
+ }
+ bar.hidden = false;
+ document.body.classList.add('has-selection');
+ countSpan.textContent = selected.length === 1
+ ? '1 agent selected'
+ : selected.length + ' agents selected';
+ namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
+
+ // Recompute action availability + tooltips per render. Each
+ // action declares which agents it CAN'T run on; the bar disables
+ // the button and surfaces the offending names in the tooltip.
+ actions.replaceChildren();
+ const allRunning = selected.every((c) => c.running);
+ const allStopped = selected.every((c) => !c.running);
+
+ const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
+ const runningNames = selected.filter((c) => c.running).map((c) => c.name);
+
+ function why(label, blockers) {
+ if (!blockers.length) return null;
+ return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
+ }
+
+ addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
+ action: '/api/restart/',
+ confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
+ disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
+ });
+ addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
+ action: '/api/kill/',
+ confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
+ confirmLabel: '■ stop',
+ graceful: true,
+ disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
+ });
+ addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
+ action: '/api/start/',
+ confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
+ disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
+ });
+ addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
+ action: '/api/rebuild/',
+ confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
+ });
+ addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
+ action: '/api/destroy/',
+ confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
+ });
+ addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
+ action: '/api/destroy/',
+ body: { purge: 'on' },
+ confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
+ });
+
+ // Move agent(s) in the topology tree — selecting an option in the
+ // M0V3 dropdown immediately confirms + executes the move. "(no parent)"
+ // promotes to root (empty new_parent on the backend). Cycle-safe:
+ // dropdown filters out self and descendants on the client side; the
+ // backend rechecks via `topology::set_parent`.
+ //
+ // Backend: POST /api/topology/set-parent (dashboard.rs),
+ // form-encoded `child=&new_parent=`. Re-emits
+ // container snapshots on success so the tree repaints without a
+ // separate refresh.
+ addMoveActions(actions, selected, containers);
+}
+
+// Render the M0V3 picker in the selection bar. Selecting any real option
+// (including "(no parent)") immediately fires a confirm + POST — no
+// separate button. Backend `topology::set_parent` refuses invalid moves
+// and the refusal surfaces in the alert roll-up.
+function addMoveActions(parent, selected, containers) {
+ const candidates = validReparentCandidates(selected, containers);
+ const wrap = el('span', { class: 'move-picker' });
+ const selectTitle = selected.length === 1
+ ? `change ${selected[0].name}'s parent`
+ : `change ${selected.length} agents' parent`;
+ const sel = el('select', { class: 'move-picker-select', title: selectTitle });
+ sel.append(el('option', { value: '' }, '⇢ M0V3 →'));
+ // "(no parent)" -> empty new_parent on the backend (promotes to root).
+ sel.append(el('option', { value: '__root__' }, '(no parent)'));
+ for (const name of candidates) {
+ sel.append(el('option', { value: name }, name));
+ }
+ sel.addEventListener('change', async () => {
+ if (sel.selectedIndex === 0) return;
+ const newParent = sel.value === '__root__' ? '' : sel.value;
+ const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value;
+ const names = selected.map((c) => c.name);
+ const promptMsg = names.length === 1
+ ? `move ${names[0]} → ${newParentLabel}?`
+ : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
+ if (!(await themedConfirm({ message: promptMsg, danger: true }))) {
+ sel.selectedIndex = 0;
+ return;
+ }
+ sel.disabled = true;
+ const failures = [];
+ if (names.length === 1) {
+ // Single agent — use the form-encoded endpoint (backwards compat).
+ try {
+ const body = new URLSearchParams({ child: names[0], new_parent: newParent });
+ const resp = await fetch('/api/topology/set-parent', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok) {
+ const text = await resp.text().catch(() => '');
+ failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
+ }
+ } catch (err) {
+ failures.push(`${names[0]}: ${err}`);
+ }
+ } else {
+ // Multiple agents — use the bulk endpoint so all moves land in
+ // a single git commit instead of one per agent.
+ try {
+ const payload = names.map((n) => ({ child: n, new_parent: newParent || null }));
+ const resp = await fetch('/api/topology/set-parent-bulk', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok) {
+ const text = await resp.text().catch(() => '');
+ failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
+ }
+ } catch (err) {
+ failures.push(`bulk: ${err}`);
+ }
+ }
+ sel.disabled = false;
+ sel.selectedIndex = 0;
+ if (failures.length) {
+ themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
+ }
+ });
+ wrap.append(sel);
+ parent.append(wrap);
+}
+
+// Filter the dashboard's container list to those that are valid
+// re-parent targets for the `selected` agents: anyone who isn't IN
+// the selection itself, isn't a descendant of any selected agent
+// (cycle prevention across the whole batch). The backend re-checks
+// per-agent via `topology::set_parent`; this client-side filter is
+// purely UX so the operator can't pick an obviously-invalid option.
+function validReparentCandidates(selected, containers) {
+ // Build child map once.
+ const childrenOf = new Map();
+ for (const c of containers) {
+ const p = c.parent || null;
+ if (!childrenOf.has(p)) childrenOf.set(p, []);
+ childrenOf.get(p).push(c.name);
+ }
+ // Union descendant set across every selected agent (each agent's
+ // descendants AND itself).
+ const blocked = new Set();
+ for (const t of selected) {
+ const queue = [t.name];
+ blocked.add(t.name);
+ while (queue.length) {
+ const n = queue.shift();
+ for (const child of (childrenOf.get(n) || [])) {
+ if (blocked.has(child)) continue;
+ blocked.add(child);
+ queue.push(child);
+ }
+ }
+ }
+ return containers
+ .filter((c) => !blocked.has(c.name))
+ .map((c) => c.name)
+ .sort();
+}
+
+function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
+ const names = selected.map((c) => c.name);
+ const btn = el('button', {
+ type: 'button',
+ class: 'btn ' + btnClass,
+ }, label);
+ if (!enabled) {
+ btn.disabled = true;
+ if (opts.disabledTitle) btn.title = opts.disabledTitle;
+ }
+ btn.addEventListener('click', async () => {
+ if (btn.disabled) return;
+ const msg = opts.confirm(names);
+ let graceful = false;
+ if (msg) {
+ const r = await themedConfirm({
+ message: msg,
+ danger: true,
+ confirmLabel: opts.confirmLabel || 'confirm',
+ checkboxes: opts.graceful
+ ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }]
+ : [],
+ });
+ if (!r) return;
+ graceful = !!r.graceful;
+ }
+ btn.disabled = true;
+ const original = btn.innerHTML;
+ btn.innerHTML = '◐ ' + label;
+ const failures = [];
+ // Sequential POSTs to keep server-side serialisation predictable
+ // (rebuild_queue dedups but other endpoints don't); the loop is
+ // short — bulk selections are typically a handful of agents.
+ //
+ // Two URL shapes:
+ // - `opts.action` is a path prefix and the agent name gets
+ // appended (lifecycle endpoints: /start/, /rebuild/).
+ // `opts.body` is a static object applied to every POST.
+ // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
+ // full URL (no name appended) and the per-agent body comes
+ // from the callback. Used by /api/topology/set-parent, where
+ // the agent name is a body field rather than a URL component.
+ for (const name of names) {
+ const body = opts.perAgentBodyFor
+ ? new URLSearchParams(opts.perAgentBodyFor(name))
+ : new URLSearchParams(opts.body || {});
+ const url = opts.perAgentBodyFor
+ ? opts.action
+ : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
+ try {
+ const resp = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body,
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok) {
+ const text = await resp.text().catch(() => '');
+ failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
+ }
+ } catch (err) {
+ failures.push(`${name}: ${err}`);
+ }
+ }
+ btn.disabled = false;
+ btn.innerHTML = original;
+ if (failures.length) {
+ themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
+ }
+ // Container-lifecycle events (ContainerStateChanged /
+ // ContainerRemoved / RebuildQueueChanged) flow over the existing
+ // SSE channel and update the derived stores live — no manual
+ // refresh needed.
+ });
+ parent.append(btn);
+}
+
+// ─── peer hives ──────────────────────────────────────────────────────────────
+
+// Peer hives: render link cards as a headline section under SW4RM
+// (state.peer_hives). Called on every state refresh. When nothing is
+// federated, the "P33R H1V3S" headline block is hidden entirely and a
+// quiet grey note replaces the cards.
+export function renderPeerHives(peers) {
+ const block = $('peers-block');
+ const root = $('peers-section');
+ if (!root) return;
+ root.replaceChildren();
+ const hasPeers = Array.isArray(peers) && peers.length > 0;
+ if (block) block.hidden = !hasPeers;
+ if (!hasPeers) {
+ root.append(el('p', { class: 'empty' }, 'no peer hives configured'));
+ return;
+ }
+ const ul = el('ul', { class: 'containers' });
+ for (const p of peers) {
+ const li = el('li', { class: 'container-row' });
+ const head = el('div', { class: 'head' });
+ const icon = el('span', { class: 'container-icon' }, '⬡');
+ const nameEl = el('span', { class: 'name' }, p.name || p.url);
+ const linkEl = el('a', {
+ href: p.url,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ class: 'meta',
+ title: 'open ' + p.name + ' dashboard',
+ }, p.url);
+ head.append(icon, nameEl);
+ li.append(head, el('div', { class: 'meta' }, linkEl));
+ ul.append(li);
+ }
+ root.append(ul);
+}
+
+// ─── tickers ─────────────────────────────────────────────────────────────────
+
+// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
+// (unix seconds) on the `.status-age` span. Keyed container rows persist
+// across re-renders, so without this ticker the "(set N ago)" label would
+// become stale as time passes. 30s granularity matches fmtAgeSecs precision
+// (sub-minute values round to seconds, coarser above that).
+setInterval(() => {
+ const now = Math.floor(Date.now() / 1000);
+ document.querySelectorAll('.status-age[data-set-at]').forEach((node) => {
+ const setAt = Number(node.dataset.setAt);
+ if (!Number.isFinite(setAt) || setAt === 0) return;
+ node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
+ });
+}, 30_000);
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index a08520ea..be36e2e2 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -9,22 +9,19 @@
// live in `./common.js`; the flow-page surface (operator inbox, broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `dashboard.html` each load their own bundle.
+// SW4RM (containers) domain lives in `./swarm.js`.
import { marked } from 'marked';
import {
- $, el, esc, form,
- fmtAgeSecs,
+ $, el,
Panel, NOTIF,
- makePathLink, appendText, appendLinkified,
openStream, renderServerWarnings, bindAsyncForms,
} from './common.js';
-import { themedConfirm, themedToast } from './modal.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import {
containersState, syncContainersFromSnapshot,
- questionsState,
} from './state.js';
-import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
+import { fmtAgo, fmtDuration } from './util.js';
import {
applyCapabilitiesChanged, applyToolGroupsChanged,
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
@@ -42,6 +39,13 @@ import {
syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved,
renderQuestions, activeQuestionCount,
} from './call.js';
+import {
+ syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot,
+ applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
+ applyTransientSet, applyTransientCleared,
+ renderContainers, renderContainersFromState,
+ renderSelectionBar, renderPeerHives,
+} from './swarm.js';
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@@ -49,18 +53,6 @@ import {
window.marked = marked;
(() => {
- // ─── constants ──────────────────────────────────────────────────────────
- // Context-window badge thresholds. Preferred source is each container's
- // `context_window_tokens` from /api/state (the real window for the model
- // it last ran on) — thresholds are then 75% / 50% of it, matching the
- // harness compaction watermarks (compact at 75%, auto-reset at 50%). The
- // fixed token constants are the fallback for when that field is absent
- // (agent has no turns yet, or no per-model config matched the model).
- const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red
- const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow
- const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k)
- const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k)
-
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
@@ -108,1157 +100,6 @@ window.marked = marked;
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
bindAsyncForms(() => refreshState());
- // The live agent roster (`containersState`) + its snapshot-sync now
- // live in `./state.js` — it's the one piece of cross-domain state, read
- // by nearly every tab. It's still mutated live by `container_state_changed`
- // (upsert by name) and `container_removed` (drop by name) via the apply
- // handlers just below; the imported binding reflects those in place.
- //
- // Keyed container row cache. Maps agent name → { el: , fingerprint }.
- // Allows renderContainers to skip rebuilding rows whose displayed state
- // hasn't changed — prevents full-wipe flicker + avoids redundant async
- // dashboard-state fetches on every SSE event. Container-domain only,
- // so it stays here (not in state.js).
- const containerRowCache = new Map();
- function applyContainerStateChanged(ev) {
- if (!ev.container || !ev.container.name) return;
- containersState.set(ev.container.name, ev.container);
- renderContainersFromState();
- }
- function applyContainerRemoved(ev) {
- if (containersState.delete(ev.name)) renderContainersFromState();
- }
-
- // Rebuild queue state — drives "building…" / "meta-updating…" badges on
- // SW4RM agent cards (see inFlightOpsByAgent + docs/web-ui.md::Container row).
- let rebuildQueueState = [];
- function syncRebuildQueueFromSnapshot(s) {
- rebuildQueueState = (s.rebuild_queue || []).slice();
- }
- function applyRebuildQueueChanged(ev) {
- rebuildQueueState = (ev.queue || []).slice();
- // Re-render the SW4RM tab so newly-queued / newly-running
- // rebuild-queue ops light up the right card with a building…
- // badge, and finished ops fall back to the regular state badges.
- // See docs/web-ui.md::Container row for the badge taxonomy.
- renderContainersFromState();
- }
- // Map from agent name → highest-priority in-flight queue entry
- // (`running` beats `queued`). Used by the container row renderer
- // to surface "building..." / "meta-updating..." badges on the
- // SW4RM tab when an op is still in the rebuild queue but no
- // operator-initiated transient is set.
- function inFlightOpsByAgent() {
- const out = new Map();
- for (const e of rebuildQueueState) {
- if (e.state !== 'queued' && e.state !== 'running') continue;
- // spawn ops target an agent that doesn't exist yet as a
- // container — the transient store already drives the
- // pending row for that case. Skip here to avoid double-
- // surfacing if the spawn op happens to land in the queue
- // while the row exists transiently.
- if (e.kind === 'spawn') continue;
- const cur = out.get(e.agent);
- // Prefer running over queued; otherwise keep the first match.
- if (!cur || (cur.state === 'queued' && e.state === 'running')) {
- out.set(e.agent, e);
- }
- }
- return out;
- }
-
- // Derived transient state — cold-loaded from /api/state.transients,
- // then mutated live by `transient_set` / `transient_cleared`. Keyed
- // by agent name so add/remove are O(1). `since_unix` is wall-clock so
- // the elapsed-seconds badge ticks without polling.
- const transientsState = new Map();
- function syncTransientsFromSnapshot(s) {
- transientsState.clear();
- for (const t of s.transients || []) {
- // Snapshot ships `secs` (server-computed); reconstruct an
- // approximate since_unix so the live ticker keeps progressing
- // without surprising jumps when the next snapshot lands.
- const nowUnix = Math.floor(Date.now() / 1000);
- transientsState.set(t.name, {
- kind: t.kind,
- since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
- });
- }
- }
- function applyTransientSet(ev) {
- transientsState.set(ev.name, {
- kind: ev.transient_kind,
- since_unix: ev.since_unix,
- });
- renderContainersFromState();
- }
- function applyTransientCleared(ev) {
- if (transientsState.delete(ev.name)) renderContainersFromState();
- }
- // Re-render using the last cached snapshot (containers come from
- // /api/state, transients overlay from the derived map). The snapshot
- // is stashed on window.__hyperhive_state by refreshState; on cold
- // load before the first snapshot we just skip.
- function renderContainersFromState() {
- const s = window.__hyperhive_state;
- if (s) renderContainers(s);
- }
-
- // ─── selection ──────────────────────────────────────────────────────
- // In-memory set of selected agent logical names backing the sticky
- // #selection-bar. See docs/web-ui.md::Selection bar for the
- // interaction model (icon-click toggle, Esc/clear button drop,
- // tab-gated visibility).
- const selectionState = new Set();
- function toggleSelection(name) {
- if (selectionState.has(name)) selectionState.delete(name);
- else selectionState.add(name);
- renderContainersFromState();
- }
- function clearSelection() {
- if (selectionState.size === 0) return;
- selectionState.clear();
- renderContainersFromState();
- }
- // Esc clears the current selection (operator escape hatch — mirrors
- // the side-panel close pattern). Ignored when an editable element
- // has focus so typing in compose / answer / journal-search isn't
- // intercepted.
- document.addEventListener('keydown', (e) => {
- if (e.key !== 'Escape') return;
- if (!selectionState.size) return;
- const a = document.activeElement;
- if (a && (a.isContentEditable
- || a.tagName === 'INPUT'
- || a.tagName === 'TEXTAREA'
- || a.tagName === 'SELECT')) return;
- e.preventDefault();
- clearSelection();
- });
- document.addEventListener('click', (e) => {
- if (e.target && e.target.closest('#selection-clear')) {
- clearSelection();
- }
- });
-
- // ─── per-agent context menu ──────────────────────────────────────────
- // Three-dot (⋮) button on each agent card for quick single-agent
- // lifecycle actions without needing to select first. State-aware:
- // restart/stop only shown when running, start only shown when
- // stopped; rebuild + destroy/purge always shown.
- // The button is CSS-invisible until the row is hovered (or menu is
- // open) so it doesn't clutter quiet rows.
-
- let openAgentMenu = null; // currently open dropdown element, or null
-
- function closeAllAgentMenus() {
- if (!openAgentMenu) return;
- openAgentMenu.hidden = true;
- const wrap = openAgentMenu.closest('.agent-menu');
- if (wrap) {
- wrap.classList.remove('open');
- const btn = wrap.querySelector('.agent-menu-btn');
- if (btn) btn.setAttribute('aria-expanded', 'false');
- }
- openAgentMenu = null;
- }
-
- // Close on any click outside an agent-menu element.
- document.addEventListener('click', (e) => {
- if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
- }, true);
- // Close on Escape. stopImmediatePropagation so the selection-clear
- // handler on the same element doesn't also fire when a menu is open.
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape' && openAgentMenu) {
- closeAllAgentMenus();
- e.stopImmediatePropagation();
- }
- }, true);
-
- // Single-agent POST helper shared by all menu items.
- async function agentMenuPost(actionPath, name, body, graceful) {
- const url = actionPath + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
- try {
- const resp = await fetch(url, {
- method: 'POST',
- headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
- body: body ? new URLSearchParams(body) : undefined,
- redirect: 'manual',
- });
- const ok = resp.ok || resp.type === 'opaqueredirect'
- || (resp.status >= 200 && resp.status < 400);
- if (!ok) {
- const text = await resp.text().catch(() => '');
- themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
- }
- } catch (err) {
- themedToast('action failed: ' + err, { type: 'error' });
- }
- }
-
- function buildAgentMenu(c, forgeBase) {
- const wrap = el('div', { class: 'agent-menu' });
- const btn = el('button', {
- type: 'button',
- class: 'agent-menu-btn',
- title: `actions for ${c.name}`,
- 'aria-label': `actions for ${c.name}`,
- 'aria-haspopup': 'menu',
- 'aria-expanded': 'false',
- }, '⋮');
-
- const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
-
- function menuItem(label, opts) {
- const li = el('li', { role: 'presentation' });
- const item = el('button', {
- type: 'button',
- class: 'agent-menu-item',
- role: 'menuitem',
- }, label);
- item.addEventListener('click', async () => {
- closeAllAgentMenus();
- let graceful = false;
- if (opts.confirm) {
- const r = await themedConfirm({
- message: opts.confirm,
- danger: true,
- confirmLabel: opts.confirmLabel || 'confirm',
- checkboxes: opts.graceful
- ? [{ name: 'graceful', label: 'stop gracefully — let the agent finish its turn and flush state before the container stops' }]
- : [],
- });
- if (!r) return;
- graceful = !!r.graceful;
- }
- await agentMenuPost(opts.action, c.name, opts.body || null, graceful);
- });
- li.append(item);
- return li;
- }
-
- function menuSep() {
- return el('li', { class: 'agent-menu-sep', role: 'separator' });
- }
-
- // Navigation link item (opens in same tab by default).
- function menuLink(label, href, title) {
- const li = el('li', { role: 'presentation' });
- const a = el('a', {
- class: 'agent-menu-item',
- href,
- role: 'menuitem',
- title: title || '',
- }, label);
- a.addEventListener('click', () => closeAllAgentMenus());
- li.append(a);
- return li;
- }
-
- // Show only actions that are applicable in the current state.
- if (c.running) {
- dropdown.append(
- menuItem('↺ R3ST4RT', { action: '/api/restart/', confirm: `restart ${c.name}?` }),
- menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
- );
- } else {
- dropdown.append(
- menuItem('▶ ST4RT', { action: '/api/start/', confirm: `start ${c.name}?` }),
- );
- }
- dropdown.append(
- menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
- menuSep(),
- // Deep-link to the AGENT log tab pre-filtered to this container.
- // The ?agent= param is read by logs.js on load and pre-selects this
- // agent's journal without extra clicks.
- menuLink('journal logs →',
- `/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
- `view ${c.name} journal logs`),
- );
-
- dropdown.append(
- menuSep(),
- menuItem('DESTR0Y', {
- action: '/api/destroy/',
- confirm: `destroy ${c.name}? container removed; state + creds kept.`,
- }),
- menuItem('PURG3', {
- action: '/api/destroy/',
- body: { purge: 'on' },
- confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
- }),
- );
-
- if (c.deployed_sha && forgeBase) {
- const li = el('li', { role: 'presentation' });
- const a = el('a', {
- class: 'agent-menu-item',
- href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
- target: '_blank',
- rel: 'noopener',
- role: 'menuitem',
- title: 'deployed config commit on forge',
- }, `deployed:${c.deployed_sha} ↗`);
- li.append(a);
- dropdown.append(menuSep(), li);
- }
-
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- const wasHidden = dropdown.hidden;
- closeAllAgentMenus();
- if (wasHidden) {
- dropdown.hidden = false;
- btn.setAttribute('aria-expanded', 'true');
- wrap.classList.add('open');
- openAgentMenu = dropdown;
- }
- });
-
- wrap.append(btn, dropdown);
- return wrap;
- }
-
- // Re-derive port conflicts from the live containers map. Mirrors the
- // server-side `build_port_conflicts` so the banner reacts to event
- // updates instead of waiting for a /api/state refetch.
- function derivePortConflicts(containers) {
- const byPort = new Map();
- for (const c of containers) {
- if (!byPort.has(c.port)) byPort.set(c.port, []);
- byPort.get(c.port).push(c.name);
- }
- const out = [];
- for (const [port, agents] of byPort) {
- if (agents.length > 1) {
- agents.sort();
- out.push({ port, agents });
- }
- }
- out.sort((a, b) => a.port - b.port);
- return out;
- }
-
- // ─── state rendering ────────────────────────────────────────────────────
- // ─── agent topology ─────────────────────────────────────────────────
- // See docs/web-ui.md::Topology tree for the rendering contract
- // (forest walk, alphabetical sort, orphan + cycle handling).
- function buildAgentTree(containers) {
- // Close any open context menu before replacing the DOM tree — the
- // previous dropdown element would otherwise be a stale reference.
- closeAllAgentMenus();
- const byName = new Map();
- for (const c of containers) byName.set(c.name, c);
- const children = new Map(); // parent_name → [child_name, …]
- const roots = [];
- for (const c of containers) {
- const p = c.parent || null;
- if (p == null || !byName.has(p)) {
- roots.push(c.name);
- } else {
- const list = children.get(p) || [];
- list.push(c.name);
- children.set(p, list);
- }
- }
- roots.sort();
- for (const list of children.values()) list.sort();
- const out = [];
- const visited = new Set();
- function visit(name, depth, ancestorIsLast, isLast) {
- if (visited.has(name)) return;
- visited.add(name);
- const c = byName.get(name);
- if (!c) return;
- out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
- const kids = children.get(name) || [];
- kids.forEach((kid, i) =>
- visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
- }
- roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
- // Cycle safety: anything not reached lands at root level so no
- // agent silently disappears when a config is malformed.
- for (const c of containers) {
- if (!visited.has(c.name)) visit(c.name, 0, [], true);
- }
- return out;
- }
- // Builds the .tree-prefix DOM for a row at the given depth.
- // See docs/web-ui.md::Topology tree for why this is DOM-painted
- // (one positioned per lane) rather than text-glyph-painted.
- function treePrefixDom({ depth, ancestorIsLast, isLast }) {
- if (depth === 0) return null;
- const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
- // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
- // continuation column — top-level rows are separated visually as
- // top-level rows already).
- for (let d = 1; d < depth; d++) {
- const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
- prefix.append(el('span', { class: cls }));
- }
- const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
- prefix.append(el('span', { class: jointCls }));
- return prefix;
- }
-
- // Serialise the visible state of a container row into a stable string
- // for change-detection. Includes everything that affects what the row
- // renders — container fields, derived pending/selection state, tree
- // position, and link-base context. The async dashboard-state (nav
- // strip, ctx badge, status text) is intentionally excluded: it
- // populates in-place and is preserved when a row is reused.
- function containerRowFingerprint(c, node, pending, opRunning, selected,
- askerCount, targetCount, gatewayLinks, hostname) {
- return JSON.stringify({
- running: c.running,
- needs_login: c.needs_login,
- needs_update: c.needs_update,
- pending_reminders: c.pending_reminders,
- active_model: c.active_model,
- port: c.port,
- pending,
- opRunning,
- selected,
- askerCount,
- targetCount,
- depth: node.depth,
- isLast: node.isLast,
- ancestorIsLast: node.ancestorIsLast,
- gatewayLinks,
- hostname,
- });
- }
-
- // Build a single container-row from scratch. Extracted so
- // renderContainers can call this only for rows whose fingerprint
- // changed (keyed cache), skipping the build + async dashboard-state
- // fetch for stable rows.
- function buildContainerLi(c, node, opts) {
- const {
- pending, opRunning, selected,
- askerCount, targetCount, agentQCount,
- url, containerBase, forgeBase, s,
- } = opts;
- const li = el('li', {
- class: 'container-row'
- + (pending ? ' pending' : '')
- + (opRunning ? ' pending-running' : '')
- + (selected ? ' selected' : ''),
- });
- // Topology: depth contributes left-padding; the glyph string in
- // the .tree-prefix span draws the ├─ / └─ joint + continuation
- // lines (`│ `) for ancestors whose subtree extends below this
- // row. Both are CSS-driven from the data attributes so the
- // legacy flat layout (every container at depth 0) is bit-
- // identical to today's render — no glyph, no indent.
- if (node.depth > 0) li.dataset.depth = String(node.depth);
- const prefix = treePrefixDom(node);
- if (prefix) li.prepend(prefix);
-
- // Agent icon: 5em square wrapper with an absolutely-positioned
- //
+ fire-and-forget load with /favicon.svg fallback. The
- // wrapper doubles as the selection toggle (click / keyboard).
- // See docs/web-ui.md::Container row for the layout + load-strategy
- // rationale.
- const iconImg = el('img', { class: 'container-icon-img', alt: '' });
- const icon = el('div', {
- class: 'container-icon',
- role: 'button',
- tabindex: '0',
- 'aria-pressed': selected ? 'true' : 'false',
- title: selected
- ? `deselect ${c.name} (or press Esc to clear all)`
- : `select ${c.name} for bulk actions`,
- }, iconImg);
- icon.addEventListener('click', (e) => {
- e.preventDefault();
- toggleSelection(c.name);
- });
- icon.addEventListener('keydown', (e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- toggleSelection(c.name);
- }
- });
- if (c.running) {
- iconImg.src = `${url}icon`;
- iconImg.addEventListener('error', () => {
- if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
- iconImg.dataset.fallback = '1';
- icon.classList.add('icon-unreachable');
- iconImg.src = '/favicon.svg';
- });
- } else {
- // Container stopped — skip the doomed `${url}icon` fetch and
- // go straight to the dimmed hyperhive mark. Avoids a noisy
- // failed request in the console + the brief broken-image flash.
- icon.classList.add('icon-unreachable');
- iconImg.src = '/favicon.svg';
- }
- // Card body: the three stacked content lines, right of the icon.
- const body = el('div', { class: 'card-body' });
-
- // ── identity ─────────────────────────────────────────────────
- const head = el('div', { class: 'head' });
- head.append(
- el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
- );
- // Icon-only nav strip — populated async from the agent's own
- // `GET /api/dashboard-state` (via gateway when enabled, direct
- // TCP otherwise). The agent is the single source of truth for its
- // link list: stats / screen (GUI agents only — c0re's disk-based
- // fallback cannot detect this) / forge profile / agent-configs /
- // extras. DOM-built — link strings come from the agent's process
- // and must never reach the HTML parser.
- const navStrip = el('span', { class: 'nav-strip' });
- head.append(navStrip);
- if (c.running) {
- // Fetch the lean dashboard-state snapshot from the agent directly.
- // Populates: nav strip links (including the screen link that
- // c0re's disk-based build cannot detect), rate_limited badge,
- // ctx-window badge, and self-reported status text.
- // Fails gracefully when the agent is starting up or the gateway
- // is not yet routing to it — badges simply don't appear.
- // Only runs when the row is first built (fingerprint changed) —
- // reused rows keep their previously-fetched nav strip + badges.
- fetch(`${containerBase}/api/dashboard-state`)
- .then((r) => (r.ok ? r.json() : null))
- .then((ds) => {
- if (!ds) return;
- // ── nav strip ───────────────────────────────────────────
- if (Array.isArray(ds.links)) {
- for (const lnk of ds.links) {
- const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
- : lnk.kind === 'external' ? (lnk.url || '')
- : /* container */ containerBase + '/' + (lnk.url || '');
- const a = el('a', {
- class: 'nav-link',
- href,
- target: '_blank',
- rel: 'noopener',
- title: lnk.label || '',
- });
- // Plain text — agent-controlled strings stay out of innerHTML.
- a.textContent = lnk.icon || lnk.label || '';
- navStrip.append(a);
- }
- }
- // ── agent-owned status badges ────────────────────────────
- // rate_limited: only show when no other critical badge is
- // already shown (pending / not-running already handled sync).
- if (ds.rate_limited) {
- head.append(el('span',
- { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
- '⊘ rate limited'));
- }
- // ctx-window badge
- if (ds.ctx_tokens != null) {
- const k = Math.round(ds.ctx_tokens / 1000);
- const win = ds.context_window_tokens;
- const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
- const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
- const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
- : ds.ctx_tokens >= caution ? 'badge-ctx-caution'
- : 'badge-ctx-ok';
- const title = win != null
- ? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
- + `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
- : `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
- head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
- }
- // ── agent status text (self-reported via set_status) ─────
- if (ds.status_text) {
- const nowUnix = Math.floor(Date.now() / 1000);
- const ageStr = ds.status_set_at != null
- ? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
- // Stamp data-set-at so the 30s ticker below keeps the age
- // label current even when the row is reused from the cache.
- const ageAttrs = ds.status_set_at != null
- ? { class: 'status-age', 'data-set-at': String(ds.status_set_at) }
- : { class: 'status-age' };
- // Icon + message are wrapped together in `.status-msg` so the
- // CSS can clamp *just the message* to two lines; the age span is
- // a separate flex sibling that is never clipped, so the "(set N
- // ago)" stamp stays visible even when the status text is long.
- body.append(el('div', {
- class: 'agent-status',
- title: `agent self-reported status${ageStr}`,
- },
- el('span', { class: 'status-msg' },
- el('span', { class: 'status-icon' }, '◈ '),
- ds.status_text,
- ),
- el('span', ageAttrs, ageStr),
- ));
- }
- })
- .catch(() => { /* graceful: agent starting / gateway miss → no data */ });
- }
- // Status / runtime badges. Pending transients always win
- // (start / stop / restart / rebuild is in progress). Otherwise,
- // when the container is stopped, surface a single `■ not running`
- // badge. `needs_login` is still c0re-owned (reads auth sentinel
- // files on the host). rate_limited / ctx / status_text are
- // agent-owned and rendered by the async dashboard-state fetch above.
- if (pending) {
- head.append(el('span', { class: 'pending-state' },
- el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
- } else if (!c.running) {
- head.append(el('span',
- { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
- '■ not running'));
- } else if (c.needs_login) {
- head.append(el('a',
- { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
- 'needs login →'));
- }
- if (c.needs_update) {
- head.append(form(
- '/api/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
- 'rebuild ' + c.name + '? hot-reloads the container.',
- {}, { noRefresh: true },
- ));
- }
-
- if (c.active_model) {
- head.append(el('span',
- { class: 'badge badge-model', title: `active claude model: ${c.active_model}` },
- c.active_model));
- }
-
- if (c.pending_reminders && c.pending_reminders > 0) {
- head.append(el('span',
- {
- class: 'badge badge-reminder',
- title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
- },
- `⏰ ${c.pending_reminders}`));
- }
- // Pending questions where this agent is the asker (awaiting an
- // answer) or the target (owes a reply). Derived live from
- // questionsState so the badge updates instantly on QuestionAdded /
- // QuestionResolved without a separate backend field.
- if (agentQCount > 0) {
- const parts = [];
- if (askerCount > 0) parts.push(`${askerCount} asked`);
- if (targetCount > 0) parts.push(`${targetCount} to answer`);
- head.append(el('span',
- {
- class: 'badge badge-loose-ends',
- title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
- },
- `❓ ${agentQCount}`));
- }
- body.append(head);
-
- // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
- // DESTR0Y / PURG3) moved to the selection bar — see
- // docs/web-ui.md::Selection bar. The contextual `needs update ↻`
- // chip in the head row stays — it's a state-hint, not an
- // action button.
-
- li.append(icon, body, buildAgentMenu(c, forgeBase));
- return li;
- }
-
- function renderContainers(s) {
- const root = $('containers-section');
- // #containers-section only exists on /dashboard.html. tabs.js is the
- // bundle for that page only (flow.html loads flow.js instead).
- // Belt-and-suspenders for any future page that adds tabs.js
- // without a #containers-section — matches the
- // no-op-when-target-absent convention the other renderers
- // (renderQuestions, renderApprovals, etc.) follow.
- if (!root) return;
-
- // Containers come from the derived map (event-driven) rather than
- // `s.containers`; `s` still supplies hostname (for the web-ui
- // link) and tombstones/meta_inputs (not event-derived yet). The
- // tree builder handles the ordering — we don't pre-sort here.
- const containers = Array.from(containersState.values());
- const portConflicts = derivePortConflicts(containers);
- const anyStale = containers.some((c) => c.needs_update);
-
- // Preserve the keyed container list across the header-section
- // rebuild. We wipe the banners/buttons above the list on every
- // render (simple), but recycle elements for unchanged rows
- // (keyed) to avoid full-DOM thrash and redundant async fetches.
- const existingUl = root.querySelector('ul.containers');
- root.replaceChildren();
-
- // Port-hash collisions: rename one of the listed agents and
- // rebuild. The banner sits above the agent list so it's the
- // first thing the operator sees when something's wedged.
- if (portConflicts.length) {
- const banner = el('div', { class: 'port-conflict' },
- el('strong', {}, '⚠ port collision'), ' — ');
- const groups = portConflicts.map((c) =>
- `:${c.port} (${c.agents.join(' + ')})`).join('; ');
- banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
- root.append(banner);
- }
-
- if (anyStale) {
- root.append(form(
- '/api/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
- 'rebuild every stale container?',
- {}, { noRefresh: true },
- ));
- }
-
- // Queue-summary banner: when the rebuild queue has active work,
- // show one compact at-a-glance line + a link to the full queue on the
- // C0R3 page. Replaces the old per-transient spinner list — the actual
- // running step is already visible per-agent on each card (transient +
- // in-flight-queue badges), so the top of the tab only needs the summary.
- const activeQueue = rebuildQueueState.filter(
- (e) => e.state === 'queued' || e.state === 'running',
- );
- if (activeQueue.length) {
- const running = activeQueue.filter((e) => e.state === 'running').length;
- const queued = activeQueue.length - running;
- const parts = [];
- if (running) parts.push(`${running} running`);
- if (queued) parts.push(`${queued} queued`);
- root.append(el('div', { class: 'queue-summary' },
- el('span', { class: 'glyph spinner' }, '◐'), ' ',
- el('strong', {}, 'build queue'), ' — ',
- parts.join(' · '), ' ',
- el('a', { class: 'queue-summary-link', href: '/builds.html' }, 'view queue →'),
- ));
- }
-
- if (!containers.length && !transientsState.size) {
- root.append(el('p', { class: 'empty' }, 'no managed containers'));
- return;
- }
-
- // Drop stale selections (agent destroyed while selected). Defensive —
- // the action bar would otherwise loop POST against a gone agent.
- const liveNames = new Set(containers.map((c) => c.name));
- for (const n of Array.from(selectionState)) {
- if (!liveNames.has(n)) selectionState.delete(n);
- }
-
- const hostname = (s && s.hostname) || window.location.hostname;
- // When hive-gateway is in front of the dashboard, build same-origin
- // `/agent//` URLs instead of the direct `http://:/`
- // TCP fallback — the gateway proxies the prefix to the per-agent
- // harness (unix-domain via `agent-sockets.json`, or a computed TCP
- // loopback port while the socket marker is absent). See
- // `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
- const gatewayLinks = !!(s && s.gateway_enabled);
- // Forge public URL: prefer state.forge_public_url (set by the NixOS
- // module when forge.behindGateway=true), fall back to
- // ":3000" for gateway-off / local-dev deploys.
- const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
- const ul = existingUl ?? el('ul', { class: 'containers' });
- const tree = buildAgentTree(containers);
- // In-flight rebuild / meta-update / destroy ops per agent name —
- // see docs/web-ui.md::Container row for the building… badge
- // rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
- // gap when no operator transient is set).
- const inFlight = inFlightOpsByAgent();
-
- // Build the ordered list of elements, reusing cached rows
- // whose displayed state hasn't changed.
- const orderedLis = [];
- for (const node of tree) {
- const c = node.container;
- const url = gatewayLinks
- ? `/agent/${encodeURIComponent(c.name)}/`
- : `http://${hostname}:${c.port}/`;
- // Container nav-strip base: gateway prefix or direct TCP.
- const containerBase = gatewayLinks
- ? `/agent/${encodeURIComponent(c.name)}`
- : `http://${hostname}:${c.port}`;
- // Pending-state derivation + queued-vs-running split — see
- // docs/web-ui.md::Container row for the transient → in-flight
- // queue priority order and the opRunning rationale.
- const transientKind = transientsState.get(c.name)?.kind || null;
- const op = !transientKind ? inFlight.get(c.name) : null;
- const pending = transientKind
- || (op && (op.state === 'running'
- ? (op.kind === 'meta_update' ? 'meta-updating'
- : op.kind === 'destroy' ? 'destroying'
- : op.kind === 'restart' ? 'restarting'
- : op.kind === 'start' ? 'starting'
- : op.kind === 'stop' ? 'stopping'
- : op.kind === 'graceful_stop' ? 'stopping'
- : 'rebuilding')
- : (op.kind === 'meta_update' ? 'meta-update queued'
- : op.kind === 'destroy' ? 'destroy queued'
- : op.kind === 'restart' ? 'restart queued'
- : op.kind === 'start' ? 'start queued'
- : op.kind === 'stop' ? 'stop queued'
- : op.kind === 'graceful_stop' ? 'stop queued'
- : 'rebuild queued')));
- const opRunning = transientKind != null
- || (op != null && op.state === 'running');
- const selected = selectionState.has(c.name);
- // Pending questions where this agent is the asker (awaiting an
- // answer) or the target (owes a reply). Derived live from
- // questionsState so the badge updates instantly on QuestionAdded /
- // QuestionResolved without a separate backend field.
- const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
- const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
- const agentQCount = questionsState.pending.filter(
- (q) => q.asker === c.name || q.target === c.name,
- ).length;
-
- const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
- askerCount, targetCount, gatewayLinks, hostname);
- const cached = containerRowCache.get(c.name);
-
- let li;
- if (cached && cached.fingerprint === fp) {
- // Row unchanged — reuse the existing DOM node. The async
- // dashboard-state (nav strip, ctx badge, status text) stays
- // intact from the previous build, avoiding a redundant fetch.
- li = cached.el;
- } else {
- li = buildContainerLi(c, node, {
- pending, opRunning, selected,
- askerCount, targetCount, agentQCount,
- url, containerBase, forgeBase, s,
- });
- containerRowCache.set(c.name, { el: li, fingerprint: fp });
- }
- orderedLis.push(li);
- }
-
- // Remove cache entries for agents that no longer exist.
- for (const [name, entry] of containerRowCache) {
- if (!liveNames.has(name)) {
- entry.el.remove();
- containerRowCache.delete(name);
- }
- }
-
- // Apply correct DOM order without a full wipe. insertBefore is a
- // no-op when the node is already at position i, so stable sections
- // of the list cause zero layout work.
- for (let i = 0; i < orderedLis.length; i++) {
- if (ul.children[i] !== orderedLis[i]) {
- ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
- }
- }
- // Trim any excess children (defensive — shouldn't happen after the
- // cache-removal pass above, but keeps the ul length exact).
- while (ul.children.length > orderedLis.length) ul.lastChild.remove();
-
- root.append(ul);
- renderSelectionBar(containers);
- }
-
- // ─── selection bar ──────────────────────────────────────────────────
- // Sticky-bottom strip; visible when ≥1 agent selected on the SW4RM
- // tab. See docs/web-ui.md::Selection bar for the interaction model
- // and the per-action availability rules (disabled-with-tooltip for
- // actions that don't apply to the full selection). Actions POST per
- // agent in a loop (endpoints are individually idempotent /
- // event-covered, so no new bulk backend wire is needed).
- function renderSelectionBar(containers) {
- const bar = $('selection-bar');
- if (!bar) return;
- const countSpan = $('selection-count');
- const namesSpan = $('selection-names');
- const actions = $('selection-actions');
- if (!countSpan || !namesSpan || !actions) return;
-
- const selected = containers.filter((c) => selectionState.has(c.name));
- // Tab-gate: bar only renders on SW4RM (the only tab with agent
- // cards to cross-reference). Selection state lives in-memory and
- // the bar reappears on return to SW4RM if still non-empty.
- const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm';
- if (!selected.length || !onSwarmTab) {
- bar.hidden = true;
- document.body.classList.remove('has-selection');
- return;
- }
- bar.hidden = false;
- document.body.classList.add('has-selection');
- countSpan.textContent = selected.length === 1
- ? '1 agent selected'
- : selected.length + ' agents selected';
- namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
-
- // Recompute action availability + tooltips per render. Each
- // action declares which agents it CAN'T run on; the bar disables
- // the button and surfaces the offending names in the tooltip.
- actions.replaceChildren();
- const allRunning = selected.every((c) => c.running);
- const allStopped = selected.every((c) => !c.running);
-
- const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
- const runningNames = selected.filter((c) => c.running).map((c) => c.name);
-
- function why(label, blockers) {
- if (!blockers.length) return null;
- return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
- }
-
- addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
- action: '/api/restart/',
- confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
- disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
- });
- addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
- action: '/api/kill/',
- confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
- confirmLabel: '■ stop',
- graceful: true,
- disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
- });
- addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
- action: '/api/start/',
- confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
- disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
- });
- addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
- action: '/api/rebuild/',
- confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
- });
- addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
- action: '/api/destroy/',
- confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
- });
- addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
- action: '/api/destroy/',
- body: { purge: 'on' },
- confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
- });
-
- // Move agent(s) in the topology tree — selecting an option in the
- // M0V3 dropdown immediately confirms + executes the move. "(no parent)"
- // promotes to root (empty new_parent on the backend). Cycle-safe:
- // dropdown filters out self and descendants on the client side; the
- // backend rechecks via `topology::set_parent`.
- //
- // Backend: POST /api/topology/set-parent (dashboard.rs),
- // form-encoded `child=&new_parent=`. Re-emits
- // container snapshots on success so the tree repaints without a
- // separate refresh.
- addMoveActions(actions, selected, containers);
- }
-
- // Render the M0V3 picker in the selection bar. Selecting any real option
- // (including "(no parent)") immediately fires a confirm + POST — no
- // separate button. Backend `topology::set_parent` refuses invalid moves
- // and the refusal surfaces in the alert roll-up.
- function addMoveActions(parent, selected, containers) {
- const candidates = validReparentCandidates(selected, containers);
- const wrap = el('span', { class: 'move-picker' });
- const selectTitle = selected.length === 1
- ? `change ${selected[0].name}'s parent`
- : `change ${selected.length} agents' parent`;
- const sel = el('select', { class: 'move-picker-select', title: selectTitle });
- sel.append(el('option', { value: '' }, '⇢ M0V3 →'));
- // "(no parent)" → empty new_parent on the backend (promotes to root).
- sel.append(el('option', { value: '__root__' }, '(no parent)'));
- for (const name of candidates) {
- sel.append(el('option', { value: name }, name));
- }
- sel.addEventListener('change', async () => {
- if (sel.selectedIndex === 0) return;
- const newParent = sel.value === '__root__' ? '' : sel.value;
- const newParentLabel = sel.value === '__root__' ? '(no parent)' : sel.value;
- const names = selected.map((c) => c.name);
- const promptMsg = names.length === 1
- ? `move ${names[0]} → ${newParentLabel}?`
- : `move ${names.length} agents (${names.join(', ')}) → ${newParentLabel}?`;
- if (!(await themedConfirm({ message: promptMsg, danger: true }))) {
- sel.selectedIndex = 0;
- return;
- }
- sel.disabled = true;
- const failures = [];
- if (names.length === 1) {
- // Single agent — use the form-encoded endpoint (backwards compat).
- try {
- const body = new URLSearchParams({ child: names[0], new_parent: newParent });
- const resp = await fetch('/api/topology/set-parent', {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body,
- redirect: 'manual',
- });
- const ok = resp.ok || resp.type === 'opaqueredirect'
- || (resp.status >= 200 && resp.status < 400);
- if (!ok) {
- const text = await resp.text().catch(() => '');
- failures.push(`${names[0]}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
- }
- } catch (err) {
- failures.push(`${names[0]}: ${err}`);
- }
- } else {
- // Multiple agents — use the bulk endpoint so all moves land in
- // a single git commit instead of one per agent.
- try {
- const payload = names.map((n) => ({ child: n, new_parent: newParent || null }));
- const resp = await fetch('/api/topology/set-parent-bulk', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- redirect: 'manual',
- });
- const ok = resp.ok || resp.type === 'opaqueredirect'
- || (resp.status >= 200 && resp.status < 400);
- if (!ok) {
- const text = await resp.text().catch(() => '');
- failures.push(`bulk: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
- }
- } catch (err) {
- failures.push(`bulk: ${err}`);
- }
- }
- sel.disabled = false;
- sel.selectedIndex = 0;
- if (failures.length) {
- themedToast(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
- }
- });
- wrap.append(sel);
- parent.append(wrap);
- }
-
- // Filter the dashboard's container list to those that are valid
- // re-parent targets for the `selected` agents: anyone who isn't IN
- // the selection itself, isn't a descendant of any selected agent
- // (cycle prevention across the whole batch). The backend re-checks
- // per-agent via `topology::set_parent`; this client-side filter is
- // purely UX so the operator can't pick an obviously-invalid option.
- function validReparentCandidates(selected, containers) {
- // Build child map once.
- const childrenOf = new Map();
- for (const c of containers) {
- const p = c.parent || null;
- if (!childrenOf.has(p)) childrenOf.set(p, []);
- childrenOf.get(p).push(c.name);
- }
- // Union descendant set across every selected agent (each agent's
- // descendants AND itself).
- const blocked = new Set();
- for (const t of selected) {
- const queue = [t.name];
- blocked.add(t.name);
- while (queue.length) {
- const n = queue.shift();
- for (const child of (childrenOf.get(n) || [])) {
- if (blocked.has(child)) continue;
- blocked.add(child);
- queue.push(child);
- }
- }
- }
- return containers
- .filter((c) => !blocked.has(c.name))
- .map((c) => c.name)
- .sort();
- }
-
- function addBulkButton(parent, btnClass, label, enabled, selected, opts) {
- const names = selected.map((c) => c.name);
- const btn = el('button', {
- type: 'button',
- class: 'btn ' + btnClass,
- }, label);
- if (!enabled) {
- btn.disabled = true;
- if (opts.disabledTitle) btn.title = opts.disabledTitle;
- }
- btn.addEventListener('click', async () => {
- if (btn.disabled) return;
- const msg = opts.confirm(names);
- let graceful = false;
- if (msg) {
- const r = await themedConfirm({
- message: msg,
- danger: true,
- confirmLabel: opts.confirmLabel || 'confirm',
- checkboxes: opts.graceful
- ? [{ name: 'graceful', label: 'stop gracefully — let each agent finish its turn and flush state before the container stops' }]
- : [],
- });
- if (!r) return;
- graceful = !!r.graceful;
- }
- btn.disabled = true;
- const original = btn.innerHTML;
- btn.innerHTML = '◐ ' + label;
- const failures = [];
- // Sequential POSTs to keep server-side serialisation predictable
- // (rebuild_queue dedups but other endpoints don't); the loop is
- // short — bulk selections are typically a handful of agents.
- //
- // Two URL shapes:
- // - `opts.action` is a path prefix and the agent name gets
- // appended (lifecycle endpoints: /start/, /rebuild/).
- // `opts.body` is a static object applied to every POST.
- // - `opts.perAgentBodyFor(name)` is set: `opts.action` is the
- // full URL (no name appended) and the per-agent body comes
- // from the callback. Used by /api/topology/set-parent, where
- // the agent name is a body field rather than a URL component.
- for (const name of names) {
- const body = opts.perAgentBodyFor
- ? new URLSearchParams(opts.perAgentBodyFor(name))
- : new URLSearchParams(opts.body || {});
- const url = opts.perAgentBodyFor
- ? opts.action
- : opts.action + encodeURIComponent(name) + (graceful ? '?graceful=true' : '');
- try {
- const resp = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body,
- redirect: 'manual',
- });
- const ok = resp.ok || resp.type === 'opaqueredirect'
- || (resp.status >= 200 && resp.status < 400);
- if (!ok) {
- const text = await resp.text().catch(() => '');
- failures.push(`${name}: http ${resp.status}${text ? ' — ' + text.slice(0, 200) : ''}`);
- }
- } catch (err) {
- failures.push(`${name}: ${err}`);
- }
- }
- btn.disabled = false;
- btn.innerHTML = original;
- if (failures.length) {
- themedToast(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n'), { type: 'error', duration: 0 });
- }
- // Container-lifecycle events (ContainerStateChanged /
- // ContainerRemoved / RebuildQueueChanged) flow over the existing
- // SSE channel and update the derived stores live — no manual
- // refresh needed.
- });
- parent.append(btn);
- }
-
- // The Y3R C4LL questions domain (sync/apply/render/filter/TTL) now lives in
- // call.js — `syncQuestionsFromSnapshot`, `applyQuestionAdded`,
- // `applyQuestionResolved`, `renderQuestions`, and `activeQuestionCount` are
- // imported above. `questionsState` itself lives in state.js (the SW4RM
- // container rows read `questionsState.pending` for per-agent count badges).
-
- // 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
- // (unix seconds) on the `.status-age` span. Keyed container rows persist
- // across re-renders, so without this ticker the "(set N ago)" label would
- // become stale as time passes. 30s granularity matches fmtAgeSecs precision
- // (sub-minute values round to seconds, coarser above that).
- setInterval(() => {
- const now = Math.floor(Date.now() / 1000);
- document.querySelectorAll('.status-age[data-set-at]').forEach((node) => {
- const setAt = Number(node.dataset.setAt);
- if (!Number.isFinite(setAt) || setAt === 0) return;
- node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
- });
- }, 30_000);
-
// Live ticker for approval request-age chips. Approval cards only
// re-render on `approval_added`/`approval_resolved` SSE events, so
// a request pending for an hour could still show "0s ago" without
@@ -1267,8 +108,8 @@ window.marked = marked;
// Live countdown for reminder due-at labels and schedule next-fire
// cells. Both renderers stamp `data-due-at` on the element so this
// single ticker keeps them fresh without triggering a full re-render.
- // `.reminder-due` → "overdue X ago" / "in Y"
- // `.sched-due` → same pattern
+ // `.reminder-due` -> "overdue X ago" / "in Y"
+ // `.sched-due` -> same pattern
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
@@ -1288,41 +129,6 @@ window.marked = marked;
});
}, 1000);
- // Peer hives: render link cards as a headline section under SW4RM
- // (state.peer_hives). Called on every state refresh. When nothing is
- // federated, the "P33R H1V3S" headline block is hidden entirely and a
- // quiet grey note replaces the cards.
- function renderPeerHives(peers) {
- const block = $('peers-block');
- const root = $('peers-section');
- if (!root) return;
- root.replaceChildren();
- const hasPeers = Array.isArray(peers) && peers.length > 0;
- if (block) block.hidden = !hasPeers;
- if (!hasPeers) {
- root.append(el('p', { class: 'empty' }, 'no peer hives configured'));
- return;
- }
- const ul = el('ul', { class: 'containers' });
- for (const p of peers) {
- const li = el('li', { class: 'container-row' });
- const head = el('div', { class: 'head' });
- const icon = el('span', { class: 'container-icon' }, '⬡');
- const nameEl = el('span', { class: 'name' }, p.name || p.url);
- const linkEl = el('a', {
- href: p.url,
- target: '_blank',
- rel: 'noopener noreferrer',
- class: 'meta',
- title: 'open ' + p.name + ' dashboard',
- }, p.url);
- head.append(icon, nameEl);
- li.append(head, el('div', { class: 'meta' }, linkEl));
- ul.append(li);
- }
- root.append(ul);
- }
-
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
// Sections whose innerHTML gets blown away on each refresh. If the
@@ -1465,7 +271,7 @@ window.marked = marked;
}
refreshState();
// Cold-load the operator inbox so the Y3R C4LL pill + browser
- // title reflect unread agent→operator messages immediately, before the
+ // title reflect unread agent->operator messages immediately, before the
// operator opens the tab. Live updates arrive via the broker stream.
refreshOperatorInbox();
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
@@ -1554,8 +360,8 @@ window.marked = marked;
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
// per-tab side-effects via the strip's onShow. Constructed below (after
- // the lazy-load fns it calls are defined). The responsive overflow ⋮
- // menu was dropped — if the strip ever runs out of room we move panes
+ // the lazy-load fns it calls are defined). The responsive overflow menu
+ // was dropped — if the strip ever runs out of room we move panes
// to their own pages rather than hide them in a dropdown.
function activateTab(target) {
// Track active tab on the body so renderSelectionBar can gate
@@ -1599,7 +405,7 @@ window.marked = marked;
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
- // The operator inbox (unread agent→operator messages) now lives in
+ // The operator inbox (unread agent->operator messages) now lives in
// call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and
// `operatorInboxCount` are imported above. It calls back through the
// `onCountsChanged` callback registered via `initCall` at boot.
@@ -1622,7 +428,7 @@ window.marked = marked;
}
setTabCount('swarm', swarm);
// Y3R C4LL — pending approvals + operator-targeted questions +
- // unread agent→operator messages.
+ // unread agent->operator messages.
const callCount =
activeApprovalCount() +
activeQuestionCount() +