hyperhive/frontend/packages/dashboard/src/tabs.js
iris 7d00928c69 feat(dashboard): operator inbox with mark-as-read on Y3R C4LL
Agents that `send(to: "operator")` were easy to miss — they only
surfaced on the FL0W firehose with no read-state (#1469). Surface them
on the Y3R C4LL ("things waiting on you") tab as a proper inbox.

Backend:
- broker: `unread_for_recipient(recipient, limit)` — unacked messages
  for a recipient, newest-first. Mirrors `mark_all_read`'s filter
  EXACTLY (`recipient = ?1 AND acked_at IS NULL`, no `delivered_at`
  condition) so everything listed is exactly what mark-read clears —
  operator rows never get `delivered_at` set (no agent-socket recv).
- dashboard: `GET /api/operator-inbox` → `{ messages: [...] }` (id,
  from, body, at, in_reply_to, validated file_refs). Mark-read reuses
  the existing `POST /api/agent/operator/mark-all-read` (the route
  format-validates the name; "operator" passes; `mark_all_read`
  already acks `to="operator"` rows).

Frontend (Y3R C4LL):
- New ◆ 1NB0X ◆ section listing unread messages (sender · time · body,
  path-linkified) + a "✓ mark all read" button.
- Cold-loaded on page load + on tab activation; appended live from the
  broker `sent` stream (deduped on row id); cleared on mark-all-read.
- Unread count folds into the Y3R C4LL tab pill + the browser-title
  `(N)` prefix, so messages are visible from any tab.

Removing the now-redundant FL0W operator-inbox UI is a clean follow-up
(deferred to avoid a flow.js conflict with the in-flight #1473).
Backend (broker + route) is host-side — @damocles to review per plan.

Closes #1469.
2026-06-06 12:34:31 +02:00

4625 lines
192 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// /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 = '<span class="spinner">◐</span>'; }
try {
const resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
// Re-enable the button — refreshState() rebuilds most lists but
// skips forms that didn't change (e.g. the spawn form), so without
// this the spinner sticks and the button can't be clicked again.
if (btn) { btn.disabled = false; btn.innerHTML = original; }
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
// Forms whose endpoint already emits a DashboardEvent that
// updates the derived store can opt out of the post-submit
// /api/state refetch (the event delivers the new row faster
// than the snapshot poll anyway). Container-lifecycle forms
// still rely on the refresh since `ContainerView` isn't yet
// event-derivable.
if (!f.hasAttribute('data-no-refresh')) {
refreshState();
}
} catch (err) {
alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
// Derived container state — cold-loaded from /api/state.containers,
// then mutated live by `container_state_changed` (upsert by name)
// and `container_removed` (drop by name). The coordinator's rescan
// helper fires these after every mutation site + on a periodic poll
// in crash_watch. Keyed by ContainerView.name so the lifecycle
// forms' POST → 200 → matching event flips the row without a
// snapshot refetch.
const containersState = new Map();
// Keyed container row cache. Maps agent name → { el: <li>, fingerprint }.
// Allows renderContainers to skip rebuilding rows whose displayed state
// hasn't changed — prevents full-wipe flicker + avoids redundant async
// dashboard-state fetches on every SSE event.
const containerRowCache = new Map();
function syncContainersFromSnapshot(s) {
containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c);
}
function applyContainerStateChanged(ev) {
if (!ev.container || !ev.container.name) return;
containersState.set(ev.container.name, ev.container);
renderContainersFromState();
}
function applyContainerRemoved(ev) {
if (containersState.delete(ev.name)) renderContainersFromState();
}
// Derived tombstones + meta_inputs. Both are emitted as full
// snapshots (not diffs) — the lists are tiny and recomputing
// avoids ordering races between a same-tick destroy + purge.
let tombstonesState = [];
let metaInputsState = [];
// True while a dashboard-triggered meta-update (flake lock bump +
// agent rebuild ripple) runs in the background. Cold-loaded from
// `s.meta_update_running`, then flipped live by the
// `meta_update_running` event. Drives the META INPUTS panel's
// disabled "updating…" state.
let metaUpdateRunning = false;
function syncTombstonesFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
function syncMetaInputsFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
metaUpdateRunning = !!s.meta_update_running;
}
function applyTombstonesChanged(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstonesFromState();
}
function applyMetaInputsChanged(ev) {
metaInputsState = (ev.inputs || []).slice();
renderMetaInputsFromState();
}
function applyMetaUpdateRunning(ev) {
metaUpdateRunning = !!ev.running;
renderMetaInputsFromState();
}
function renderTombstonesFromState() {
renderTombstones({ tombstones: tombstonesState });
}
function renderMetaInputsFromState() {
renderMetaInputs({ meta_inputs: metaInputsState });
}
// Derived rebuild queue state — cold-loaded from
// `/api/state.rebuild_queue`, then mutated live by the
// `rebuild_queue_changed` snapshot event. Same shape as the meta-
// inputs panel (full snapshot per change, no diff).
let rebuildQueueState = [];
function syncRebuildQueueFromSnapshot(s) {
rebuildQueueState = (s.rebuild_queue || []).slice();
}
function applyRebuildQueueChanged(ev) {
rebuildQueueState = (ev.queue || []).slice();
renderRebuildQueueFromState();
// Re-render the SW4RM tab so newly-queued / newly-running
// rebuild-queue ops light up the right card with a building…
// badge, and finished ops fall back to the regular state badges.
// See docs/web-ui.md::Container row for the badge taxonomy.
renderContainersFromState();
}
function applySchedulesChanged(ev) {
schedulesState = (ev.schedules || []).slice();
renderSchedulesList();
}
function applyRemindersChanged(ev) {
renderReminders(ev.reminders || []);
}
// Map from agent name → highest-priority in-flight queue entry
// (`running` beats `queued`). Used by the container row renderer
// to surface "building..." / "meta-updating..." badges on the
// SW4RM tab when an op is still in the rebuild queue but no
// operator-initiated transient is set — see docs/web-ui.md::Container
// row for the badge cross-reference between SW4RM and SYST3M.
function inFlightOpsByAgent() {
const out = new Map();
for (const e of rebuildQueueState) {
if (e.state !== 'queued' && e.state !== 'running') continue;
// spawn ops target an agent that doesn't exist yet as a
// container — the transient store already drives the
// pending row for that case. Skip here to avoid double-
// surfacing if the spawn op happens to land in the queue
// while the row exists transiently.
if (e.kind === 'spawn') continue;
const cur = out.get(e.agent);
// Prefer running over queued; otherwise keep the first match.
if (!cur || (cur.state === 'queued' && e.state === 'running')) {
out.set(e.agent, e);
}
}
return out;
}
function renderRebuildQueueFromState() {
renderRebuildQueue({ rebuild_queue: rebuildQueueState });
}
// Derived transient state — cold-loaded from /api/state.transients,
// then mutated live by `transient_set` / `transient_cleared`. Keyed
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
// the elapsed-seconds badge ticks without polling.
const transientsState = new Map();
function syncTransientsFromSnapshot(s) {
transientsState.clear();
for (const t of s.transients || []) {
// Snapshot ships `secs` (server-computed); reconstruct an
// approximate since_unix so the live ticker keeps progressing
// without surprising jumps when the next snapshot lands.
const nowUnix = Math.floor(Date.now() / 1000);
transientsState.set(t.name, {
kind: t.kind,
since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
});
}
}
function applyTransientSet(ev) {
transientsState.set(ev.name, {
kind: ev.transient_kind,
since_unix: ev.since_unix,
});
renderContainersFromState();
}
function applyTransientCleared(ev) {
if (transientsState.delete(ev.name)) renderContainersFromState();
}
// Re-render using the last cached snapshot (containers come from
// /api/state, transients overlay from the derived map). The snapshot
// is stashed on window.__hyperhive_state by refreshState; on cold
// load before the first snapshot we just skip.
function renderContainersFromState() {
const s = window.__hyperhive_state;
if (s) renderContainers(s);
}
// ─── selection ──────────────────────────────────────────────────────
// In-memory set of selected agent logical names backing the sticky
// #selection-bar. See docs/web-ui.md::Selection bar for the
// interaction model (icon-click toggle, Esc/clear button drop,
// tab-gated visibility).
const selectionState = new Set();
function toggleSelection(name) {
if (selectionState.has(name)) selectionState.delete(name);
else selectionState.add(name);
renderContainersFromState();
}
function clearSelection() {
if (selectionState.size === 0) return;
selectionState.clear();
renderContainersFromState();
}
// Esc clears the current selection (operator escape hatch — mirrors
// the side-panel close pattern). Ignored when an editable element
// has focus so typing in compose / answer / journal-search isn't
// intercepted.
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (!selectionState.size) return;
const a = document.activeElement;
if (a && (a.isContentEditable
|| a.tagName === 'INPUT'
|| a.tagName === 'TEXTAREA'
|| a.tagName === 'SELECT')) return;
e.preventDefault();
clearSelection();
});
document.addEventListener('click', (e) => {
if (e.target && e.target.closest('#selection-clear')) {
clearSelection();
}
});
// ─── per-agent context menu ──────────────────────────────────────────
// Three-dot (⋮) button on each agent card for quick single-agent
// lifecycle actions without needing to select first. State-aware:
// restart/stop only shown when running, start only shown when stopped,
// destroy/purge hidden for the manager.
// The button is CSS-invisible until the row is hovered (or menu is
// open) so it doesn't clutter quiet rows.
let openAgentMenu = null; // currently open dropdown element, or null
function closeAllAgentMenus() {
if (!openAgentMenu) return;
openAgentMenu.hidden = true;
const wrap = openAgentMenu.closest('.agent-menu');
if (wrap) {
wrap.classList.remove('open');
const btn = wrap.querySelector('.agent-menu-btn');
if (btn) btn.setAttribute('aria-expanded', 'false');
}
openAgentMenu = null;
}
// Close on any click outside an agent-menu element.
document.addEventListener('click', (e) => {
if (!e.target.closest('.agent-menu')) closeAllAgentMenus();
}, true);
// Close on Escape. stopImmediatePropagation so the selection-clear
// handler on the same element doesn't also fire when a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openAgentMenu) {
closeAllAgentMenus();
e.stopImmediatePropagation();
}
}, true);
// Single-agent POST helper shared by all menu items.
async function agentMenuPost(actionPath, name, body) {
const url = actionPath + encodeURIComponent(name);
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
}
} catch (err) {
alert('action failed: ' + err);
}
}
function buildAgentMenu(c, forgeBase) {
const wrap = el('div', { class: 'agent-menu' });
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', hidden: true, role: 'menu' });
function menuItem(label, opts) {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
closeAllAgentMenus();
if (opts.confirm && !confirm(opts.confirm)) return;
await agentMenuPost(opts.action, c.name, opts.body || null);
});
li.append(item);
return li;
}
function menuSep() {
return el('li', { class: 'agent-menu-sep', role: 'separator' });
}
// Navigation link item (opens in same tab by default).
function menuLink(label, href, title) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', () => closeAllAgentMenus());
li.append(a);
return li;
}
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', { action: '/restart/', confirm: `restart ${c.name}?` }),
menuItem('■ ST0P', { action: '/kill/', confirm: `stop ${c.name}?` }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', { action: '/start/', confirm: `start ${c.name}?` }),
);
}
dropdown.append(
menuItem('↻ R3BU1LD', { action: '/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
);
{
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/destroy/',
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/destroy/',
body: { purge: 'on' },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
}
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
li.append(a);
dropdown.append(menuSep(), li);
}
btn.addEventListener('click', (e) => {
e.stopPropagation();
const wasHidden = dropdown.hidden;
closeAllAgentMenus();
if (wasHidden) {
dropdown.hidden = false;
btn.setAttribute('aria-expanded', 'true');
wrap.classList.add('open');
openAgentMenu = dropdown;
}
});
wrap.append(btn, dropdown);
return wrap;
}
// Re-derive port conflicts from the live containers map. Mirrors the
// server-side `build_port_conflicts` so the banner reacts to event
// updates instead of waiting for a /api/state refetch.
function derivePortConflicts(containers) {
const byPort = new Map();
for (const c of containers) {
if (!byPort.has(c.port)) byPort.set(c.port, []);
byPort.get(c.port).push(c.name);
}
const out = [];
for (const [port, agents] of byPort) {
if (agents.length > 1) {
agents.sort();
out.push({ port, agents });
}
}
out.sort((a, b) => a.port - b.port);
return out;
}
// ─── state rendering ────────────────────────────────────────────────────
// ─── agent topology ─────────────────────────────────────────────────
// See docs/web-ui.md::Topology tree for the rendering contract
// (forest walk, alphabetical sort, orphan + cycle handling).
function buildAgentTree(containers) {
// Close any open context menu before replacing the DOM tree — the
// previous dropdown element would otherwise be a stale reference.
closeAllAgentMenus();
const byName = new Map();
for (const c of containers) byName.set(c.name, c);
const children = new Map(); // parent_name → [child_name, …]
const roots = [];
for (const c of containers) {
const p = c.parent || null;
if (p == null || !byName.has(p)) {
roots.push(c.name);
} else {
const list = children.get(p) || [];
list.push(c.name);
children.set(p, list);
}
}
roots.sort();
for (const list of children.values()) list.sort();
const out = [];
const visited = new Set();
function visit(name, depth, ancestorIsLast, isLast) {
if (visited.has(name)) return;
visited.add(name);
const c = byName.get(name);
if (!c) return;
out.push({ container: c, depth, ancestorIsLast: [...ancestorIsLast], isLast });
const kids = children.get(name) || [];
kids.forEach((kid, i) =>
visit(kid, depth + 1, [...ancestorIsLast, isLast], i === kids.length - 1));
}
roots.forEach((name, i) => visit(name, 0, [], i === roots.length - 1));
// Cycle safety: anything not reached lands at root level so no
// agent silently disappears when a config is malformed.
for (const c of containers) {
if (!visited.has(c.name)) visit(c.name, 0, [], true);
}
return out;
}
// Builds the .tree-prefix DOM for a row at the given depth.
// See docs/web-ui.md::Topology tree for why this is DOM-painted
// (one positioned <span> per lane) rather than text-glyph-painted.
function treePrefixDom({ depth, ancestorIsLast, isLast }) {
if (depth === 0) return null;
const prefix = el('span', { class: 'tree-prefix', 'aria-hidden': 'true' });
// Ancestor columns (depth 1..depth-1). Skip depth 0 (root has no
// continuation column — top-level rows are separated visually as
// top-level rows already).
for (let d = 1; d < depth; d++) {
const cls = ancestorIsLast[d] ? 'tree-lane lane-blank' : 'tree-lane lane-line';
prefix.append(el('span', { class: cls }));
}
const jointCls = 'tree-lane lane-joint ' + (isLast ? 'lane-joint-last' : 'lane-joint-branch');
prefix.append(el('span', { class: jointCls }));
return prefix;
}
// Serialise the visible state of a container row into a stable string
// for change-detection. Includes everything that affects what the row
// renders — container fields, derived pending/selection state, tree
// position, and link-base context. The async dashboard-state (nav
// strip, ctx badge, status text) is intentionally excluded: it
// populates in-place and is preserved when a row is reused.
function containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname) {
return JSON.stringify({
running: c.running,
needs_login: c.needs_login,
needs_update: c.needs_update,
pending_reminders: c.pending_reminders,
port: c.port,
pending,
opRunning,
selected,
askerCount,
targetCount,
depth: node.depth,
isLast: node.isLast,
ancestorIsLast: node.ancestorIsLast,
gatewayLinks,
hostname,
});
}
// Build a single container-row <li> from scratch. Extracted so
// renderContainers can call this only for rows whose fingerprint
// changed (keyed cache), skipping the build + async dashboard-state
// fetch for stable rows.
function buildContainerLi(c, node, opts) {
const {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
} = opts;
const li = el('li', {
class: 'container-row'
+ (pending ? ' pending' : '')
+ (opRunning ? ' pending-running' : '')
+ (selected ? ' selected' : ''),
});
// Topology: depth contributes left-padding; the glyph string in
// the .tree-prefix span draws the ├─ / └─ joint + continuation
// lines (`│ `) for ancestors whose subtree extends below this
// row. Both are CSS-driven from the data attributes so the
// legacy flat layout (every container at depth 0) is bit-
// identical to today's render — no glyph, no indent.
if (node.depth > 0) li.dataset.depth = String(node.depth);
const prefix = treePrefixDom(node);
if (prefix) li.prepend(prefix);
// Agent icon: 5em square wrapper with an absolutely-positioned
// <img> + fire-and-forget load with /favicon.svg fallback. The
// wrapper doubles as the selection toggle (click / keyboard).
// See docs/web-ui.md::Container row for the layout + load-strategy
// rationale.
const iconImg = el('img', { class: 'container-icon-img', alt: '' });
const icon = el('div', {
class: 'container-icon',
role: 'button',
tabindex: '0',
'aria-pressed': selected ? 'true' : 'false',
title: selected
? `deselect ${c.name} (or press Esc to clear all)`
: `select ${c.name} for bulk actions`,
}, iconImg);
icon.addEventListener('click', (e) => {
e.preventDefault();
toggleSelection(c.name);
});
icon.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSelection(c.name);
}
});
if (c.running) {
iconImg.src = `${url}icon`;
iconImg.addEventListener('error', () => {
if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
iconImg.dataset.fallback = '1';
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
});
} else {
// Container stopped — skip the doomed `${url}icon` fetch and
// go straight to the dimmed hyperhive mark. Avoids a noisy
// failed request in the console + the brief broken-image flash.
icon.classList.add('icon-unreachable');
iconImg.src = '/favicon.svg';
}
// Card body: the three stacked content lines, right of the icon.
const body = el('div', { class: 'card-body' });
// ── identity ─────────────────────────────────────────────────
const head = el('div', { class: 'head' });
head.append(
el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
);
// Icon-only nav strip — populated async from the agent's own
// `GET /api/dashboard-state` (via gateway when enabled, direct
// TCP otherwise). The agent is the single source of truth for its
// link list: stats / screen (GUI agents only — c0re's disk-based
// fallback cannot detect this) / forge profile / agent-configs /
// extras. DOM-built — link strings come from the agent's process
// and must never reach the HTML parser.
const navStrip = el('span', { class: 'nav-strip' });
head.append(navStrip);
if (c.running) {
// Fetch the lean dashboard-state snapshot from the agent directly.
// Populates: nav strip links (including the screen link that
// c0re's disk-based build cannot detect), rate_limited badge,
// ctx-window badge, and self-reported status text.
// Fails gracefully when the agent is starting up or the gateway
// is not yet routing to it — badges simply don't appear.
// Only runs when the row is first built (fingerprint changed) —
// reused rows keep their previously-fetched nav strip + badges.
fetch(`${containerBase}/api/dashboard-state`)
.then((r) => (r.ok ? r.json() : null))
.then((ds) => {
if (!ds) return;
// ── nav strip ───────────────────────────────────────────
if (Array.isArray(ds.links)) {
for (const lnk of ds.links) {
const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
: lnk.kind === 'external' ? (lnk.url || '')
: /* container */ containerBase + '/' + (lnk.url || '');
const a = el('a', {
class: 'nav-link',
href,
target: '_blank',
rel: 'noopener',
title: lnk.label || '',
});
// Plain text — agent-controlled strings stay out of innerHTML.
a.textContent = lnk.icon || lnk.label || '';
navStrip.append(a);
}
}
// ── agent-owned status badges ────────────────────────────
// rate_limited: only show when no other critical badge is
// already shown (pending / not-running already handled sync).
if (ds.rate_limited) {
head.append(el('span',
{ class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
'⊘ rate limited'));
}
// ctx-window badge
if (ds.ctx_tokens != null) {
const k = Math.round(ds.ctx_tokens / 1000);
const win = ds.context_window_tokens;
const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
const ctxClass = ds.ctx_tokens >= warn ? 'badge-ctx-warn'
: ds.ctx_tokens >= caution ? 'badge-ctx-caution'
: 'badge-ctx-ok';
const title = win != null
? `last turn context: ${ds.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
+ `tokens (${Math.round((ds.ctx_tokens / win) * 100)}% of the window)`
: `last turn context size: ${ds.ctx_tokens.toLocaleString()} tokens`;
head.append(el('span', { class: `badge ${ctxClass}`, title }, `ctx·${k}k`));
}
// ── agent status text (self-reported via set_status) ─────
if (ds.status_text) {
const nowUnix = Math.floor(Date.now() / 1000);
const ageStr = ds.status_set_at != null
? ` (set ${fmtAgeSecs(nowUnix - ds.status_set_at)} ago)` : '';
// Stamp data-set-at so the 30s ticker below keeps the age
// label current even when the row is reused from the cache.
const ageAttrs = ds.status_set_at != null
? { class: 'status-age', 'data-set-at': String(ds.status_set_at) }
: { class: 'status-age' };
body.append(el('div', {
class: 'agent-status',
title: `agent self-reported status${ageStr}`,
},
el('span', { class: 'status-icon' }, '◈ '),
ds.status_text,
el('span', ageAttrs, ageStr),
));
}
})
.catch(() => { /* graceful: agent starting / gateway miss → no data */ });
}
// Status / runtime badges. Pending transients always win
// (start / stop / restart / rebuild is in progress). Otherwise,
// when the container is stopped, surface a single `■ not running`
// badge. `needs_login` is still c0re-owned (reads auth sentinel
// files on the host). rate_limited / ctx / status_text are
// agent-owned and rendered by the async dashboard-state fetch above.
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} else if (!c.running) {
head.append(el('span',
{ class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' },
'■ not running'));
} else if (c.needs_login) {
head.append(el('a',
{ class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
'needs login →'));
}
if (c.needs_update) {
head.append(form(
'/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true },
));
}
if (c.pending_reminders && c.pending_reminders > 0) {
head.append(el('span',
{
class: 'badge badge-reminder',
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
},
`${c.pending_reminders}`));
}
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
if (agentQCount > 0) {
const parts = [];
if (askerCount > 0) parts.push(`${askerCount} asked`);
if (targetCount > 0) parts.push(`${targetCount} to answer`);
head.append(el('span',
{
class: 'badge badge-loose-ends',
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
},
`${agentQCount}`));
}
body.append(head);
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
// DESTR0Y / PURG3) moved to the selection bar — see
// docs/web-ui.md::Selection bar. The contextual `needs update ↻`
// chip in the head row stays — it's a state-hint, not an
// action button.
li.append(icon, body, buildAgentMenu(c, forgeBase));
return li;
}
function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /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;
// Containers come from the derived map (event-driven) rather than
// `s.containers`; `s` still supplies hostname (for the web-ui
// link) and tombstones/meta_inputs (not event-derived yet). The
// tree builder handles the ordering — we don't pre-sort here.
const containers = Array.from(containersState.values());
const portConflicts = derivePortConflicts(containers);
const anyStale = containers.some((c) => c.needs_update);
// Preserve the keyed container list across the header-section
// rebuild. We wipe the banners/buttons above the list on every
// render (simple), but recycle <li> elements for unchanged rows
// (keyed) to avoid full-DOM thrash and redundant async fetches.
const existingUl = root.querySelector('ul.containers');
root.replaceChildren();
// Port-hash collisions: rename one of the listed agents and
// rebuild. The banner sits above the agent list so it's the
// first thing the operator sees when something's wedged.
if (portConflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = portConflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (anyStale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
{}, { noRefresh: true },
));
}
if (transientsState.size) {
const ul = el('ul');
const nowUnix = Math.floor(Date.now() / 1000);
for (const [name, t] of transientsState) {
const secs = Math.max(0, nowUnix - t.since_unix);
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${secs}s)`),
));
}
root.append(ul);
}
if (!containers.length && !transientsState.size) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
// Drop stale selections (agent destroyed while selected). Defensive —
// the action bar would otherwise loop POST against a gone agent.
const liveNames = new Set(containers.map((c) => c.name));
for (const n of Array.from(selectionState)) {
if (!liveNames.has(n)) selectionState.delete(n);
}
const hostname = (s && s.hostname) || window.location.hostname;
// When hive-gateway is in front of the dashboard, build same-origin
// `/agent/<name>/` URLs instead of the direct `http://<host>:<port>/`
// TCP fallback — the gateway proxies the prefix to the per-agent
// harness (TCP via `agent-ports.json` or unix-domain via
// `agent-sockets.json`). See
// `docs/web-ui.md::Container row` + `docs/gateway.md::Vhost map`.
const gatewayLinks = !!(s && s.gateway_enabled);
// Forge public URL: prefer state.forge_public_url (set by the NixOS
// module when forge.behindGateway=true), fall back to
// "<hostname>:3000" for gateway-off / local-dev deploys.
const forgeBase = (s && s.forge_public_url) || `http://${hostname}:3000`;
const ul = existingUl ?? el('ul', { class: 'containers' });
const tree = buildAgentTree(containers);
// In-flight rebuild / meta-update / destroy ops per agent name —
// see docs/web-ui.md::Container row for the building… badge
// rationale (covers the SYST3M-shows-rebuild-but-SW4RM-shows-stopped
// gap when no operator transient is set).
const inFlight = inFlightOpsByAgent();
// Build the ordered list of <li> elements, reusing cached rows
// whose displayed state hasn't changed.
const orderedLis = [];
for (const node of tree) {
const c = node.container;
const url = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}/`
: `http://${hostname}:${c.port}/`;
// Container nav-strip base: gateway prefix or direct TCP.
const containerBase = gatewayLinks
? `/agent/${encodeURIComponent(c.name)}`
: `http://${hostname}:${c.port}`;
// Pending-state derivation + queued-vs-running split — see
// docs/web-ui.md::Container row for the transient → in-flight
// queue priority order and the opRunning rationale.
const transientKind = transientsState.get(c.name)?.kind || null;
const op = !transientKind ? inFlight.get(c.name) : null;
const pending = transientKind
|| (op && (op.state === 'running'
? (op.kind === 'meta_update' ? 'meta-updating'
: op.kind === 'destroy' ? 'destroying'
: op.kind === 'restart' ? 'restarting'
: 'rebuilding')
: (op.kind === 'meta_update' ? 'meta-update queued'
: op.kind === 'destroy' ? 'destroy queued'
: op.kind === 'restart' ? 'restart queued'
: 'rebuild queued')));
const opRunning = transientKind != null
|| (op != null && op.state === 'running');
const selected = selectionState.has(c.name);
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
const agentQCount = questionsState.pending.filter(
(q) => q.asker === c.name || q.target === c.name,
).length;
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname);
const cached = containerRowCache.get(c.name);
let li;
if (cached && cached.fingerprint === fp) {
// Row unchanged — reuse the existing DOM node. The async
// dashboard-state (nav strip, ctx badge, status text) stays
// intact from the previous build, avoiding a redundant fetch.
li = cached.el;
} else {
li = buildContainerLi(c, node, {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
});
containerRowCache.set(c.name, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
// Remove cache entries for agents that no longer exist.
for (const [name, entry] of containerRowCache) {
if (!liveNames.has(name)) {
entry.el.remove();
containerRowCache.delete(name);
}
}
// Apply correct DOM order without a full wipe. insertBefore is a
// no-op when the node is already at position i, so stable sections
// of the list cause zero layout work.
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
// Trim any excess children (defensive — shouldn't happen after the
// cache-removal pass above, but keeps the ul length exact).
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
root.append(ul);
renderSelectionBar(containers);
}
// ─── selection bar ──────────────────────────────────────────────────
// Sticky-bottom strip; visible when ≥1 agent selected on the SW4RM
// tab. See docs/web-ui.md::Selection bar for the interaction model
// and the per-action availability rules (disabled-with-tooltip for
// actions that don't apply to the full selection). Actions POST per
// agent in a loop (endpoints are individually idempotent /
// event-covered, so no new bulk backend wire is needed).
function renderSelectionBar(containers) {
const bar = $('selection-bar');
if (!bar) return;
const countSpan = $('selection-count');
const namesSpan = $('selection-names');
const actions = $('selection-actions');
if (!countSpan || !namesSpan || !actions) return;
const selected = containers.filter((c) => selectionState.has(c.name));
// Tab-gate: bar only renders on SW4RM (the only tab with agent
// cards to cross-reference). Selection state lives in-memory and
// the bar reappears on return to SW4RM if still non-empty.
const onSwarmTab = (document.body.dataset.activeTab || 'swarm') === 'swarm';
if (!selected.length || !onSwarmTab) {
bar.hidden = true;
document.body.classList.remove('has-selection');
return;
}
bar.hidden = false;
document.body.classList.add('has-selection');
countSpan.textContent = selected.length === 1
? '1 agent selected'
: selected.length + ' agents selected';
namesSpan.textContent = '· ' + selected.map((c) => c.name).join(', ');
// Recompute action availability + tooltips per render. Each
// action declares which agents it CAN'T run on; the bar disables
// the button and surfaces the offending names in the tooltip.
actions.replaceChildren();
const allRunning = selected.every((c) => c.running);
const allStopped = selected.every((c) => !c.running);
const stoppedNames = selected.filter((c) => !c.running).map((c) => c.name);
const runningNames = selected.filter((c) => c.running).map((c) => c.name);
function why(label, blockers) {
if (!blockers.length) return null;
return `${label} not available — ${blockers.join(', ')} ${blockers.length === 1 ? 'is' : 'are'} blocking it`;
}
addBulkButton(actions, 'btn-restart', '↺ R3ST4RT', allRunning, selected, {
action: '/restart/',
confirm: (names) => `restart ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('↺ R3ST4RT', stoppedNames.map((n) => `\`${n}\` is stopped`)),
});
addBulkButton(actions, 'btn-stop', '■ ST0P', allRunning, selected, {
action: '/kill/',
confirm: (names) => `stop ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('■ ST0P', stoppedNames.map((n) => `\`${n}\` is already stopped`)),
});
addBulkButton(actions, 'btn-start', '▶ ST4RT', allStopped, selected, {
action: '/start/',
confirm: (names) => `start ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})?`,
disabledTitle: why('▶ ST4RT', runningNames.map((n) => `\`${n}\` is already running`)),
});
addBulkButton(actions, 'btn-rebuild', '↻ R3BU1LD', true, selected, {
action: '/rebuild/',
confirm: (names) => `rebuild ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? hot-reloads each container.`,
});
addBulkButton(actions, 'btn-destroy', 'DESTR0Y', true, selected, {
action: '/destroy/',
confirm: (names) => `destroy ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers are removed; state + creds kept.`,
});
addBulkButton(actions, 'btn-destroy', 'PURG3', true, selected, {
action: '/destroy/',
body: { purge: 'on' },
confirm: (names) => `PURGE ${names.length} agent${names.length === 1 ? '' : 's'} (${names.join(', ')})? containers, config history, claude creds, and notes are all WIPED. no undo.`,
});
// Move agent(s) in the topology tree — selecting an option in the
// M0V3 dropdown immediately confirms + executes the move. "(no parent)"
// promotes to root (empty new_parent on the backend). Cycle-safe:
// dropdown filters out self and descendants on the client side; the
// backend rechecks via `topology::set_parent`.
//
// Backend: POST /api/topology/set-parent (dashboard.rs),
// form-encoded `child=<name>&new_parent=<target-or-empty>`. 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 = '<span class="spinner">◐</span> ' + 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/<name>, /rebuild/<name>).
// `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. Live updates via
// `capabilities_changed` / `tool_groups_changed` SSE events fired
// after the rebuild-queue worker commits the perm JSON file.
function applyCapabilitiesChanged(ev) {
const root = $('capabilities-section');
if (!root) return;
// Skip re-render while operator has a checkbox focused in this
// section — the tab-activation re-fetch is the recovery path.
if (root.contains(document.activeElement)) return;
renderCapabilities(root, ev);
}
function applyToolGroupsChanged(ev) {
const root = $('tool-groups-section');
if (!root) return;
if (root.contains(document.activeElement)) return;
renderToolGroups(root, ev);
}
async function fetchAndRenderCapabilities() {
const root = $('capabilities-section');
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/capabilities');
if (!resp.ok) throw new Error('http ' + resp.status);
const data = await resp.json();
renderCapabilities(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
}
}
function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments.
const agentNames = [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
return;
}
const wrap = el('div', { class: 'cap-table-wrap' });
const table = el('table', { class: 'cap-table' });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
for (const c of caps) {
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c));
}
hrow.append(el('th', { class: 'cap-save-col' }, ''));
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
for (const name of agentNames) {
const assigned = assignments[name] || [];
const tr = el('tr', { class: 'cap-row' });
// Agent name cell.
tr.append(el('td', { class: 'cap-agent-col' },
el('span', { class: 'cap-agent-name' }, name)));
// One checkbox per capability.
const checkboxes = [];
for (const c of caps) {
const checked = assigned.includes(c);
const td = el('td', { class: 'cap-col' });
const cb = el('input', {
type: 'checkbox',
class: 'cap-cb',
'data-cap': c,
'aria-label': c,
});
cb.checked = checked;
td.append(cb);
tr.append(td);
checkboxes.push(cb);
}
// Save button cell.
const saveTd = el('td', { class: 'cap-save-col' });
const saveBtn = el('button', { type: 'button', class: 'btn cap-save-btn' }, 'save');
saveBtn.addEventListener('click', async () => {
const selectedCaps = checkboxes
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.cap);
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const r = await fetch('/api/capabilities/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caps: selectedCaps }),
});
if (!r.ok) {
const txt = await r.text();
saveBtn.textContent = 'err';
saveBtn.title = txt;
} else {
saveBtn.textContent = '✓';
setTimeout(fetchAndRenderCapabilities, 800);
}
} catch (err) {
saveBtn.textContent = 'err';
saveBtn.title = String(err);
} finally {
saveBtn.disabled = false;
}
});
saveTd.append(saveBtn);
tr.append(saveTd);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
root.append(wrap);
}
async function fetchAndRenderToolGroups() {
const root = $('tool-groups-section');
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/tool-groups');
if (!resp.ok) throw new Error('http ' + resp.status);
const data = await resp.json();
renderToolGroups(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
}
}
function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
return;
}
// Agent names: union of live containers + keys already in assignments,
// sorted alphabetically.
const agentNames = [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
return;
}
const wrap = el('div', { class: 'tg-table-wrap' });
const table = el('table', { class: 'tg-table' });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
for (const g of groups) {
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g));
}
hrow.append(el('th', { class: 'tg-save-col' }, ''));
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
for (const name of agentNames) {
// Explicit assignment or empty = using role default.
const assigned = assignments[name] || [];
const hasExplicit = Object.prototype.hasOwnProperty.call(assignments, name);
const tr = el('tr', { class: 'tg-row' });
// Agent name cell.
const nameTd = el('td', { class: 'tg-agent-col' });
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
if (!hasExplicit) {
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
}
tr.append(nameTd);
// One checkbox per group.
const checkboxes = [];
for (const g of groups) {
const checked = assigned.includes(g);
const td = el('td', { class: 'tg-group-col' });
const cb = el('input', {
type: 'checkbox',
class: 'tg-cb',
'data-group': g,
'aria-label': g,
});
cb.checked = checked;
td.append(cb);
tr.append(td);
checkboxes.push(cb);
}
// Save button cell.
const saveTd = el('td', { class: 'tg-save-col' });
const saveBtn = el('button', { type: 'button', class: 'btn tg-save-btn' }, 'save');
saveBtn.addEventListener('click', async () => {
const selectedGroups = checkboxes
.filter((cb) => cb.checked)
.map((cb) => cb.dataset.group);
saveBtn.disabled = true;
saveBtn.textContent = '…';
try {
const r = await fetch('/api/tool-groups/' + encodeURIComponent(name), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ groups: selectedGroups }),
});
if (!r.ok) {
const txt = await r.text();
saveBtn.textContent = 'err';
saveBtn.title = txt;
} else {
saveBtn.textContent = '✓';
setTimeout(fetchAndRenderToolGroups, 800);
}
} catch (err) {
saveBtn.textContent = 'err';
saveBtn.title = String(err);
} finally {
saveBtn.disabled = false;
}
});
saveTd.append(saveBtn);
tr.append(saveTd);
tbody.append(tr);
}
table.append(tbody);
wrap.append(table);
root.append(wrap);
}
// Derived question state — cold-loaded from /api/state, then mutated
// live by `question_added` / `question_resolved` dashboard events.
const QUESTION_HISTORY_LIMIT = 20;
const questionsState = { pending: [], history: [] };
// Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions
// to reuse <li> elements whose state hasn't changed. The main benefit is
// preserving textarea draft text and radio/checkbox selections when an
// unrelated question arrives while the operator is composing a reply.
const questionRowCache = new Map();
function syncQuestionsFromSnapshot(s) {
questionsState.pending = (s.questions || []).slice();
questionsState.history = (s.question_history || []).slice();
}
function applyQuestionAdded(ev) {
if (questionsState.pending.some((q) => q.id === ev.id)) return;
questionsState.pending.push({
id: ev.id,
asker: ev.asker,
question: ev.question,
options: ev.options || [],
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
target: ev.target || null,
question_refs: ev.question_refs || [],
});
renderQuestions();
renderContainersFromState();
}
function applyQuestionResolved(ev) {
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
const existing = idx >= 0 ? questionsState.pending[idx] : null;
if (idx >= 0) questionsState.pending.splice(idx, 1);
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
// carry this same answered row in `question_history` while a live
// event also delivers it — guard the unshift so history can't
// double a row.
if (!questionsState.history.some((h) => h.id === ev.id)) {
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
}
renderQuestions();
renderContainersFromState();
}
// Filter selection for the questions section. Persisted so the
// operator's preferred view (all / operator-targeted / peer)
// survives a reload.
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
function getQuestionsFilter() {
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
}
function setQuestionsFilter(v) {
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
renderQuestions();
}
function questionMatchesFilter(q, filter) {
if (filter === 'all') return true;
if (filter === 'operator') return !q.target;
if (filter === 'peer') return !!q.target;
// `agent:<name>` matches when the agent appears as asker OR target.
if (filter.startsWith('agent:')) {
const name = filter.slice('agent:'.length);
return q.asker === name || q.target === name;
}
return true;
}
// Serialise the fields that determine a pending-question <li>'s DOM
// structure. Used by questionRowCache to skip rebuilds when nothing
// visible has changed. deadline_at controls whether the TTL chip node
// exists at all (its text is kept current by the global 1s ticker).
function questionRowFingerprint(q) {
return JSON.stringify({
asker: q.asker, target: q.target, asked_at: q.asked_at,
deadline_at: q.deadline_at, question: q.question,
question_refs: q.question_refs, options: q.options, multi: q.multi,
});
}
// Build a single pending-question <li>. Extracted from renderQuestions so
// questionRowCache can reuse unchanged nodes without re-running this body.
// Event listeners attached here (keydown on textarea, submit on form) are
// preserved in the reused node — no re-attachment needed.
function buildQuestionLi(q) {
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
);
if (q.deadline_at) {
// Tag the chip with its deadline so the global 1s ticker
// can refresh the text without re-rendering the questions section.
const ttlEl = el('span', {
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
});
ttlEl.textContent = formatTtl(
q.deadline_at - Math.floor(Date.now() / 1000),
);
head.append(' ', ttlEl);
}
const qBody = el('div', { class: 'q-body' });
appendLinkified(qBody, q.question, q.question_refs);
li.append(head, qBody);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('textarea', {
name: 'answer-free', rows: '2', autocomplete: 'off',
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ ' (shift+enter for newline)',
});
// Enter submits; shift+enter inserts a newline (textarea default).
freeText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
f.requestSubmit();
}
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
for (const opt of q.options) {
const inputType = isMulti ? 'checkbox' : 'radio';
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
const label = el('label', { for: id }, ' ' + opt);
optionGroup.append(el('div', { class: 'q-option' }, input, label));
}
}
// On submit, build the final `answer` field from selected
// options + free-text, joined by ', '. This lets the operator
// pick options AND add free text in the same form.
f.addEventListener('submit', (ev) => {
const parts = [];
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
parts.push(cb.value);
}
const ft = (freeText.value || '').trim();
if (ft) parts.push(ft);
const merged = parts.join(', ');
// Replace the existing hidden `answer` (if any) with the merged value.
const existing = f.querySelector('input[name="answer"]');
if (existing) existing.remove();
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
}, true);
if (hasOptions) f.append(optionGroup);
const buttons = el('div', { class: 'q-buttons' });
// On peer threads the operator's answer is an override —
// mark the button so it's clear what the click does (the
// backend permits it via OperatorQuestions::answer's
// answerer-auth rule).
const answerLabel = q.target
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
buttons.append(
el('button', {
type: 'submit',
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
}, answerLabel),
);
f.append(
el('div', { class: 'q-free' }, freeText),
buttons,
);
li.append(f);
// Separate form so the cancel button doesn't get the answer
// merge-on-submit handler attached to the main form.
const cancelTargetLabel = q.target ? q.target : 'asker';
const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
);
li.append(cancelForm);
return li;
}
function renderQuestions() {
const root = $('questions-section');
// #questions-section only lives on /index.html (Y3R C4LL tab);
// no-op when the section is missing. `question_added` /
// `question_resolved` SSE events route through here.
if (!root) return;
// Snapshot open <details> state so SSE-triggered re-renders restore
// any expanded sections. The keyed-cache approach reuses question
// <li> nodes (preserving textarea/checkbox state) and only rebuilds
// cache-miss rows, so we no longer wipe the DOM at the start.
const openDetails = snapshotOpenDetails();
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
// Filter chips. Always include `all` / `operator` / `peer`; add
// per-agent chips for any agent that appears as asker or target
// in the pending list so the operator can isolate a single
// thread without typing.
const participants = new Set();
for (const q of allPending) {
participants.add(q.asker);
if (q.target) participants.add(q.target);
}
// Auto-reset a stale per-agent filter: if the operator had `agent:foo`
// selected and all of foo's questions resolved, foo's chip disappears
// from the row. Without a reset the section would show "no questions
// match this filter" with no active chip visible — confusing. Fall back
// to `all` whenever the stored value is no longer a valid chip value.
let activeFilter = getQuestionsFilter();
const validFilters = new Set(['all', 'operator', 'peer',
...Array.from(participants).map((n) => 'agent:' + n)]);
if (!validFilters.has(activeFilter)) {
activeFilter = 'all';
// Write directly to localStorage to avoid the re-render that
// setQuestionsFilter() triggers (we're already mid-render).
localStorage.setItem(QUESTIONS_FILTER_KEY, 'all');
}
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
const filterRow = el('div', { class: 'questions-filters' });
const mkChip = (value, label) => {
const b = el('button', {
type: 'button',
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
}, label);
b.addEventListener('click', () => setQuestionsFilter(value));
return b;
};
// Count pending questions per filter value so each chip shows its
// own hit count — the operator can see "operator · 2 / peer · 3"
// at a glance without clicking through each tab.
const operatorCount = allPending.filter((q) => !q.target).length;
const peerCount = allPending.filter((q) => !!q.target).length;
const agentCount = (name) => allPending.filter(
(q) => q.asker === name || q.target === name).length;
filterRow.append(
mkChip('all', `all · ${allPending.length}`),
mkChip('operator', `@operator · ${operatorCount}`),
mkChip('peer', `@peer · ${peerCount}`),
);
for (const name of Array.from(participants).sort()) {
filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`));
}
// Evict resolved/cancelled questions from the row cache.
const allPendingIds = new Set(allPending.map((q) => q.id));
for (const id of questionRowCache.keys()) {
if (!allPendingIds.has(id)) questionRowCache.delete(id);
}
// Build the ordered list of <li> elements, reusing cached nodes whose
// serialised state hasn't changed. This is what preserves textarea
// draft text and radio/checkbox selections across re-renders.
const orderedLis = pending.map((q) => {
const fp = questionRowFingerprint(q);
const cached = questionRowCache.get(q.id);
if (cached && cached.fingerprint === fp) return cached.el;
const li = buildQuestionLi(q);
questionRowCache.set(q.id, { el: li, fingerprint: fp });
return li;
});
// Save the history <details> open state before the DOM swap so it
// isn't collapsed every time a question arrives while it's open.
const historyWasOpen = root.querySelector('.q-history')?.open ?? false;
const children = [filterRow];
if (!pending.length) {
children.push(el('p', { class: 'empty' },
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
} else {
const ul = el('ul', { class: 'questions' });
for (const li of orderedLis) ul.append(li);
children.push(ul);
}
// Answered question history (read-only, no inputs — built fresh each render).
const hist = questionsState.history;
if (hist.length) {
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
const hul = el('ul', { class: 'questions questions-answered' });
for (const q of hist) {
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asked:'),
);
const histBody = el('div', { class: 'q-body' });
appendLinkified(histBody, q.question, q.question_refs);
const ansText = el('span', { class: 'q-answer-text' });
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText,
);
li.append(head, histBody, ansLine);
hul.append(li);
}
details.append(hul);
children.push(details);
}
root.replaceChildren(...children);
// Restore history open state after the DOM swap.
if (historyWasOpen) {
const histEl = root.querySelector('.q-history');
if (histEl) histEl.open = true;
}
restoreOpenDetails(openDetails);
}
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
// question card. Bucketed at minutes / hours so a long deadline stays
// readable; "expiring…" once the deadline has passed (the host-side
// ttl-watchdog will fire shortly).
function formatTtl(remaining) {
if (remaining <= 0) return 'expiring…';
if (remaining < 60) return '⏳ ' + remaining + 's';
if (remaining < 3600) {
return '⏳ ' + Math.floor(remaining / 60) + 'm '
+ (remaining % 60) + 's';
}
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
+ Math.floor((remaining % 3600) / 60) + 'm';
}
// Single page-wide ticker that refreshes every TTL chip in place
// each second. Renderers stamp `data-deadline` on the
// chip; this just updates `textContent`, no re-render of the
// questions section. No-op when no chips are on screen, so the
// cost is negligible.
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
const deadline = Number(node.getAttribute('data-deadline'));
if (!Number.isFinite(deadline)) return;
node.textContent = formatTtl(deadline - now);
});
}, 1000);
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
// (unix seconds) on the `.status-age` span. Keyed container rows persist
// across re-renders, so without this ticker the "(set N ago)" label would
// become stale as time passes. 30s granularity matches fmtAgeSecs precision
// (sub-minute values round to seconds, coarser above that).
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.status-age[data-set-at]').forEach((node) => {
const setAt = Number(node.dataset.setAt);
if (!Number.isFinite(setAt) || setAt === 0) return;
node.textContent = ` (set ${fmtAgeSecs(now - setAt)} ago)`;
});
}, 30_000);
// Live ticker for approval request-age chips. Approval cards only
// re-render on `approval_added`/`approval_resolved` SSE events, so
// a request pending for an hour could still show "0s ago" without
// this ticker. Also flips `.stale` (amber highlight) at exactly 1h
// rather than only at the next re-render.
// Live countdown for reminder due-at labels and schedule next-fire
// cells. Both renderers stamp `data-due-at` on the element so this
// single ticker keeps them fresh without triggering a full re-render.
// `.reminder-due` → "overdue X ago" / "in Y"
// `.sched-due` → same pattern
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
const requestedAt = Number(node.getAttribute('data-requested-at'));
if (!Number.isFinite(requestedAt)) return;
const ageSec = Math.max(0, now - requestedAt);
node.textContent = 'requested ' + fmtAgo(requestedAt);
node.classList.toggle('stale', ageSec >= 3600);
});
document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => {
const dueAt = Number(node.getAttribute('data-due-at'));
if (!Number.isFinite(dueAt)) return;
const dueIn = dueAt - now;
node.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(dueAt)
: (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn);
});
}, 1000);
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] };
function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
}
function applyApprovalAdded(ev) {
// Upsert by id so a snapshot that already included the row (cold
// load + event lands at the same tick) doesn't double it.
const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
const row = {
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
sha_short: ev.sha_short || null,
diff: ev.diff || null,
description: ev.description || null,
// The ApprovalAdded event carries no requested_at; a live-added
// approval was queued just now, so client-now is accurate — and
// consistent with how fmtAgo compares everything to client-now.
// A later /api/state cold-load swaps in the server value.
requested_at: ev.requested_at != null
? ev.requested_at : Math.floor(Date.now() / 1000),
};
if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row);
renderApprovals();
}
function applyApprovalResolved(ev) {
// Drop from pending; prepend to history (newest-first), cap at 30.
approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
// carry this same resolved row in `approval_history` while a live
// event also delivers it — guard the unshift so history can't
// double a row.
if (!approvalsState.history.some((h) => h.id === ev.id)) {
approvalsState.history.unshift({
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
sha_short: ev.sha_short || null,
status: ev.status,
resolved_at: ev.resolved_at,
note: ev.note || null,
description: ev.description || null,
});
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
}
}
renderApprovals();
}
// Classify each unified-diff line by its leading char so
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
// `.diff-ctx` colour the output. Built as text-only spans (no
// innerHTML) so there's no HTML-escape surface.
function buildDiffPre(text) {
const pre = el('pre', { class: 'diff' });
for (const raw of String(text).split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
return pre;
}
// Open an approval's diff in the side panel with a 3-way base
// toggle: vs applied (running tree), vs last-approved, vs previous
// proposal. `applied` uses the diff already shipped on the approval
// for instant paint; the other two fetch /api/approval-diff.
function openDiffPanel(a) {
const bases = [
['applied', 'vs applied'],
['approved', 'vs last-approved'],
['previous', 'vs previous proposal'],
];
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'diff-host' });
async function selectBase(base) {
for (const btn of tabs.children) {
btn.classList.toggle('active', btn.dataset.base === base);
}
if (base === 'applied' && a.diff != null) {
host.replaceChildren(buildDiffPre(a.diff));
return;
}
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
const text = await resp.text();
host.replaceChildren(resp.ok
? buildDiffPre(text)
: el('div', { class: 'meta' }, 'error: ' + text));
} catch (e) {
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
}
}
for (const [base, label] of bases) {
const btn = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
btn.addEventListener('click', () => selectBase(base));
tabs.append(btn);
}
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
selectBase('applied');
}
// 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;
// Save spawn form input + focus state before the DOM wipe so a live
// approval_added/resolved event doesn't erase a partially-typed name
// or steal focus from the operator.
const savedSpawnName = root.querySelector('.spawnform input[name="name"]')?.value ?? '';
const spawnHadFocus = document.activeElement === root.querySelector('.spawnform input[name="name"]');
root.replaceChildren();
// Spawn request form: submitting it queues a Spawn approval that
// lands in this same list, so the form belongs here rather than on
// the containers list (the agent doesn't exist yet).
const spawnNameInput = el('input', {
name: 'name',
placeholder: 'new agent name (≤9 chars)',
maxlength: '9', required: '', autocomplete: 'off',
});
if (savedSpawnName) spawnNameInput.value = savedSpawnName;
if (spawnHadFocus) spawnNameInput.focus();
const spawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
});
spawn.append(
spawnNameInput,
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
);
root.append(spawn);
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const pendingTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
},
`pending · ${pending.length}`,
);
const historyTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
},
`history · ${history.length}`,
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
renderApprovals();
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === 'history') {
renderApprovalHistory(root, history);
return;
}
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
// forge link base — only when the hive-forge container is up.
const fs = window.__hyperhive_state;
const hostname = (fs && fs.hostname) || window.location.hostname;
// Prefer state.forge_public_url (set when forge.behindGateway=true,
// e.g. "https://forge.pr1ma.darkest.space") over the direct :3000 port.
const forgeBase = (fs && fs.forge_present)
? (fs.forge_public_url || `http://${hostname}:3000`)
: null;
const ul = el('ul', { class: 'approvals' });
for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const isInit = a.kind === 'init_config';
const li = el('li', { class: 'approval-card' });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
isApply ? 'apply' : isInit ? 'init' : 'spawn'),
);
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
'data-requested-at': String(a.requested_at),
}, 'requested ' + fmtAgo(a.requested_at)));
}
li.append(head);
// ── what-changed body ────────────────────────────────────────
const body = el('div', { class: 'approval-body' });
if (a.description) {
body.append(el('div', { class: 'approval-description' }, a.description));
}
if (isApply) {
const drill = el('div', { class: 'drill-ins' });
const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
'↳ view diff');
diffBtn.addEventListener('click', () => openDiffPanel(a));
drill.append(diffBtn);
if (forgeBase && a.sha_short) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
title: 'this proposal commit on the hive forge',
}, '↳ commit on forge ↗'));
}
body.append(drill);
} else {
body.append(el('span', { class: 'meta' },
isInit
? 'scaffold proposed config repo — manager customises agent.nix before spawn'
: 'new sub-agent — container will be created on approve'));
}
li.append(body);
// ── decision actions ─────────────────────────────────────────
// Deny prompts the operator for an optional reason; the submit
// handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the manager via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/deny/' + a.id,
class: 'inline', 'data-async': '', 'data-no-refresh': '',
'data-prompt': 'reason for denying (optional, sent to manager):',
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
li.append(el('div', { class: 'approval-actions' },
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
denyForm,
));
ul.append(li);
}
root.append(ul);
}
function renderApprovalHistory(root, history) {
if (!history.length) {
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
return;
}
const ul = el('ul', { class: 'approvals approvals-history' });
for (const a of history) {
const li = el('li');
const row = el('div', { class: 'row' });
const glyph = a.status === 'approved' ? '✓'
: a.status === 'denied' ? '✗'
: a.status === 'cancelled' ? '⊘'
: '⚠';
row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ',
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note));
}
ul.append(li);
}
root.append(ul);
}
// Relative time, anchored to now. resolved_at is unix seconds (server-
// authored), so we don't have to worry about client/server clock skew
// for sub-minute precision.
function fmtAgo(unixSecs) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
}
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
if (!root) return;
// Snapshot which checkboxes the operator has ticked before wiping the
// DOM. A MetaInputsChanged event (e.g. triggered by a concurrent
// meta-update completing) would otherwise silently clear pending
// selections mid-flight. We restore them after rebuilding the list.
const checkedInputs = new Set(
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'))
.map((cb) => cb.dataset.metaInput),
);
root.replaceChildren();
const inputs = s.meta_inputs || [];
if (!inputs.length) {
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
return;
}
if (metaUpdateRunning) {
root.append(el('p', { class: 'meta-update-running' },
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
+ 'watch the agent cards for per-rebuild progress.'));
}
const form = el('form', {
method: 'POST',
action: '/meta-update',
class: 'meta-inputs-form',
'data-async': '',
// run_meta_update emits MetaInputsChanged once the lock
// bump finishes; per-agent rebuilds fire their own
// ContainerStateChanged. No /api/state refetch needed.
'data-no-refresh': '',
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
});
// Bulk select — the full input tree gets long; ticking each box
// one by one is tedious.
const bulk = el('div', { class: 'meta-inputs-bulk' });
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
bulk.append('bulk: ', selAll, ' ', selNone);
form.append(bulk);
const ul = el('ul', { class: 'meta-inputs' });
for (const inp of inputs) {
// `name` is a slash-path from the meta root. Indent depth = its
// segment count; the row label shows just the leaf segment, the
// full path stays as the checkbox value + the label title.
const depth = (inp.name.match(/\//g) || []).length;
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
const li = el('li');
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', {
type: 'checkbox',
name: 'meta_input_' + inp.name,
id,
value: inp.name,
'data-meta-input': inp.name,
});
if (checkedInputs.has(inp.name)) cb.checked = true;
const label = el('label', { for: id, title: inp.name });
label.append(cb);
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
label.append(
el('span', { class: 'meta-input-name' }, leaf), ' ',
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
);
if (inp.url) {
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
'· ' + truncate(inp.url, 48)));
}
li.append(label);
ul.append(li);
}
form.append(ul);
// Hidden input the POST handler reads — populated at submit
// time from the checkbox states. axum's Form extractor doesn't
// natively decode repeated keys, so we join into one CSV.
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
form.append(hidden);
const btn = el('button', {
type: 'submit',
class: 'btn btn-meta-update',
disabled: '',
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
form.append(btn);
function refreshDisabled() {
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
// Stay disabled while an update is already in flight — no
// stacking a second run on top of the rebuild ripple.
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
}
form.addEventListener('change', refreshDisabled);
function setAllChecked(val) {
for (const b of form.querySelectorAll('input[data-meta-input]')) {
b.checked = val;
}
refreshDisabled();
}
selAll.addEventListener('click', () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false));
form.addEventListener('submit', () => {
const selected = Array.from(form.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput);
hidden.value = selected.join(',');
});
root.append(form);
}
function truncate(s, n) {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
}
// ─── rebuild queue ──────────────────────────────────────────────────────
// Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }.
// Same pattern as containerRowCache: reuse <li> nodes whose state hasn't
// changed rather than replacing the entire list on every snapshot event.
// The elapsed-time ticker (data-rqe-elapsed + 1s setInterval below) already
// updates running-entry timestamps in-place, so started_at doesn't need to
// invalidate — including it in the fingerprint only matters for the initial
// render of a newly-running entry.
const rebuildQueueRowCache = new Map();
// Glyph + verb per QueueKind. Mirrors the labels used in
// hive-c0re::rebuild_queue::QueueKind::as_str.
const QUEUE_KIND_GLYPH = {
rebuild: '↻',
meta_update: '◆',
spawn: '✨',
destroy: '🗑',
restart: '↺',
startup_sweep: '⚡',
perm_change: '🔑',
};
const QUEUE_STATE_GLYPH = {
queued: '⏸',
running: '▶',
done: '✔',
failed: '✖',
cancelled: '⊘',
};
// Fingerprint for a single rebuild-queue row. Everything visible in the
// row except the ticking elapsed seconds (handled by data-rqe-elapsed
// ticker, not by re-rendering).
function rebuildQueueEntryFingerprint(entry, isChild) {
return JSON.stringify({
state: entry.state,
step: entry.step,
kind: entry.kind,
agent: entry.agent,
source: entry.source,
started_at: entry.started_at,
enqueued_at: entry.enqueued_at,
finished_at: entry.finished_at,
reason: entry.reason,
error: entry.error,
build_log_id: entry.build_log_id,
isChild,
});
}
function renderRebuildQueue(s) {
const root = $('rebuild-queue-section');
if (!root) return;
const queue = s.rebuild_queue || [];
if (!queue.length) {
// Queue drained — show placeholder and purge cache.
rebuildQueueRowCache.clear();
root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
return;
}
// Index by id for parent lookup.
const byId = new Map(queue.map((e) => [e.id, e]));
// Top-level entries first; children render nested under their parent.
const tops = queue.filter((e) => e.parent_id == null);
const childrenOf = new Map();
for (const e of queue) {
if (e.parent_id != null) {
if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
childrenOf.get(e.parent_id).push(e);
}
}
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
const orphans = queue.filter(
(e) => e.parent_id != null && !byId.has(e.parent_id),
);
// Build ordered list of <li>, reusing cached nodes for unchanged entries.
const orderedLis = [];
function addEntry(entry, isChild) {
const fp = rebuildQueueEntryFingerprint(entry, isChild);
const cached = rebuildQueueRowCache.get(entry.id);
let li;
if (cached && cached.fingerprint === fp) {
li = cached.el;
} else {
li = renderQueueEntry(entry, byId, isChild);
rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
}
orderedLis.push(li);
}
for (const top of tops) {
addEntry(top, false);
for (const child of childrenOf.get(top.id) || []) {
addEntry(child, true);
}
}
for (const o of orphans) {
addEntry(o, true);
}
// Drop cache entries for IDs no longer in the snapshot.
const liveIds = new Set(queue.map((e) => e.id));
for (const [id, entry] of rebuildQueueRowCache) {
if (!liveIds.has(id)) {
entry.el.remove();
rebuildQueueRowCache.delete(id);
}
}
// Get or create the <ul>; remove any "empty" placeholder if present.
let ul = root.querySelector('ul.rebuild-queue');
if (!ul) {
ul = el('ul', { class: 'rebuild-queue' });
root.replaceChildren(ul);
}
// Reconcile DOM order without a wipe.
for (let i = 0; i < orderedLis.length; i++) {
if (ul.children[i] !== orderedLis[i]) {
ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
}
}
while (ul.children.length > orderedLis.length) ul.lastChild.remove();
}
function renderQueueEntry(entry, _byId, isChild) {
const li = el('li', {
class: 'rebuild-queue-entry rqe-' + entry.state,
'data-id': String(entry.id),
});
if (isChild) li.classList.add('rqe-child');
// State glyph + kind + agent.
li.append(
el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
' ',
el('span', { class: 'rqe-kind', title: entry.kind },
(QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
' ',
el('code', { class: 'rqe-agent' }, entry.agent),
);
// Source chip (manual / meta_update / auto_update / crash_recover).
li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
// Timing: queued Xs ago when pending, elapsed when running,
// finished Xs ago for terminal. Queued + terminal stamps use
// data-rqe-enqueued / data-rqe-finished so the 30s ticker below
// can keep them fresh — keyed rows persist across snapshots, so
// without a ticker "queued 2m ago" would never advance.
if (entry.state === 'queued') {
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-enqueued': String(entry.enqueued_at),
}, '· queued ' + fmtAgo(entry.enqueued_at)));
} else if (entry.state === 'running' && entry.started_at) {
const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-elapsed': String(entry.started_at),
}, '· ' + fmtElapsed(elapsed)));
} else if (entry.finished_at) {
li.append(' ', el('span', {
class: 'rqe-when',
'data-rqe-finished': String(entry.finished_at),
'data-rqe-state': entry.state,
}, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
}
// Reason (truncated; full text on hover).
if (entry.reason) {
const r = entry.reason.split('\n')[0];
li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
}
// Current step: backend annotates the in-flight phase on
// `running` entries — sub-line below the main row so the
// operator can see "what's happening right now" inside a long
// build. Terminal transitions clear `step` on the backend so this
// doesn't render stale labels on Done / Failed rows.
if (entry.step) {
li.append(el('div', { class: 'rqe-step' }, '↳ ' + entry.step));
}
// Live-log link: when a build_log_id is present the update/create op
// opened a build_logs row; the SSE stream endpoint lets the operator
// follow output in real time without polling.
if (entry.build_log_id != null) {
li.append(
' ',
el('a', {
class: 'rqe-log-link',
href: '/logs.html?id=' + entry.build_log_id + '#build',
target: '_blank',
title: 'view build log #' + entry.build_log_id,
}, 'logs →'),
);
}
// Error block, when failed.
if (entry.error) {
li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
}
// Cancel-X for queued entries. Backend
// `POST /api/rebuild-queue/{id}/cancel` refuses Running /
// terminal entries, so we surface it only on `queued` rows here
// — the operator never sees a dead button. Uses the same
// `data-async` form + `data-confirm` pattern as the reminder
// cancel, so the global async-form handler takes care of POST +
// spinner + error toast. A successful cancel flips `state` from
// `queued` → `cancelled` via the live RebuildQueueChanged
// snapshot and the row re-renders without this button.
if (entry.state === 'queued') {
const cancelForm = el('form', {
method: 'POST',
action: '/api/rebuild-queue/' + entry.id + '/cancel',
class: 'inline rqe-cancel',
'data-async': '',
'data-confirm':
`cancel ${entry.kind} for \`${entry.agent}\` (queue id ${entry.id})? ` +
`the row drops from the queue and never runs. running / done / failed entries can't be cancelled this way.`,
});
cancelForm.append(el('button', {
type: 'submit',
class: 'rqe-cancel-btn',
title: 'cancel this queued ' + entry.kind,
'aria-label': 'cancel queued ' + entry.kind + ' for ' + entry.agent,
}, '✗'));
li.append(cancelForm);
}
return li;
}
function fmtElapsed(secs) {
if (secs < 60) return secs + 's running';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
}
// Tick once per second to refresh "running Xs" badges in place
// (mirrors the question-TTL ticker pattern above).
// Tick rebuild-queue elapsed-time badges once per second.
// `.rqe-when[data-rqe-elapsed]` is set on running queue entries by
// `renderQueueEntry`; the ticker avoids a full re-render just for the
// wall-clock update.
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
const started = parseInt(span.dataset.rqeElapsed, 10);
if (!started) continue;
const elapsed = Math.max(0, now - started);
span.textContent = '· ' + fmtElapsed(elapsed);
}
for (const span of document.querySelectorAll('.build-logs-runtime[data-bl-elapsed]')) {
const started = parseInt(span.dataset.blElapsed, 10);
if (!started) continue;
const elapsed = Math.max(0, now - started);
span.textContent = fmtElapsed(elapsed);
}
}, 1000);
// 30s ticker for queued-age and terminal-age labels. Keyed rows
// persist across rebuild_queue_changed snapshots, so without this
// "queued 1m ago" / "done 5m ago" labels would never advance.
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
for (const span of document.querySelectorAll('.rqe-when[data-rqe-enqueued]')) {
const enqueued = parseInt(span.dataset.rqeEnqueued, 10);
if (!enqueued) continue;
span.textContent = '· queued ' + fmtAgo(enqueued);
}
for (const span of document.querySelectorAll('.rqe-when[data-rqe-finished]')) {
const finished = parseInt(span.dataset.rqeFinished, 10);
if (!finished) continue;
const state = span.dataset.rqeState || '';
span.textContent = '· ' + state + ' ' + fmtAgo(finished);
}
}, 30_000);
// ─── reminders ──────────────────────────────────────────────────────────
// Reminders aren't part of /api/state (separate sqlite table, separate
// mutation cadence). refreshReminders() is called from refreshState() for
// cold-load and reconnect recovery. Live mutations are covered by the
// `reminders_changed` SSE event → `applyRemindersChanged` so no periodic
// poll is needed.
async function refreshReminders() {
const liveRoot = $('reminders-section');
if (!liveRoot) return;
try {
const resp = await fetch('/api/reminders');
if (!resp.ok) {
paintAtomic(liveRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
});
return;
}
const rows = await resp.json();
renderReminders(rows);
} catch (err) {
paintAtomic(liveRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
});
}
}
function renderReminders(rows) {
const liveRoot = $('reminders-section');
if (!liveRoot) return;
paintAtomic(liveRoot, (root) => {
if (!rows.length) {
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
return;
}
const ul = el('ul', { class: 'reminders' });
for (const r of rows) {
const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`;
const head = el('div', { class: 'reminder-head' },
el('span', { class: 'agent' }, r.agent), ' ',
el('span', {
class: 'meta reminder-due',
title: new Date(r.due_at * 1000).toISOString(),
'data-due-at': String(r.due_at),
}, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
if (r.file_path) {
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
appendLinkified(head, r.file_path);
}
if (failed) {
head.append(' ', el('span',
{
class: 'badge badge-warn',
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
},
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
appendLinkified(body, r.message);
li.append(head, body);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
r.last_error,
));
}
const actions = el('div', { class: 'reminder-actions' });
if (failed) {
// Retry resets the failure counters so the scheduler picks
// the row up again on its next 5s tick. No data-no-refresh
// — the resulting refreshState re-fires refreshReminders.
const retryForm = el('form', {
method: 'POST', action: '/retry-reminder/' + r.id,
class: 'inline', 'data-async': '',
});
retryForm.append(el('button',
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
actions.append(retryForm);
}
const cancelForm = el('form', {
method: 'POST', action: '/cancel-reminder/' + r.id,
class: 'inline', 'data-async': '',
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
});
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
actions.append(cancelForm);
li.append(actions);
ul.append(li);
}
root.append(ul);
});
}
function fmtDuration(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
}
// ─── scheduled prompts ─────────────────────────────────────────────────
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
// (whole or per-target), `/api/schedules/{id}` (PATCH edit),
// `/api/schedules/{id}/fire-now` (POST). Mutations now emit a
// `schedules_changed` SSE event so the list updates live;
// `applySchedulesChanged` handles it. Tab-activation re-fetch kept as
// a safety net for approval-path inserts and disconnect windows.
// Local cache lets `refreshTabCounts` show the active count without
// re-fetching every second.
let schedulesState = [];
// Schedule ids whose inline edit form is currently open. The set
// survives across refreshSchedules() calls so a state
// poll doesn't yank the form out from under the operator. Per-id
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
// also rides the refresh.
const editingSchedules = new Set();
const scheduleEditCarry = new Map();
async function refreshSchedules() {
const listRoot = $('schedules-section');
if (!listRoot) return;
try {
const resp = await fetch('/api/schedules');
if (!resp.ok) {
paintAtomic(listRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status));
});
return;
}
schedulesState = await resp.json();
} catch (err) {
paintAtomic(listRoot, (root) => {
root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err));
});
return;
}
renderSchedulesList();
}
// Active = at least one target still alive (no `cancelled_at_unix`)
// AND the whole schedule isn't cancelled. Drives the tab pill count.
function activeScheduleCount() {
let n = 0;
for (const s of schedulesState) {
if (s.cancelled_at_unix) continue;
if ((s.targets || []).some((t) => !t.cancelled_at_unix)) n++;
}
return n;
}
// Label + caption wrapper shared by every field on the schedule
// forms — `<label class="schedule-field"><span
// class="schedule-field-label">…</span>…children…</label>`. 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 `<label class="schedule-field">`
// ready to append to the form.
function buildTargetChips({ idPrefix, fieldName, checked, extraNames = [] }) {
const candidates = ['operator'];
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== 'operator')
.sort();
for (const n of containerNames) candidates.push(n);
// `extraNames` lets the edit form keep showing an already-active
// target that's vanished from the live container list (operator's
// typo, container destroyed mid-schedule, etc.) so it's still
// explicitly uncheckable. New-schedule callers pass `[]`.
for (const n of extraNames) if (!candidates.includes(n)) candidates.push(n);
const box = el('div', { class: 'schedule-targets' });
for (const name of candidates) {
const id_ = idPrefix + name;
const cb = el('input', {
type: 'checkbox', name: fieldName, value: name, id: id_,
});
if (checked.has(name)) cb.checked = true;
box.append(el('label', { class: 'schedule-target-chip', for: id_ },
cb, el('span', {}, name)));
}
return scheduleField('targets', box);
}
// Inline-create carry-state — survives paintAtomic re-renders.
// The schedules table refreshes on tab activate + after every mutation,
// and each refresh rebuilds the DOM via `paintAtomic`. The bottom
// create row's inputs would lose mid-typing values without this
// carry. `readNewScheduleCarryFromDOM` is called BEFORE every
// re-render so the carry sees the latest user input; the row
// builders then pre-fill from `newScheduleCarry`.
const newScheduleCarry = {
targets: new Set(),
body: '',
description: '',
first_fire: '',
interval_d: '',
interval_h: '',
interval_m: '',
interval_s: '',
};
function readNewScheduleCarryFromDOM(tr) {
if (!tr) return;
const get = (sel) => tr.querySelector(sel);
const checked = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
.map((i) => i.value);
newScheduleCarry.targets = new Set(checked);
const setIfDefined = (key, sel) => {
const el_ = get(sel);
if (el_) newScheduleCarry[key] = el_.value || '';
};
setIfDefined('body', 'textarea[name="new_body"]');
setIfDefined('description', 'input[name="new_description"]');
setIfDefined('first_fire', 'input[name="new_first_fire"]');
setIfDefined('interval_d', 'input[name="new_interval_d"]');
setIfDefined('interval_h', 'input[name="new_interval_h"]');
setIfDefined('interval_m', 'input[name="new_interval_m"]');
setIfDefined('interval_s', 'input[name="new_interval_s"]');
}
function resetNewScheduleCarry() {
newScheduleCarry.targets = new Set();
newScheduleCarry.body = '';
newScheduleCarry.description = '';
newScheduleCarry.first_fire = '';
newScheduleCarry.interval_d = '';
newScheduleCarry.interval_h = '';
newScheduleCarry.interval_m = '';
newScheduleCarry.interval_s = '';
}
function isoForDatetimeLocal(date) {
// `<input type="datetime-local">` expects `YYYY-MM-DDTHH:MM`
// in the user's local timezone (with no trailing Z). Build it
// by hand rather than slicing toISOString which is always UTC.
const pad = (n) => String(n).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
// Schedules render as a single table — one row per schedule,
// attribute columns + one ✓/✕ column per agent (tilted 45° header
// so a row of agents takes ~28px each instead of full word width),
// actions column on the right. Edit form expands into a colspan'd
// row underneath when its row's `✎` is toggled on.
function renderSchedulesList() {
const liveRoot = $('schedules-section');
if (!liveRoot) return;
// Capture any mid-typed inline-create state BEFORE paintAtomic
// blows the row away so a refresh doesn't yank the operator's
// half-filled form. The carry is replayed by
// `renderInlineCreateRow` below.
readNewScheduleCarryFromDOM(liveRoot.querySelector('.schedules-table-create-row'));
paintAtomic(liveRoot, (root) => {
const agents = schedulesTableAgentSet();
const table = el('table', { class: 'schedules-table' });
table.append(renderSchedulesTableHead(agents));
const tbody = el('tbody', {});
const sorted = schedulesState.slice().sort((a, b) => {
const aDone = a.cancelled_at_unix ? 1 : 0;
const bDone = b.cancelled_at_unix ? 1 : 0;
if (aDone !== bDone) return aDone - bDone;
return a.next_fire_at_unix - b.next_fire_at_unix;
});
for (const s of sorted) {
tbody.append(renderScheduleRow(s, agents));
if (editingSchedules.has(s.id) && !s.cancelled_at_unix) {
tbody.append(renderScheduleEditRow(s, agents));
}
}
// Always-visible inline create row at the bottom of the table
// — fill cells + click to POST. See
// docs/web-ui.md::SCH3DUL3S tab for the layout rationale.
tbody.append(renderInlineCreateRow(agents));
table.append(tbody);
const wrap = el('div', { class: 'schedules-table-wrap' });
wrap.append(table);
root.append(wrap);
});
}
// Inline create row. Each column carries an input matching its
// display semantics (datetime-local for next-fire, mini d/h/m/s
// number inputs for every, textarea for body, checkbox per agent
// column for targets). Submitting POSTs `/api/schedules` and clears
// the carry on success; the next `refreshSchedules` redraws the
// table with the new schedule above.
function renderInlineCreateRow(agents) {
const tr = el('tr', { class: 'schedules-table-create-row' });
tr.append(el('td', { class: 'meta schedules-table-id' }, 'new'));
tr.append(el('td', { class: 'meta' }, '—'));
const nextInput = el('input', {
type: 'datetime-local',
name: 'new_first_fire',
class: 'schedules-table-inline-input schedules-table-inline-datetime',
required: 'required',
title: 'first fire time (defaults to 5 minutes from now)',
});
// Default: 5 minutes from now so a stale-clock or quick-submit
// accident doesn't fire immediately on `now()`.
nextInput.value = newScheduleCarry.first_fire
|| isoForDatetimeLocal(new Date(Date.now() + 5 * 60 * 1000));
tr.append(el('td', { class: 'schedules-table-create-cell' }, nextInput));
const intervalRow = el('div', {
class: 'schedules-table-inline-interval',
title: 'recurring every D days H hours M minutes S seconds (all blank / zero = one-shot)',
});
const mkUnit = (suffix, unit) => {
const inp = el('input', {
type: 'number',
name: 'new_interval_' + suffix,
min: '0',
step: '1',
placeholder: '0',
class: 'schedules-table-inline-num',
'aria-label': 'interval ' + suffix,
});
inp.value = newScheduleCarry['interval_' + suffix] || '';
intervalRow.append(inp, el('span', { class: 'schedules-table-inline-unit' }, unit));
};
mkUnit('d', 'd');
mkUnit('h', 'h');
mkUnit('m', 'm');
mkUnit('s', 's');
tr.append(el('td', { class: 'schedules-table-create-cell' }, intervalRow));
tr.append(el('td', { class: 'meta' }, 'operator'));
const bodyTa = el('textarea', {
name: 'new_body',
rows: '1',
required: 'required',
placeholder: 'prompt body (required, multi-line ok)',
class: 'schedules-table-inline-textarea',
});
bodyTa.value = newScheduleCarry.body;
const descInput = el('input', {
type: 'text',
name: 'new_description',
placeholder: 'description (optional)',
class: 'schedules-table-inline-input schedules-table-inline-desc',
});
descInput.value = newScheduleCarry.description;
tr.append(el('td', { class: 'schedules-table-create-cell schedules-table-create-body' },
bodyTa, descInput));
// Per-agent target checkboxes — one cell per agent column. Wrap
// each checkbox in a label so the whole cell area is clickable.
for (const a of agents) {
const td = el('td', { class: 'schedules-table-check schedules-table-create-cell' });
const id_ = 'new-target-' + a;
const cb = el('input', {
type: 'checkbox',
name: 'new_targets',
value: a,
id: id_,
class: 'schedules-table-inline-check',
});
if (newScheduleCarry.targets.has(a)) cb.checked = true;
const lbl = el('label', {
for: id_,
class: 'schedules-table-inline-check-lbl',
title: 'tick to target ' + a,
}, cb);
td.append(lbl);
tr.append(td);
}
// Actions cell — submit button + reset.
const actionsCell = el('td', { class: 'schedules-table-actions' });
const submitBtn = el('button', {
type: 'button',
class: 'btn btn-spawn btn-inline-small schedules-table-create-submit',
title: 'queue this new schedule',
}, '');
submitBtn.addEventListener('click', () => submitNewScheduleInline(tr, submitBtn));
const resetBtn = el('button', {
type: 'button',
class: 'btn btn-inline-small schedules-table-create-reset',
title: 'clear all fields',
}, '⌫');
resetBtn.addEventListener('click', () => {
resetNewScheduleCarry();
renderSchedulesList();
});
actionsCell.append(submitBtn, resetBtn);
tr.append(actionsCell);
return tr;
}
async function submitNewScheduleInline(tr, submitBtn) {
const targets = Array.from(tr.querySelectorAll('input[name="new_targets"]:checked'))
.map((i) => i.value);
const body = String(tr.querySelector('textarea[name="new_body"]')?.value || '').trim();
const description = String(tr.querySelector('input[name="new_description"]')?.value || '').trim();
const firstFireStr = String(tr.querySelector('input[name="new_first_fire"]')?.value || '');
if (!targets.length) {
alert('schedule must have at least one target — tick at least one agent column');
return;
}
if (!body) { alert('prompt body must be non-empty'); return; }
if (!firstFireStr) { alert('first fire timestamp is required'); return; }
const firstFireDate = new Date(firstFireStr);
if (Number.isNaN(firstFireDate.getTime())) {
alert('first fire is not a valid datetime'); return;
}
const first_fire_at_unix = Math.floor(firstFireDate.getTime() / 1000);
// Parse the d/h/m/s parts inline — we don't use FormData since
// the inline row isn't wrapped in a <form>. Mirrors
// `intervalSecondsFromFormData` semantics: blank/0 = one-shot,
// anything non-integer or negative → NaN → alert.
const part = (suffix, mult) => {
const raw = String(tr.querySelector(`input[name="new_interval_${suffix}"]`)?.value || '').trim();
if (!raw) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return NaN;
return n * mult;
};
const intervalTotal = part('d', 86400) + part('h', 3600) + part('m', 60) + part('s', 1);
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers (or blank for one-shot)');
return;
}
const interval_seconds = intervalTotal > 0 ? intervalTotal : null;
const payload = { targets, body, first_fire_at_unix };
if (interval_seconds != null) payload.interval_seconds = interval_seconds;
if (description) payload.description = description;
const originalLabel = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner">◐</span>';
try {
const resp = await fetch('/api/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('schedule submit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
// Reset carry so the next render shows an empty row.
resetNewScheduleCarry();
await refreshSchedules();
} catch (err) {
alert('schedule submit failed: ' + err);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalLabel;
}
}
// The set of agent columns in the schedules table: operator + root
// (manager) first, then live containers (sorted), then any extra names
// that appear as a schedule target but aren't in the live container list
// (operator typo, container destroyed mid-schedule, etc.) — same
// membership rule as `buildTargetChips` so the table and the new-/
// edit-form chip boxes agree on what's addressable.
function schedulesTableAgentSet() {
const seen = new Set();
const out = [];
const push = (n) => { if (!seen.has(n)) { seen.add(n); out.push(n); } };
push('operator');
const containerNames = Array.from(containersState.values())
.map((c) => c.name)
.filter((n) => n !== 'operator')
.sort();
for (const n of containerNames) push(n);
for (const s of schedulesState) {
for (const t of s.targets || []) push(t.target);
}
return out;
}
function renderSchedulesTableHead(agents) {
const thead = el('thead', {});
const headerRow = el('tr', {});
headerRow.append(
el('th', { class: 'schedules-table-id' }, '#'),
el('th', {}, 'src'),
el('th', { class: 'schedules-table-next-col' }, 'next'),
el('th', { class: 'schedules-table-every-col' }, 'every'),
el('th', {}, 'owner'),
el('th', { class: 'schedules-table-body-th' }, 'body'),
);
for (const a of agents) {
headerRow.append(el('th', { class: 'schedules-table-agent-th', title: a },
el('div', {}, el('span', {}, a))));
}
headerRow.append(el('th', { class: 'schedules-table-actions-th' }, ''));
thead.append(headerRow);
return thead;
}
function renderScheduleRow(s, agents) {
const cancelled = !!s.cancelled_at_unix;
const tr = el('tr', {
class: 'schedules-table-row' + (cancelled ? ' schedules-table-row-cancelled' : ''),
});
tr.append(el('td', { class: 'meta schedules-table-id' }, '#' + s.id));
const srcKind = s.source && s.source.kind === 'approval' ? 'approval' : 'manual';
tr.append(el('td', {},
el('span', { class: 'rqe-source rqe-source-' + srcKind },
srcKind === 'approval' ? 'approval' : 'operator')));
// "next" cell — relative due-in for active schedules, "cancelled"
// for cancelled ones. Both carry the absolute ISO in the title.
const nextCell = el('td', { class: 'meta schedules-table-next-col' });
if (cancelled) {
nextCell.title = 'cancelled ' + new Date(s.cancelled_at_unix * 1000).toISOString();
nextCell.textContent = 'cancelled';
} else {
const dueIn = s.next_fire_at_unix - Math.floor(Date.now() / 1000);
nextCell.title = new Date(s.next_fire_at_unix * 1000).toISOString();
nextCell.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(s.next_fire_at_unix)
: fmtDuration(dueIn);
nextCell.classList.add('sched-due');
nextCell.dataset.dueAt = String(s.next_fire_at_unix);
}
tr.append(nextCell);
tr.append(el('td', { class: 'meta schedules-table-every-col' },
s.interval_seconds ? '↻ ' + fmtDuration(s.interval_seconds) : 'one-shot'));
tr.append(el('td', { class: 'meta' }, s.owner));
// Body cell — truncates with ellipsis; full body + description (if
// any) on hover. Description used to be a separate visible block on
// the card layout; the table compresses it into the title to keep
// row height tight. Mara nit-flag candidate if she actually wants
// it visible in-table.
const bodyCell = el('td', { class: 'schedules-table-body-cell' });
const bodyText = s.body || '';
bodyCell.title = (s.description ? s.description + '\n\n' : '') + bodyText;
bodyCell.textContent = bodyText;
tr.append(bodyCell);
// Per-agent target cells. Three states:
// - active target → ✓ button that cancels just that target on
// click (same affordance as the per-row ✕ on the old layout's
// targets table)
// - cancelled target → muted ✕ glyph (no button — backend
// re-add flows through the edit form's targets multi-select)
// - not a target → empty cell
const targetByName = new Map();
for (const t of s.targets || []) targetByName.set(t.target, t);
for (const a of agents) {
const t = targetByName.get(a);
const td = el('td', { class: 'schedules-table-check' });
if (!t) {
// empty — no button, no glyph
} else if (t.cancelled_at_unix) {
td.title = 'cancelled — '
+ (t.last_fired_at_unix
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
: 'never fired')
+ (t.last_result ? ' · ' + t.last_result : '');
td.append(el('span', { class: 'schedules-table-check-cancelled' }, '✕'));
} else {
const lastFireDesc = t.last_fired_at_unix
? 'last fired ' + fmtAgo(t.last_fired_at_unix) + ' ago'
: 'never fired';
const lastResultDesc = t.last_result ? ' · ' + t.last_result : '';
const checkBtn = el('button', {
type: 'button',
class: 'schedules-table-check-btn',
}, '✓');
checkBtn.title = lastFireDesc + lastResultDesc
+ (cancelled ? '' : '\nclick to cancel this target');
if (cancelled) {
checkBtn.disabled = true;
} else {
checkBtn.addEventListener('click', () => cancelScheduleTargets(s.id, [a]));
}
td.append(checkBtn);
}
tr.append(td);
}
// Actions cell — fire / edit / cancel-all. Glyph-only to fit a
// compact column; the buttons keep their existing colour classes
// so the visual cue (mauve = fire, yellow = edit, red = cancel)
// carries over from the card layout.
const actionsCell = el('td', { class: 'schedules-table-actions' });
if (!cancelled) {
const activeTargets = (s.targets || []).filter((t) => !t.cancelled_at_unix);
const isOneShot = !s.interval_seconds;
const fireBtn = el('button', {
type: 'button',
class: 'btn btn-fire-now btn-inline-small',
}, '↯');
fireBtn.title = isOneShot
? 'fire once — one-shot, consumed after the manual fire'
: 'fire once now — recurring, next regular fire unaffected';
if (!activeTargets.length) {
fireBtn.disabled = true;
fireBtn.title = 'every target is cancelled — nothing to fire';
}
fireBtn.addEventListener('click', () =>
fireScheduleNow(s.id, isOneShot, activeTargets.map((t) => t.target), fireBtn));
actionsCell.append(fireBtn);
const editingThis = editingSchedules.has(s.id);
const editBtn = el('button', {
type: 'button',
class: 'btn btn-edit-schedule btn-inline-small',
}, editingThis ? '✎×' : '✎');
editBtn.title = editingThis
? 'close edit'
: 'edit body / description / interval / next-fire / targets';
editBtn.addEventListener('click', () => {
if (editingThis) {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
} else {
editingSchedules.add(s.id);
}
renderSchedulesList();
});
actionsCell.append(editBtn);
const cancelBtn = el('button', {
type: 'button',
class: 'btn btn-deny btn-inline-small',
}, '✕');
cancelBtn.title = 'cancel the whole schedule';
cancelBtn.addEventListener('click', () => cancelScheduleAll(s.id));
actionsCell.append(cancelBtn);
}
tr.append(actionsCell);
return tr;
}
function renderScheduleEditRow(s, agents) {
// colspan = 6 attribute cols + N agent cols + 1 actions col
const colCount = 7 + agents.length;
const tr = el('tr', { class: 'schedules-table-edit-row' });
const td = el('td', { colspan: String(colCount) });
td.append(renderScheduleEditForm(s));
tr.append(td);
return tr;
}
// Inline edit form. Renders inside the schedule row when the
// row's `✎ edit` button is toggled on. Pre-filled with current
// values; submit PATCHes /api/schedules/{id}. Targets stay
// immutable (per damocles's backend; the workaround for retargeting
// is cancel + new schedule). Mid-edit field values survive a
// state-poll refresh via `scheduleEditCarry`.
function renderScheduleEditForm(s) {
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
const form_ = el('form', { class: 'schedule-edit-form' });
form_.addEventListener('submit', (e) => {
e.preventDefault();
submitEditSchedule(s, form_);
});
const carry = scheduleEditCarry.get(s.id) || {};
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
form_.append(scheduleField('body', bodyInput));
const descInput = el('input', { type: 'text', name: 'description' });
descInput.value = carry.description !== undefined
? carry.description
: (s.description || '');
form_.append(scheduleField('description (blank to clear)', descInput));
const firstFireInput = el('input', {
type: 'datetime-local', name: 'next_fire', required: 'required',
});
firstFireInput.value = carry.next_fire !== undefined
? carry.next_fire
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
form_.append(scheduleField('next fire', firstFireInput));
const intervalCx = buildIntervalComposer({
label: 'interval (blank / all-zero = flip to one-shot)',
namePrefix: 'edit_interval_',
initialSeconds: 0,
});
form_.append(intervalCx.wrapper);
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
intervalCx.setParts({
d: carry.interval_d || '',
h: carry.interval_h || '',
m: carry.interval_m || '',
s: carry.interval_s || '',
});
} else if (s.interval_seconds) {
intervalCx.fillFromSeconds(s.interval_seconds);
}
intervalCx.updatePreview();
// Persist carry on every input + checkbox change so a refresh
// repaint preserves it.
const saveCarry = () => {
const fd = new FormData(form_);
scheduleEditCarry.set(s.id, {
body: String(fd.get('body') || ''),
description: String(fd.get('description') || ''),
next_fire: String(fd.get('next_fire') || ''),
interval_d: String(fd.get('edit_interval_d') || ''),
interval_h: String(fd.get('edit_interval_h') || ''),
interval_m: String(fd.get('edit_interval_m') || ''),
interval_s: String(fd.get('edit_interval_s') || ''),
targets: fd.getAll('edit_targets').map(String),
});
};
form_.addEventListener('input', saveCarry);
form_.addEventListener('change', saveCarry);
// Targets multi-select. Shares the chip-box pattern with the
// new-schedule form via `buildTargetChips`;
// cancelled tombstones aren't listed (re-adding them flows
// through `targets_add`, which the backend replace-on-conflict
// drops the tombstone for). Submit diffs against the original
// active set to populate `targets_add` / `targets_remove` on the
// PATCH body.
const originalActiveTargets = new Set(
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
);
form_.append(buildTargetChips({
idPrefix: 'se-' + s.id + '-',
fieldName: 'edit_targets',
checked: carry.targets ? new Set(carry.targets) : originalActiveTargets,
extraNames: [...originalActiveTargets],
}));
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
're-adding a previously cancelled target drops its history and starts fresh; '
+ 'unchecking an active target cancels it.'));
const actions = el('div', { class: 'schedule-actions' });
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
cancelEdit.addEventListener('click', () => {
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
});
actions.append(submit, cancelEdit);
form_.append(actions);
wrapper.append(form_);
return wrapper;
}
async function submitEditSchedule(originalSchedule, form_) {
const s = originalSchedule;
const fd = new FormData(form_);
const newBody = String(fd.get('body') || '').trim();
const newDescription = String(fd.get('description') || '').trim();
const newNextFireStr = String(fd.get('next_fire') || '');
if (!newBody) { alert('body must be non-empty'); return; }
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
const newNextFireDate = new Date(newNextFireStr);
if (Number.isNaN(newNextFireDate.getTime())) {
alert('next-fire is not a valid datetime'); return;
}
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
// Compute interval total from the d/h/m/s fields (composer uses
// `edit_interval_*` names in this form).
const intervalTotal = intervalSecondsFromFormData(fd, 'edit_interval_');
if (Number.isNaN(intervalTotal)) {
alert('interval fields must be non-negative integers');
return;
}
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
// Compute target diff against the schedule's currently-active set
// (cancelled tombstones don't count). The backend treats
// `targets_add` as replace-on-conflict (re-adding a tombstoned
// target drops its history), so we can just blanket "send the
// checked list as add, send the unchecked-but-was-active list as
// remove."
const newTargets = new Set(fd.getAll('edit_targets').map(String));
const originalActive = new Set(
(s.targets || [])
.filter((t) => !t.cancelled_at_unix)
.map((t) => t.target),
);
if (!newTargets.size) {
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
return;
}
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
// Build the PATCH body. Only include keys for fields that
// actually changed; explicit `null` clears description / flips
// recurring→one-shot. `targets_add` / `targets_remove` only
// populated when non-empty so the wire body stays minimal.
const patch = {};
if (newBody !== s.body) patch.body = newBody;
if (newDescription !== (s.description || '')) {
patch.description = newDescription || null;
}
if (newNextFireUnix !== s.next_fire_at_unix) {
patch.next_fire_at_unix = newNextFireUnix;
}
if (newIntervalSeconds !== (s.interval_seconds || null)) {
patch.interval_seconds = newIntervalSeconds;
}
if (targetsAdd.length) patch.targets_add = targetsAdd;
if (targetsRemove.length) patch.targets_remove = targetsRemove;
if (!Object.keys(patch).length) {
// No-op submit. Treat as "close edit form".
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
renderSchedulesList();
return;
}
const submitBtn = form_.querySelector('button[type="submit"]');
const originalLabel = submitBtn ? submitBtn.textContent : '';
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
editingSchedules.delete(s.id);
scheduleEditCarry.delete(s.id);
await refreshSchedules();
} catch (err) {
alert('edit failed: ' + err);
} finally {
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
}
}
async function fireScheduleNow(id, isOneShot, targets, btn) {
const targetList = targets.length ? targets.join(', ') : '(no active targets)';
const prompt = isOneShot
? `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is a ONE-SHOT — firing now consumes the schedule. '
+ 'the scheduled fire time will no longer trigger.'
: `fire schedule #${id} now to ${targetList}?\n\n`
+ 'this is RECURRING — sends an extra pulse out-of-band. '
+ 'the regular cadence keeps firing on schedule.';
if (!confirm(prompt)) return;
// Capture child nodes so we can restore on error, then replace
// with DOM-built content (textContent + element children rather
// than innerHTML — the format string only carries server-side
// ints/bool today but textContent is the safer pattern if a
// stringy field ever lands).
const originalChildren = btn ? Array.from(btn.childNodes) : [];
const restoreBtn = () => {
if (!btn) return;
btn.disabled = false;
while (btn.firstChild) btn.removeChild(btn.firstChild);
for (const n of originalChildren) btn.appendChild(n);
};
if (btn) {
btn.disabled = true;
while (btn.firstChild) btn.removeChild(btn.firstChild);
btn.append(el('span', { class: 'spinner' }, '◐'), ' firing…');
}
try {
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/fire-now', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('fire-now failed: http ' + resp.status + (text ? '\n\n' + text : ''));
restoreBtn();
return;
}
// Backend returns FireNowReport { ok, failed, missing, one_shot_consumed }.
// Flash the per-target outcome on the button itself so the operator
// sees the result immediately, then refresh to pick up the
// authoritative per-target `last_result` annotations.
let report = null;
try { report = await resp.json(); } catch { /* shape drift / empty body — ignore */ }
if (btn && report) {
const bits = [];
if (report.ok) bits.push(report.ok + ' ok');
if (report.failed) bits.push(report.failed + ' failed');
if (report.missing) bits.push(report.missing + ' missing');
const suffix = report.one_shot_consumed ? ' — consumed' : '';
while (btn.firstChild) btn.removeChild(btn.firstChild);
btn.textContent = '↯ fired: ' + (bits.join(', ') || 'no targets') + suffix;
btn.classList.add('btn-fire-now-flashed');
}
// Hold the flash briefly so the operator can read it before the
// refresh wipes the row in place.
setTimeout(refreshSchedules, 1500);
} catch (err) {
alert('fire-now failed: ' + err);
restoreBtn();
}
}
async function cancelScheduleAll(id) {
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
await postScheduleCancel(id, null);
}
async function cancelScheduleTargets(id, targets) {
if (!confirm(`cancel schedule #${id} for ${targets.join(', ')}? other targets keep firing.`)) return;
await postScheduleCancel(id, targets);
}
async function postScheduleCancel(id, targets) {
try {
const opts = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
};
if (targets) opts.body = JSON.stringify({ targets });
const resp = await fetch('/api/schedules/' + encodeURIComponent(id) + '/cancel', opts);
if (!resp.ok) {
const text = await resp.text().catch(() => '');
alert('cancel failed: http ' + resp.status + (text ? '\n\n' + text : ''));
return;
}
await refreshSchedules();
} catch (err) {
alert('cancel failed: ' + err);
}
}
// ─── state polling ──────────────────────────────────────────────────────
let pollTimer = null;
// Sections whose innerHTML gets blown away on each refresh. If the
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
'containers-section',
'tombstones-section',
'questions-section',
'inbox-section',
'approvals-section',
'meta-inputs-section',
'rebuild-queue-section',
'reminders-section',
'schedules-section',
'capabilities-section',
'tool-groups-section',
];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
// sections and records which keys are currently open; restoreOpenDetails
// re-applies after the render. (Long-content drill-ins — file
// previews, diffs, logs, config — open in the side panel instead,
// which lives outside the managed sections and survives re-render
// on its own.)
function snapshotOpenDetails() {
const open = new Set();
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
if (d.open) open.add(d.dataset.restoreKey);
}
}
return open;
}
function restoreOpenDetails(open) {
if (!open.size) return;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
if (open.has(d.dataset.restoreKey)) d.open = true;
}
}
}
function operatorIsTyping() {
const el_ = document.activeElement;
if (!el_ || el_ === document.body) return false;
const tag = el_.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
return MANAGED_SECTION_IDS.some((id) => {
const sect = document.getElementById(id);
return sect && sect.contains(el_);
});
}
async function refreshState() {
// Don't yank the form out from under the operator. Try again
// shortly on the next tick; eventually they'll blur and the
// refresh lands.
if (operatorIsTyping()) {
if (pollTimer) clearTimeout(pollTimer);
pollTimer = setTimeout(refreshState, 2000);
return;
}
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
// Stash the latest snapshot for any sub-widget that wants a
// synchronous read (e.g. the compose autocomplete pulls agent
// names from here instead of refetching on every keystroke).
window.__hyperhive_state = s;
// Gate the P33RS tab — hidden when no peers are configured.
const peersTab = $('tab-peers');
const peers = s.peer_hives || [];
if (peersTab) peersTab.hidden = peers.length === 0;
renderPeerHives(peers);
// Gate the M4TR1X → tab strip entry — see
// docs/web-ui.md::Tab strip for the matrix_gui_enabled rationale.
const matrixTab = $('tab-matrix');
if (matrixTab) matrixTab.hidden = !s.matrix_gui_enabled;
// Hive identity: update the chrome banner + page title once the
// server-side display names are known. `hive_name` / `swarm_name`
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
// (set by services.hyperhive.{hiveName,swarmName} nix options).
// When unset we fall back gracefully — no chrome change.
const hiveId = $('hive-identity');
if (hiveId) {
const hive = s.hive_name;
const swarm = s.swarm_name;
if (hive || swarm) {
const label = swarm && hive ? `${swarm} / ${hive}`
: hive || swarm;
hiveId.textContent = label;
hiveId.hidden = false;
// Preserve any (N) call-count prefix already applied by
// refreshTabCounts so the title doesn't flicker on reload.
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
document.title = existingPrefix + label + ' // h1ve-c0re';
}
}
const openDetails = snapshotOpenDetails();
// Sync transients + containers first so renderContainers below
// sees the current derived maps (it reads from
// `transientsState` + `containersState`, not from `s.*`).
syncTransientsFromSnapshot(s);
syncContainersFromSnapshot(s);
syncTombstonesFromSnapshot(s);
syncMetaInputsFromSnapshot(s);
syncRebuildQueueFromSnapshot(s);
renderContainers(s);
renderTombstones(s);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events
// mutate the stores directly and re-render without a snapshot
// refetch.
syncQuestionsFromSnapshot(s);
renderQuestions();
// (renderInbox now lives in ./flow.js — dashboard has no
// #inbox-section element to render into.)
syncApprovalsFromSnapshot(s);
renderApprovals();
renderMetaInputs(s);
renderRebuildQueue(s);
refreshReminders();
refreshSchedules();
restoreOpenDetails(openDetails);
notifyDeltas(s);
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
// approvals + questions + transients have their own events;
// broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
} catch (err) {
console.error('refreshState failed', err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
// Cold-load the operator inbox (#1469) so the Y3R C4LL pill + browser
// title reflect unread agent→operator messages immediately, before the
// operator opens the tab. Live updates arrive via the broker stream.
refreshOperatorInbox();
NOTIF.bind();
Panel.bind();
// ─── live updates: dashboard event stream ──────────────────────────────
// The dashboard subscribes to /dashboard/stream for live mutation
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
// operator action triggering a refreshState.
//
// Bare `EventSource` (no terminal infrastructure needed — the
// dashboard doesn't render broker rows). Each event's `kind` is
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
// `sent` / `delivered`, anything new the backend adds) silently
// no-op. On (re)connect we kick a refreshState() to recover events
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
//
// Both /index.html and /flow.html subscribe to `/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
question_added: applyQuestionAdded,
question_resolved: applyQuestionResolved,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
container_state_changed: applyContainerStateChanged,
container_removed: applyContainerRemoved,
tombstones_changed: applyTombstonesChanged,
meta_inputs_changed: applyMetaInputsChanged,
meta_update_running: applyMetaUpdateRunning,
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
reminders_changed: applyRemindersChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs share
// one upstream SSE connection — see docs/web-ui.md (SSE multiplexing
// paragraph) for the design + Firefox throttling motivation.
// `openStream` returns an EventSource-shaped facade with a graceful
// direct-EventSource fallback when SharedWorker isn't supported.
const es = openStream('/dashboard/stream');
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
// Broker `sent` frames aren't mutation events, but the operator
// inbox (#1469) cares about ones addressed to "operator".
if (ev.kind === 'sent' && ev.to === 'operator') {
operatorInboxAppendFromEvent(ev);
return;
}
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try { h(ev); }
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
};
es.onopen = () => {
// Re-sync to recover events that fired during the SSE disconnect
// window. Initial connect also fires onopen — the first
// refreshState() above and this one race, but refreshState is
// idempotent so the second call just overwrites with the
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
refreshState();
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do beyond logging.
console.debug('dashboard SSE error, will retry');
};
})();
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` /
// `#settings` activate the matching pane on the dashboard. Empty
// hash defaults to SW4RM. FL0W is NOT a tab — it's a separate page
// (`/flow.html`) reached via the tab-strip link. Tab routing only
// applies when the tab DOM is present (e.g. not on the flow page
// itself, where these elements don't exist and the loop no-ops).
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'stats', 'peers', 'settings'];
function activateTab(name) {
const target = TABS.includes(name) ? name : TABS[0];
for (const t of TABS) {
const tab = $('tab-' + t);
const pane = $('tab-pane-' + t);
if (tab) tab.classList.toggle('active', t === target);
if (pane) pane.classList.toggle('tab-pane-active', t === target);
}
// Track active tab on the body so renderSelectionBar can gate
// visibility (bar only belongs on SW4RM where agent cards live).
// Re-render the bar so the toggle takes effect immediately
// on hashchange without waiting for the next SSE update.
document.body.dataset.activeTab = target;
renderSelectionBar(Array.from(containersState.values()));
// Keep overflow button active state in sync after tab change.
updateTabbarOverflow();
// Re-fetch schedules on activation as a safety net (SSE covers
// live mutations but re-sync ensures consistency after disconnect
// windows or approval-path inserts that don't yet emit). Also
// re-fetch reminders on SCH3DUL3S activation since both sections
// live on the same tab.
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
// the cold-load snapshot.
if (target === 'permissions') {
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
if (target === 'stats') { refreshHiveStats(); }
if (target === 'call') { refreshOperatorInbox(); }
// SYST3M C0NT41N3R L04D: live cgroup poll only while the tab is
// open (cpu needs a short two-sample read each refresh).
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
}
// ─── tabbar overflow menu ────────────────────────────────────────────────
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
// the ⋮ dropdown. When the bar is too narrow to show all remaining tabs,
// rightmost tabs spill into the dropdown too (right-to-left).
//
// Implementation: all tabs stay in the DOM. The ⋮ wrapper sits at the
// flex end. `tab-overflowed` hides a tab from the bar (display:none).
// The dropdown is rebuilt from scratch on every call — it holds cloned
// <a>/<button> items, not the originals.
//
// IMPORTANT: these must be declared before syncTabFromHash() is called
// below — activateTab() calls updateTabbarOverflow() which closes over
// these variables, and const/let are not accessible before their
// declaration (TDZ).
const overflowWrap = $('tabbar-overflow');
const overflowBtn = $('tabbar-overflow-btn');
const overflowDrop = $('tabbar-overflow-dropdown');
let overflowOpen = false;
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
// on window change. Plain tables/bars — the dashboard bundle has no chart
// lib, and per-agent trend charts live on each agent's own /stats page.
let hiveStatsWindow = '24h';
function hsFmtInt(n) {
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
}
function hsFmtTokens(n) {
if (!Number.isFinite(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(Math.round(n));
}
function hsFmtUsd(n) {
if (!Number.isFinite(n)) return '$0';
if (n >= 100) return '$' + n.toFixed(0);
if (n >= 1) return '$' + n.toFixed(2);
return '$' + n.toFixed(3);
}
function hsChip(parent, label, value, est) {
const c = document.createElement('span');
c.className = 'hive-stats-chip' + (est ? ' est' : '');
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
c.append(k, v);
parent.append(c);
}
function hsMeta(parent, text) {
parent.replaceChildren();
const p = document.createElement('p');
p.className = 'meta';
p.textContent = text;
parent.append(p);
}
function renderHiveStats(s) {
const sum = $('hive-stats-summary');
if (sum) {
sum.replaceChildren();
hsChip(sum, 'window', s.window);
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
}
const at = $('hive-stats-agents');
if (at) {
const agents = s.agents || [];
if (!agents.length) {
hsMeta(at, 'no turns in window');
} else {
at.replaceChildren();
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
const tb = document.createElement('tbody');
for (const a of agents) {
const tr = document.createElement('tr');
const cells = [
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
hsFmtUsd(a.est_cost_usd),
];
cells.forEach((txt, i) => {
const td = document.createElement('td');
if (i > 0) td.className = 'num';
td.textContent = txt;
tr.append(td);
});
tb.append(tr);
}
table.append(tb);
at.append(table);
}
}
const mm = $('hive-stats-models');
if (mm) {
const mix = s.model_mix || [];
if (!mix.length) {
hsMeta(mm, 'no turns in window');
} else {
mm.replaceChildren();
const max = mix[0].count || 1;
for (const kc of mix) {
const row = document.createElement('div'); row.className = 'hive-stats-bar';
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
const track = document.createElement('span'); track.className = 'track';
const fill = document.createElement('span'); fill.className = 'fill';
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
track.append(fill);
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
row.append(lbl, track, cnt);
mm.append(row);
}
}
}
}
async function refreshHiveStats() {
try {
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
if (!resp.ok) throw new Error('http ' + resp.status);
renderHiveStats(await resp.json());
} catch (e) {
const at = $('hive-stats-agents');
if (at) hsMeta(at, 'stats fetch failed: ' + e);
}
}
function bindHiveStatsWindows() {
const tabs = $('hive-stats-windows');
if (!tabs) return;
tabs.addEventListener('click', (ev) => {
const btn = ev.target.closest('button[data-w]');
if (!btn) return;
hiveStatsWindow = btn.dataset.w;
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
refreshHiveStats();
});
}
bindHiveStatsWindows();
// ─── SYST3M C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a
// short two-sample read on the server). Data from /api/container-resources.
let containerLoadTimer = null;
function cloadFmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0';
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0; let v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
}
function cloadMeter(pct) {
const p = Math.max(0, Math.min(100, pct));
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
const m = document.createElement('span');
m.className = 'cload-meter';
m.title = p.toFixed(0) + '%';
const f = document.createElement('span');
f.className = 'fill' + (cls ? ' ' + cls : '');
f.style.width = p + '%';
m.append(f);
return m;
}
function renderContainerLoad(rows) {
const root = $('container-load-section');
if (!root) return;
if (!Array.isArray(rows) || rows.length === 0) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'no running agent containers'; root.append(p);
return;
}
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
+ '<th>peak</th><th>limit</th></tr></thead>';
const tb = document.createElement('tbody');
for (const r of rows) {
const tr = document.createElement('tr');
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
const cpu = document.createElement('td'); cpu.className = 'num';
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
tr.append(cpu);
const mem = document.createElement('td'); mem.className = 'num';
const memCur = Number(r.mem_current_bytes) || 0;
if (r.mem_max_bytes) {
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
} else {
mem.textContent = cloadFmtBytes(memCur);
}
tr.append(mem);
const peak = document.createElement('td'); peak.className = 'num';
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
tr.append(peak);
const lim = document.createElement('td'); lim.className = 'num';
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
tr.append(lim);
tb.append(tr);
}
table.append(tb);
root.replaceChildren(table);
}
async function refreshContainerLoad() {
try {
const resp = await fetch('/api/container-resources');
if (!resp.ok) throw new Error('http ' + resp.status);
renderContainerLoad(await resp.json());
} catch (e) {
const root = $('container-load-section');
if (root) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'container load fetch failed: ' + e; root.append(p);
}
}
}
function startContainerLoadPolling() {
refreshContainerLoad();
if (containerLoadTimer) return;
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
}
function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
}
function syncTabFromHash() {
const h = (window.location.hash || '#swarm').replace(/^#/, '');
activateTab(h);
}
window.addEventListener('hashchange', () => {
syncTabFromHash();
updateTabbarOverflow();
});
syncTabFromHash();
function closeOverflowMenu() {
if (!overflowOpen) return;
overflowOpen = false;
if (overflowDrop) overflowDrop.hidden = true;
if (overflowBtn) overflowBtn.setAttribute('aria-expanded', 'false');
}
if (overflowBtn) {
overflowBtn.addEventListener('click', (e) => {
e.stopPropagation();
overflowOpen = !overflowOpen;
overflowDrop.hidden = !overflowOpen;
overflowBtn.setAttribute('aria-expanded', String(overflowOpen));
});
}
document.addEventListener('click', (e) => {
if (overflowOpen && !overflowWrap?.contains(e.target)) closeOverflowMenu();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overflowOpen) { closeOverflowMenu(); e.stopImmediatePropagation(); }
}, true);
function updateTabbarOverflow() {
const tabbar = $('tabbar');
if (!tabbar || !overflowBtn || !overflowDrop) return;
// Skip dropdown rebuild while it is open — the 1s badge tick would
// replace DOM nodes and cause a flicker mid-interaction.
if (overflowOpen) return;
// Collect all tabs that are not JS-hidden (P33RS/M4TR1X may be hidden
// by feature-gating) and not already removed from the DOM.
const allTabs = [...tabbar.querySelectorAll('.tab')];
// Separate default-overflow tabs from dynamic ones.
const defaultOverflow = allTabs.filter(t => t.dataset.overflow === 'default');
const dynamic = allTabs.filter(t => t.dataset.overflow !== 'default' && !t.hidden);
// Step 1: un-overflow all dynamic tabs so we can measure natural widths.
dynamic.forEach(t => t.classList.remove('tab-overflowed'));
// Step 2: compute the right boundary where visible tabs must end.
// getBoundingClientRect is used instead of clientWidth because
// clientWidth includes the tabbar's horizontal padding (~2em total),
// causing an over-allocation of ~2em; flex gap between tabs is also
// not captured by offsetWidth accumulation. Together these pushed
// the ⋮ button off the right edge of the screen.
const tabbarRect = tabbar.getBoundingClientRect();
const padR = parseFloat(getComputedStyle(tabbar).paddingRight) || 0;
// Right edge of the flex content area (inside right padding).
// Fall back to clientWidth-based estimate when the rect is zero
// (element not in layout, e.g. display:none ancestor).
const contentRight = tabbarRect.width > 0
? (tabbarRect.right - padR)
: (tabbar.clientWidth - padR);
// Space to reserve for the overflow wrapper. Use its actual offsetWidth
// when available; fall back to 40px on the first call (button hidden).
const btnReserve = (overflowWrap.offsetWidth || 40) + 4;
const cutoffRight = contentRight - btnReserve;
// Step 3: any tab whose right edge exceeds the cutoff is overflowed,
// along with all subsequent tabs (keeps the visible set contiguous
// and left-anchored). Once the first offending tab is found, all
// following tabs are also overflowed without re-measuring.
const dynamicOverflow = [];
for (const tab of dynamic) {
if (dynamicOverflow.length > 0 || tab.getBoundingClientRect().right > cutoffRight) {
dynamicOverflow.push(tab);
}
}
dynamicOverflow.forEach(t => t.classList.add('tab-overflowed'));
// Step 4: rebuild the dropdown from the two overflow sets.
const overflowedTabs = [...dynamicOverflow, ...defaultOverflow];
overflowDrop.replaceChildren();
for (const tab of overflowedTabs) {
const li = document.createElement('li');
li.setAttribute('role', 'presentation');
// Build a menu item that mirrors the tab's link/button behaviour.
const href = tab.getAttribute('href');
const item = href
? document.createElement('a')
: document.createElement('button');
if (href) {
item.href = href;
} else {
item.type = 'button';
item.addEventListener('click', () => {
closeOverflowMenu();
window.location.hash = tab.dataset.tab || '';
});
}
item.className = 'tabbar-overflow-item';
item.setAttribute('role', 'menuitem');
// Copy the tab label text.
const labelEl = tab.querySelector('.tab-label');
item.textContent = labelEl ? labelEl.textContent : (tab.textContent || '').trim();
// Active state: hash tabs match current route.
if (tab.dataset.tab && tab.classList.contains('active')) {
item.classList.add('tabbar-overflow-item-active');
}
// Count badge: copy from the tab's count pill if non-zero.
const countEl = tab.querySelector('.tab-count');
if (countEl && !countEl.hidden && countEl.textContent) {
const badge = document.createElement('span');
badge.className = 'tabbar-overflow-badge' + (countEl.classList.contains('tab-count-attn') ? ' tab-count-attn' : '');
badge.textContent = countEl.textContent;
item.append(badge);
}
if (href) {
item.addEventListener('click', closeOverflowMenu);
}
li.append(item);
overflowDrop.append(li);
}
// Step 5: show/hide the overflow button.
const hasItems = overflowedTabs.length > 0;
overflowBtn.hidden = !hasItems;
if (!hasItems) closeOverflowMenu();
// Step 6: mark the button active when the current tab is overflowed.
const activeTab = document.body.dataset.activeTab;
const activeIsOverflowed = overflowedTabs.some(t => t.dataset.tab === activeTab);
overflowBtn.classList.toggle('tabbar-overflow-active', activeIsOverflowed);
}
// Call on init and whenever the bar width changes.
updateTabbarOverflow();
if (typeof ResizeObserver !== 'undefined') {
const ro = new ResizeObserver(() => updateTabbarOverflow());
const tabbar = $('tabbar');
if (tabbar) ro.observe(tabbar);
}
// Also refresh after P33RS/M4TR1X visibility changes (they're toggled by
// refreshState). A MutationObserver on the tabbar catches attribute changes
// (hidden attr) on child tabs without coupling to specific render paths.
const tabbarEl = $('tabbar');
if (tabbarEl && typeof MutationObserver !== 'undefined') {
new MutationObserver(() => updateTabbarOverflow()).observe(tabbarEl, {
attributes: true, subtree: true, attributeFilter: ['hidden'],
});
}
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
// ─── operator inbox (#1469) — unread agent→operator messages ────────────
// The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so
// the operator stops missing them. Unread = broker rows to "operator"
// with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`,
// appended live from the broker `sent` stream, and cleared via the
// existing per-recipient ack (`POST /api/agent/operator/mark-all-read`).
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
async function refreshOperatorInbox() {
try {
const r = await fetch('/api/operator-inbox');
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch { /* keep prior list on transient failure */ }
renderOperatorInbox();
refreshTabCounts();
}
function renderOperatorInbox() {
const root = $('operator-inbox-section');
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el('p', { class: 'meta' }, 'no unread messages'));
return;
}
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' });
appendLinkified(body, m.body, m.file_refs);
ul.append(el('li', {},
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
));
}
root.append(ul);
}
async function markOperatorInboxRead() {
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
catch { /* best-effort; the next refresh reconciles */ }
operatorInbox = [];
renderOperatorInbox();
refreshTabCounts();
}
// Live append from the broker stream — a `sent` frame addressed to
// "operator". De-dupes on broker row id so a history/live overlap or
// a refresh racing the stream doesn't double-list.
function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
renderOperatorInbox();
refreshTabCounts();
}
function setTabCount(tab, n) {
const el_ = $('tab-count-' + tab);
if (!el_) return;
el_.textContent = String(n);
el_.hidden = n <= 0;
}
/** Recompute every tab's count from the current state. Called on
* every renderXxx that's tab-relevant. */
function refreshTabCounts() {
// SW4RM — flag any container that's stale (needs_update). Empty
// when everyone's current. Container-row pulse signals state
// transitions; the pill catches "deploy-pending" specifically.
let swarm = 0;
for (const c of containersState.values()) {
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
// Y3R C4LL — pending approvals + operator-targeted questions +
// unread agent→operator messages (#1469).
const callCount =
(approvalsState?.pending?.length ?? 0) +
(questionsState?.pending?.length ?? 0) +
operatorInbox.length;
setTabCount('call', callCount);
// Browser tab title prefix — lets the operator see the pending
// call count without switching to the window. Strips any existing
// `(N) ` prefix before re-applying so identity-title updates
// (which run once on state load, not every tick) compose cleanly.
const rawTitle = document.title.replace(/^\(\d+\) /, '');
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
// SYST3M — queued + running rebuild_queue entries (terminal
// entries are kept for history but aren't 'attention').
let sysCount = 0;
if (rebuildQueueState) {
for (const e of rebuildQueueState) {
if (e.state === 'queued' || e.state === 'running') sysCount++;
}
}
setTabCount('system', sysCount);
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
// FL0W pill count: lives in ./flow.js now (it has the inbox
// derived store). Dashboard tab strip's `#tab-count-flow` slot
// stays hidden by default; future plumbing could broadcast the
// count via localStorage / BroadcastChannel if both pages are
// open.
// Re-render overflow dropdown so badges stay in sync.
updateTabbarOverflow();
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE
// event + refreshState call, so polling them is correct and cheap
// — no per-renderer hookup needed.
refreshTabCounts();
setInterval(refreshTabCounts, 1000);
// Flow-specific code (inbox-pill wiring, broker terminal init, the
// @-mention composer) lives in ./flow.js — loaded only by /flow.html.
})();