// /index.html entry point: tab renderers + tab routing + refreshState // + notification deltas. Reads /api/state on cold load and after every // async-form submit; live updates run through `applyXxx` mutation // handlers triggered by the dashboard event stream. // // #406 step 1: pure helpers + side panel + OS notifications + path // linkification moved to `./common.js`. // #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker // terminal, @-mention composer) moved to `./flow.js`. /flow.html loads // `flow.js` as its own bundle entry; this file is loaded only by // /index.html. // #406 step 3: file renamed from `app.js` → `tabs.js` since it owns // the dashboard *tabs* surface only (the FL0W page has its own bundle). // Live SSE subscription is wired through `openStream` from common.js // (#406 step 3 hookup; see also #408 for stream-side filtering). import { marked } from 'marked'; import { $, el, esc, form, fmtAgeSecs, Panel, NOTIF, makePathLink, appendText, appendLinkified, openStream, } from './common.js'; // mdNode (in common.js) reads `window.marked` for the markdown side // panel preview path. Set it here on the dashboard entry so file // previews work; flow.js does the same for the flow-page entry. window.marked = marked; (() => { // ─── constants ────────────────────────────────────────────────────────── // Context-window badge thresholds. Preferred source is each container's // `context_window_tokens` from /api/state (the real window for the model // it last ran on) — thresholds are then 75% / 50% of it, matching the // harness compaction watermarks (compact at 75%, auto-reset at 50%). The // fixed token constants are the fallback for when that field is absent // (agent has no turns yet, or no per-model config matched the model). const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). // #464 — atomic-swap render helper. Each managed section's render // function used to do `root.innerHTML = ''; root.append(...);` in // sequence; even though both operations sit in the same JS turn, // operators could still see a "blink" on every poll cycle because // (a) on async paths the await yield gave the browser a paint // opportunity, and (b) complex builds with many `el()` allocations // can blow the browser's per-task budget enough for layout to flash // empty before the new children land. // // The fix: build the new content off-DOM into a `DocumentFragment`, // then move it into the live root in a single `replaceChildren` // call. The browser never sees an intermediate empty state. Builder // callbacks receive the fragment as their `root` argument, so each // renderer's existing `root.append(...)` code carries over with // zero internal changes. Early-return inside the builder is fine — // the commit still happens with whatever the builder appended. function paintAtomic(liveRoot, build) { const buf = document.createDocumentFragment(); build(buf); liveRoot.replaceChildren(buf); } // Side panel singleton (Panel) moved to ./common.js (#406). // Path linkification + file-preview side panel (openFilePanel, // makePathLink, appendText, appendLinkified) moved to ./common.js (#406). // OS notification module (NOTIF) moved to ./common.js (#406). // Track which items we've already notified about so a re-render // doesn't re-fire for the same row. Keyed by stable ids; reset only // when the page reloads. const seenApprovals = new Set(); const seenQuestions = new Set(); let seededNotify = false; function notifyDeltas(s) { const approvals = s.approvals || []; const questions = s.questions || []; if (!seededNotify) { // First render after page load — fill the "seen" sets without // firing notifications. We only want to notify on NEW items // that arrived while the page is open. The inbox no longer // needs seeding here: it's derived from the broker stream which // does its own per-event notification on live arrival, and // history-replayed events are silent by virtue of `fromHistory`. for (const a of approvals) seenApprovals.add(a.id); for (const q of questions) seenQuestions.add(q.id); seededNotify = true; return; } for (const a of approvals) { if (seenApprovals.has(a.id)) continue; seenApprovals.add(a.id); const verb = a.kind === 'spawn' ? 'spawn approval' : a.kind === 'init_config' ? 'config-init approval' : 'config commit'; NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`, 'hyperhive:approval:' + a.id); } for (const q of questions) { if (seenQuestions.has(q.id)) continue; seenQuestions.add(q.id); const targetLabel = q.target || 'operator'; NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`, q.question.slice(0, 120), 'hyperhive:question:' + q.id); } } // ─── async forms ──────────────────────────────────────────────────────── document.addEventListener('submit', async (e) => { const f = e.target; if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return; e.preventDefault(); if (f.dataset.confirm && !confirm(f.dataset.confirm)) return; if (f.dataset.prompt) { const ans = prompt(f.dataset.prompt, ''); if (ans === null) return; // operator hit Cancel // Drop into a hidden input named after `data-prompt-field` (or // 'note' by default) so the value rides along on the POST. const field = f.dataset.promptField || 'note'; let input = f.querySelector(`input[name="${field}"]`); if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = field; f.append(input); } input.value = ans; } const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline'); const original = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = ''; } try { const resp = await fetch(f.action, { method: f.method || 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams(new FormData(f)), redirect: 'manual', }); const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); if (!ok) { const text = await resp.text().catch(() => ''); alert('action failed: ' + resp.status + (text ? '\n\n' + text : '')); if (btn) { btn.disabled = false; btn.innerHTML = original; } return; } // Re-enable the button — refreshState() rebuilds most lists but // skips forms that didn't change (e.g. the spawn form), so without // this the spinner sticks and the button can't be clicked again. if (btn) { btn.disabled = false; btn.innerHTML = original; } // Clear text inputs whose value was just submitted. f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); // Forms whose endpoint already emits a DashboardEvent that // updates the derived store can opt out of the post-submit // /api/state refetch (the event delivers the new row faster // than the snapshot poll anyway). Container-lifecycle forms // still rely on the refresh since `ContainerView` isn't yet // event-derivable. if (!f.hasAttribute('data-no-refresh')) { refreshState(); } } catch (err) { alert('action failed: ' + err); if (btn) { btn.disabled = false; btn.innerHTML = original; } } }); // Derived container state — cold-loaded from /api/state.containers, // then mutated live by `container_state_changed` (upsert by name) // and `container_removed` (drop by name). The coordinator's rescan // helper fires these after every mutation site + on a periodic poll // in crash_watch. Keyed by ContainerView.name so the lifecycle // forms' POST → 200 → matching event flips the row without a // snapshot refetch. const containersState = new Map(); function syncContainersFromSnapshot(s) { containersState.clear(); for (const c of s.containers || []) containersState.set(c.name, c); } function applyContainerStateChanged(ev) { if (!ev.container || !ev.container.name) return; containersState.set(ev.container.name, ev.container); renderContainersFromState(); } function applyContainerRemoved(ev) { if (containersState.delete(ev.name)) renderContainersFromState(); } // Derived tombstones + meta_inputs. Both are emitted as full // snapshots (not diffs) — the lists are tiny and recomputing // avoids ordering races between a same-tick destroy + purge. let tombstonesState = []; let metaInputsState = []; // True while a dashboard-triggered meta-update (flake lock bump + // agent rebuild ripple) runs in the background. Cold-loaded from // `s.meta_update_running`, then flipped live by the // `meta_update_running` event. Drives the META INPUTS panel's // disabled "updating…" state (issue #259). let metaUpdateRunning = false; function syncTombstonesFromSnapshot(s) { tombstonesState = (s.tombstones || []).slice(); } function syncMetaInputsFromSnapshot(s) { metaInputsState = (s.meta_inputs || []).slice(); metaUpdateRunning = !!s.meta_update_running; } function applyTombstonesChanged(ev) { tombstonesState = (ev.tombstones || []).slice(); renderTombstonesFromState(); } function applyMetaInputsChanged(ev) { metaInputsState = (ev.inputs || []).slice(); renderMetaInputsFromState(); } function applyMetaUpdateRunning(ev) { metaUpdateRunning = !!ev.running; renderMetaInputsFromState(); } function renderTombstonesFromState() { renderTombstones({ tombstones: tombstonesState }); } function renderMetaInputsFromState() { renderMetaInputs({ meta_inputs: metaInputsState }); } // Derived rebuild queue state — cold-loaded from // `/api/state.rebuild_queue`, then mutated live by the // `rebuild_queue_changed` snapshot event. Same shape as the meta- // inputs panel (full snapshot per change, no diff). let rebuildQueueState = []; function syncRebuildQueueFromSnapshot(s) { rebuildQueueState = (s.rebuild_queue || []).slice(); } function applyRebuildQueueChanged(ev) { rebuildQueueState = (ev.queue || []).slice(); renderRebuildQueueFromState(); // Container cards surface in-flight rebuild / meta-update ops as // a "building..." badge (#398) — re-render the SW4RM tab so // newly-queued / newly-running ops light up the right card, // and finished ops fall back to the regular state badges. renderContainersFromState(); } // Map from agent name → highest-priority in-flight queue entry // (`running` beats `queued`). Used by the container row renderer // to surface "building..." / "meta-updating..." badges on the // SW4RM tab when an op is still in the rebuild queue but no // operator-initiated transient is set (#398). function inFlightOpsByAgent() { const out = new Map(); for (const e of rebuildQueueState) { if (e.state !== 'queued' && e.state !== 'running') continue; // spawn ops target an agent that doesn't exist yet as a // container — the transient store already drives the // pending row for that case. Skip here to avoid double- // surfacing if the spawn op happens to land in the queue // while the row exists transiently. if (e.kind === 'spawn') continue; const cur = out.get(e.agent); // Prefer running over queued; otherwise keep the first match. if (!cur || (cur.state === 'queued' && e.state === 'running')) { out.set(e.agent, e); } } return out; } function renderRebuildQueueFromState() { renderRebuildQueue({ rebuild_queue: rebuildQueueState }); } // Derived transient state — cold-loaded from /api/state.transients, // then mutated live by `transient_set` / `transient_cleared`. Keyed // by agent name so add/remove are O(1). `since_unix` is wall-clock so // the elapsed-seconds badge ticks without polling. const transientsState = new Map(); function syncTransientsFromSnapshot(s) { transientsState.clear(); for (const t of s.transients || []) { // Snapshot ships `secs` (server-computed); reconstruct an // approximate since_unix so the live ticker keeps progressing // without surprising jumps when the next snapshot lands. const nowUnix = Math.floor(Date.now() / 1000); transientsState.set(t.name, { kind: t.kind, since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)), }); } } function applyTransientSet(ev) { transientsState.set(ev.name, { kind: ev.transient_kind, since_unix: ev.since_unix, }); renderContainersFromState(); } function applyTransientCleared(ev) { if (transientsState.delete(ev.name)) renderContainersFromState(); } // Re-render using the last cached snapshot (containers come from // /api/state, transients overlay from the derived map). The snapshot // is stashed on window.__hyperhive_state by refreshState; on cold // load before the first snapshot we just skip. function renderContainersFromState() { const s = window.__hyperhive_state; if (s) renderContainers(s); } // ─── selection (#443) ─────────────────────────────────────────────── // Set of selected agent logical names. Toggled by clicking the // container-row icon. When non-empty, the sticky #selection-bar // becomes visible with the bulk actions. Per-card action buttons // are gone — actions live in the bar. const selectionState = new Set(); function toggleSelection(name) { if (selectionState.has(name)) selectionState.delete(name); else selectionState.add(name); renderContainersFromState(); } function clearSelection() { if (selectionState.size === 0) return; selectionState.clear(); renderContainersFromState(); } // Esc clears the current selection (operator escape hatch — mirrors // the side-panel close pattern). Ignored when an editable element // has focus so typing in compose / answer / journal-search isn't // intercepted. document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') return; if (!selectionState.size) return; const a = document.activeElement; if (a && (a.isContentEditable || a.tagName === 'INPUT' || a.tagName === 'TEXTAREA' || a.tagName === 'SELECT')) return; e.preventDefault(); clearSelection(); }); document.addEventListener('click', (e) => { if (e.target && e.target.closest('#selection-clear')) { clearSelection(); } }); // Re-derive port conflicts from the live containers map. Mirrors the // server-side `build_port_conflicts` so the banner reacts to event // updates instead of waiting for a /api/state refetch. function derivePortConflicts(containers) { const byPort = new Map(); for (const c of containers) { if (!byPort.has(c.port)) byPort.set(c.port, []); byPort.get(c.port).push(c.name); } const out = []; for (const [port, agents] of byPort) { if (agents.length > 1) { agents.sort(); out.push({ port, agents }); } } out.sort((a, b) => a.port - b.port); return out; } // ─── state rendering ──────────────────────────────────────────────────── // ─── agent topology (#363) ────────────────────────────────────────────── // Build a forest from `ContainerView.parent` and walk depth-first to // produce a render order with per-row depth + sibling-position info. // Top-level (parent = null OR parent not in the container map) are // roots. Within each level, children are sorted alphabetically by // name; roots likewise. Cycles in the parent graph (malformed config) // are tolerated: any container not reached via root-walk is appended // as a root at the end, so no agent ever disappears from the list. // // For the visual: each row gets a textual prefix column (`├─`, `└─`, // continuation `│ ` or padding ` ` for ancestor columns). When // every container has `parent = null` (today's pre-#361 state), the // tree collapses to a flat list with no glyphs and no indent — bit- // identical to the legacy render. function buildAgentTree(containers) { const byName = new Map(); for (const c of containers) byName.set(c.name, c); const children = new Map(); // parent_name → [child_name, …] const roots = []; for (const c of containers) { const p = c.parent || null; if (p == null || !byName.has(p)) { roots.push(c.name); } else { const list = children.get(p) || []; list.push(c.name); children.set(p, list); } } roots.sort(); for (const list of children.values()) list.sort(); const out = []; const visited = new Set(); function visit(name, depth, ancestorIsLast, isLast) { if (visited.has(name)) return; visited.add(name); const c = byName.get(name); if (!c) return; out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast }); const kids = children.get(name) || []; kids.forEach((kid, i) => visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1)); } roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1)); // Cycle safety: anything not reached lands at root level so no // agent silently disappears when a config is malformed. for (const c of containers) { if (!visited.has(c.name)) visit(c.name, 0, [], true); } return out; } // Builds the .tree-prefix DOM for a row at the given depth. Each // lane is its own positioned child so CSS can paint full-height // vertical bars that bridge the gap between sibling rows — text // box-drawing glyphs only paint one text-line tall, which left // visible breaks between rows once we grew taller-than-one-line // container cards (#388). Ancestor lanes are either continuation // (vertical bar top→bottom+gap) or blank; the joint at this row's // own depth is ├ (branch — vertical continues below) or └ (last — // vertical stops at the row's icon midline). CSS at // `.container-row .tree-prefix` paints the bars + horizontal stub. function treePrefixDom({ depth, ancestorIsLast, isLast }) { if (depth === 0) return null; const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' }); // Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no // continuation column — top-level rows are separated visually as // top-level rows already). for (let d = 1; d < depth; d++) { const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line'; prefix.append(el('span', { class: cls })); } const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch'); prefix.append(el('span', { class: jointCls })); return prefix; } function renderContainers(s) { const root = $('containers-section'); // #containers-section only exists on /index.html. tabs.js is the // bundle for that page only (#406 step 3 — /flow.html loads // flow.js instead), but historical context: pre-split the // `container_state_changed` SSE handler routed through // `applyContainerStateChanged → renderContainersFromState` on // every page that loaded the single combined bundle, and the // guard prevented a `root is null` throw on /flow.html (#399). // Today it's belt-and-suspenders for any future page that adds // tabs.js without a #containers-section. Matches the // no-op-when-target-absent convention the other renderers // (renderTombstones, etc.) follow. if (!root) return; root.innerHTML = ''; // Containers come from the derived map (event-driven) rather than // `s.containers`; `s` still supplies hostname (for the web-ui // link) and tombstones/meta_inputs (not event-derived yet). The // tree builder handles the ordering — we don't pre-sort here. const containers = Array.from(containersState.values()); const portConflicts = derivePortConflicts(containers); const anyStale = containers.some((c) => c.needs_update); // Port-hash collisions: rename one of the listed agents and // rebuild. The banner sits above the agent list so it's the // first thing the operator sees when something's wedged. if (portConflicts.length) { const banner = el('div', { class: 'port-conflict' }, el('strong', {}, '⚠ port collision'), ' — '); const groups = portConflicts.map((c) => `:${c.port} (${c.agents.join(' + ')})`).join('; '); banner.append(groups + '. rename one of each and ↻ R3BU1LD.'); root.append(banner); } if (anyStale) { root.append(form( '/update-all', 'btn-rebuild', '↻ UPD4TE 4LL', 'rebuild every stale container?', {}, { noRefresh: true }, )); } if (transientsState.size) { const ul = el('ul'); const nowUnix = Math.floor(Date.now() / 1000); for (const [name, t] of transientsState) { const secs = Math.max(0, nowUnix - t.since_unix); ul.append(el('li', {}, el('span', { class: 'glyph spinner' }, '◐'), ' ', el('span', { class: 'agent' }, name), ' ', el('span', { class: 'role role-pending' }, t.kind + '…'), ' ', el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`), )); } root.append(ul); } if (!containers.length && !transientsState.size) { root.append(el('p', { class: 'empty' }, 'no managed containers')); return; } // Drop stale selections (agent destroyed while selected). Defensive — // the action bar would otherwise loop POST against a gone agent. const liveNames = new Set(containers.map((c) => c.name)); for (const n of Array.from(selectionState)) { if (!liveNames.has(n)) selectionState.delete(n); } const hostname = (s && s.hostname) || window.location.hostname; const ul = el('ul', { class: 'containers' }); const tree = buildAgentTree(containers); // In-flight rebuild / meta-update / destroy ops per agent name. // Surface them as "building..." style badges on the container // card when no operator-initiated transient already covers the // row (#398). Mara: the SW4RM tab showed an agent as stopped // while SYST3M showed an active rebuild; cross-reference fixes // that. const inFlight = inFlightOpsByAgent(); for (const node of tree) { const c = node.container; const url = `http://${hostname}:${c.port}/`; // Pending state is overlaid from the transient store first // (operator-initiated spawn/destroy/rebuild — covers the // create+start window where the container literally isn't up // yet), then from the rebuild_queue (#398 — covers in-flight // ops the worker is running even if no transient was set). // `ContainerStateChanged` doesn't carry either signal. const transientKind = transientsState.get(c.name)?.kind || null; const op = !transientKind ? inFlight.get(c.name) : null; const pending = transientKind || (op && (op.state === 'running' ? (op.kind === 'meta_update' ? 'meta-updating' : op.kind === 'destroy' ? 'destroying' : 'rebuilding') : (op.kind === 'meta_update' ? 'meta-update queued' : op.kind === 'destroy' ? 'destroy queued' : 'rebuild queued'))); const selected = selectionState.has(c.name); const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') + (selected ? ' selected' : ''), }); // Topology: depth contributes left-padding; the glyph string in // the .tree-prefix span draws the ├─ / └─ joint + continuation // lines (`│ `) for ancestors whose subtree extends below this // row. Both are CSS-driven from the data attributes so the // legacy flat layout (every container at depth 0) is bit- // identical to today's render — no glyph, no indent. if (node.depth > 0) li.dataset.depth = String(node.depth); const prefix = treePrefixDom(node); if (prefix) li.prepend(prefix); // Full-height square agent icon, left of the card body. The // icon is an absolutely positioned inside a wrapper div: // the div is the flex child and sizes itself via aspect-ratio + // stretch, the is out of flow so its load state — pending, // loaded or broken — can never contribute intrinsic size or // reflow the row. (issue #177) // // The icon points straight at the agent's `/icon`. We don't // guess whether the agent is reachable from the container row — // we just let the try, and if it actually fails to load // (agent stopped, restarting, rebuilding — web server not // answering) the error handler falls it back to the dimmed // hyperhive mark (`/favicon.svg`, served by the dashboard // itself, always reachable). (issues #195, #202) const iconImg = el('img', { class: 'container-icon-img', alt: '' }); // #443: icon is the selection toggle. Click → add/remove from // `selectionState` → re-render. role=button + tabindex makes it // keyboard-accessible; aria-pressed reflects the toggle state. const icon = el('div', { class: 'container-icon', role: 'button', tabindex: '0', 'aria-pressed': selected ? 'true' : 'false', title: selected ? `deselect ${c.name} (or press Esc to clear all)` : `select ${c.name} for bulk actions`, }, iconImg); icon.addEventListener('click', (e) => { e.preventDefault(); toggleSelection(c.name); }); icon.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSelection(c.name); } }); if (c.running) { iconImg.src = `${url}icon`; iconImg.addEventListener('error', () => { if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s iconImg.dataset.fallback = '1'; icon.classList.add('icon-unreachable'); iconImg.src = '/favicon.svg'; }); } else { // Container stopped (#432) — skip the doomed `${url}icon` fetch // and go straight to the dimmed hyperhive mark. Avoids a noisy // failed request in the console + the brief broken-image flash. icon.classList.add('icon-unreachable'); iconImg.src = '/favicon.svg'; } // Card body: the three stacked content lines, right of the icon. const body = el('div', { class: 'card-body' }); // ── identity ───────────────────────────────────────────────── const head = el('div', { class: 'head' }); head.append( el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); // Icon-only nav strip — populated async from `/api/agent/{name}/links`, // a same-origin proxy that forwards the agent backend's own link list // (stats / screen-if-gui / forge profile / agent-configs / extras). // The agent backend is the single source of truth; no hardcoded link // list here (issue #262). DOM-built — link strings come from the // agent's process and must never reach the HTML parser. const navStrip = el('span', { class: 'nav-strip' }); head.append(navStrip); const forgeBase = `http://${hostname}:3000`; const containerBase = `http://${hostname}:${c.port}`; if (c.running) { fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) .then((r) => (r.ok ? r.json() : [])) .then((links) => { if (!Array.isArray(links)) return; for (const lnk of links) { const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') : lnk.kind === 'external' ? (lnk.url || '') : /* container */ containerBase + (lnk.url || ''); const a = el('a', { class: 'nav-link', href, target: '_blank', rel: 'noopener', title: lnk.label || '', }); // Plain text — agent-controlled strings stay out of innerHTML. a.textContent = lnk.icon || lnk.label || ''; navStrip.append(a); } }) .catch(() => { /* graceful: agent down → no strip */ }); } // Status / runtime badges. Pending transients always win // (start / stop / restart / rebuild is in progress). Otherwise, // when the container is stopped, surface a single `■ not // running` badge; the backend has already cleared rate_limited / // needs_login / ctx_tokens / status_text in that case (#432) so // the rest of the chain is a no-op for stopped containers — but // we still want SOME badge there so the row doesn't look empty. if (pending) { head.append(el('span', { class: 'pending-state' }, el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); } else if (!c.running) { head.append(el('span', { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, '■ not running')); } else if (c.rate_limited) { head.append(el('span', { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, '⊘ rate limited')); } else if (c.needs_login) { head.append(el('a', { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' }, 'needs login →')); } if (c.needs_update) { head.append(form( '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', {}, { noRefresh: true }, )); } head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); if (c.deployed_sha) { head.append(el('span', { class: 'meta', title: 'sha currently locked in /meta/flake.lock' }, `deployed:${c.deployed_sha}`)); } if (c.pending_reminders && c.pending_reminders > 0) { head.append(el('span', { class: 'badge badge-reminder', title: 'pending reminders queued for this agent — see the reminders section to view / cancel', }, `⏰ ${c.pending_reminders}`)); } if (c.ctx_tokens != null) { const k = Math.round(c.ctx_tokens / 1000); // Thresholds track the model's real context window when the // backend supplies it; otherwise fall back to fixed constants. const win = c.context_window_tokens; const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS; const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS; const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn' : c.ctx_tokens >= caution ? 'badge-ctx-caution' : 'badge-ctx-ok'; const title = win != null ? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} ` + `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)` : `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`; head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`)); } body.append(head); // ── agent status text ───────────────────────────────────────── // Self-reported status (via set_status MCP tool) — only fresh // while the harness is up. The backend already clears // `status_text` on stopped containers (#432) so we can render // unconditionally here: a stopped container simply has no // `status_text` and skips this block naturally. if (c.status_text) { const nowUnix = Math.floor(Date.now() / 1000); const ageStr = c.status_set_at != null ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : ''; body.append(el('div', { class: 'agent-status', title: `agent self-reported status${ageStr}`, }, el('span', { class: 'status-icon' }, '◈ '), c.status_text, el('span', { class: 'status-age' }, ageStr), )); } // Per-card action buttons used to live here (R3ST4RT / ST0P / // ST4RT / R3BU1LD / DESTR0Y / PURG3). Per mara on #443: "dont // show all the restart buttons etc., just show state and links. // instead, clicking an agent icon selects that agent." Actions // moved into the sticky #selection-bar (see renderSelectionBar) // which appears when the operator has at least one agent // selected via the icon click. The contextual `needs update ↻` // chip in the head row stays — it's a state-hint, not an // action button per se. // ── drill-ins ──────────────────────────────────────────────── const drill = el('div', { class: 'drill-ins' }); // Per-container journald viewer. Opens the side panel and // fetches the last N lines; refresh re-fetches; unit selector // narrows to the harness service (or empty = full machine). const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; drill.append(buildJournalTrigger(c.container, journalUnit)); // The hardcoded config-repo trigger and the agent-declared // extras block both moved into the unified nav strip in the // head row above (sourced from the agent backend via // `/api/agent/{name}/links` — issue #262). Only the journald // trigger stays here since it opens the side panel rather // than a link. body.append(drill); li.append(icon, body); ul.append(li); } root.append(ul); renderSelectionBar(containers); } // ─── selection bar (#443) ─────────────────────────────────────────── // Sticky-bottom strip; visible when ≥1 agent selected. mara picked // option B: show every action button, disable the ones that don't // apply to the full selection, hover tooltip explains why. Actions // POST per agent in a loop (no new backend wire — endpoints already // exist and are individually idempotent / event-covered). function renderSelectionBar(containers) { const bar = $('selection-bar'); if (!bar) return; const countSpan = $('selection-count'); const namesSpan = $('selection-names'); const actions = $('selection-actions'); if (!countSpan || !namesSpan || !actions) return; const selected = containers.filter((c) => selectionState.has(c.name)); // #596: the bar's actions only make sense on the SW4RM tab — that's // where the agent cards are visible to cross-reference against the // selection. On other tabs the operator just sees a floating bar // with no context, so hide it. Selection state persists in-memory // and the bar reappears on return to SW4RM if still non-empty. const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm'; if (!selected.length || !onSwarmTab) { bar.hidden = true; document.body.classList.remove('has-selection'); return; } bar.hidden = false; document.body.classList.add('has-selection'); countSpan.textContent = selected.length === 1 ? '1 agent selected' : selected.length + ' agents selected'; namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', '); // Recompute action availability + tooltips per render. Each // action declares which agents it CAN'T run on; the bar disables // the button and surfaces the offending names in the tooltip. actions.innerHTML = ''; const allRunning = selected.every((c) => c.running); const allStopped = selected.every((c) => !c.running); const noManagers = selected.every((c) => !c.is_manager); const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name); const runningNames = selected.filter((c) => c.running).map((c) => c.name); const managerNames = selected.filter((c) => c.is_manager).map((c) => c.name); function why(label, blockers) { if (!blockers.length) return null; return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`; } addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, { action: '/restart/', confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)), }); // #443 also lifts the manager-stop guard: when the whole selection // is running, ST0P applies — manager included. host-side hive-c0re // keeps serving the dashboard either way + per-agent approvals + // meta-input updates still work without the manager up, so we // don't special-case the confirm prompt when the manager is in // the selection (mara: "dont special case manager for stopping"). addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, { action: '/kill/', confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)), }); addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, { action: '/start/', confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`, disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)), }); addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, { action: '/rebuild/', confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`, }); // DESTR0Y / PURG3: sub-agents only (manager has its own // `refusing to destroy` guard at the host layer). When the // selection includes the manager, both buttons go disabled with a // clear reason rather than letting the operator submit and eat a // 500. addBulkButton(actions, 'btn-destroy', 'DESTR0Y', noManagers, selected, { action: '/destroy/', confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`, disabledTitle: why('DESTR0Y', managerNames.map((n) => `\`${n}\` is the manager`)), }); addBulkButton(actions, 'btn-destroy', 'PURG3', noManagers, selected, { action: '/destroy/', body: { purge: 'on' }, confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`, disabledTitle: why('PURG3', managerNames.map((n) => `\`${n}\` is the manager`)), }); } 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. for (const name of names) { const body = new URLSearchParams(opts.body || {}); try { const resp = await fetch(opts.action + encodeURIComponent(name), { 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); } // Per-container journald viewer. Returns an inline trigger; the // click opens the side panel and fetches the last N lines. Refresh // re-fetches; the unit toggle switches between the harness service // and the full machine journal. function buildJournalTrigger(containerName, defaultUnit) { const trigger = el('button', { type: 'button', class: 'panel-trigger' }, '↳ logs · ' + containerName); trigger.addEventListener('click', () => { const body = el('div', { class: 'journal-body' }); const controls = el('div', { class: 'journal-controls' }); const unitSelect = el('select', { class: 'journal-unit' }); unitSelect.append( el('option', { value: defaultUnit }, defaultUnit), el('option', { value: '' }, '(full machine journal)'), ); const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' }, '↻ refresh'); const pre = el('pre', { class: 'journal-output' }, 'fetching…'); let fetching = false; async function fetchLogs() { if (fetching) return; fetching = true; pre.textContent = 'fetching…'; const unit = unitSelect.value; const params = new URLSearchParams({ lines: '500' }); if (unit) params.set('unit', unit); try { const resp = await fetch('/api/journal/' + containerName + '?' + params); const text = await resp.text(); if (!resp.ok) { pre.textContent = 'error: ' + resp.status + '\n' + text; } else { pre.textContent = text || '(empty)'; // Auto-scroll to the newest lines on fresh fetch. #541 // moved the scroll surface from side-panel-body onto the //
 itself (the panel-body now fills the viewport and
            // the 
 is the inner overflow container), so scroll
            // the 
 instead of the side-panel-body.
            pre.scrollTop = pre.scrollHeight;
          }
        } catch (err) {
          pre.textContent = 'fetch failed: ' + err;
        } finally {
          fetching = false;
        }
      }
      refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
      unitSelect.addEventListener('change', fetchLogs);
      controls.append(unitSelect, refresh);
      body.append(controls, pre);
      Panel.open('logs · ' + containerName, body);
      fetchLogs();
    });
    return trigger;
  }

  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 (#399).
    if (!root) return;
    root.innerHTML = '';
    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);
  }

  // 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 (issue #163) 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 missing (#406 step 3 — only /index.html loads
    // tabs.js, but kept as belt-and-suspenders for any future page
    // adding it without that section). `question_added` /
    // `question_resolved` SSE events route through here (#399).
    if (!root) return;
    root.innerHTML = '';
    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
        // (issue #335).
        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 (issue #335). 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);

  // Operator-inbox derived store moved to ./flow.js (#406 step 2).

  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. (#272)
      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 (issue #163) 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');
  }

  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
    // (#399).
    if (!root) return;
    root.innerHTML = '';

    // 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;
    const forgeBase = (fs && fs.forge_present) ? `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. (issue #272)
      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.innerHTML = '';
    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 (issue #275).
    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.innerHTML = '';
    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 (#437 / #501): 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));
    }
    // Error block, when failed.
    if (entry.error) {
      li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
    }
    // #575: cancel-X for queued entries. Backend
    // `POST /api/rebuild-queue/{id}/cancel` is already implemented
    // (refuses Running / terminal entries); we surface it only on
    // `queued` rows here so 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 from #335).
  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);
    }
  }, 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 (#459) ──────────────────────────────────────────
  // Backend (#444) 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 = [];
  // #474 — 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 (#466) — shared between the new-schedule form
  // and the edit-schedule form (#474). 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 + manager); 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 `