// /index.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. // // #406 step 1: pure helpers + side panel + OS notifications + path // linkification moved to `./common.js`. // #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker // terminal, @-mention composer) moved to `./flow.js`. /flow.html loads // `flow.js` as its own bundle entry; this file is loaded only by // /index.html. // #406 step 3: file renamed from `app.js` → `tabs.js` since it owns // the dashboard *tabs* surface only (the FL0W page has its own bundle). // Live SSE subscription is wired through `openStream` from common.js // (#406 step 3 hookup; see also #408 for stream-side filtering). import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, Panel, NOTIF, makePathLink, appendText, appendLinkified, openStream, } from './common.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) // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). // #464 — atomic-swap render helper. Each managed section's render // function used to do `root.innerHTML = ''; root.append(...);` in // sequence; even though both operations sit in the same JS turn, // operators could still see a "blink" on every poll cycle because // (a) on async paths the await yield gave the browser a paint // opportunity, and (b) complex builds with many `el()` allocations // can blow the browser's per-task budget enough for layout to flash // empty before the new children land. // // The fix: build the new content off-DOM into a `DocumentFragment`, // then move it into the live root in a single `replaceChildren` // call. The browser never sees an intermediate empty state. Builder // callbacks receive the fragment as their `root` argument, so each // renderer's existing `root.append(...)` code carries over with // zero internal changes. Early-return inside the builder is fine — // the commit still happens with whatever the builder appended. function paintAtomic(liveRoot, build) { const buf = document.createDocumentFragment(); build(buf); liveRoot.replaceChildren(buf); } // Side panel singleton (Panel) moved to ./common.js (#406). // Path linkification + file-preview side panel (openFilePanel, // makePathLink, appendText, appendLinkified) moved to ./common.js (#406). // OS notification module (NOTIF) moved to ./common.js (#406). // 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(); 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 (issue #259). 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(); // Container cards surface in-flight rebuild / meta-update ops as // a "building..." badge (#398) — re-render the SW4RM tab so // newly-queued / newly-running ops light up the right card, // and finished ops fall back to the regular state badges. renderContainersFromState(); } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the // SW4RM tab when an op is still in the rebuild queue but no // operator-initiated transient is set (#398). 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 (#443) ─────────────────────────────────────────────── // Set of selected agent logical names. Toggled by clicking the // container-row icon. When non-empty, the sticky #selection-bar // becomes visible with the bulk actions. Per-card action buttons // are gone — actions live in the bar. 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(); } }); // 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) { 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; } function renderContainers(s) { const root = $('containers-section'); // #containers-section only exists on /index.html. tabs.js is the // bundle for that page only (#406 step 3 — /flow.html loads // flow.js instead), but historical context: pre-split the // `container_state_changed` SSE handler routed through // `applyContainerStateChanged → renderContainersFromState` on // every page that loaded the single combined bundle, and the // guard prevented a `root is null` throw on /flow.html (#399). // Today it's 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; root.innerHTML = ''; // 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); // 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; const ul = el('ul', { class: 'containers' }); const tree = buildAgentTree(containers); // In-flight rebuild / meta-update / destroy ops per agent name. // Surface them as "building..." style badges on the container // card when no operator-initiated transient already covers the // row (#398). Mara: the SW4RM tab showed an agent as stopped // while SYST3M showed an active rebuild; cross-reference fixes // that. const inFlight = inFlightOpsByAgent(); for (const node of tree) { const c = node.container; const url = `http://${hostname}:${c.port}/`; // Pending state is overlaid from the transient store first // (operator-initiated spawn/destroy/rebuild — covers the // create+start window where the container literally isn't up // yet), then from the rebuild_queue (#398 — covers in-flight // ops the worker is running even if no transient was set). // `ContainerStateChanged` doesn't carry either signal. 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' : 'rebuilding') : (op.kind === 'meta_update' ? 'meta-update queued' : op.kind === 'destroy' ? 'destroy queued' : 'rebuild queued'))); const selected = selectionState.has(c.name); const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') + (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); // Full-height square agent icon, left of the card body. The // icon is an absolutely positioned inside a wrapper div: // the div is the flex child and sizes itself via aspect-ratio + // stretch, the is out of flow so its load state — pending, // loaded or broken — can never contribute intrinsic size or // reflow the row. (issue #177) // // The icon points straight at the agent's `/icon`. We don't // guess whether the agent is reachable from the container row — // we just let the try, and if it actually fails to load // (agent stopped, restarting, rebuilding — web server not // answering) the error handler falls it back to the dimmed // hyperhive mark (`/favicon.svg`, served by the dashboard // itself, always reachable). (issues #195, #202) const iconImg = el('img', { class: 'container-icon-img', alt: '' }); // #443: icon is the selection toggle. Click → add/remove from // `selectionState` → re-render. role=button + tabindex makes it // keyboard-accessible; aria-pressed reflects the toggle state. 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 (#432) — 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), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); // Icon-only nav strip — populated async from `/api/agent/{name}/links`, // a same-origin proxy that forwards the agent backend's own link list // (stats / screen-if-gui / forge profile / agent-configs / extras). // The agent backend is the single source of truth; no hardcoded link // list here (issue #262). 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); const forgeBase = `http://${hostname}:3000`; const containerBase = `http://${hostname}:${c.port}`; if (c.running) { fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) .then((r) => (r.ok ? r.json() : [])) .then((links) => { if (!Array.isArray(links)) return; for (const lnk of 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); } }) .catch(() => { /* graceful: agent down → no strip */ }); } // 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; the backend has already cleared rate_limited / // needs_login / ctx_tokens / status_text in that case (#432) so // the rest of the chain is a no-op for stopped containers — but // we still want SOME badge there so the row doesn't look empty. 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.rate_limited) { head.append(el('span', { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, '⊘ rate limited')); } 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 }, )); } head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); if (c.deployed_sha) { head.append(el('span', { class: 'meta', title: 'sha currently locked in /meta/flake.lock' }, `deployed:${c.deployed_sha}`)); } 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}`)); } if (c.ctx_tokens != null) { const k = Math.round(c.ctx_tokens / 1000); // Thresholds track the model's real context window when the // backend supplies it; otherwise fall back to fixed constants. const win = c.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 = c.ctx_tokens >= warn ? 'badge-ctx-warn' : c.ctx_tokens >= caution ? 'badge-ctx-caution' : 'badge-ctx-ok'; const title = win != null ? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` + `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)` : `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`; head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); } body.append(head); // ── agent status text ───────────────────────────────────────── // Self-reported status (via set_status MCP tool) — only fresh // while the harness is up. The backend already clears // `status_text` on stopped containers (#432) so we can render // unconditionally here: a stopped container simply has no // `status_text` and skips this block naturally. if (c.status_text) { const nowUnix = Math.floor(Date.now() / 1000); const ageStr = c.status_set_at != null ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : ''; body.append(el('div', { class: 'agent-status', title: `agent self-reported status${ageStr}`, }, el('span', { class: 'status-icon' }, '◈ '), c.status_text, el('span', { class: 'status-age' }, ageStr), )); } // Per-card action buttons used to live here (R3ST4RT / ST0P / // ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont // show all the restart buttons etc., just show state and links. // instead, clicking an agent icon selects that agent." Actions // moved into the sticky #selection-bar (see renderSelectionBar) // which appears when the operator has at least one agent // selected via the icon click. The contextual `needs update ↻` // chip in the head row stays — it's a state-hint, not an // action button per se. // ── drill-ins ──────────────────────────────────────────────── const drill = el('div', { class: 'drill-ins' }); // Per-container journald viewer. Opens the side panel and // fetches the last N lines; refresh re-fetches; unit selector // narrows to the harness service (or empty = full machine). const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; drill.append(buildJournalTrigger(c.container, journalUnit)); // The hardcoded config-repo trigger and the agent-declared // extras block both moved into the unified nav strip in the // head row above (sourced from the agent backend via // `/api/agent/{name}/links` — issue #262). Only the journald // trigger stays here since it opens the side panel rather // than a link. body.append(drill); li.append(icon, body); ul.append(li); } root.append(ul); renderSelectionBar(containers); } // ─── selection bar (#443) ─────────────────────────────────────────── // Sticky-bottom strip; visible when ≥1 agent selected. mara picked // option B: show every action button, disable the ones that don't // apply to the full selection, hover tooltip explains why. Actions // POST per agent in a loop (no new backend wire — endpoints already // exist and are individually idempotent / event-covered). 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)); // #596: the bar's actions only make sense on the SW4RM tab — that's // where the agent cards are visible to cross-reference against the // selection. On other tabs the operator just sees a floating bar // with no context, so hide it. Selection state persists 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.innerHTML = ''; const allRunning = selected.every((c) => c.running); const allStopped = selected.every((c) => !c.running); const noManagers = selected.every((c) => !c.is_manager); const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); const runningNames = selected.filter((c) => c.running).map((c) => c.name); const managerNames = selected.filter((c) => c.is_manager).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`)), }); // #443 also lifts the manager-stop guard: when the whole selection // is running, ST0P applies — manager included. host-side hive-c0re // keeps serving the dashboard either way + per-agent approvals + // meta-input updates still work without the manager up, so we // don't special-case the confirm prompt when the manager is in // the selection (mara: "dont special case manager for stopping"). 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.`, }); // DESTR0Y / PURG3: sub-agents only (manager has its own // `refusing to destroy` guard at the host layer). When the // selection includes the manager, both buttons go disabled with a // clear reason rather than letting the operator submit and eat a // 500. addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, { action: '/destroy/', confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`, disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)), }); addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, 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.`, disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)), }); // #486 — move agent(s) in the topology tree. Two affordances: // // ⇡ M0V3 → ROOT promote selected agent(s) to top-level (parent=null) // ⇢ M0V3 → [sel] reparent the single selected agent under a picked // parent (cycle-safe — the dropdown filters out self // and own descendants on the client side; the // backend rechecks via `topology::set_parent`). // // Backend lives at POST /api/topology/set-parent (dashboard.rs#2170), // form-encoded `child=&new_parent=`. The // backend re-emits container snapshots on success, so the tree // repaints without a separate refresh. addMoveActions(actions, selected, containers); } // #486 — render the M0V3 affordances inside the selection bar. Split // into its own helper because the picker variant needs a select + button // pair, not the single-button shape addBulkButton ships. // // No client-side manager special-case (mara on #695): backend // `topology::set_parent` refuses to move the manager and surfaces the // refusal as a per-agent failure in the bulk-action error roll-up. Same // pattern as #443 ST0P (which also doesn't special-case manager). function addMoveActions(parent, selected, containers) { // M0V3 → ROOT: parent=null for every selected agent. Only meaningful // when at least one selected agent currently has a non-null parent; // otherwise it's a no-op for everything. const someNotAtRoot = selected.some((c) => c.parent); addBulkButton(parent, 'btn-move', '⇡ M0V3 → ROOT', someNotAtRoot, selected, { action: '/api/topology/set-parent', perAgentBodyFor: (name) => ({ child: name, new_parent: '' }), confirm: (names) => `promote ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')}) to top-level (parent → root)?`, disabledTitle: !someNotAtRoot ? '⇡ M0V3 → ROOT not available — all selected agents are already at root' : null, }); // M0V3 → : inline `` expects `YYYY-MM-DDTHH:MM` // in the user's local timezone (with no trailing Z). Build it // by hand rather than slicing toISOString which is always UTC. const pad = (n) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}`; } // #535: schedules render as a single table — one row per schedule, // attribute columns + one ✓/✕ column per agent (tilted 45° header // so a row of agents takes ~28px each instead of full word width), // actions column on the right. Edit form expands into a colspan'd // row underneath when its row's `✎` is toggled on. function renderSchedulesList() { const liveRoot = $('schedules-section'); if (!liveRoot) return; // #564: capture any mid-typed inline-create state BEFORE // paintAtomic blows the row away so a refresh doesn't yank the // operator's half-filled form. The carry is replayed by // `renderInlineCreateRow` below. readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row')); paintAtomic(liveRoot, (root) => { const agents = schedulesTableAgentSet(); const table = el('table', { class: 'schedules-table' }); table.append(renderSchedulesTableHead(agents)); const tbody = el('tbody', {}); const sorted = schedulesState.slice().sort((a, b) => { const aDone = a.cancelled_at_unix ? 1 : 0; const bDone = b.cancelled_at_unix ? 1 : 0; if (aDone !== bDone) return aDone - bDone; return a.next_fire_at_unix - b.next_fire_at_unix; }); for (const s of sorted) { tbody.append(renderScheduleRow(s, agents)); if (editingSchedules.has(s.id) && !s.cancelled_at_unix) { tbody.append(renderScheduleEditRow(s, agents)); } } // #564: always-visible inline create row at the bottom of the // table — fill cells + click + to POST. Folds the old // `#schedule-new-section` form into the same surface as the // schedules list so creation and display share one mental model. tbody.append(renderInlineCreateRow(agents)); table.append(tbody); root.append(table); }); } // #564: inline create row. Each column carries an input matching // its display semantics (datetime-local for next-fire, mini d/h/m/s // number inputs for every, textarea for body, checkbox per agent // column for targets). Submitting POSTs `/api/schedules` and clears // the carry on success; the next `refreshSchedules` redraws the // table with the new schedule above. function renderInlineCreateRow(agents) { const tr = el('tr', { class: 'schedules-table-create-row' }); tr.append(el('td', { class: 'meta schedules-table-id' }, 'new')); tr.append(el('td', { class: 'meta' }, '—')); const nextInput = el('input', { type: 'datetime-local', name: 'new_first_fire', class: 'schedules-table-inline-input schedules-table-inline-datetime', required: 'required', title: 'first fire time (defaults to 5 minutes from now)', }); // Default: 5 minutes from now so a stale-clock or quick-submit // accident doesn't fire immediately on `now()`. nextInput.value = newScheduleCarry.first_fire || isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000)); tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput)); const intervalRow = el('div', { class: 'schedules-table-inline-interval', title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)', }); const mkUnit = (suffix, unit) => { const inp = el('input', { type: 'number', name: 'new_interval_' + suffix, min: '0', step: '1', placeholder: '0', class: 'schedules-table-inline-num', 'aria-label': 'interval ' + suffix, }); inp.value = newScheduleCarry['interval_' + suffix] || ''; intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit)); }; mkUnit('d', 'd'); mkUnit('h', 'h'); mkUnit('m', 'm'); mkUnit('s', 's'); tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow)); tr.append(el('td', { class: 'meta' }, 'operator')); const bodyTa = el('textarea', { name: 'new_body', rows: '1', required: 'required', placeholder: 'prompt body (required, multi-line ok)', class: 'schedules-table-inline-textarea', }); bodyTa.value = newScheduleCarry.body; const descInput = el('input', { type: 'text', name: 'new_description', placeholder: 'description (optional)', class: 'schedules-table-inline-input schedules-table-inline-desc', }); descInput.value = newScheduleCarry.description; tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' }, bodyTa, descInput)); // Per-agent target checkboxes — one cell per agent column. Wrap // each checkbox in a label so the whole cell area is clickable. for (const a of agents) { const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' }); const id_ = 'new-target-' + a; const cb = el('input', { type: 'checkbox', name: 'new_targets', value: a, id: id_, class: 'schedules-table-inline-check', }); if (newScheduleCarry.targets.has(a)) cb.checked = true; const lbl = el('label', { for: id_, class: 'schedules-table-inline-check-lbl', title: 'tick to target ' + a, }, cb); td.append(lbl); tr.append(td); } // Actions cell — submit button + reset. const actionsCell = el('td', { class: 'schedules-table-actions' }); const submitBtn = el('button', { type: 'button', class: 'btn btn-spawn btn-inline-small schedules-table-create-submit', title: 'queue this new schedule', }, '+'); submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn)); const resetBtn = el('button', { type: 'button', class: 'btn btn-inline-small schedules-table-create-reset', title: 'clear all fields', }, '⌫'); resetBtn.addEventListener('click', () => { resetNewScheduleCarry(); renderSchedulesList(); }); actionsCell.append(submitBtn, resetBtn); tr.append(actionsCell); return tr; } async function submitNewScheduleInline(tr, submitBtn) { const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked')) .map((i) => i.value); const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim(); const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim(); const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || ''); if (!targets.length) { alert('schedule must have at least one target — tick at least one agent column'); return; } if (!body) { alert('prompt body must be non-empty'); return; } if (!firstFireStr) { alert('first fire timestamp is required'); return; } const firstFireDate = new Date(firstFireStr); if (Number.isNaN(firstFireDate.getTime())) { alert('first fire is not a valid datetime'); return; } const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000); // Parse the d/h/m/s parts inline — we don't use FormData since // the inline row isn't wrapped in a
. Mirrors // `intervalSecondsFromFormData` semantics: blank/0 = one-shot, // anything non-integer or negative → NaN → alert. const part = (suffix, mult) => { const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim(); if (!raw) return 0; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 0) return NaN; return n * mult; }; const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1); if (Number.isNaN(intervalTotal)) { alert('interval fields must be non-negative integers (or blank for one-shot)'); return; } const interval_seconds = intervalTotal > 0 ? intervalTotal : null; const payload = { targets, body, first_fire_at_unix }; if (interval_seconds != null) payload.interval_seconds = interval_seconds; if (description) payload.description = description; const originalLabel = submitBtn.innerHTML; submitBtn.disabled = true; submitBtn.innerHTML = ''; try { const resp = await fetch('/api/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } // Reset carry so the next render shows an empty row. resetNewScheduleCarry(); await refreshSchedules(); } catch (err) { alert('schedule submit failed: ' + err); } finally { submitBtn.disabled = false; submitBtn.innerHTML = originalLabel; } } // The set of agent columns in the schedules table: operator + manager // first, then live containers (sorted), then any extra names that // appear as a schedule target but aren't in the live container list // (operator typo, container destroyed mid-schedule, etc.) — same // membership rule as `buildTargetChips` so the table and the new-/ // edit-form chip boxes agree on what's addressable. function schedulesTableAgentSet() { const seen = new Set(); const out = []; const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } }; push('operator'); push('manager'); const containerNames = Array.from(containersState.values()) .map((c) => c.name) .filter((n) => n !== 'manager' && n !== 'operator') .sort(); for (const n of containerNames) push(n); for (const s of schedulesState) { for (const t of s.targets || []) push(t.target); } return out; } function renderSchedulesTableHead(agents) { const thead = el('thead', {}); const headerRow = el('tr', {}); headerRow.append( el('th', { class: 'schedules-table-id' }, '#'), el('th', {}, 'src'), el('th', {}, 'next'), el('th', {}, 'every'), el('th', {}, 'owner'), el('th', { class: 'schedules-table-body-th' }, 'body'), ); for (const a of agents) { headerRow.append(el('th', { class: 'schedules-table-agent-th', title: a }, el('div', {}, el('span', {}, a)))); } headerRow.append(el('th', { class: 'schedules-table-actions-th' }, '')); thead.append(headerRow); return thead; } function renderScheduleRow(s, agents) { const cancelled = !!s.cancelled_at_unix; const tr = el('tr', { class: 'schedules-table-row' + (cancelled ? ' schedules-table-row-cancelled' : ''), }); tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id)); const srcKind = s.source && s.source.kind === 'approval' ? 'approval' : 'manual'; tr.append(el('td', {}, el('span', { class: 'rqe-source rqe-source-' + srcKind }, srcKind === 'approval' ? 'approval' : 'operator'))); // "next" cell — relative due-in for active schedules, "cancelled" // for cancelled ones. Both carry the absolute ISO in the title. const nextCell = el('td', { class: 'meta' }); if (cancelled) { nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString(); nextCell.textContent = 'cancelled'; } else { const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000); nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString(); nextCell.textContent = dueIn <= 0 ? 'overdue ' + fmtAgo(s.next_fire_at_unix) : fmtDuration(dueIn); } tr.append(nextCell); tr.append(el('td', { class: 'meta' }, s.interval_seconds ? '↻ ' + fmtDuration(s.interval_seconds) : 'one-shot')); tr.append(el('td', { class: 'meta' }, s.owner)); // Body cell — truncates with ellipsis; full body + description (if // any) on hover. Description used to be a separate visible block on // the card layout; the table compresses it into the title to keep // row height tight. Mara nit-flag candidate if she actually wants // it visible in-table. const bodyCell = el('td', { class: 'schedules-table-body-cell' }); const bodyText = s.body || ''; bodyCell.title = (s.description ? s.description + '\n\n' : '') + bodyText; bodyCell.textContent = bodyText; tr.append(bodyCell); // Per-agent target cells. Three states: // - active target → ✓ button that cancels just that target on // click (same affordance as the per-row ✕ on the old layout's // targets table) // - cancelled target → muted ✕ glyph (no button — backend // re-add flows through the edit form's targets multi-select) // - not a target → empty cell const targetByName = new Map(); for (const t of s.targets || []) targetByName.set(t.target, t); for (const a of agents) { const t = targetByName.get(a); const td = el('td', { class: 'schedules-table-check' }); if (!t) { // empty — no button, no glyph } else if (t.cancelled_at_unix) { td.title = 'cancelled — ' + (t.last_fired_at_unix ? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago' : 'never fired') + (t.last_result ? ' · ' + t.last_result : ''); td.append(el('span', { class: 'schedules-table-check-cancelled' }, '✕')); } else { const lastFireDesc = t.last_fired_at_unix ? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago' : 'never fired'; const lastResultDesc = t.last_result ? ' · ' + t.last_result : ''; const checkBtn = el('button', { type: 'button', class: 'schedules-table-check-btn', }, '✓'); checkBtn.title = lastFireDesc + lastResultDesc + (cancelled ? '' : '\nclick to cancel this target'); if (cancelled) { checkBtn.disabled = true; } else { checkBtn.addEventListener('click', () => cancelScheduleTargets(s.id, [a])); } td.append(checkBtn); } tr.append(td); } // Actions cell — fire / edit / cancel-all. Glyph-only to fit a // compact column; the buttons keep their existing colour classes // so the visual cue (mauve = fire, yellow = edit, red = cancel) // carries over from the card layout. const actionsCell = el('td', { class: 'schedules-table-actions' }); if (!cancelled) { const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix); const isOneShot = !s.interval_seconds; const fireBtn = el('button', { type: 'button', class: 'btn btn-fire-now btn-inline-small', }, '↯'); fireBtn.title = isOneShot ? 'fire once — one-shot, consumed after the manual fire' : 'fire once now — recurring, next regular fire unaffected'; if (!activeTargets.length) { fireBtn.disabled = true; fireBtn.title = 'every target is cancelled — nothing to fire'; } fireBtn.addEventListener('click', () => fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn)); actionsCell.append(fireBtn); const editingThis = editingSchedules.has(s.id); const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule btn-inline-small', }, editingThis ? '✎×' : '✎'); editBtn.title = editingThis ? 'close edit' : 'edit body / description / interval / next-fire / targets'; editBtn.addEventListener('click', () => { if (editingThis) { editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); } else { editingSchedules.add(s.id); } renderSchedulesList(); }); actionsCell.append(editBtn); const cancelBtn = el('button', { type: 'button', class: 'btn btn-deny btn-inline-small', }, '✕'); cancelBtn.title = 'cancel the whole schedule'; cancelBtn.addEventListener('click', () => cancelScheduleAll(s.id)); actionsCell.append(cancelBtn); } tr.append(actionsCell); return tr; } function renderScheduleEditRow(s, agents) { // colspan = 6 attribute cols + N agent cols + 1 actions col const colCount = 7 + agents.length; const tr = el('tr', { class: 'schedules-table-edit-row' }); const td = el('td', { colspan: String(colCount) }); td.append(renderScheduleEditForm(s)); tr.append(td); return tr; } // #474 — inline edit form. Renders inside the schedule row when the // row's `✎ edit` button is toggled on. Pre-filled with current // values; submit PATCHes /api/schedules/{id}. Targets stay // immutable (per damocles's backend; the workaround for retargeting // is cancel + new schedule). Mid-edit field values survive a // state-poll refresh via `scheduleEditCarry`. function renderScheduleEditForm(s) { const wrapper = el('div', { class: 'schedule-edit-form-wrapper' }); const form_ = el('form', { class: 'schedule-edit-form' }); form_.addEventListener('submit', (e) => { e.preventDefault(); submitEditSchedule(s, form_); }); const carry = scheduleEditCarry.get(s.id) || {}; const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' }); bodyInput.value = carry.body !== undefined ? carry.body : s.body; form_.append(scheduleField('body', bodyInput)); const descInput = el('input', { type: 'text', name: 'description' }); descInput.value = carry.description !== undefined ? carry.description : (s.description || ''); form_.append(scheduleField('description (blank to clear)', descInput)); const firstFireInput = el('input', { type: 'datetime-local', name: 'next_fire', required: 'required', }); firstFireInput.value = carry.next_fire !== undefined ? carry.next_fire : isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000)); form_.append(scheduleField('next fire', firstFireInput)); const intervalCx = buildIntervalComposer({ label: 'interval (blank / all-zero = flip to one-shot)', namePrefix: 'edit_interval_', initialSeconds: 0, }); form_.append(intervalCx.wrapper); if (carry.interval_d !== undefined || carry.interval_h !== undefined || carry.interval_m !== undefined || carry.interval_s !== undefined) { intervalCx.setParts({ d: carry.interval_d || '', h: carry.interval_h || '', m: carry.interval_m || '', s: carry.interval_s || '', }); } else if (s.interval_seconds) { intervalCx.fillFromSeconds(s.interval_seconds); } intervalCx.updatePreview(); // Persist carry on every input + checkbox change so a refresh // repaint preserves it. const saveCarry = () => { const fd = new FormData(form_); scheduleEditCarry.set(s.id, { body: String(fd.get('body') || ''), description: String(fd.get('description') || ''), next_fire: String(fd.get('next_fire') || ''), interval_d: String(fd.get('edit_interval_d') || ''), interval_h: String(fd.get('edit_interval_h') || ''), interval_m: String(fd.get('edit_interval_m') || ''), interval_s: String(fd.get('edit_interval_s') || ''), targets: fd.getAll('edit_targets').map(String), }); }; form_.addEventListener('input', saveCarry); form_.addEventListener('change', saveCarry); // Targets multi-select (#474 fast-follow). Shares the chip-box // pattern with the new-schedule form via `buildTargetChips`; // cancelled tombstones aren't listed (re-adding them flows // through `targets_add`, which the backend replace-on-conflict // drops the tombstone for). Submit diffs against the original // active set to populate `targets_add` / `targets_remove` on the // PATCH body. const originalActiveTargets = new Set( (s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target), ); form_.append(buildTargetChips({ idPrefix: 'se-' + s.id + '-', fieldName: 'edit_targets', checked: carry.targets ? new Set(carry.targets) : originalActiveTargets, extraNames: [...originalActiveTargets], })); form_.append(el('p', { class: 'meta schedule-edit-targets-note' }, 're-adding a previously cancelled target drops its history and starts fresh; ' + 'unchecking an active target cancels it.')); const actions = el('div', { class: 'schedule-actions' }); const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes'); const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel'); cancelEdit.addEventListener('click', () => { editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); renderSchedulesList(); }); actions.append(submit, cancelEdit); form_.append(actions); wrapper.append(form_); return wrapper; } async function submitEditSchedule(originalSchedule, form_) { const s = originalSchedule; const fd = new FormData(form_); const newBody = String(fd.get('body') || '').trim(); const newDescription = String(fd.get('description') || '').trim(); const newNextFireStr = String(fd.get('next_fire') || ''); if (!newBody) { alert('body must be non-empty'); return; } if (!newNextFireStr) { alert('next-fire timestamp is required'); return; } const newNextFireDate = new Date(newNextFireStr); if (Number.isNaN(newNextFireDate.getTime())) { alert('next-fire is not a valid datetime'); return; } const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000); // Compute interval total from the d/h/m/s fields (composer uses // `edit_interval_*` names in this form). const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_'); if (Number.isNaN(intervalTotal)) { alert('interval fields must be non-negative integers'); return; } const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null; // Compute target diff against the schedule's currently-active set // (cancelled tombstones don't count). The backend treats // `targets_add` as replace-on-conflict (re-adding a tombstoned // target drops its history), so we can just blanket "send the // checked list as add, send the unchecked-but-was-active list as // remove." const newTargets = new Set(fd.getAll('edit_targets').map(String)); const originalActive = new Set( (s.targets || []) .filter((t) => !t.cancelled_at_unix) .map((t) => t.target), ); if (!newTargets.size) { alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead'); return; } const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t)); const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t)); // Build the PATCH body. Only include keys for fields that // actually changed; explicit `null` clears description / flips // recurring→one-shot. `targets_add` / `targets_remove` only // populated when non-empty so the wire body stays minimal. const patch = {}; if (newBody !== s.body) patch.body = newBody; if (newDescription !== (s.description || '')) { patch.description = newDescription || null; } if (newNextFireUnix !== s.next_fire_at_unix) { patch.next_fire_at_unix = newNextFireUnix; } if (newIntervalSeconds !== (s.interval_seconds || null)) { patch.interval_seconds = newIntervalSeconds; } if (targetsAdd.length) patch.targets_add = targetsAdd; if (targetsRemove.length) patch.targets_remove = targetsRemove; if (!Object.keys(patch).length) { // No-op submit. Treat as "close edit form". editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); renderSchedulesList(); return; } const submitBtn = form_.querySelector('button[type="submit"]'); const originalLabel = submitBtn ? submitBtn.textContent : ''; if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } editingSchedules.delete(s.id); scheduleEditCarry.delete(s.id); await refreshSchedules(); } catch (err) { alert('edit failed: ' + err); } finally { if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; } } } async function fireScheduleNow(id, isOneShot, targets, btn) { const targetList = targets.length ? targets.join(', ') : '(no active targets)'; const prompt = isOneShot ? `fire schedule #${id} now to ${targetList}?\n\n` + 'this is a ONE-SHOT — firing now consumes the schedule. ' + 'the scheduled fire time will no longer trigger.' : `fire schedule #${id} now to ${targetList}?\n\n` + 'this is RECURRING — sends an extra pulse out-of-band. ' + 'the regular cadence keeps firing on schedule.'; if (!confirm(prompt)) return; // Capture child nodes so we can restore on error, then replace // with DOM-built content (textContent + element children rather // than innerHTML — per argus's review note on #471, the format // string only carries server-side ints/bool today but textContent // is the safer pattern if a stringy field ever lands). const originalChildren = btn ? Array.from(btn.childNodes) : []; const restoreBtn = () => { if (!btn) return; btn.disabled = false; while (btn.firstChild) btn.removeChild(btn.firstChild); for (const n of originalChildren) btn.appendChild(n); }; if (btn) { btn.disabled = true; while (btn.firstChild) btn.removeChild(btn.firstChild); btn.append(el('span', { class: 'spinner' }, '◐'), ' firing…'); } try { const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : '')); restoreBtn(); return; } // Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }. // Flash the per-target outcome on the button itself so the operator // sees the result immediately, then refresh to pick up the // authoritative per-target `last_result` annotations. let report = null; try { report = await resp.json(); } catch { /* shape drift / empty body — ignore */ } if (btn && report) { const bits = []; if (report.ok) bits.push(report.ok + ' ok'); if (report.failed) bits.push(report.failed + ' failed'); if (report.missing) bits.push(report.missing + ' missing'); const suffix = report.one_shot_consumed ? ' — consumed' : ''; while (btn.firstChild) btn.removeChild(btn.firstChild); btn.textContent = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix; btn.classList.add('btn-fire-now-flashed'); } // Hold the flash briefly so the operator can read it before the // refresh wipes the row in place. setTimeout(() => { refreshSchedules(); }, 1500); } catch (err) { alert('fire-now failed: ' + err); restoreBtn(); } } async function cancelScheduleAll(id) { if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return; await postScheduleCancel(id, null); } async function cancelScheduleTargets(id, targets) { if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return; await postScheduleCancel(id, targets); } async function postScheduleCancel(id, targets) { try { const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, }; if (targets) opts.body = JSON.stringify({ targets }); const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts); if (!resp.ok) { const text = await resp.text().catch(() => ''); alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : '')); return; } await refreshSchedules(); } catch (err) { alert('cancel failed: ' + err); } } // ─── state polling ────────────────────────────────────────────────────── let pollTimer = null; // Sections whose innerHTML gets blown away on each refresh. If the // operator is typing in one of them, skip the refresh — the next // tick (or a manual action) will pick it up after they blur. const MANAGED_SECTION_IDS = [ 'containers-section', 'tombstones-section', 'questions-section', 'inbox-section', 'approvals-section', 'meta-inputs-section', 'rebuild-queue-section', 'reminders-section', 'schedules-section', ]; //
sections that should survive a refresh need a stable // `data-restore-key` attribute. snapshotOpenDetails walks managed // sections and records which keys are currently open; restoreOpenDetails // re-applies after the render. (Long-content drill-ins — file // previews, diffs, logs, config — open in the side panel instead, // which lives outside the managed sections and survives re-render // on its own.) function snapshotOpenDetails() { const open = new Set(); for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (d.open) open.add(d.dataset.restoreKey); } } return open; } function restoreOpenDetails(open) { if (!open.size) return; for (const id of MANAGED_SECTION_IDS) { const sect = document.getElementById(id); if (!sect) continue; for (const d of sect.querySelectorAll('details[data-restore-key]')) { if (open.has(d.dataset.restoreKey)) d.open = true; } } } function operatorIsTyping() { const el_ = document.activeElement; if (!el_ || el_ === document.body) return false; const tag = el_.tagName; if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false; return MANAGED_SECTION_IDS.some((id) => { const sect = document.getElementById(id); return sect && sect.contains(el_); }); } async function refreshState() { // Don't yank the form out from under the operator. Try again // shortly on the next tick; eventually they'll blur and the // refresh lands. if (operatorIsTyping()) { if (pollTimer) clearTimeout(pollTimer); pollTimer = setTimeout(refreshState, 2000); return; } try { const resp = await fetch('/api/state'); if (!resp.ok) throw new Error('http ' + resp.status); const s = await resp.json(); // Stash the latest snapshot for any sub-widget that wants a // synchronous read (e.g. the compose autocomplete pulls agent // names from here instead of refetching on every keystroke). window.__hyperhive_state = s; // #607: surface the M4TR1X → tab strip entry only when the // backend's HIVE_MATRIX_GUI_DIR mount is live (otherwise // clicking would 404). `state.matrix_gui_enabled` flips when // the operator toggles `hyperhive.matrix.gui.enable` and rebuilds. const matrixTab = $('tab-matrix'); if (matrixTab) matrixTab.hidden = !s.matrix_gui_enabled; const openDetails = snapshotOpenDetails(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(s); syncTombstonesFromSnapshot(s); syncMetaInputsFromSnapshot(s); syncRebuildQueueFromSnapshot(s); renderContainers(s); renderTombstones(s); // Sync the derived approvals + questions stores from the // snapshot, then render. Live `*_added` / `*_resolved` events // mutate the stores directly and re-render without a snapshot // refetch. syncQuestionsFromSnapshot(s); renderQuestions(); // (renderInbox now lives in ./flow.js — dashboard has no // #inbox-section element to render into.) syncApprovalsFromSnapshot(s); renderApprovals(); renderMetaInputs(s); renderRebuildQueue(s); refreshReminders(); refreshSchedules(); restoreOpenDetails(openDetails); notifyDeltas(s); // No periodic refresh timer. Phase 6 covers every container // mutation with `ContainerStateChanged` / `ContainerRemoved` // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll); // approvals + questions + transients have their own events; // broker traffic flows through the SSE channel. The only // /api/state fetches are the initial cold load and the // post-submit refetch on forms without `data-no-refresh` // (tombstones, meta-input updates). if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } } catch (err) { console.error('refreshState failed', err); // Schedule a single retry on transient errors so the page // recovers from a brief network blip without making the // operator reload. pollTimer = setTimeout(refreshState, 5000); } } refreshState(); NOTIF.bind(); Panel.bind(); // ─── live updates: dashboard event stream (#406 step 3) ──────────────── // The dashboard subscribes to /dashboard/stream for live mutation // events so the SW4RM / Y3R C4LL / SYST3M panes update without an // operator action triggering a refreshState. Pre-step-2 this wiring // lived inside the broker-terminal IIFE which only fired on /flow.html // — meaning /index.html only updated on cold load + after async-form // submits. // // Bare `EventSource` (no terminal infrastructure needed — the // dashboard doesn't render broker rows). Each event's `kind` is // looked up against `MUTATION_HANDLERS`; unknown kinds (broker // `sent` / `delivered`, anything new the backend adds) silently // no-op. On (re)connect we kick a refreshState() to recover events // lost during the disconnect window (same pattern as flow.js's // onStreamOpen). // // `#408` will give /index.html its own stream that omits the // broker traffic the dashboard never uses; for now both pages // subscribe to `/dashboard/stream` and filter client-side. const MUTATION_HANDLERS = { approval_added: applyApprovalAdded, approval_resolved: applyApprovalResolved, question_added: applyQuestionAdded, question_resolved: applyQuestionResolved, transient_set: applyTransientSet, transient_cleared: applyTransientCleared, container_state_changed: applyContainerStateChanged, container_removed: applyContainerRemoved, tombstones_changed: applyTombstonesChanged, meta_inputs_changed: applyMetaInputsChanged, meta_update_running: applyMetaUpdateRunning, rebuild_queue_changed: applyRebuildQueueChanged, }; (function bindDashboardStream() { // #448: route the EventSource through a SharedWorker so all open // hyperhive tabs share ONE backend SSE connection. Survives // Firefox's per-tab connection throttling under many-open-tabs // pressure (the actual mara symptom). `openStream` returns an // EventSource-shaped facade so the rest of this IIFE is unchanged; // graceful fallback to direct `new EventSource` when SharedWorker // isn't supported. const es = openStream('/dashboard/stream'); es.onmessage = (e) => { let ev; try { ev = JSON.parse(e.data); } catch { return; } const h = MUTATION_HANDLERS[ev.kind]; if (!h) return; // broker rows + future kinds — dashboard doesn't care try { h(ev); } catch (err) { console.error('dashboard SSE handler', ev.kind, err); } }; es.onopen = () => { // Re-sync to recover events that fired during a disconnect // window (issue #163). Initial connect also fires onopen — the // first refreshState() above and this one race, but refreshState // is idempotent so the second call just overwrites with the // freshest snapshot. Cheap on a quiescent server, fine to repeat. refreshState(); }; es.onerror = () => { // EventSource auto-reconnects; nothing to do beyond logging. console.debug('dashboard SSE error, will retry'); }; })(); // ─── tab routing (#369) ──────────────────────────────────────────────── // Hash-based: `#swarm` / `#call` / `#system` activate the matching // pane on the dashboard. Empty hash defaults to SW4RM. FL0W is NOT // a tab — it's a separate page (`/flow.html`) reached via the // tab-strip link. Tab routing only applies when the tab DOM is // present (e.g. not on the flow page itself, where these elements // don't exist and the loop no-ops). const TABS = ['swarm', 'call', 'system', 'schedules']; function activateTab(name) { const target = TABS.includes(name) ? name : TABS[0]; for (const t of TABS) { const tab = $('tab-' + t); const pane = $('tab-pane-' + t); if (tab) tab.classList.toggle('active', t === target); if (pane) pane.classList.toggle('tab-pane-active', t === target); } // #596: track active tab on the body so renderSelectionBar can // gate visibility (bar only belongs on SW4RM where agent cards // live). Re-render the bar so the toggle takes effect immediately // on hashchange without waiting for the next SSE update. document.body.dataset.activeTab = target; renderSelectionBar(Array.from(containersState.values())); // #459: schedules pane has no SSE channel yet (PR C follow-up), so // re-fetch on activation so the operator never lands on stale data. if (target === 'schedules') refreshSchedules(); } function syncTabFromHash() { const h = (window.location.hash || '#swarm').replace(/^#/, ''); activateTab(h); } window.addEventListener('hashchange', syncTabFromHash); syncTabFromHash(); // Tab count pills — pure derived data from the existing state // stores so SSE-driven updates flow through without extra plumbing. // Set `hidden` when the count is zero so the pill doesn't draw // attention to an empty room. function setTabCount(tab, n) { const el_ = $('tab-count-' + tab); if (!el_) return; el_.textContent = String(n); el_.hidden = n <= 0; } /** Recompute every tab's count from the current state. Called on * every renderXxx that's tab-relevant. */ function refreshTabCounts() { // SW4RM — flag any container that's stale (needs_update). Empty // when everyone's current. Container-row pulse signals state // transitions; the pill catches "deploy-pending" specifically. let swarm = 0; for (const c of containersState.values()) { if (c.needs_update) swarm++; } setTabCount('swarm', swarm); // Y3R C4LL — pending approvals + operator-targeted questions. const callCount = (approvalsState?.pending?.length ?? 0) + (questionsState?.pending?.length ?? 0); setTabCount('call', callCount); // SYST3M — queued + running rebuild_queue entries (terminal // entries are kept for history but aren't 'attention'). let sysCount = 0; if (rebuildQueueState) { for (const e of rebuildQueueState) { if (e.state === 'Queued' || e.state === 'Running') sysCount++; } } setTabCount('system', sysCount); // SCH3DUL3S — count of schedules with at least one still-active // target (whole-schedule cancellation or all-targets-cancelled // means "not waiting on the worker"; those don't pull attention). setTabCount('schedules', activeScheduleCount()); // FL0W pill count: lives in ./flow.js now (it has the inbox // derived store). Dashboard tab strip's `#tab-count-flow` slot // stays hidden by default; future plumbing could broadcast the // count via localStorage / BroadcastChannel if both pages are // open. } // Poll the state stores on a 1s tick to keep the pill counts in // sync. The state stores are mutated synchronously by every SSE // event + refreshState call, so polling them is correct and cheap // — no per-renderer hookup needed. refreshTabCounts(); setInterval(refreshTabCounts, 1000); // Flow-specific IIFEs moved to ./flow.js (#406 step 2): inbox-pill // wiring, the broker terminal init, and the @-mention composer. // /index.html no longer loads them — only /flow.html does. })();