hyperhive/hive-c0re/assets/app.js
iris 270ef19920 dashboard: add per-agent ctx-window usage chip to container rows
Reads the most recent turn's context-window token count directly from
each agent's hyperhive-turn-stats.sqlite (same path the host-side
stats_vacuum uses). Adds ctx_tokens: Option<u64> to ContainerView;
populated in build_all via a single best-effort SQL query.

Dashboard app.js renders a 'ctx·Nk' badge colour-coded by harness
watermarks: green <100k (safe), yellow 100-150k (near auto-reset),
red ≥150k (compact territory). Badge only shown when ctx_tokens
is present (agent has run at least one turn).

Closes #17
2026-05-20 15:01:28 +02:00

1869 lines
75 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.

// Dashboard SPA. Renders containers + approvals from `/api/state`, wires
// up async-form submission (URL-encoded POST + spinner + state refresh),
// and tails the unified dashboard event channel over `/dashboard/stream`.
(() => {
// ─── helpers ────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
const el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
else if (k.startsWith('data-')) e.setAttribute(k, v);
else e.setAttribute(k, v);
}
for (const c of children) {
if (c == null) continue;
e.append(c.nodeType ? c : document.createTextNode(c));
}
return e;
};
const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
// Endpoints whose mutation fires a DashboardEvent (and whose
// derived store applies it live) opt out of the post-submit
// /api/state refetch. See the async-form handler.
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value }));
}
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
return f;
};
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
// (file previews, approval diffs, journald logs, applied config)
// opens here via `Panel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
const Panel = (() => {
const root = $('side-panel');
const titleEl = $('side-panel-title');
const bodyEl = $('side-panel-body');
function open(title, content) {
titleEl.textContent = title;
bodyEl.replaceChildren(...(content ? [content] : []));
root.classList.add('open');
root.setAttribute('aria-hidden', 'false');
}
function close() {
root.classList.remove('open');
root.setAttribute('aria-hidden', 'true');
}
function bind() {
$('side-panel-close').addEventListener('click', close);
$('side-panel-backdrop').addEventListener('click', close);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && root.classList.contains('open')) close();
});
}
return { open, close, bind };
})();
// ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is
// deliberately not matched — it's ambiguous from the host's
// perspective (we'd need to know which agent the message is about
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
return text;
}
// Lazy-load `path` from /api/state-file into the side panel.
// Markdown files render through `marked` into a `.md` block; every
// other file stays raw text in a <pre>.
async function openFilePanel(path) {
const isMd = /\.(md|markdown)$/i.test(path);
const view = isMd
? el('div', { class: 'md' })
: el('pre', { class: 'path-preview-body' });
view.textContent = '(fetching…)';
Panel.open('↳ ' + path, view);
try {
const text = await fetchStateFile(path);
if (isMd && window.marked && typeof window.marked.parse === 'function') {
marked.setOptions({ breaks: true, gfm: true });
view.innerHTML = marked.parse(text);
} else {
view.textContent = text;
}
} catch (e) {
view.textContent = 'error: ' + (e.message || e);
}
}
function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
e.preventDefault();
openFilePanel(path);
});
return anchor;
}
// Append `text` to `parent` as a mix of text nodes + path anchors.
// `refs` is the server-attached `file_refs` array (verified-file
// tokens that appear in `text`); each occurrence of a ref becomes a
// clickable anchor that opens the file in the side panel. Anything
// not in `refs` stays plain text. No client-side regex, no probe
// endpoint — the server saw the body first and made the call. When
// `refs` is empty/missing we just emit plain text.
function appendLinkified(parent, text, refs) {
if (text == null) return;
const str = String(text);
const tokens = (refs || []).slice();
if (!tokens.length) {
if (str) parent.appendChild(document.createTextNode(str));
return;
}
// Walk the string left-to-right, at each step looking for the
// next occurrence of any token. Longest-first tie-break so a
// ref like `/agents/foo/state/x.md` wins over a (hypothetical)
// shorter token that prefixes it. O(text * refs) worst case;
// refs is bounded server-side to whatever fits in a body, so
// this stays cheap.
tokens.sort((a, b) => b.length - a.length);
let i = 0;
while (i < str.length) {
let bestStart = -1;
let bestToken = null;
for (const t of tokens) {
const idx = str.indexOf(t, i);
if (idx === -1) continue;
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
bestStart = idx;
bestToken = t;
}
}
if (bestStart === -1) {
parent.appendChild(document.createTextNode(str.slice(i)));
break;
}
if (bestStart > i) {
parent.appendChild(document.createTextNode(str.slice(i, bestStart)));
}
parent.appendChild(makePathLink(bestToken));
i = bestStart + bestToken.length;
}
}
// ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue
// - new operator question queued (ask, target IS NULL)
// - broker message sent `to: "operator"`
// permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// localhost) — on other origins the API is unavailable and we hide
// the controls.
const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
renderControls();
}
function show(title, body, tag) {
if (!supported) {
console.debug('notify: Notification API not supported');
return;
}
if (Notification.permission !== 'granted') {
console.debug('notify: permission not granted', Notification.permission);
return;
}
if (isMuted()) {
console.debug('notify: muted');
return;
}
try {
// Per-event tag so distinct messages stack instead of
// collapsing into one slot. Caller passes a unique tag per
// notification kind/id; we don't fall back to 'hyperhive'
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || ('hyperhive:' + Date.now()),
});
n.onclick = () => { window.focus(); n.close(); };
console.debug('notify: shown', title, 'tag=', tag);
} catch (err) {
console.warn('notification show failed', err);
}
}
return { bind, show, renderControls };
})();
// 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' : '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();
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 = [];
function syncTombstonesFromSnapshot(s) {
tombstonesState = (s.tombstones || []).slice();
}
function syncMetaInputsFromSnapshot(s) {
metaInputsState = (s.meta_inputs || []).slice();
}
function applyTombstonesChanged(ev) {
tombstonesState = (ev.tombstones || []).slice();
renderTombstonesFromState();
}
function applyMetaInputsChanged(ev) {
metaInputsState = (ev.inputs || []).slice();
renderMetaInputsFromState();
}
function renderTombstonesFromState() {
renderTombstones({ tombstones: tombstonesState });
}
function renderMetaInputsFromState() {
renderMetaInputs({ meta_inputs: metaInputsState });
}
// 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);
}
// 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 ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
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).
const containers = Array.from(containersState.values())
.sort((a, b) => a.name.localeCompare(b.name));
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;
}
const hostname = (s && s.hostname) || window.location.hostname;
const ul = el('ul', { class: 'containers' });
for (const c of containers) {
const url = `http://${hostname}:${c.port}/`;
// Pending state is overlaid from the transient store, not from
// the container row — `ContainerStateChanged` doesn't carry it,
// `TransientSet` / `TransientCleared` do.
const pending = transientsState.get(c.name)?.kind || null;
const li = el('li', { class: 'container-row' + (pending ? ' pending' : '') });
// ── line 1: 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'),
el('a', {
class: 'meta',
href: url + 'stats',
target: '_blank',
rel: 'noopener',
title: 'per-agent stats page (turn rate, durations, tokens, tool mix)',
}, '📊'),
);
if (pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
} 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) {
// Colour thresholds mirror the harness compaction watermarks:
// < 100k = safe (green), 100k150k = approaching reset (yellow),
// ≥ 150k = compact territory (red).
const k = Math.round(c.ctx_tokens / 1000);
const ctxClass = c.ctx_tokens >= 150_000 ? 'badge-ctx-warn'
: c.ctx_tokens >= 100_000 ? 'badge-ctx-caution'
: 'badge-ctx-ok';
head.append(el('span',
{
class: `badge ${ctxClass}`,
title: `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`,
},
`ctx·${k}k`));
}
li.append(head);
// ── line 2: action buttons ───────────────────────────────────
const actions = el('div', { class: 'actions' });
if (c.running) {
actions.append(
form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
'restart ' + c.name + '?', {}, { noRefresh: true }),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P',
'stop ' + c.name + '?', {}, { noRefresh: true }),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT',
'start ' + c.name + '?', {}, { noRefresh: true }),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.',
{}, { noRefresh: true }),
);
if (!c.is_manager) {
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
// wipes tombstone state which isn't event-derived yet, so it
// Both event-covered now (ContainerRemoved +
// TombstonesChanged); no /api/state refetch needed.
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.',
{}, { noRefresh: true }),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{ purge: 'on' }, { noRefresh: true }),
);
}
li.append(actions);
// ── line 3: 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));
// Applied config now lives on the forge — link to the
// agent-configs mirror repo instead of a one-file viewer.
if (s && s.forge_present) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `http://${hostname}:3000/agent-configs/${c.name}`,
title: 'applied config repo on the hive forge',
}, '↳ config repo ↗'));
}
li.append(drill);
ul.append(li);
}
root.append(ul);
}
// Per-container journald viewer. Returns an inline trigger; the
// click opens the side panel and fetches the last N lines. Refresh
// re-fetches; the unit toggle switches between the harness service
// and the full machine journal.
function buildJournalTrigger(containerName, defaultUnit) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ logs · ' + containerName);
trigger.addEventListener('click', () => {
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll the panel to the newest lines on fresh fetch.
const sb = $('side-panel-body');
if (sb) sb.scrollTop = sb.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
Panel.open('logs · ' + containerName, body);
fetchLogs();
});
return trigger;
}
function renderTombstones(s) {
const root = $('tombstones-section');
root.innerHTML = '';
if (!s.tombstones || !s.tombstones.length) {
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
return;
}
const fmtBytes = (n) => {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
};
const fmtAge = (ts) => {
if (!ts) return '?';
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return 'today';
if (d === 1) return '1 day ago';
return d + ' days ago';
};
const ul = el('ul', { class: 'containers' });
for (const t of s.tombstones) {
const li = el('li', { class: 'container-row tombstone' });
const head = el('div', { class: 'head' });
head.append(
el('span', { class: 'name' }, t.name),
el('span', { class: 'badge badge-muted' }, 'destroyed'),
);
if (t.has_creds) {
head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
}
head.append(el('span', { class: 'meta' },
`${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
li.append(head);
const actions = el('div', { class: 'actions' });
// Reuse the existing spawn form pattern via /request-spawn — operator
// can queue an approval that recreates the agent with the same name
// and reuses the kept state.
const respawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'inline', 'data-async': '',
'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
});
respawn.append(
el('input', { type: 'hidden', name: 'name', value: t.name }),
el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
);
actions.append(respawn);
actions.append(form(
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
'PURGE ' + t.name + '? config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{}, { noRefresh: true },
));
li.append(actions);
ul.append(li);
}
root.append(ul);
}
// Derived question state — cold-loaded from /api/state, then mutated
// live by `question_added` / `question_resolved` dashboard events.
const QUESTION_HISTORY_LIMIT = 20;
const questionsState = { pending: [], history: [] };
function syncQuestionsFromSnapshot(s) {
questionsState.pending = (s.questions || []).slice();
questionsState.history = (s.question_history || []).slice();
}
function applyQuestionAdded(ev) {
if (questionsState.pending.some((q) => q.id === ev.id)) return;
questionsState.pending.push({
id: ev.id,
asker: ev.asker,
question: ev.question,
options: ev.options || [],
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
target: ev.target || null,
question_refs: ev.question_refs || [],
});
renderQuestions();
}
function applyQuestionResolved(ev) {
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
const existing = idx >= 0 ? questionsState.pending[idx] : null;
if (idx >= 0) questionsState.pending.splice(idx, 1);
questionsState.history.unshift({
id: ev.id,
asker: existing?.asker || '?',
question: existing?.question || '',
options: existing?.options || [],
multi: existing?.multi || false,
asked_at: existing?.asked_at || ev.answered_at,
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
question_refs: existing?.question_refs || [],
answer_refs: ev.answer_refs || [],
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
renderQuestions();
}
// Filter selection for the questions section. Persisted so the
// operator's preferred view (all / operator-targeted / peer)
// survives a reload.
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
function getQuestionsFilter() {
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
}
function setQuestionsFilter(v) {
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
renderQuestions();
}
function questionMatchesFilter(q, filter) {
if (filter === 'all') return true;
if (filter === 'operator') return !q.target;
if (filter === 'peer') return !!q.target;
// `agent:<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;
}
function renderQuestions() {
const root = $('questions-section');
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const allPending = questionsState.pending;
const activeFilter = getQuestionsFilter();
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
// Filter chips. Always include `all` / `operator` / `peer`; add
// per-agent chips for any agent that appears as asker or target
// in the pending list so the operator can isolate a single
// thread without typing.
const participants = new Set();
for (const q of allPending) {
participants.add(q.asker);
if (q.target) participants.add(q.target);
}
const filterRow = el('div', { class: 'questions-filters' });
const mkChip = (value, label) => {
const b = el('button', {
type: 'button',
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
}, label);
b.addEventListener('click', () => setQuestionsFilter(value));
return b;
};
filterRow.append(
mkChip('all', `all · ${allPending.length}`),
mkChip('operator', '@operator'),
mkChip('peer', '@peer'),
);
for (const name of Array.from(participants).sort()) {
filterRow.append(mkChip('agent:' + name, '@' + name));
}
root.append(filterRow);
if (!pending.length) {
root.append(el('p', { class: 'empty' },
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
}
const ul = el('ul', { class: 'questions' });
for (const q of pending) {
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asks:'),
);
if (q.deadline_at) {
const remaining = q.deadline_at - Math.floor(Date.now() / 1000);
let txt;
if (remaining <= 0) txt = 'expiring…';
else if (remaining < 60) txt = '⏳ ' + remaining + 's';
else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm '
+ (remaining % 60) + 's';
else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h '
+ Math.floor((remaining % 3600) / 60) + 'm';
head.append(' ', el('span', { class: 'q-ttl' }, txt));
}
const qBody = el('div', { class: 'q-body' });
appendLinkified(qBody, q.question, q.question_refs);
li.append(head, qBody);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '', 'data-no-refresh': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('textarea', {
name: 'answer-free', rows: '2', autocomplete: 'off',
placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ ' (shift+enter for newline)',
});
// Enter submits; shift+enter inserts a newline (textarea default).
freeText.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
f.requestSubmit();
}
});
const optionGroup = el('div', { class: 'q-options' });
if (hasOptions) {
for (const opt of q.options) {
const inputType = isMulti ? 'checkbox' : 'radio';
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
const label = el('label', { for: id }, ' ' + opt);
optionGroup.append(el('div', { class: 'q-option' }, input, label));
}
}
// On submit, build the final `answer` field from selected
// options + free-text, joined by ', '. This lets the operator
// pick options AND add free text in the same form.
f.addEventListener('submit', (ev) => {
const parts = [];
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
parts.push(cb.value);
}
const ft = (freeText.value || '').trim();
if (ft) parts.push(ft);
const merged = parts.join(', ');
// Replace the existing hidden `answer` (if any) with the merged value.
const existing = f.querySelector('input[name="answer"]');
if (existing) existing.remove();
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
}, true);
if (hasOptions) f.append(optionGroup);
const buttons = el('div', { class: 'q-buttons' });
// On peer threads the operator's answer is an override —
// mark the button so it's clear what the click does (the
// backend permits it via OperatorQuestions::answer's
// answerer-auth rule).
const answerLabel = q.target
? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
: (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
buttons.append(
el('button', {
type: 'submit',
class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
}, answerLabel),
);
f.append(
el('div', { class: 'q-free' }, freeText),
buttons,
);
li.append(f);
// Separate form so the cancel button doesn't get the answer
// merge-on-submit handler attached to the main form.
const cancelTargetLabel = q.target ? q.target : 'asker';
const cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
);
li.append(cancelForm);
ul.append(li);
}
if (pending.length) root.append(ul);
// Answered question history
const hist = questionsState.history;
if (hist.length) {
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
const hul = el('ul', { class: 'questions questions-answered' });
for (const q of hist) {
const targetLabel = q.target || 'operator';
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
const head = el('div', { class: 'q-head' },
el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
el('span', { class: 'msg-from' }, q.asker), ' ',
el('span', { class: 'msg-sep' }, '→'), ' ',
el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
el('span', { class: 'msg-sep' }, 'asked:'),
);
const histBody = el('div', { class: 'q-body' });
appendLinkified(histBody, q.question, q.question_refs);
const ansText = el('span', { class: 'q-answer-text' });
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
const ansLine = el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
ansText,
);
li.append(head, histBody, ansLine);
hul.append(li);
}
details.append(hul);
root.append(details);
}
}
// ─── operator inbox (derived from the broker message stream) ───────────
// No longer shipped on `/api/state.operator_inbox`. The dashboard
// terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from
// `/dashboard/history` populates on load, live SSE keeps it current.
// Newest-first to match the previous behaviour.
const INBOX_LIMIT = 50;
const operatorInbox = [];
function inboxAppendFromEvent(ev) {
if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
operatorInbox.unshift({
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
return true;
}
function renderInbox() {
const root = $('inbox-section');
if (!root) return;
root.innerHTML = '';
if (!operatorInbox.length) {
root.append(el('p', { class: 'empty' }, 'no messages'));
return;
}
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 li = el('li');
const body = el('span', { class: 'msg-body' });
appendLinkified(body, m.body, m.file_refs);
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
);
ul.append(li);
}
root.append(ul);
}
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,
};
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);
approvalsState.history.unshift({
id: ev.id,
agent: ev.agent,
kind: ev.approval_kind,
sha_short: ev.sha_short || null,
status: ev.status,
resolved_at: ev.resolved_at,
note: ev.note || null,
description: ev.description || null,
});
if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
}
renderApprovals();
}
// Classify each unified-diff line by its leading char so
// `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
// `.diff-ctx` colour the output. Built as text-only spans (no
// innerHTML) so there's no HTML-escape surface.
function buildDiffPre(text) {
const pre = el('pre', { class: 'diff' });
for (const raw of String(text).split('\n')) {
let cls = 'diff-ctx';
if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
else if (raw.startsWith('@')) cls = 'diff-hunk';
else if (raw.startsWith('+')) cls = 'diff-add';
else if (raw.startsWith('-')) cls = 'diff-del';
const span = document.createElement('span');
span.className = cls;
span.textContent = raw + '\n';
pre.appendChild(span);
}
return pre;
}
// Open an approval's diff in the side panel with a 3-way base
// toggle: vs applied (running tree), vs last-approved, vs previous
// proposal. `applied` uses the diff already shipped on the approval
// for instant paint; the other two fetch /api/approval-diff.
function openDiffPanel(a) {
const bases = [
['applied', 'vs applied'],
['approved', 'vs last-approved'],
['previous', 'vs previous proposal'],
];
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'diff-host' });
async function selectBase(base) {
for (const btn of tabs.children) {
btn.classList.toggle('active', btn.dataset.base === base);
}
if (base === 'applied' && a.diff != null) {
host.replaceChildren(buildDiffPre(a.diff));
return;
}
host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
try {
const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
const text = await resp.text();
host.replaceChildren(resp.ok
? buildDiffPre(text)
: el('div', { class: 'meta' }, 'error: ' + text));
} catch (e) {
host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
}
}
for (const [base, label] of bases) {
const btn = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
btn.addEventListener('click', () => selectBase(base));
tabs.append(btn);
}
const wrap = el('div', { class: 'diff-panel' }, tabs, host);
Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
selectBase('applied');
}
function renderApprovals() {
const root = $('approvals-section');
root.innerHTML = '';
// Spawn request form: submitting it queues a Spawn approval that
// lands in this same list, so the form belongs here rather than on
// the containers list (the agent doesn't exist yet).
const spawn = el('form', {
method: 'POST', action: '/request-spawn',
class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
});
spawn.append(
el('input', {
name: 'name',
placeholder: 'new agent name (≤9 chars)',
maxlength: '9', required: '', autocomplete: 'off',
}),
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
);
root.append(spawn);
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const pendingTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
},
`pending · ${pending.length}`,
);
const historyTab = el(
'button',
{
type: 'button',
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
},
`history · ${history.length}`,
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
renderApprovals();
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === 'history') {
renderApprovalHistory(root, history);
return;
}
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
// forge link base — only when the hive-forge container is up.
const fs = window.__hyperhive_state;
const hostname = (fs && fs.hostname) || window.location.hostname;
const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
const ul = el('ul', { class: 'approvals' });
for (const a of pending) {
const isApply = a.kind === 'apply_commit';
const 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' : 'spawn'),
);
if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
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' },
'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' ? '✗'
: '⚠';
row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ',
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ',
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note));
}
ul.append(li);
}
root.append(ul);
}
// Relative time, anchored to now. resolved_at is unix seconds (server-
// authored), so we don't have to worry about client/server clock skew
// for sub-minute precision.
function fmtAgo(unixSecs) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
}
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
if (!root) return;
root.innerHTML = '';
const inputs = s.meta_inputs || [];
if (!inputs.length) {
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
return;
}
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?',
});
const ul = el('ul', { class: 'meta-inputs' });
for (const inp of inputs) {
const li = el('li');
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', {
type: 'checkbox',
name: 'meta_input_' + inp.name,
id,
value: inp.name,
'data-meta-input': inp.name,
});
const label = el('label', { for: id });
label.append(
cb,
el('span', { class: 'meta-input-name' }, inp.name), ' ',
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: '',
}, '◆ UPD4TE & R3BU1LD');
form.append(btn);
function refreshDisabled() {
const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
if (any) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
}
form.addEventListener('change', refreshDisabled);
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) + '…';
}
// ─── reminders ──────────────────────────────────────────────────────────
// Reminders aren't part of /api/state (separate sqlite table, separate
// mutation cadence). Refresh fires alongside refreshState() so a
// cancel POST or a cold load both reflect within the same tick. A
// periodic poll isn't necessary — new reminders are queued by the
// agents themselves and the operator already sees them next time
// they interact with the page.
async function refreshReminders() {
const root = $('reminders-section');
if (!root) return;
try {
const resp = await fetch('/api/reminders');
if (!resp.ok) {
root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
return;
}
const rows = await resp.json();
renderReminders(rows);
} catch (err) {
root.innerHTML = '';
root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
}
}
function renderReminders(rows) {
const root = $('reminders-section');
if (!root) return;
root.innerHTML = '';
if (!rows.length) {
root.append(el('p', { class: 'empty' }, 'no queued reminders'));
return;
}
const ul = el('ul', { class: 'reminders' });
for (const r of rows) {
const failed = (r.attempt_count || 0) > 0;
const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
const dueIn = r.due_at - Math.floor(Date.now() / 1000);
const dueLabel = dueIn <= 0
? `overdue ${fmtAgo(r.due_at)}`
: `in ${fmtDuration(dueIn)}`;
const head = el('div', { class: 'reminder-head' },
el('span', { class: 'agent' }, r.agent), ' ',
el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
' ',
el('span', { class: 'meta' }, `· id ${r.id}`),
);
if (r.file_path) {
head.append(' ', el('span', { class: 'meta' }, '· payload → '));
appendLinkified(head, r.file_path);
}
if (failed) {
head.append(' ', el('span',
{
class: 'badge badge-warn',
title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
},
`${r.attempt_count} failed`));
}
const body = el('div', { class: 'reminder-body' });
appendLinkified(body, r.message);
li.append(head, body);
if (r.last_error) {
li.append(el('div', { class: 'reminder-error' },
el('span', { class: 'msg-sep' }, 'error: '),
r.last_error,
));
}
const actions = el('div', { class: 'reminder-actions' });
if (failed) {
// Retry resets the failure counters so the scheduler picks
// the row up again on its next 5s tick. No data-no-refresh
// — the resulting refreshState re-fires refreshReminders.
const retryForm = el('form', {
method: 'POST', action: '/retry-reminder/' + r.id,
class: 'inline', 'data-async': '',
});
retryForm.append(el('button',
{ type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
actions.append(retryForm);
}
const cancelForm = el('form', {
method: 'POST', action: '/cancel-reminder/' + r.id,
class: 'inline', 'data-async': '',
'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
});
cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
actions.append(cancelForm);
li.append(actions);
ul.append(li);
}
root.append(ul);
}
function fmtDuration(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
}
// ─── 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',
'reminders-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;
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);
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();
syncApprovalsFromSnapshot(s);
renderApprovals();
renderMetaInputs(s);
refreshReminders();
restoreOpenDetails(openDetails);
notifyDeltas(s);
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
// approvals + questions + transients have their own events;
// broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
} catch (err) {
console.error('refreshState failed', err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
NOTIF.bind();
Panel.bind();
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
// (window.HiveTerminal). What stays here is the broker-message
// renderer + the page-local side effects (banner pulse, inbox refresh
// on operator-bound traffic, OS notifications).
(() => {
const flow = $('msgflow');
if (!flow || !window.HiveTerminal) return;
flow.innerHTML = '';
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. Each event
// nudges the shimmer window; if traffic stops, the class falls off
// after the grace timer.
const banner = document.querySelector('.banner');
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
}
function renderMsg(ev, api, glyph) {
const row = api.row('msgrow ' + ev.kind, '');
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement('span');
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
const arrow = document.createElement('span');
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
appendLinkified(body, ev.body, ev.file_refs);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
}
HiveTerminal.create({
logEl: flow,
historyUrl: '/dashboard/history',
streamUrl: '/dashboard/stream',
renderers: {
sent: (ev, api) => renderMsg(ev, api, '→'),
delivered: (ev, api) => renderMsg(ev, api, '✓'),
// Mutation events update derived state and trigger a
// section re-render — no terminal log row (the terminal is
// for broker traffic, not state-change chatter).
approval_added: (ev) => { applyApprovalAdded(ev); },
approval_resolved: (ev) => { applyApprovalResolved(ev); },
question_added: (ev) => { applyQuestionAdded(ev); },
question_resolved: (ev) => { applyQuestionResolved(ev); },
transient_set: (ev) => { applyTransientSet(ev); },
transient_cleared: (ev) => { applyTransientCleared(ev); },
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
container_removed: (ev) => { applyContainerRemoved(ev); },
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
},
// Both history backfill and live frames flow through here, so the
// inbox section ends up populated correctly on first paint and
// updated thereafter — no /api/state refetch needed for inbox
// freshness (which used to be the workaround for the
// double-render bug).
onAnyEvent: (ev /* , { fromHistory } */) => {
if (inboxAppendFromEvent(ev)) renderInbox();
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {
NOTIF.show(
'◆ ' + ev.from + ' → operator',
String(ev.body || '').slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
);
}
},
});
})();
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $('op-compose-input');
const prompt = $('op-compose-prompt');
const suggest = $('op-compose-suggest');
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to';
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
}
function knownAgents() {
// Read live from the derived containers map so newly-spawned
// agents become addressable without an /api/state refetch.
// Broker uses the literal recipient `manager` for the manager's
// inbox, not the container name `hm1nd`.
const names = Array.from(containersState.values())
.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
return names;
}
function autosize() {
input.style.height = 'auto';
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
/// with a known @-mention, otherwise null.
function parseAddressed(raw) {
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
if (!m) return null;
return { to: m[1], body: m[2] };
}
function hideSuggest() {
suggest.hidden = true;
suggest.innerHTML = '';
suggestActive = -1;
}
function renderSuggest(matches) {
suggest.innerHTML = '';
if (!matches.length) { hideSuggest(); return; }
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div');
item.className = 'item' + (i === suggestActive ? ' active' : '');
item.textContent = '@' + matches[i];
item.addEventListener('mousedown', (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
suggest.append(item);
}
suggest.hidden = false;
}
function applySuggestion(name) {
// Replace the partial @-token at the start with the full name.
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
} else {
input.value = `@${name} ` + v;
}
hideSuggest();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autosize();
}
function updateSuggest() {
const v = input.value;
// Only suggest when an @-token sits at the very start of the
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; }
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
if (!matches.length) { hideSuggest(); return; }
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
const raw = input.value.trim();
if (!raw) return;
let to;
let body;
const addressed = parseAddressed(raw);
if (addressed) {
to = addressed.to;
body = addressed.body.trim();
} else if (stickyTo) {
to = stickyTo;
body = raw;
} else {
flashError('no recipient — start with @name to address a message');
return;
}
if (!body) return;
const fd = new FormData();
fd.append('to', to);
fd.append('body', body);
input.disabled = true;
try {
// /op-send now returns 200 (no more 303-to-/). The SSE channel
// carries the resulting MessageEvent → the terminal renders the
// sent row + the inbox updates on its own; no /api/state
// refetch needed.
const resp = await fetch('/op-send', {
method: 'POST',
body: new URLSearchParams(fd),
});
if (!resp.ok) {
flashError(`send failed: http ${resp.status}`);
return;
}
} catch (err) {
flashError(`send failed: ${err}`);
return;
} finally {
input.disabled = false;
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = msg;
flow.insertBefore(row, flow.firstChild);
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
if (!suggest.hidden) {
if (e.key === 'ArrowDown') {
const items = suggest.querySelectorAll('.item');
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
const items = suggest.querySelectorAll('.item');
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const active = suggest.querySelector('.item.active');
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});
renderPrompt();
autosize();
})();
})();