// /dashboard.html entry point: tab renderers + tab routing + refreshState // + notification deltas. Reads /api/state on cold load and after every // async-form submit; live updates run through `applyXxx` mutation // handlers triggered by the dashboard event stream (subscribed via // `openStream` from common.js). See docs/web-ui.md::Shape (shared by // both) for the broader contract. // // Pure helpers (DOM, side panel, OS notifications, path linkification) // 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. import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, 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 { applyCapabilitiesChanged, applyToolGroupsChanged, fetchAndRenderCapabilities, fetchAndRenderToolGroups, initPermissions, } from './permissions.js'; import { applySchedulesChanged, applyRemindersChanged, refreshSchedules, refreshReminders, activeScheduleCount, } from './schedules.js'; import { initCall, refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount, syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved, renderApprovals, activeApprovalCount, syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, renderQuestions, activeQuestionCount, } from './call.js'; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file // previews work; flow.js does the same for the flow-page entry. 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. const seenApprovals = new Set(); const seenQuestions = new Set(); let seededNotify = false; function notifyDeltas(s) { const approvals = s.approvals || []; const questions = s.questions || []; if (!seededNotify) { // First render after page load — fill the "seen" sets without // firing notifications. We only want to notify on NEW items // that arrived while the page is open. The inbox no longer // needs seeding here: it's derived from the broker stream which // does its own per-event notification on live arrival, and // history-replayed events are silent by virtue of `fromHistory`. for (const a of approvals) seenApprovals.add(a.id); for (const q of questions) seenQuestions.add(q.id); seededNotify = true; return; } for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); const verb = a.kind === 'spawn' ? 'spawn approval' : a.kind === 'init_config' ? 'config-init approval' : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`, 'hyperhive:approval:' + a.id); } for (const q of questions) { if (seenQuestions.has(q.id)) continue; seenQuestions.add(q.id); const targetLabel = q.target || 'operator'; NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`, q.question.slice(0, 120), 'hyperhive:question:' + q.id); } } // ─── async forms ──────────────────────────────────────────────────────── // Shared `data-async` submit interceptor (now in common.js so /core.html's // bundle gets it too). On success it re-runs refreshState unless the form // 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: '/core.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 // this ticker. Also flips `.stale` (amber highlight) at exactly 1h // rather than only at the next re-render. // 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 setInterval(() => { const now = Math.floor(Date.now() / 1000); document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => { const requestedAt = Number(node.getAttribute('data-requested-at')); if (!Number.isFinite(requestedAt)) return; const ageSec = Math.max(0, now - requestedAt); node.textContent = 'requested ' + fmtAgo(requestedAt); node.classList.toggle('stale', ageSec >= 3600); }); document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => { const dueAt = Number(node.getAttribute('data-due-at')); if (!Number.isFinite(dueAt)) return; const dueIn = dueAt - now; node.textContent = dueIn <= 0 ? 'overdue ' + fmtAgo(dueAt) : (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn); }); }, 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 // operator is typing in one of them, skip the refresh — the next // tick (or a manual action) will pick it up after they blur. const MANAGED_SECTION_IDS = [ 'containers-section', 'questions-section', 'inbox-section', 'approvals-section', 'reminders-section', 'schedules-section', 'capabilities-section', 'tool-groups-section', ]; //
    sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails // re-applies after the render. (Long-content drill-ins — file // previews, diffs, logs, config — open in the side panel instead, // which lives outside the managed sections and survives re-render // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (d.open) open.add(d.dataset.restoreKey); } } return open; } function restoreOpenDetails(open) { if (!open.size) return; for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (open.has(d.dataset.restoreKey)) d.open = true; } } } function operatorIsTyping() { const el_ = document.activeElement; if (!el_ || el_ === document.body) return false; const tag = el_.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false; return MANAGED_SECTION_IDS.some((id) => { const sect = document.getElementById(id); return sect && sect.contains(el_); }); } async function refreshState() { // Don't yank the form out from under the operator. Try again // shortly on the next tick; eventually they'll blur and the // refresh lands. if (operatorIsTyping()) { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(refreshState, 2000); return; } try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); // Stash the latest snapshot for any sub-widget that wants a // synchronous read (e.g. the compose autocomplete pulls agent // names from here instead of refetching on every keystroke). window.__hyperhive_state = s; // Peer hives render as a headline section under SW4RM (no longer // a tab); renderPeerHives shows/hides its own headline block. renderPeerHives(s.peer_hives || []); renderServerWarnings(s.server_warnings); // (The M4TR1X surface is reachable from the H0M3 hub now, not the // dashboard tab strip — home.js gates its tile on matrix_gui_enabled.) // Hive identity: render the swarm/hive name as a headline at the // top of the SW4RM pane + update the page title once the // server-side display names are known. `hive_name` / `swarm_name` // come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars // (set by services.hyperhive.{hiveName,swarmName} nix options). // When unset we fall back gracefully — the headline stays hidden. const hiveId = $('swarm-identity'); if (hiveId) { const hive = s.hive_name; const swarm = s.swarm_name; if (hive || swarm) { const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm; hiveId.textContent = label; hiveId.hidden = false; // Preserve any (N) call-count prefix already applied by // refreshTabCounts so the title doesn't flicker on reload. const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || ''; document.title = existingPrefix + label + ' // h1ve-c0re'; } } const openDetails = snapshotOpenDetails(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); // Rebuild-queue state feeds the SW4RM agent-card badges // (inFlightOpsByAgent); its own panel now lives on /core.html. syncRebuildQueueFromSnapshot(s); renderContainers(s); // Sync the derived approvals + questions stores from the // snapshot, then render. Live `*_added` / `*_resolved` events // mutate the stores directly and re-render without a snapshot // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); // (renderInbox now lives in ./flow.js — dashboard has no // #inbox-section element to render into.) syncApprovalsFromSnapshot(s); renderApprovals(); refreshReminders(); refreshSchedules(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // approvals + questions + transients have their own events; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` // (tombstones, meta-input updates). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } catch (err) { console.error('refreshState failed', err); // Schedule a single retry on transient errors so the page // recovers from a brief network blip without making the // operator reload. pollTimer = setTimeout(refreshState, 5000); } } refreshState(); // Cold-load the operator inbox so the Y3R C4LL pill + browser // 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 // with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/ // question notifications — it reads the browser permission + localStorage // mute flag the settings page sets, so no bind() is needed here. Panel.bind(); // ─── live updates: dashboard event stream ────────────────────────────── // The dashboard subscribes to /dashboard/stream for live mutation // events so the SW4RM / Y3R C4LL / SYST3M panes update without an // operator action triggering a refreshState. // // Bare `EventSource` (no terminal infrastructure needed — the // dashboard doesn't render broker rows). Each event's `kind` is // looked up against `MUTATION_HANDLERS`; unknown kinds (broker // `sent` / `delivered`, anything new the backend adds) silently // no-op. On (re)connect we kick a refreshState() to recover events // lost during the disconnect window (same pattern as flow.js's // onStreamOpen). // // Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream` // and filter client-side — the dashboard ignores broker traffic // and the inbox ignores mutation events. const MUTATION_HANDLERS = { approval_added: applyApprovalAdded, approval_resolved: applyApprovalResolved, question_added: applyQuestionAdded, question_resolved: applyQuestionResolved, transient_set: applyTransientSet, transient_cleared: applyTransientCleared, container_state_changed: applyContainerStateChanged, container_removed: applyContainerRemoved, // tombstones_changed / meta_inputs_changed / meta_update_running are // handled on /core.html now (the SYST3M panels moved there). // rebuild_queue_changed stays: it refreshes the SW4RM badges. rebuild_queue_changed: applyRebuildQueueChanged, schedules_changed: applySchedulesChanged, reminders_changed: applyRemindersChanged, capabilities_changed: applyCapabilitiesChanged, tool_groups_changed: applyToolGroupsChanged, }; (function bindDashboardStream() { // Route through the SharedWorker so all open hyperhive tabs share // one upstream SSE connection — see docs/web-ui.md (SSE multiplexing // paragraph) for the design + Firefox throttling motivation. // `openStream` returns an EventSource-shaped facade with a graceful // direct-EventSource fallback when SharedWorker isn't supported. const es = openStream('/api/dashboard/stream'); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } // Broker `sent` frames aren't mutation events, but the operator // inbox cares about ones addressed to "operator". if (ev.kind === 'sent' && ev.to === 'operator') { operatorInboxAppendFromEvent(ev); return; } const h = MUTATION_HANDLERS[ev.kind]; if (!h) return; // broker rows + future kinds — dashboard doesn't care try { h(ev); } catch (err) { console.error('dashboard SSE handler', ev.kind, err); } }; es.onopen = () => { // Re-sync to recover events that fired during the SSE disconnect // window. Initial connect also fires onopen — the first // refreshState() above and this one race, but refreshState is // idempotent so the second call just overwrites with the // freshest snapshot. Cheap on a quiescent server, fine to repeat. refreshState(); }; es.onerror = () => { // EventSource auto-reconnects; nothing to do beyond logging. console.debug('dashboard SSE error, will retry'); }; })(); // ─── tab routing ─────────────────────────────────────────────────────── // Hash-based: `#swarm` / `#call` / `#system` / `#permissions` / // `#schedules` activate the matching pane on the dashboard. Empty // hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs — // they're separate pages (`/flow.html`, `/settings.html`, // `/stats.html`) reached from the H0M3 hub. Tab routing only // applies when the tab DOM is present (e.g. not on the flow page // itself, where these elements don't exist and the loop no-ops). // 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 // to their own pages rather than hide them in a dropdown. function activateTab(target) { // Track active tab on the body so renderSelectionBar can gate // visibility (bar only belongs on SW4RM where agent cards live). document.body.dataset.activeTab = target; renderSelectionBar(Array.from(containersState.values())); // Re-fetch on activation as a safety net: SSE covers live mutations, // re-sync covers disconnect windows / approval-path inserts that // don't yet emit. SCH3DUL3S also re-fetches reminders (same tab). if (target === 'schedules') { refreshSchedules(); refreshReminders(); } // Permissions tables: SSE covers worker-applied changes // (capabilities_changed / tool_groups_changed); re-fetch on // activation as a safety net for any gap between SSE events and // the cold-load snapshot. if (target === 'permissions') { initPermissions(); fetchAndRenderCapabilities(); fetchAndRenderToolGroups(); } if (target === 'call') { refreshOperatorInbox(); } } // ST4TS (hive-wide turn-stats rollup) moved to its own page, // `/stats.html` — the render JS + the window selector live in // stats.js now. The dashboard no longer fetches // /api/stats-hive. // Wire the shared tab strip now that activateTab + the lazy-load fns it // calls are defined. The strip resolves the active tab from the hash // (default SW4RM), toggles the active tab/pane + aria-selected, and // fires activateTab for the per-tab side-effects on every change. createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab }); // Register the Y3R C4LL domain's count callback (call.js) — its live // mutations (inbox stream append, mark-read) trigger a tab-count refresh // through this instead of reaching back into the coordinator directly. initCall({ onCountsChanged: refreshTabCounts, onContainersDirty: renderContainersFromState }); // Tab count pills — pure derived data from the existing state // 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 // call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and // `operatorInboxCount` are imported above. It calls back through the // `onCountsChanged` callback registered via `initCall` at boot. function setTabCount(tab, n) { const el_ = $('tab-count-' + tab); if (!el_) return; el_.textContent = String(n); el_.hidden = n <= 0; } /** Recompute every tab's count from the current state. Called on * every renderXxx that's tab-relevant. */ function refreshTabCounts() { // SW4RM — flag any container that's stale (needs_update). Empty // when everyone's current. Container-row pulse signals state // transitions; the pill catches "deploy-pending" specifically. let swarm = 0; for (const c of containersState.values()) { if (c.needs_update) swarm++; } setTabCount('swarm', swarm); // Y3R C4LL — pending approvals + operator-targeted questions + // unread agent→operator messages. const callCount = activeApprovalCount() + activeQuestionCount() + operatorInboxCount(); setTabCount('call', callCount); // Browser tab title prefix — lets the operator see the pending // call count without switching to the window. Strips any existing // `(N) ` prefix before re-applying so identity-title updates // (which run once on state load, not every tick) compose cleanly. const rawTitle = document.title.replace(/^\(\d+\) /, ''); document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle; // SCH3DUL3S — count of schedules with at least one still-active // target (whole-schedule cancellation or all-targets-cancelled // means "not waiting on the worker"; those don't pull attention). setTabCount('schedules', activeScheduleCount()); } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE // event + refreshState call, so polling them is correct and cheap // — no per-renderer hookup needed. refreshTabCounts(); setInterval(refreshTabCounts, 1000); // Flow-specific code (broker terminal init + the @-mention composer) // lives in ./flow.js — loaded only by /flow.html. })();