// SW4RM (containers) domain — extracted from tabs.js. // Agent topology, container-row rendering, selection bar, and all // live-update apply handlers for container-state, transient, and // job-queue-rollup ops. See docs/web-ui/dashboard.md::Container row for the // rendering contract. import { $, form, fmtAgeSecs } from "./common.js"; import { el } from "@hive/shared/dom.js"; import { themedConfirm, themedToast } from "@hive/shared/modal.js"; import { h, render } from "preact"; import { JobqRollup } from "@hive/shared/jobq-rollup.js"; import { containersState } from "./state.js"; import { closeAllMenus } from "@hive/shared/hive-menu.js"; import "./agent-menu/hive-agent-menu.js"; // registers — side-effect import // 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.5; // >= 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 ───────────────────────────────────────────────────── // 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 -> Map: an agent can hold // several pills at once (every *running* node naming the agent lights // one now, not just a curated "worth surfacing" subset — see // docs/web-ui/dashboard.md::Container row), keyed the same way the backend's // own tombstone map is, `(agent, label)`, so a clear only removes the // specific pill it names rather than guessing. `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(); // ─── job queue rollup (queue-summary banner only) ────────────────────────── // // The ONE piece of job-queue-derived state on this page, and deliberately // narrow — mara: "swarm.js should not need to pull in the jobq to do its // job." Rendering is owned by the shared `JobqRollup` Preact component // (same one swarm-ui's /jobs page mounts, self-fetching // GET /api/jobq/rollup); this file just calls `render(h(...))` and bumps // a refresh token to force a refetch. // // Rendered into #jobq-rollup-section, a sibling of #containers-section // kept OUTSIDE that section's per-render `replaceChildren()` wipe (see // dashboard.html) — a section that gets wiped on every container-state // tick would defeat the component owning its own fetch lifecycle. let jobqRollupToken = 0; export function initJobqRollup() { const root = $("jobq-rollup-section"); if (!root) return; render( h(JobqRollup, { endpoint: "/api/jobq/rollup", queueHref: "/builds.html", refreshToken: jobqRollupToken, }), root, ); } // Refetches on cold load (tabs.js's refreshState) and whenever // `rebuild_queue_changed` fires — a payload-less push trigger by // design, confirmed with atlas on the jobq-deletion tracker: the event // carries no `queue` field this page reads, same "something changed, // go refetch" treatment builds.js already gives its own JobqGraph mount // handle's .refresh(). export function applyRebuildQueueChanged() { jobqRollupToken += 1; initJobqRollup(); } // ─── transients ───────────────────────────────────────────────────────────── export function syncTransientsFromSnapshot(s) { transientsState.clear(); const nowUnix = Math.floor(Date.now() / 1000); for (const t of s.transients || []) { // Snapshot is a flat list — multiple rows CAN share the same // `name` (one row per running node) — so group into this agent's // kind map rather than overwrite. let byKind = transientsState.get(t.name); if (!byKind) { byKind = new Map(); transientsState.set(t.name, byKind); } // Snapshot ships `secs` (server-computed); reconstruct an // approximate since_unix so the live ticker keeps progressing // without surprising jumps when the next snapshot lands. byKind.set(t.kind, t.since_unix ?? nowUnix - (t.secs || 0)); } } export function applyTransientSet(ev) { let byKind = transientsState.get(ev.name); if (!byKind) { byKind = new Map(); transientsState.set(ev.name, byKind); } byKind.set(ev.transient_kind, ev.since_unix); renderContainersFromState(); } export function applyTransientCleared(ev) { const byKind = transientsState.get(ev.name); if (!byKind || !byKind.delete(ev.transient_kind)) return; // Drop the now-empty outer entry too — keeps `transientsState.size` // meaning "agents with at least one pill" (checked at the // no-containers-and-no-transients empty-state gate below). if (byKind.size === 0) 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. // Rendering + agent-specific interaction lives in the // custom element (./agent-menu/hive-agent-menu.js, imported above for its // customElements.define side effect); the generic dropdown mechanics // (open/close, positioning, singleton coordination — closed via // `closeAllMenus` below) live in the shared `` component it // composes internally (@hive/shared/hive-menu.js). This is a thin // wrapper matching 's `._opts`-before-append convention, // since a custom element created via `document.createElement` can't // take constructor args. function buildAgentMenu(c, forgeBase) { const menu = document.createElement("hive-agent-menu"); menu._opts = { c, forgeBase }; return menu; } // ─── 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. closeAllMenus(); 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, gatewayLinks, hostname, ) { return JSON.stringify({ running: c.running, failed: c.failed, paused: c.paused, needs_login: c.needs_login, needs_update: c.needs_update, active_model: c.active_model, port: c.port, pending, opRunning, selected, 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, url, containerBase, forgeBase, s } = opts; // A single `pending-running` class covers any active transient // badge state (see the pending-badge derivation comment in // renderContainers). const li = el("li", { class: "container-row" + (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. A depth-0 row gets neither — 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/dashboard.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) { // No public forge URL to link to — hide rather than build // a broken/guessed href (see forgeBase above). if (lnk.kind === "forge" && !forgeBase) continue; 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: "hive-pill-sm 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: `hive-pill-sm ${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 badge — `✖ gave // up` (red) when systemd's restart bound was hit (`c.failed`), else // the plain muted `■ not running`. Both read `running: false`; // `failed` is what tells "gave up" apart from "stopped on purpose" // (an absent `failed` — an older backend — is `undefined`, falsy, // so this degrades to the old single 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.length) { // One badge per pill — an agent can carry several transients at // once now (see docs/web-ui/dashboard.md::Container row), each rendered // independently rather than collapsed into one label. for (const label of pending) { head.append( el( "span", { class: "pending-state" }, el("span", { class: "spinner" }, "◐"), " ", label + "…", ), ); } } else if (!c.running && c.failed) { head.append( el( "span", { class: "hive-pill-sm badge-fail", title: "container gave up — its unit hit systemd's bounded restart limit and stopped " + "on its own, not on operator request; start it to bring the harness back up", }, "✖ gave up", ), ); } else if (!c.running) { head.append( el( "span", { class: "hive-pill-sm 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: "hive-pill-sm badge-warn", href: url, target: "_blank", rel: "noopener", }, "needs login →", ), ); } if (c.paused) { // Paused badge is also a resume button: clicking POSTs /api/resume/{name} // which removes the marker and flips the badge off via the SSE rescan. head.append( form( "/api/resume/" + c.name, "hive-pill-sm badge-paused btn-inline", "⏸ paused", `resume ${c.name}? the turn loop restarts and drains queued messages.`, {}, { noRefresh: true }, ), ); } if (c.needs_update) { head.append( form( "/api/rebuild/" + c.name, "hive-pill-sm badge-warn btn-inline", "needs update ↻", "rebuild " + c.name + "? hot-reloads the container.", {}, { noRefresh: true }, ), ); } if (c.active_model) { head.append( el( "span", { class: "hive-pill-sm badge-model", title: `active claude model: ${c.active_model}`, }, c.active_model, ), ); } body.append(head); // Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / // DESTR0Y / PURG3) live on the selection bar, not here — 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 // (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( "hive-warn", { level: "error" }, 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: see initJobqRollup, above. 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/dashboard.md::Container row` + `docs/networking/gateway.md::Vhost map`. const gatewayLinks = !!(s && s.gateway_enabled); // Forge public URL: state.forge_public_url (set by the NixOS module // from services.hyperhive.forge.publicUrl) or null — never guessed // from ":3000". `null` propagates down to // buildContainerLi/buildAgentMenu, which already hide forge links // rather than emit a possibly-wrong one. const forgeBase = (s && s.forge_public_url) || null; const ul = existingUl ?? el("ul", { class: "containers" }); const tree = buildAgentTree(containers); // 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-badge derivation: transients only — "what is running," // full stop (mara, on review: "remove the per agent pending stuff - // only show what is running"; rendered one badge per pill — mara, // earlier: "show all running nodes that name the agent"). See // docs/web-ui/dashboard.md::Container row. const transientKindsMap = transientsState.get(c.name); // Sorted for stable badge order across renders — Map iteration // order is insertion order, which shifts as pills clear/re-add. const pending = transientKindsMap ? Array.from(transientKindsMap.keys()).sort() : []; const opRunning = pending.length > 0; const selected = selectionState.has(c.name); const fp = containerRowFingerprint( c, node, pending, opRunning, selected, 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, 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); // Pause/resume is orthogonal to running (see hive-agent-menu.js's own // comment on the per-agent version of these two actions) — a paused // stopped agent boots paused, a paused running agent keeps its // container but drives no turns. Same all-or-nothing enablement rule // as every other bulk action here: mixed pause state disables the // button rather than silently no-op'ing on part of the selection. const allUnpaused = selected.every((c) => !c.paused); const allPaused = selected.every((c) => c.paused); const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); const runningNames = selected.filter((c) => c.running).map((c) => c.name); const pausedNames = selected.filter((c) => c.paused).map((c) => c.name); const unpausedNames = selected.filter((c) => !c.paused).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(", ")})?`, graceful: true, gracefulLabel: "restart gracefully — let each agent finish its turn and flush state before the container restarts", 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-pause", "⏸ P4US3", allUnpaused, selected, { action: "/api/pause/", confirm: (names) => `pause ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? parks the turn loop — inbox messages queue unacked.`, disabledTitle: why( "⏸ P4US3", pausedNames.map((n) => `\`${n}\` is already paused`), ), }); addBulkButton(actions, "btn-resume", "▶ R3SUM3", allPaused, selected, { action: "/api/resume/", confirm: (names) => `resume ${names.length} agent${names.length === 1 ? "" : "s"} (${names.join(", ")})? the turn loop restarts and drains queued messages.`, disabledTitle: why( "▶ R3SUM3", unpausedNames.map((n) => `\`${n}\` is not paused`), ), }); 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: opts.gracefulLabel || "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); } // ─── 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);