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