// /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, } from './common.js'; import { createTabStrip } from '@hive/shared/tabs.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) // Atomic-swap render helper: build into a DocumentFragment off-DOM, // commit with one `replaceChildren`. See docs/web-ui.md::Atomic // section repaint for why (no intermediate empty-state flash on // poll cycles even when the builder allocates a lot of `el()`). function paintAtomic(liveRoot, build) { const buf = document.createDocumentFragment(); build(buf); liveRoot.replaceChildren(buf); } // 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 ──────────────────────────────────────────────────────── document.addEventListener('submit', async (e) => { const f = e.target; if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; e.preventDefault(); if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; if (f.dataset.prompt) { const ans = prompt(f.dataset.prompt, ''); if (ans === null) return; // operator hit Cancel // Drop into a hidden input named after `data-prompt-field` (or // 'note' by default) so the value rides along on the POST. const field = f.dataset.promptField || 'note'; let input = f.querySelector(`input[name="${field}"]`); if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = field; f.append(input); } input.value = ans; } const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline'); const original = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const resp = await fetch(f.action, { method: f.method || 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(new FormData(f)), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); if (btn) { btn.disabled = false; btn.innerHTML = original; } return; } // Re-enable the button — refreshState() rebuilds most lists but // skips forms that didn't change (e.g. the spawn form), so without // this the spinner sticks and the button can't be clicked again. if (btn) { btn.disabled = false; btn.innerHTML = original; } // Clear text inputs whose value was just submitted. f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); // Forms whose endpoint already emits a DashboardEvent that // updates the derived store can opt out of the post-submit // /api/state refetch (the event delivers the new row faster // than the snapshot poll anyway). Container-lifecycle forms // still rely on the refresh since `ContainerView` isn't yet // event-derivable. if (!f.hasAttribute('data-no-refresh')) { refreshState(); } } catch (err) { alert('action failed: ' + err); if (btn) { btn.disabled = false; btn.innerHTML = original; } } }); // Derived container state — cold-loaded from /api/state.containers, // then mutated live by `container_state_changed` (upsert by name) // and `container_removed` (drop by name). The coordinator's rescan // helper fires these after every mutation site + on a periodic poll // in crash_watch. Keyed by ContainerView.name so the lifecycle // forms' POST → 200 → matching event flips the row without a // snapshot refetch. const containersState = new Map(); // 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(); function syncContainersFromSnapshot(s) { containersState.clear(); for (const c of s.containers || []) containersState.set(c.name, c); } 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(); } // Derived tombstones + meta_inputs. Both are emitted as full // snapshots (not diffs) — the lists are tiny and recomputing // avoids ordering races between a same-tick destroy + purge. let tombstonesState = []; let metaInputsState = []; // True while a dashboard-triggered meta-update (flake lock bump + // agent rebuild ripple) runs in the background. Cold-loaded from // `s.meta_update_running`, then flipped live by the // `meta_update_running` event. Drives the META INPUTS panel's // disabled "updating…" state. let metaUpdateRunning = false; function syncTombstonesFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); } function syncMetaInputsFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; } function applyTombstonesChanged(ev) { tombstonesState = (ev.tombstones || []).slice(); renderTombstonesFromState(); } function applyMetaInputsChanged(ev) { metaInputsState = (ev.inputs || []).slice(); renderMetaInputsFromState(); } function applyMetaUpdateRunning(ev) { metaUpdateRunning = !!ev.running; renderMetaInputsFromState(); } function renderTombstonesFromState() { renderTombstones({ tombstones: tombstonesState }); } function renderMetaInputsFromState() { renderMetaInputs({ meta_inputs: metaInputsState }); } // Derived rebuild queue state — cold-loaded from // `/api/state.rebuild_queue`, then mutated live by the // `rebuild_queue_changed` snapshot event. Same shape as the meta- // inputs panel (full snapshot per change, no diff). let rebuildQueueState = []; function syncRebuildQueueFromSnapshot(s) { rebuildQueueState = (s.rebuild_queue || []).slice(); } function applyRebuildQueueChanged(ev) { rebuildQueueState = (ev.queue || []).slice(); renderRebuildQueueFromState(); // 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(); } function applySchedulesChanged(ev) { schedulesState = (ev.schedules || []).slice(); renderSchedulesList(); } function applyRemindersChanged(ev) { renderReminders(ev.reminders || []); } // 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 — see docs/web-ui.md::Container // row for the badge cross-reference between SW4RM and SYST3M. 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; } function renderRebuildQueueFromState() { renderRebuildQueue({ rebuild_queue: rebuildQueueState }); } // 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, // destroy/purge hidden for the manager. // 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) { const url = actionPath + encodeURIComponent(name); 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(() => ''); alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); } } catch (err) { alert('action failed: ' + err); } } 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(); if (opts.confirm && !confirm(opts.confirm)) return; await agentMenuPost(opts.action, c.name, opts.body || null); }); 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: '/restart/', confirm: `restart ${c.name}?` }), menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?` }), ); } else { dropdown.append( menuItem('▶ ST4RT', { action: '/start/', confirm: `start ${c.name}?` }), ); } dropdown.append( menuItem('↻ R3BU1LD', { action: '/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: '/destroy/', confirm: `destroy ${c.name}? container removed; state + creds kept.`, }), menuItem('PURG3', { action: '/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, 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' }; body.append(el('div', { class: 'agent-status', title: `agent self-reported status${ageStr}`, }, 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( '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', {}, { noRefresh: true }, )); } 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 // (renderTombstones, 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( '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', 'rebuild every stale container?', {}, { noRefresh: true }, )); } if (transientsState.size) { const ul = el('ul'); const nowUnix = Math.floor(Date.now() / 1000); for (const [name, t] of transientsState) { const secs = Math.max(0, nowUnix - t.since_unix); ul.append(el('li', {}, el('span', { class: 'glyph spinner' }, '◐'), ' ', el('span', { class: 'agent' }, name), ' ', el('span', { class: 'role role-pending' }, t.kind + '…'), ' ', el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`), )); } root.append(ul); } 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 (TCP via `agent-ports.json` or unix-domain via // `agent-sockets.json`). 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' : 'rebuilding') : (op.kind === 'meta_update' ? 'meta-update queued' : op.kind === 'destroy' ? 'destroy queued' : op.kind === 'restart' ? 'restart 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: '/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: '/kill/', confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), }); addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { action: '/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: '/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: '/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: '/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 (!confirm(promptMsg)) { 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) { alert(`M0V3 completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); } }); 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); if (msg && !confirm(msg)) return; 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); 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) { alert(`${label} completed with ${failures.length} failure${failures.length === 1 ? '' : 's'}:\n\n` + failures.join('\n')); } // 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); } function renderTombstones(s) { const root = $('tombstones-section'); // #tombstones-section only lives on /dashboard.html (SYST3M tab); // no-op on /flow.html and any other page that loads the shared // bundle without the dashboard's tab panes. if (!root) return; root.replaceChildren(); if (!s.tombstones || !s.tombstones.length) { root.append(el('p', { class: 'empty' }, 'no kept state — clean')); return; } const fmtBytes = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB'; return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB'; }; const fmtAge = (ts) => { if (!ts) return '?'; const d = Math.floor((Date.now() / 1000 - ts) / 86400); if (d <= 0) return 'today'; if (d === 1) return '1 day ago'; return d + ' days ago'; }; const ul = el('ul', { class: 'containers' }); for (const t of s.tombstones) { const li = el('li', { class: 'container-row tombstone' }); const head = el('div', { class: 'head' }); head.append( el('span', { class: 'name' }, t.name), el('span', { class: 'badge badge-muted' }, 'destroyed'), ); if (t.has_creds) { head.append(el('span', { class: 'badge badge-muted' }, 'creds kept')); } head.append(el('span', { class: 'meta' }, `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`)); li.append(head); const actions = el('div', { class: 'actions' }); // Reuse the existing spawn form pattern via /request-spawn — operator // can queue an approval that recreates the agent with the same name // and reuses the kept state. const respawn = el('form', { method: 'POST', action: '/request-spawn', class: 'inline', 'data-async': '', 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.', }); respawn.append( el('input', { type: 'hidden', name: 'name', value: t.name }), el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'), ); actions.append(respawn); actions.append(form( '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3', 'PURGE ' + t.name + '? config history, claude creds, ' + 'and notes are all WIPED. no undo.', {}, { noRefresh: true }, )); li.append(actions); ul.append(li); } root.append(ul); } // ── tool-groups (permissions) table ───────────────────────────────────── // Fetched from GET /api/tool-groups on system tab activation and after // each save. Groups (columns) come from the backend so the UI doesn't // need updating when a new group is added. Live updates via // `capabilities_changed` / `tool_groups_changed` SSE events fired // after the rebuild-queue worker commits the perm JSON file. function applyCapabilitiesChanged(ev) { const root = $('capabilities-section'); if (!root) return; // Skip re-render while operator has a checkbox focused in this // section — the tab-activation re-fetch is the recovery path. if (root.contains(document.activeElement)) return; renderCapabilities(root, ev); } function applyToolGroupsChanged(ev) { const root = $('tool-groups-section'); if (!root) return; if (root.contains(document.activeElement)) return; renderToolGroups(root, ev); } async function fetchAndRenderCapabilities() { const root = $('capabilities-section'); if (!root) return; root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'loading…')); try { const resp = await fetch('/api/capabilities'); if (!resp.ok) throw new Error('http ' + resp.status); const data = await resp.json(); renderCapabilities(root, data); } catch (err) { root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); } } function renderCapabilities(root, data) { root.replaceChildren(); const { caps, descriptions = {}, assignments } = data; if (!caps || !caps.length) { root.append(el('p', { class: 'meta' }, '(no capabilities defined)')); return; } // Agent names: union of live containers + keys already in assignments. const agentNames = [...new Set([ ...Array.from(containersState.keys()), ...Object.keys(assignments), ])].sort(); if (!agentNames.length) { root.append(el('p', { class: 'meta' }, '(no agents)')); return; } const wrap = el('div', { class: 'cap-table-wrap' }); const table = el('table', { class: 'cap-table' }); // Header row. const thead = el('thead'); const hrow = el('tr'); hrow.append(el('th', { class: 'cap-agent-col' }, 'agent')); for (const c of caps) { hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c)); } hrow.append(el('th', { class: 'cap-save-col' }, '')); thead.append(hrow); table.append(thead); const tbody = el('tbody'); for (const name of agentNames) { const assigned = assignments[name] || []; const tr = el('tr', { class: 'cap-row' }); // Agent name cell. tr.append(el('td', { class: 'cap-agent-col' }, el('span', { class: 'cap-agent-name' }, name))); // One checkbox per capability. const checkboxes = []; for (const c of caps) { const checked = assigned.includes(c); const td = el('td', { class: 'cap-col' }); const cb = el('input', { type: 'checkbox', class: 'cap-cb', 'data-cap': c, 'aria-label': c, }); cb.checked = checked; td.append(cb); tr.append(td); checkboxes.push(cb); } // Save button cell. const saveTd = el('td', { class: 'cap-save-col' }); const saveBtn = el('button', { type: 'button', class: 'btn cap-save-btn' }, 'save'); saveBtn.addEventListener('click', async () => { const selectedCaps = checkboxes .filter((cb) => cb.checked) .map((cb) => cb.dataset.cap); saveBtn.disabled = true; saveBtn.textContent = '…'; try { const r = await fetch('/api/capabilities/' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caps: selectedCaps }), }); if (!r.ok) { const txt = await r.text(); saveBtn.textContent = 'err'; saveBtn.title = txt; } else { saveBtn.textContent = '✓'; setTimeout(fetchAndRenderCapabilities, 800); } } catch (err) { saveBtn.textContent = 'err'; saveBtn.title = String(err); } finally { saveBtn.disabled = false; } }); saveTd.append(saveBtn); tr.append(saveTd); tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); } async function fetchAndRenderToolGroups() { const root = $('tool-groups-section'); if (!root) return; root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'loading…')); try { const resp = await fetch('/api/tool-groups'); if (!resp.ok) throw new Error('http ' + resp.status); const data = await resp.json(); renderToolGroups(root, data); } catch (err) { root.replaceChildren(); root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err)); } } function renderToolGroups(root, data) { root.replaceChildren(); const { groups, descriptions = {}, assignments } = data; if (!groups || !groups.length) { root.append(el('p', { class: 'meta' }, '(no tool groups defined)')); return; } // Agent names: union of live containers + keys already in assignments, // sorted alphabetically. const agentNames = [...new Set([ ...Array.from(containersState.keys()), ...Object.keys(assignments), ])].sort(); if (!agentNames.length) { root.append(el('p', { class: 'meta' }, '(no agents)')); return; } const wrap = el('div', { class: 'tg-table-wrap' }); const table = el('table', { class: 'tg-table' }); // Header row. const thead = el('thead'); const hrow = el('tr'); hrow.append(el('th', { class: 'tg-agent-col' }, 'agent')); for (const g of groups) { hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g)); } hrow.append(el('th', { class: 'tg-save-col' }, '')); thead.append(hrow); table.append(thead); const tbody = el('tbody'); for (const name of agentNames) { // Explicit assignment or empty = using role default. const assigned = assignments[name] || []; const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name); const tr = el('tr', { class: 'tg-row' }); // Agent name cell. const nameTd = el('td', { class: 'tg-agent-col' }); nameTd.append(el('span', { class: 'tg-agent-name' }, name)); if (!hasExplicit) { nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)')); } tr.append(nameTd); // One checkbox per group. const checkboxes = []; for (const g of groups) { const checked = assigned.includes(g); const td = el('td', { class: 'tg-group-col' }); const cb = el('input', { type: 'checkbox', class: 'tg-cb', 'data-group': g, 'aria-label': g, }); cb.checked = checked; td.append(cb); tr.append(td); checkboxes.push(cb); } // Save button cell. const saveTd = el('td', { class: 'tg-save-col' }); const saveBtn = el('button', { type: 'button', class: 'btn tg-save-btn' }, 'save'); saveBtn.addEventListener('click', async () => { const selectedGroups = checkboxes .filter((cb) => cb.checked) .map((cb) => cb.dataset.group); saveBtn.disabled = true; saveBtn.textContent = '…'; try { const r = await fetch('/api/tool-groups/' + encodeURIComponent(name), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ groups: selectedGroups }), }); if (!r.ok) { const txt = await r.text(); saveBtn.textContent = 'err'; saveBtn.title = txt; } else { saveBtn.textContent = '✓'; setTimeout(fetchAndRenderToolGroups, 800); } } catch (err) { saveBtn.textContent = 'err'; saveBtn.title = String(err); } finally { saveBtn.disabled = false; } }); saveTd.append(saveBtn); tr.append(saveTd); tbody.append(tr); } table.append(tbody); wrap.append(table); root.append(wrap); } // Derived question state — cold-loaded from /api/state, then mutated // live by `question_added` / `question_resolved` dashboard events. const QUESTION_HISTORY_LIMIT = 20; const questionsState = { pending: [], history: [] }; // Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions // to reuse
  • elements whose state hasn't changed. The main benefit is // preserving textarea draft text and radio/checkbox selections when an // unrelated question arrives while the operator is composing a reply. const questionRowCache = new Map(); function syncQuestionsFromSnapshot(s) { questionsState.pending = (s.questions || []).slice(); questionsState.history = (s.question_history || []).slice(); } function applyQuestionAdded(ev) { if (questionsState.pending.some((q) => q.id === ev.id)) return; questionsState.pending.push({ id: ev.id, asker: ev.asker, question: ev.question, options: ev.options || [], multi: !!ev.multi, asked_at: ev.asked_at, deadline_at: ev.deadline_at ?? null, target: ev.target || null, question_refs: ev.question_refs || [], }); renderQuestions(); renderContainersFromState(); } function applyQuestionResolved(ev) { const idx = questionsState.pending.findIndex((q) => q.id === ev.id); const existing = idx >= 0 ? questionsState.pending[idx] : null; if (idx >= 0) questionsState.pending.splice(idx, 1); // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can // carry this same answered row in `question_history` while a live // event also delivers it — guard the unshift so history can't // double a row. if (!questionsState.history.some((h) => h.id === ev.id)) { questionsState.history.unshift({ id: ev.id, asker: existing?.asker || '?', question: existing?.question || '', options: existing?.options || [], multi: existing?.multi || false, asked_at: existing?.asked_at || ev.answered_at, answered_at: ev.answered_at, answer: ev.answer, answerer: ev.answerer, target: existing?.target ?? ev.target ?? null, question_refs: existing?.question_refs || [], answer_refs: ev.answer_refs || [], }); if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { questionsState.history.length = QUESTION_HISTORY_LIMIT; } } renderQuestions(); renderContainersFromState(); } // Filter selection for the questions section. Persisted so the // operator's preferred view (all / operator-targeted / peer) // survives a reload. const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; function getQuestionsFilter() { return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; } function setQuestionsFilter(v) { localStorage.setItem(QUESTIONS_FILTER_KEY, v); renderQuestions(); } function questionMatchesFilter(q, filter) { if (filter === 'all') return true; if (filter === 'operator') return !q.target; if (filter === 'peer') return !!q.target; // `agent:` matches when the agent appears as asker OR target. if (filter.startsWith('agent:')) { const name = filter.slice('agent:'.length); return q.asker === name || q.target === name; } return true; } // Serialise the fields that determine a pending-question
  • 's DOM // structure. Used by questionRowCache to skip rebuilds when nothing // visible has changed. deadline_at controls whether the TTL chip node // exists at all (its text is kept current by the global 1s ticker). function questionRowFingerprint(q) { return JSON.stringify({ asker: q.asker, target: q.target, asked_at: q.asked_at, deadline_at: q.deadline_at, question: q.question, question_refs: q.question_refs, options: q.options, multi: q.multi, }); } // Build a single pending-question
  • . Extracted from renderQuestions so // questionRowCache can reuse unchanged nodes without re-running this body. // Event listeners attached here (keydown on textarea, submit on form) are // preserved in the reused node — no re-attachment needed. function buildQuestionLi(q) { const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const targetLabel = q.target || 'operator'; const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); const head = el('div', { class: 'q-head' }, el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', el('span', { class: 'msg-from' }, q.asker), ' ', el('span', { class: 'msg-sep' }, '→'), ' ', el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', el('span', { class: 'msg-sep' }, 'asks:'), ); if (q.deadline_at) { // Tag the chip with its deadline so the global 1s ticker // can refresh the text without re-rendering the questions section. const ttlEl = el('span', { class: 'q-ttl', 'data-deadline': String(q.deadline_at), }); ttlEl.textContent = formatTtl( q.deadline_at - Math.floor(Date.now() / 1000), ); head.append(' ', ttlEl); } const qBody = el('div', { class: 'q-body' }); appendLinkified(qBody, q.question, q.question_refs); li.append(head, qBody); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', 'data-no-refresh': '', }); const hasOptions = q.options && q.options.length; const isMulti = !!q.multi && hasOptions; const freeText = el('textarea', { name: 'answer-free', rows: '2', autocomplete: 'off', placeholder: (hasOptions ? 'or type your own…' : 'your answer') + ' (shift+enter for newline)', }); // Enter submits; shift+enter inserts a newline (textarea default). freeText.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); f.requestSubmit(); } }); const optionGroup = el('div', { class: 'q-options' }); if (hasOptions) { for (const opt of q.options) { const inputType = isMulti ? 'checkbox' : 'radio'; const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); const input = el('input', { type: inputType, name: 'choice', value: opt, id }); const label = el('label', { for: id }, ' ' + opt); optionGroup.append(el('div', { class: 'q-option' }, input, label)); } } // On submit, build the final `answer` field from selected // options + free-text, joined by ', '. This lets the operator // pick options AND add free text in the same form. f.addEventListener('submit', (ev) => { const parts = []; for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { parts.push(cb.value); } const ft = (freeText.value || '').trim(); if (ft) parts.push(ft); const merged = parts.join(', '); // Replace the existing hidden `answer` (if any) with the merged value. const existing = f.querySelector('input[name="answer"]'); if (existing) existing.remove(); f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } }, true); if (hasOptions) f.append(optionGroup); const buttons = el('div', { class: 'q-buttons' }); // On peer threads the operator's answer is an override — // mark the button so it's clear what the click does (the // backend permits it via OperatorQuestions::answer's // answerer-auth rule). const answerLabel = q.target ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); buttons.append( el('button', { type: 'submit', class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', }, answerLabel), ); f.append( el('div', { class: 'q-free' }, freeText), buttons, ); li.append(f); // Separate form so the cancel button doesn't get the answer // merge-on-submit handler attached to the main form. const cancelTargetLabel = q.target ? q.target : 'asker'; const cancelForm = el('form', { method: 'POST', action: '/cancel-question/' + q.id, class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` + '"[cancelled]" as the answer.', }); cancelForm.append( el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), ); li.append(cancelForm); return li; } function renderQuestions() { const root = $('questions-section'); // #questions-section only lives on /dashboard.html (Y3R C4LL tab); // no-op when the section is missing. `question_added` / // `question_resolved` SSE events route through here. if (!root) return; // Snapshot open
    state so SSE-triggered re-renders restore // any expanded sections. The keyed-cache approach reuses question //
  • nodes (preserving textarea/checkbox state) and only rebuilds // cache-miss rows, so we no longer wipe the DOM at the start. const openDetails = snapshotOpenDetails(); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const allPending = questionsState.pending; // Filter chips. Always include `all` / `operator` / `peer`; add // per-agent chips for any agent that appears as asker or target // in the pending list so the operator can isolate a single // thread without typing. const participants = new Set(); for (const q of allPending) { participants.add(q.asker); if (q.target) participants.add(q.target); } // Auto-reset a stale per-agent filter: if the operator had `agent:foo` // selected and all of foo's questions resolved, foo's chip disappears // from the row. Without a reset the section would show "no questions // match this filter" with no active chip visible — confusing. Fall back // to `all` whenever the stored value is no longer a valid chip value. let activeFilter = getQuestionsFilter(); const validFilters = new Set(['all', 'operator', 'peer', ...Array.from(participants).map((n) => 'agent:' + n)]); if (!validFilters.has(activeFilter)) { activeFilter = 'all'; // Write directly to localStorage to avoid the re-render that // setQuestionsFilter() triggers (we're already mid-render). localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); } const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); const filterRow = el('div', { class: 'questions-filters' }); const mkChip = (value, label) => { const b = el('button', { type: 'button', class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), }, label); b.addEventListener('click', () => setQuestionsFilter(value)); return b; }; // Count pending questions per filter value so each chip shows its // own hit count — the operator can see "operator · 2 / peer · 3" // at a glance without clicking through each tab. const operatorCount = allPending.filter((q) => !q.target).length; const peerCount = allPending.filter((q) => !!q.target).length; const agentCount = (name) => allPending.filter( (q) => q.asker === name || q.target === name).length; filterRow.append( mkChip('all', `all · ${allPending.length}`), mkChip('operator', `@operator · ${operatorCount}`), mkChip('peer', `@peer · ${peerCount}`), ); for (const name of Array.from(participants).sort()) { filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); } // Evict resolved/cancelled questions from the row cache. const allPendingIds = new Set(allPending.map((q) => q.id)); for (const id of questionRowCache.keys()) { if (!allPendingIds.has(id)) questionRowCache.delete(id); } // Build the ordered list of
  • elements, reusing cached nodes whose // serialised state hasn't changed. This is what preserves textarea // draft text and radio/checkbox selections across re-renders. const orderedLis = pending.map((q) => { const fp = questionRowFingerprint(q); const cached = questionRowCache.get(q.id); if (cached && cached.fingerprint === fp) return cached.el; const li = buildQuestionLi(q); questionRowCache.set(q.id, { el: li, fingerprint: fp }); return li; }); // Save the history
    open state before the DOM swap so it // isn't collapsed every time a question arrives while it's open. const historyWasOpen = root.querySelector('.q-history')?.open ?? false; const children = [filterRow]; if (!pending.length) { children.push(el('p', { class: 'empty' }, activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); } else { const ul = el('ul', { class: 'questions' }); for (const li of orderedLis) ul.append(li); children.push(ul); } // Answered question history (read-only, no inputs — built fresh each render). const hist = questionsState.history; if (hist.length) { const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); const hul = el('ul', { class: 'questions questions-answered' }); for (const q of hist) { const targetLabel = q.target || 'operator'; const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); const head = el('div', { class: 'q-head' }, el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', el('span', { class: 'msg-from' }, q.asker), ' ', el('span', { class: 'msg-sep' }, '→'), ' ', el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', el('span', { class: 'msg-sep' }, 'asked:'), ); const histBody = el('div', { class: 'q-body' }); appendLinkified(histBody, q.question, q.question_refs); const ansText = el('span', { class: 'q-answer-text' }); appendLinkified(ansText, q.answer || '(none)', q.answer_refs); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, ); li.append(head, histBody, ansLine); hul.append(li); } details.append(hul); children.push(details); } root.replaceChildren(...children); // Restore history open state after the DOM swap. if (historyWasOpen) { const histEl = root.querySelector('.q-history'); if (histEl) histEl.open = true; } restoreOpenDetails(openDetails); } // Format a remaining-seconds count as the `⏳ …` TTL chip text on a // question card. Bucketed at minutes / hours so a long deadline stays // readable; "expiring…" once the deadline has passed (the host-side // ttl-watchdog will fire shortly). function formatTtl(remaining) { if (remaining <= 0) return 'expiring…'; if (remaining < 60) return '⏳ ' + remaining + 's'; if (remaining < 3600) { return '⏳ ' + Math.floor(remaining / 60) + 'm ' + (remaining % 60) + 's'; } return '⏳ ' + Math.floor(remaining / 3600) + 'h ' + Math.floor((remaining % 3600) / 60) + 'm'; } // Single page-wide ticker that refreshes every TTL chip in place // each second. Renderers stamp `data-deadline` on the // chip; this just updates `textContent`, no re-render of the // questions section. No-op when no chips are on screen, so the // cost is negligible. setInterval(() => { const now = Math.floor(Date.now() / 1000); document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { const deadline = Number(node.getAttribute('data-deadline')); if (!Number.isFinite(deadline)) return; node.textContent = formatTtl(deadline - now); }); }, 1000); // 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); const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab'; // Derived approval state — cold-loaded from /api/state, then mutated // live by `approval_added` / `approval_resolved` dashboard events. // `pending` is the open queue (newest-first); `history` is the last // 30 resolved rows. const APPROVAL_HISTORY_LIMIT = 30; const approvalsState = { pending: [], history: [] }; function syncApprovalsFromSnapshot(s) { approvalsState.pending = (s.approvals || []).slice(); approvalsState.history = (s.approval_history || []).slice(); } function applyApprovalAdded(ev) { // Upsert by id so a snapshot that already included the row (cold // load + event lands at the same tick) doesn't double it. const existing = approvalsState.pending.findIndex((a) => a.id === ev.id); const row = { id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, diff: ev.diff || null, description: ev.description || null, // The ApprovalAdded event carries no requested_at; a live-added // approval was queued just now, so client-now is accurate — and // consistent with how fmtAgo compares everything to client-now. // A later /api/state cold-load swaps in the server value. requested_at: ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000), }; if (existing >= 0) approvalsState.pending[existing] = row; else approvalsState.pending.push(row); renderApprovals(); } function applyApprovalResolved(ev) { // Drop from pending; prepend to history (newest-first), cap at 30. approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id); // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can // carry this same resolved row in `approval_history` while a live // event also delivers it — guard the unshift so history can't // double a row. if (!approvalsState.history.some((h) => h.id === ev.id)) { approvalsState.history.unshift({ id: ev.id, agent: ev.agent, kind: ev.approval_kind, sha_short: ev.sha_short || null, status: ev.status, resolved_at: ev.resolved_at, note: ev.note || null, description: ev.description || null, }); if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) { approvalsState.history.length = APPROVAL_HISTORY_LIMIT; } } renderApprovals(); } // Classify each unified-diff line by its leading char so // `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` / // `.diff-ctx` colour the output. Built as text-only spans (no // innerHTML) so there's no HTML-escape surface. function buildDiffPre(text) { const pre = el('pre', { class: 'diff' }); for (const raw of String(text).split('\n')) { let cls = 'diff-ctx'; if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file'; else if (raw.startsWith('@')) cls = 'diff-hunk'; else if (raw.startsWith('+')) cls = 'diff-add'; else if (raw.startsWith('-')) cls = 'diff-del'; const span = document.createElement('span'); span.className = cls; span.textContent = raw + '\n'; pre.appendChild(span); } return pre; } // Open an approval's diff in the side panel with a 3-way base // toggle: vs applied (running tree), vs last-approved, vs previous // proposal. `applied` uses the diff already shipped on the approval // for instant paint; the other two fetch /api/approval-diff. function openDiffPanel(a) { const bases = [ ['applied', 'vs applied'], ['approved', 'vs last-approved'], ['previous', 'vs previous proposal'], ]; const tabs = el('div', { class: 'diff-base-tabs' }); const host = el('div', { class: 'diff-host' }); async function selectBase(base) { for (const btn of tabs.children) { btn.classList.toggle('active', btn.dataset.base === base); } if (base === 'applied' && a.diff != null) { host.replaceChildren(buildDiffPre(a.diff)); return; } host.replaceChildren(el('div', { class: 'meta' }, 'loading…')); try { const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base); const text = await resp.text(); host.replaceChildren(resp.ok ? buildDiffPre(text) : el('div', { class: 'meta' }, 'error: ' + text)); } catch (e) { host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e)); } } for (const [base, label] of bases) { const btn = el('button', { type: 'button', class: 'diff-base-tab', 'data-base': base }, label); btn.addEventListener('click', () => selectBase(base)); tabs.append(btn); } const wrap = el('div', { class: 'diff-panel' }, tabs, host); Panel.open('diff · ' + a.agent + ' #' + a.id, wrap); selectBase('applied'); } // 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); } function renderApprovals() { const root = $('approvals-section'); // #approvals-section only lives on /dashboard.html (Y3R C4LL tab); // no-op elsewhere — `approval_added` / `approval_resolved` SSE // events route through here on every page that loads the bundle. if (!root) return; // Save spawn form input + focus state before the DOM wipe so a live // approval_added/resolved event doesn't erase a partially-typed name // or steal focus from the operator. const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? ''; const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]'); root.replaceChildren(); // Spawn request form: submitting it queues a Spawn approval that // lands in this same list, so the form belongs here rather than on // the containers list (the agent doesn't exist yet). const spawnNameInput = el('input', { name: 'name', placeholder: 'new agent name (≤9 chars)', maxlength: '9', required: '', autocomplete: 'off', }); if (savedSpawnName) spawnNameInput.value = savedSpawnName; if (spawnHadFocus) spawnNameInput.focus(); const spawn = el('form', { method: 'POST', action: '/request-spawn', class: 'spawnform', 'data-async': '', 'data-no-refresh': '', }); spawn.append( spawnNameInput, el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'), ); root.append(spawn); const pending = approvalsState.pending; const history = approvalsState.history; const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending'; const tabs = el('div', { class: 'approval-tabs' }); const pendingTab = el( 'button', { type: 'button', class: 'approval-tab' + (active === 'pending' ? ' active' : ''), }, `pending · ${pending.length}`, ); const historyTab = el( 'button', { type: 'button', class: 'approval-tab' + (active === 'history' ? ' active' : ''), }, `history · ${history.length}`, ); pendingTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'pending'); renderApprovals(); }); historyTab.addEventListener('click', () => { localStorage.setItem(APPROVAL_TAB_KEY, 'history'); renderApprovals(); }); tabs.append(pendingTab, historyTab); root.append(tabs); if (active === 'history') { renderApprovalHistory(root, history); return; } if (!pending.length) { root.append(el('p', { class: 'empty' }, 'queue empty')); return; } // forge link base — only when the hive-forge container is up. const fs = window.__hyperhive_state; const hostname = (fs && fs.hostname) || window.location.hostname; // Prefer state.forge_public_url (set when forge.behindGateway=true, // e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port. const forgeBase = (fs && fs.forge_present) ? (fs.forge_public_url || `http://${hostname}:3000`) : null; const ul = el('ul', { class: 'approvals' }); for (const a of pending) { const isApply = a.kind === 'apply_commit'; const isInit = a.kind === 'init_config'; const li = el('li', { class: 'approval-card' }); // ── identity header ────────────────────────────────────────── const head = el('div', { class: 'approval-head' }, el('span', { class: 'glyph' }, isApply ? '→' : '⊕'), el('span', { class: 'id' }, '#' + a.id), el('span', { class: 'agent' }, a.agent), el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') }, isApply ? 'apply' : isInit ? 'init' : 'spawn'), ); if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short)); // When the approval was requested — relative time, right-aligned. // Goes amber once it's been pending an hour so a stale request is // obvious at a glance (see docs/web-ui.md::Approval card). if (a.requested_at != null) { const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at)); head.append(el('span', { class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''), title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(), 'data-requested-at': String(a.requested_at), }, 'requested ' + fmtAgo(a.requested_at))); } li.append(head); // ── what-changed body ──────────────────────────────────────── const body = el('div', { class: 'approval-body' }); if (a.description) { body.append(el('div', { class: 'approval-description' }, a.description)); } if (isApply) { const drill = el('div', { class: 'drill-ins' }); const diffBtn = el('button', { type: 'button', class: 'panel-trigger' }, '↳ view diff'); diffBtn.addEventListener('click', () => openDiffPanel(a)); drill.append(diffBtn); if (forgeBase && a.sha_short) { drill.append(el('a', { class: 'panel-trigger', target: '_blank', rel: 'noopener', href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`, title: 'this proposal commit on the hive forge', }, '↳ commit on forge ↗')); } body.append(drill); } else { body.append(el('span', { class: 'meta' }, isInit ? 'scaffold proposed config repo — manager customises agent.nix before spawn' : 'new sub-agent — container will be created on approve')); } li.append(body); // ── decision actions ───────────────────────────────────────── // Deny prompts the operator for an optional reason; the submit // handler stashes it into a hidden `note` input that rides along // on the POST and is surfaced to the manager via // HelperEvent::ApprovalResolved { note }. const denyForm = el('form', { method: 'POST', action: '/deny/' + a.id, class: 'inline', 'data-async': '', 'data-no-refresh': '', 'data-prompt': 'reason for denying (optional, sent to manager):', }); denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY')); li.append(el('div', { class: 'approval-actions' }, form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }), denyForm, )); ul.append(li); } root.append(ul); } function renderApprovalHistory(root, history) { if (!history.length) { root.append(el('p', { class: 'empty' }, 'no resolved approvals yet')); return; } const ul = el('ul', { class: 'approvals approvals-history' }); for (const a of history) { const li = el('li'); const row = el('div', { class: 'row' }); const glyph = a.status === 'approved' ? '✓' : a.status === 'denied' ? '✗' : a.status === 'cancelled' ? '⊘' : '⚠'; row.append( el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ', el('span', { class: 'id' }, '#' + a.id), ' ', el('span', { class: 'agent' }, a.agent), ' ', el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ', ); if (a.sha_short) row.append(el('code', {}, a.sha_short), ' '); row.append( el('span', { class: 'status status-' + a.status }, a.status), ' ', el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)), ); li.append(row); if (a.note) { li.append(el('div', { class: 'history-note' }, a.note)); } ul.append(li); } root.append(ul); } // Relative time, anchored to now. resolved_at is unix seconds (server- // authored), so we don't have to worry about client/server clock skew // for sub-minute precision. function fmtAgo(unixSecs) { const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs)); if (ageSec < 60) return ageSec + 's ago'; if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago'; if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago'; return Math.floor(ageSec / 86400) + 'd ago'; } function renderMetaInputs(s) { const root = $('meta-inputs-section'); if (!root) return; // Snapshot which checkboxes the operator has ticked before wiping the // DOM. A MetaInputsChanged event (e.g. triggered by a concurrent // meta-update completing) would otherwise silently clear pending // selections mid-flight. We restore them after rebuilding the list. const checkedInputs = new Set( Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked')) .map((cb) => cb.dataset.metaInput), ); root.replaceChildren(); const inputs = s.meta_inputs || []; if (!inputs.length) { root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet')); return; } if (metaUpdateRunning) { root.append(el('p', { class: 'meta-update-running' }, '⏳ meta-update running — flake lock bump + affected agents rebuilding. ' + 'watch the agent cards for per-rebuild progress.')); } const form = el('form', { method: 'POST', action: '/meta-update', class: 'meta-inputs-form', 'data-async': '', // run_meta_update emits MetaInputsChanged once the lock // bump finishes; per-agent rebuilds fire their own // ContainerStateChanged. No /api/state refetch needed. 'data-no-refresh': '', 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?', }); // Bulk select — the full input tree gets long; ticking each box // one by one is tedious. const bulk = el('div', { class: 'meta-inputs-bulk' }); const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all'); const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none'); bulk.append('bulk: ', selAll, ' ', selNone); form.append(bulk); const ul = el('ul', { class: 'meta-inputs' }); for (const inp of inputs) { // `name` is a slash-path from the meta root. Indent depth = its // segment count; the row label shows just the leaf segment, the // full path stays as the checkbox value + the label title. const depth = (inp.name.match(/\//g) || []).length; const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1); const li = el('li'); if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em'; const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_'); const cb = el('input', { type: 'checkbox', name: 'meta_input_' + inp.name, id, value: inp.name, 'data-meta-input': inp.name, }); if (checkedInputs.has(inp.name)) cb.checked = true; const label = el('label', { for: id, title: inp.name }); label.append(cb); if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ ')); label.append( el('span', { class: 'meta-input-name' }, leaf), ' ', el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ', el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)), ); if (inp.url) { label.append(' ', el('span', { class: 'meta-input-url', title: inp.url }, '· ' + truncate(inp.url, 48))); } li.append(label); ul.append(li); } form.append(ul); // Hidden input the POST handler reads — populated at submit // time from the checkbox states. axum's Form extractor doesn't // natively decode repeated keys, so we join into one CSV. const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' }); form.append(hidden); const btn = el('button', { type: 'submit', class: 'btn btn-meta-update', disabled: '', }, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD'); form.append(btn); function refreshDisabled() { const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0; // Stay disabled while an update is already in flight — no // stacking a second run on top of the rebuild ripple. if (any && !metaUpdateRunning) btn.removeAttribute('disabled'); else btn.setAttribute('disabled', ''); } form.addEventListener('change', refreshDisabled); function setAllChecked(val) { for (const b of form.querySelectorAll('input[data-meta-input]')) { b.checked = val; } refreshDisabled(); } selAll.addEventListener('click', () => setAllChecked(true)); selNone.addEventListener('click', () => setAllChecked(false)); form.addEventListener('submit', () => { const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked')) .map((b) => b.dataset.metaInput); hidden.value = selected.join(','); }); root.append(form); } function truncate(s, n) { return s.length <= n ? s : s.slice(0, n - 1) + '…'; } // ─── rebuild queue ────────────────────────────────────────────────────── // Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }. // Same pattern as containerRowCache: reuse
  • nodes whose state hasn't // changed rather than replacing the entire list on every snapshot event. // The elapsed-time ticker (data-rqe-elapsed + 1s setInterval below) already // updates running-entry timestamps in-place, so started_at doesn't need to // invalidate — including it in the fingerprint only matters for the initial // render of a newly-running entry. const rebuildQueueRowCache = new Map(); // Glyph + verb per QueueKind. Mirrors the labels used in // hive-c0re::rebuild_queue::QueueKind::as_str. const QUEUE_KIND_GLYPH = { rebuild: '↻', meta_update: '◆', spawn: '✨', destroy: '🗑', restart: '↺', startup_sweep: '⚡', perm_change: '🔑', }; const QUEUE_STATE_GLYPH = { queued: '⏸', running: '▶', done: '✔', failed: '✖', cancelled: '⊘', }; // Fingerprint for a single rebuild-queue row. Everything visible in the // row except the ticking elapsed seconds (handled by data-rqe-elapsed // ticker, not by re-rendering). function rebuildQueueEntryFingerprint(entry, isChild) { return JSON.stringify({ state: entry.state, step: entry.step, kind: entry.kind, agent: entry.agent, source: entry.source, started_at: entry.started_at, enqueued_at: entry.enqueued_at, finished_at: entry.finished_at, reason: entry.reason, error: entry.error, build_log_id: entry.build_log_id, isChild, }); } function renderRebuildQueue(s) { const root = $('rebuild-queue-section'); if (!root) return; const queue = s.rebuild_queue || []; if (!queue.length) { // Queue drained — show placeholder and purge cache. rebuildQueueRowCache.clear(); root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.')); return; } // Index by id for parent lookup. const byId = new Map(queue.map((e) => [e.id, e])); // Top-level entries first; children render nested under their parent. const tops = queue.filter((e) => e.parent_id == null); const childrenOf = new Map(); for (const e of queue) { if (e.parent_id != null) { if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []); childrenOf.get(e.parent_id).push(e); } } // Children whose parent isn't in the snapshot (history-evicted) still render flat. const orphans = queue.filter( (e) => e.parent_id != null && !byId.has(e.parent_id), ); // Build ordered list of
  • , reusing cached nodes for unchanged entries. const orderedLis = []; function addEntry(entry, isChild) { const fp = rebuildQueueEntryFingerprint(entry, isChild); const cached = rebuildQueueRowCache.get(entry.id); let li; if (cached && cached.fingerprint === fp) { li = cached.el; } else { li = renderQueueEntry(entry, byId, isChild); rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp }); } orderedLis.push(li); } for (const top of tops) { addEntry(top, false); for (const child of childrenOf.get(top.id) || []) { addEntry(child, true); } } for (const o of orphans) { addEntry(o, true); } // Drop cache entries for IDs no longer in the snapshot. const liveIds = new Set(queue.map((e) => e.id)); for (const [id, entry] of rebuildQueueRowCache) { if (!liveIds.has(id)) { entry.el.remove(); rebuildQueueRowCache.delete(id); } } // Get or create the
      ; remove any "empty" placeholder if present. let ul = root.querySelector('ul.rebuild-queue'); if (!ul) { ul = el('ul', { class: 'rebuild-queue' }); root.replaceChildren(ul); } // Reconcile DOM order without a wipe. for (let i = 0; i < orderedLis.length; i++) { if (ul.children[i] !== orderedLis[i]) { ul.insertBefore(orderedLis[i], ul.children[i] ?? null); } } while (ul.children.length > orderedLis.length) ul.lastChild.remove(); } function renderQueueEntry(entry, _byId, isChild) { const li = el('li', { class: 'rebuild-queue-entry rqe-' + entry.state, 'data-id': String(entry.id), }); if (isChild) li.classList.add('rqe-child'); // State glyph + kind + agent. li.append( el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'), ' ', el('span', { class: 'rqe-kind', title: entry.kind }, (QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind), ' ', el('code', { class: 'rqe-agent' }, entry.agent), ); // Source chip (manual / meta_update / auto_update / crash_recover). li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source)); // Timing: queued Xs ago when pending, elapsed when running, // finished Xs ago for terminal. Queued + terminal stamps use // data-rqe-enqueued / data-rqe-finished so the 30s ticker below // can keep them fresh — keyed rows persist across snapshots, so // without a ticker "queued 2m ago" would never advance. if (entry.state === 'queued') { li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-enqueued': String(entry.enqueued_at), }, '· queued ' + fmtAgo(entry.enqueued_at))); } else if (entry.state === 'running' && entry.started_at) { const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at)); li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-elapsed': String(entry.started_at), }, '· ' + fmtElapsed(elapsed))); } else if (entry.finished_at) { li.append(' ', el('span', { class: 'rqe-when', 'data-rqe-finished': String(entry.finished_at), 'data-rqe-state': entry.state, }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at))); } // Reason (truncated; full text on hover). if (entry.reason) { const r = entry.reason.split('\n')[0]; li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60))); } // Current step: backend annotates the in-flight phase on // `running` entries — sub-line below the main row so the // operator can see "what's happening right now" inside a long // build. Terminal transitions clear `step` on the backend so this // doesn't render stale labels on Done / Failed rows. if (entry.step) { li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step)); } // Live-log link: when a build_log_id is present the update/create op // opened a build_logs row; the SSE stream endpoint lets the operator // follow output in real time without polling. if (entry.build_log_id != null) { li.append( ' ', el('a', { class: 'rqe-log-link', href: '/logs.html?id=' + entry.build_log_id + '#build', target: '_blank', title: 'view build log #' + entry.build_log_id, }, 'logs →'), ); } // Error block, when failed. if (entry.error) { li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200))); } // Cancel-X for queued entries. Backend // `POST /api/rebuild-queue/{id}/cancel` refuses Running / // terminal entries, so we surface it only on `queued` rows here // — the operator never sees a dead button. Uses the same // `data-async` form + `data-confirm` pattern as the reminder // cancel, so the global async-form handler takes care of POST + // spinner + error toast. A successful cancel flips `state` from // `queued` → `cancelled` via the live RebuildQueueChanged // snapshot and the row re-renders without this button. if (entry.state === 'queued') { const cancelForm = el('form', { method: 'POST', action: '/api/rebuild-queue/' + entry.id + '/cancel', class: 'inline rqe-cancel', 'data-async': '', 'data-confirm': `cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` + `the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`, }); cancelForm.append(el('button', { type: 'submit', class: 'rqe-cancel-btn', title: 'cancel this queued ' + entry.kind, 'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent, }, '✗')); li.append(cancelForm); } return li; } function fmtElapsed(secs) { if (secs < 60) return secs + 's running'; if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running'; return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running'; } // Tick once per second to refresh "running Xs" badges in place // (mirrors the question-TTL ticker pattern above). // Tick rebuild-queue elapsed-time badges once per second. // `.rqe-when[data-rqe-elapsed]` is set on running queue entries by // `renderQueueEntry`; the ticker avoids a full re-render just for the // wall-clock update. setInterval(() => { const now = Math.floor(Date.now() / 1000); for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) { const started = parseInt(span.dataset.rqeElapsed, 10); if (!started) continue; const elapsed = Math.max(0, now - started); span.textContent = '· ' + fmtElapsed(elapsed); } for (const span of document.querySelectorAll('.build-logs-runtime[data-bl-elapsed]')) { const started = parseInt(span.dataset.blElapsed, 10); if (!started) continue; const elapsed = Math.max(0, now - started); span.textContent = fmtElapsed(elapsed); } }, 1000); // 30s ticker for queued-age and terminal-age labels. Keyed rows // persist across rebuild_queue_changed snapshots, so without this // "queued 1m ago" / "done 5m ago" labels would never advance. setInterval(() => { const now = Math.floor(Date.now() / 1000); for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) { const enqueued = parseInt(span.dataset.rqeEnqueued, 10); if (!enqueued) continue; span.textContent = '· queued ' + fmtAgo(enqueued); } for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) { const finished = parseInt(span.dataset.rqeFinished, 10); if (!finished) continue; const state = span.dataset.rqeState || ''; span.textContent = '· ' + state + ' ' + fmtAgo(finished); } }, 30_000); // ─── reminders ────────────────────────────────────────────────────────── // Reminders aren't part of /api/state (separate sqlite table, separate // mutation cadence). refreshReminders() is called from refreshState() for // cold-load and reconnect recovery. Live mutations are covered by the // `reminders_changed` SSE event → `applyRemindersChanged` so no periodic // poll is needed. async function refreshReminders() { const liveRoot = $('reminders-section'); if (!liveRoot) return; try { const resp = await fetch('/api/reminders'); if (!resp.ok) { paintAtomic(liveRoot, (root) => { root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); }); return; } const rows = await resp.json(); renderReminders(rows); } catch (err) { paintAtomic(liveRoot, (root) => { root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); }); } } function renderReminders(rows) { const liveRoot = $('reminders-section'); if (!liveRoot) return; paintAtomic(liveRoot, (root) => { if (!rows.length) { root.append(el('p', { class: 'empty' }, 'no queued reminders')); return; } const ul = el('ul', { class: 'reminders' }); for (const r of rows) { const failed = (r.attempt_count || 0) > 0; const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); const dueIn = r.due_at - Math.floor(Date.now() / 1000); const dueLabel = dueIn <= 0 ? `overdue ${fmtAgo(r.due_at)}` : `in ${fmtDuration(dueIn)}`; const head = el('div', { class: 'reminder-head' }, el('span', { class: 'agent' }, r.agent), ' ', el('span', { class: 'meta reminder-due', title: new Date(r.due_at * 1000).toISOString(), 'data-due-at': String(r.due_at), }, dueLabel), ' ', el('span', { class: 'meta' }, `· id ${r.id}`), ); if (r.file_path) { head.append(' ', el('span', { class: 'meta' }, '· payload → ')); appendLinkified(head, r.file_path); } if (failed) { head.append(' ', el('span', { class: 'badge badge-warn', title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)', }, `⚠ ${r.attempt_count} failed`)); } const body = el('div', { class: 'reminder-body' }); appendLinkified(body, r.message); li.append(head, body); if (r.last_error) { li.append(el('div', { class: 'reminder-error' }, el('span', { class: 'msg-sep' }, 'error: '), r.last_error, )); } const actions = el('div', { class: 'reminder-actions' }); if (failed) { // Retry resets the failure counters so the scheduler picks // the row up again on its next 5s tick. No data-no-refresh // — the resulting refreshState re-fires refreshReminders. const retryForm = el('form', { method: 'POST', action: '/retry-reminder/' + r.id, class: 'inline', 'data-async': '', }); retryForm.append(el('button', { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY')); actions.append(retryForm); } const cancelForm = el('form', { method: 'POST', action: '/cancel-reminder/' + r.id, class: 'inline', 'data-async': '', 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`, }); cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L')); actions.append(cancelForm); li.append(actions); ul.append(li); } root.append(ul); }); } function fmtDuration(secs) { if (secs < 60) return secs + 's'; if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's'; if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm'; return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h'; } // ─── scheduled prompts ───────────────────────────────────────────────── // Backend exposes `/api/schedules` (snapshot), `/api/schedules` // (POST, operator-direct submit), `/api/schedules/{id}/cancel` // (whole or per-target), `/api/schedules/{id}` (PATCH edit), // `/api/schedules/{id}/fire-now` (POST). Mutations now emit a // `schedules_changed` SSE event so the list updates live; // `applySchedulesChanged` handles it. Tab-activation re-fetch kept as // a safety net for approval-path inserts and disconnect windows. // Local cache lets `refreshTabCounts` show the active count without // re-fetching every second. let schedulesState = []; // Schedule ids whose inline edit form is currently open. The set // survives across refreshSchedules() calls so a state // poll doesn't yank the form out from under the operator. Per-id // mid-edit carry sits in `scheduleEditCarry` so unsaved typing // also rides the refresh. const editingSchedules = new Set(); const scheduleEditCarry = new Map(); async function refreshSchedules() { const listRoot = $('schedules-section'); if (!listRoot) return; try { const resp = await fetch('/api/schedules'); if (!resp.ok) { paintAtomic(listRoot, (root) => { root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status)); }); return; } schedulesState = await resp.json(); } catch (err) { paintAtomic(listRoot, (root) => { root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err)); }); return; } renderSchedulesList(); } // Active = at least one target still alive (no `cancelled_at_unix`) // AND the whole schedule isn't cancelled. Drives the tab pill count. function activeScheduleCount() { let n = 0; for (const s of schedulesState) { if (s.cancelled_at_unix) continue; if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++; } return n; } // Label + caption wrapper shared by every field on the schedule // forms — ``. Variadic // children land inside the label after the caption span, which is // what every caller wants (the input + any preset/parts/preview // sub-rows live next to the caption). function scheduleField(labelText, ...children) { return el('label', { class: 'schedule-field' }, el('span', { class: 'schedule-field-label' }, labelText), ...children); } // Interval composer — shared between the new-schedule form and // the edit-schedule form. Builds the preset chip row + // d/h/m/s sub-fields + live preview, and returns helpers to read // and write the value. // // The composer carries no opinion on what "0 / blank" means at the // semantic layer: it just reports the integer total. Callers decide // whether to treat 0 as `null` (one-shot) for their own submit path. function buildIntervalComposer({ label = 'interval (blank / all-zero = one-shot)', namePrefix = 'interval_', initialSeconds = 0, } = {}) { const wrapper = scheduleField(label); const presets = [ ['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800], ['1h', 3600], ['6h', 21600], ['12h', 43200], ['1d', 86400], ['7d', 604800], ]; const presetsRow = el('div', { class: 'schedule-interval-presets' }); for (const [presetLabel, secs] of presets) { presetsRow.append(el('button', { type: 'button', class: 'btn btn-interval-preset', 'data-secs': String(secs), }, presetLabel)); } presetsRow.append(el('button', { type: 'button', class: 'btn btn-interval-preset btn-interval-oneshot', 'data-secs': '0', }, 'one-shot')); wrapper.append(presetsRow); const partsRow = el('div', { class: 'schedule-interval-parts' }); const mkPart = (suffix, unit) => { const inp = el('input', { type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0', class: 'schedule-interval-num', }); partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit)); return inp; }; const dInp = mkPart('d', 'd'); const hInp = mkPart('h', 'h'); const mInp = mkPart('m', 'm'); const sInp = mkPart('s', 's'); wrapper.append(partsRow); const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' }); wrapper.append(preview); function getSeconds() { const n = (v) => { const x = parseInt(v, 10); return Number.isFinite(x) && x > 0 ? x : 0; }; return n(dInp.value) * 86400 + n(hInp.value) * 3600 + n(mInp.value) * 60 + n(sInp.value); } function fillFromSeconds(total) { const t = Math.max(0, Math.floor(total)); const d = Math.floor(t / 86400); const h = Math.floor((t % 86400) / 3600); const m = Math.floor((t % 3600) / 60); const s = t % 60; dInp.value = d > 0 ? String(d) : ''; hInp.value = h > 0 ? String(h) : ''; mInp.value = m > 0 ? String(m) : ''; sInp.value = s > 0 ? String(s) : ''; } function setParts({ d, h, m, s }) { if (d !== undefined) dInp.value = d; if (h !== undefined) hInp.value = h; if (m !== undefined) mInp.value = m; if (s !== undefined) sInp.value = s; } function updatePreview() { const total = getSeconds(); preview.textContent = total > 0 ? '↻ every ' + fmtDuration(total) : 'one-shot (fires once at first-fire time)'; preview.classList.toggle('schedule-interval-preview-oneshot', total === 0); } for (const inp of [dInp, hInp, mInp, sInp]) { inp.addEventListener('input', updatePreview); } for (const b of presetsRow.querySelectorAll('button[data-secs]')) { b.addEventListener('click', () => { fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0); updatePreview(); }); } if (initialSeconds > 0) fillFromSeconds(initialSeconds); updatePreview(); return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview }; } // Parse the d/h/m/s sub-fields of a `buildIntervalComposer` instance // out of submitted FormData. Returns total seconds, or NaN if any // field carries a non-integer / negative value — the caller alerts // and bails. Total `0` means "one-shot" at the call site (wire is // `interval_seconds: null`). `namePrefix` matches the prefix passed // to `buildIntervalComposer` (e.g. `interval_` or `edit_interval_`). function intervalSecondsFromFormData(fd, namePrefix) { const part = (suffix, mult) => { const raw = String(fd.get(namePrefix + suffix) || '').trim(); if (!raw) return 0; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) return NaN; return n * mult; }; return part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1); } // Targets multi-select chip box — shared between the new-schedule // form and the edit-schedule form. Same DOM shape, same candidate // list (containers + operator + root); only the chip element id // prefix and checkbox field name vary. Used to be inlined twice in // near-identical 18-line blocks; consolidated here so a future // change (new chip kind, candidate-list source swap, etc.) lives in // one place. Returns the wrapping `