// /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 (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
// `index.html` each load their own bundle.
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)
// 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();
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();
}
// 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' });
}
// 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.` }),
);
{
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;
}
function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /index.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;
root.replaceChildren();
// 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;
// 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);
const ul = 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();
for (const node of tree) {
const c = node.container;
const url = 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'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
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 `/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. 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);
// Forge public URL: prefer state.forge_public_url (set by the NixOS
// module when forge.behindGateway=true, e.g.
// "https://forge.pr1ma.darkest.space"), fall back to
// ":3000" for gateway-off / local-dev deploys.
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `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 (see
// docs/web-ui.md::Container row) 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 },
));
}
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 (docs/web-ui.md::Container
// row) so we can render unconditionally here: a stopped
// container simply has no `status_text` and skips 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 (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));
ul.append(li);
}
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 /index.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.
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: [] };
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();
}
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();
}
// 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;
}
function renderQuestions() {
const root = $('questions-section');
// #questions-section only lives on /index.html (Y3R C4LL tab);
// no-op when the section is missing. `question_added` /
// `question_resolved` SSE events route through here.
if (!root) return;
root.replaceChildren();
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
const activeFilter = getQuestionsFilter();
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
// 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);
}
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;
};
filterRow.append(
mkChip('all', `all · ${allPending.length}`),
mkChip('operator', '@operator'),
mkChip('peer', '@peer'),
);
for (const name of Array.from(participants).sort()) {
filterRow.append(mkChip('agent:' + name, '@' + name));
}
root.append(filterRow);
if (!pending.length) {
root.append(el('p', { class: 'empty' },
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
}
const ul = el('ul', { class: 'questions' });
for (const q of pending) {
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
// (set up just below this function) can refresh the text
// without re-rendering the whole 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);
ul.append(li);
}
if (pending.length) root.append(ul);
// Answered question history
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);
root.append(details);
}
}
// 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);
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');
}
// P33RS tab: render peer hive link cards from state.peer_hives.
// Called on every state refresh; hides the tab when the list is empty.
function renderPeerHives(peers) {
const root = $('peers-section');
if (!root) return;
root.replaceChildren();
if (!peers || !peers.length) {
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 /index.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;
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 spawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
});
spawn.append(
el('input', {
name: 'name',
placeholder: 'new agent name (≤9 chars)',
maxlength: '9', required: '', autocomplete: 'off',
}),
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(),
}, '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' : '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;
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,
});
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 ──────────────────────────────────────────────────────
// 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: '🗑',
};
const QUEUE_STATE_GLYPH = {
queued: '⏸',
running: '▶',
done: '✔',
failed: '✖',
cancelled: '⊘',
};
function renderRebuildQueue(s) {
const root = $('rebuild-queue-section');
if (!root) return;
root.replaceChildren();
const queue = s.rebuild_queue || [];
if (!queue.length) {
root.append(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);
}
}
const ul = el('ul', { class: 'rebuild-queue' });
for (const top of tops) {
ul.append(renderQueueEntry(top, byId));
for (const child of childrenOf.get(top.id) || []) {
ul.append(renderQueueEntry(child, byId, true));
}
}
// 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),
);
for (const o of orphans) {
ul.append(renderQueueEntry(o, byId, true));
}
root.append(ul);
}
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.
if (entry.state === 'queued') {
li.append(' ', el('span', { class: 'rqe-when' }, '· 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' }, '· ' + 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: '/api/build-logs/id/' + entry.build_log_id,
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).
setInterval(() => {
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, Math.floor(Date.now() / 1000 - 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, Math.floor(Date.now() / 1000 - started));
span.textContent = fmtElapsed(elapsed);
}
}, 1000);
// ─── reminders ──────────────────────────────────────────────────────────
// Reminders aren't part of /api/state (separate sqlite table, separate
// mutation cadence). Refresh fires alongside refreshState() so a
// cancel POST or a cold load both reflect within the same tick. A
// periodic poll isn't necessary — new reminders are queued by the
// agents themselves and the operator already sees them next time
// they interact with the page.
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', title: new Date(r.due_at * 1000).toISOString() }, 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). No SSE channel for schedule mutations yet,
// so we refresh on tab activation + after every submit/cancel POST.
// 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 `