// 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) =>
({ '&':'&', '<':'<', '>':'>', '"':'"' }[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 = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
});
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;
};
// ─── 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);
NOTIF.show('◆ manager 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 = ''; });
refreshState();
} catch (err) {
alert('action failed: ' + err);
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
});
// ─── state rendering ────────────────────────────────────────────────────
function renderContainers(s) {
const root = $('containers-section');
root.innerHTML = '';
// 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 (s.port_conflicts && s.port_conflicts.length) {
const banner = el('div', { class: 'port-conflict' },
el('strong', {}, '⚠ port collision'), ' — ');
const groups = s.port_conflicts.map((c) =>
`:${c.port} (${c.agents.join(' + ')})`).join('; ');
banner.append(groups + '. rename one of each and ↻ R3BU1LD.');
root.append(banner);
}
if (s.any_stale) {
root.append(form(
'/update-all', 'btn-rebuild', '↻ UPD4TE 4LL',
'rebuild every stale container?',
));
}
if (s.transients.length) {
const ul = el('ul');
for (const t of s.transients) {
ul.append(el('li', {},
el('span', { class: 'glyph spinner' }, '◐'), ' ',
el('span', { class: 'agent' }, t.name), ' ',
el('span', { class: 'role role-pending' }, t.kind + '…'), ' ',
el('span', { class: 'meta' }, `nixos-container create + start (${t.secs}s)`),
));
}
root.append(ul);
}
if (!s.containers.length && !s.transients.length) {
root.append(el('p', { class: 'empty' }, 'no managed containers'));
return;
}
const ul = el('ul', { class: 'containers' });
for (const c of s.containers) {
const url = `http://${s.hostname}:${c.port}/`;
const li = el('li', { class: 'container-row' + (c.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'),
);
if (c.pending) {
head.append(el('span', { class: 'pending-state' },
el('span', { class: 'spinner' }, '◐'), ' ', c.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.',
));
}
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}`));
}
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 + '?'),
);
if (!c.is_manager) {
actions.append(
form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'),
);
}
} else {
actions.append(
form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'),
);
}
actions.append(
form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
'rebuild ' + c.name + '? hot-reloads the container.'),
);
if (!c.is_manager) {
actions.append(
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
'destroy ' + c.name + '? container is removed; state + creds kept.'),
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
'PURGE ' + c.name + '? container, config history, claude creds, '
+ 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }),
);
}
li.append(actions);
// Per-container journald viewer. Expand to fetch + render the
// last N lines; refresh button re-fetches; unit selector
// narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
li.append(buildJournalDetails(c.container, journalUnit));
// Per-container applied config viewer. Shows the agent.nix
// the container is actually built against.
li.append(buildConfigDetails(c.name));
ul.append(li);
}
root.append(ul);
}
// Build the per-container journald . Lazy-fetches when the
// operator expands; refresh re-fetches; unit toggle switches
// between the harness service and the full machine journal.
function buildJournalDetails(containerName, defaultUnit) {
const details = el('details', {
class: 'journal',
'data-restore-key': 'journal:' + containerName,
});
const summary = el('summary', {}, '↳ logs · ' + containerName);
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
const unitSelect = el('select', { class: 'journal-unit' });
unitSelect.append(
el('option', { value: defaultUnit }, defaultUnit),
el('option', { value: '' }, '(full machine journal)'),
);
const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
'↻ refresh');
const pre = el('pre', { class: 'journal-output' }, 'fetching…');
let fetching = false;
async function fetchLogs() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
const unit = unitSelect.value;
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
try {
const resp = await fetch('/api/journal/' + containerName + '?' + params);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
// Auto-scroll to bottom on fresh fetch.
pre.scrollTop = pre.scrollHeight;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
details.addEventListener('toggle', () => { if (details.open) fetchLogs(); });
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
unitSelect.addEventListener('change', fetchLogs);
controls.append(unitSelect, refresh);
body.append(controls, pre);
details.append(summary, body);
return details;
}
// Per-container applied-config viewer. Lazy-fetches on expand;
// refresh button re-fetches. Read-only — the file is hive-c0re's
// applied repo, mutated only via the approval flow.
function buildConfigDetails(agentName) {
const details = el('details', {
class: 'journal',
'data-restore-key': 'agent-config:' + agentName,
});
const summary = el('summary', {}, '↳ agent.nix · ' + agentName);
const body = el('div', { class: 'journal-body' });
const controls = el('div', { class: 'journal-controls' });
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 fetchConfig() {
if (fetching) return;
fetching = true;
pre.textContent = 'fetching…';
try {
const resp = await fetch('/api/agent-config/' + agentName);
const text = await resp.text();
if (!resp.ok) {
pre.textContent = 'error: ' + resp.status + '\n' + text;
} else {
pre.textContent = text || '(empty)';
pre.scrollTop = 0;
}
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
} finally {
fetching = false;
}
}
details.addEventListener('toggle', () => { if (details.open) fetchConfig(); });
refresh.addEventListener('click', (e) => { e.preventDefault(); fetchConfig(); });
controls.append(refresh);
body.append(controls, pre);
details.append(summary, body);
return details;
}
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, /state/ notes '
+ 'are all WIPED. no undo.',
));
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,
});
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,
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
renderQuestions();
}
function renderQuestions() {
const root = $('questions-section');
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const pending = questionsState.pending;
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'no pending questions'));
}
const ul = el('ul', { class: 'questions' });
for (const q of pending) {
const li = el('li', { class: 'question' });
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' }, '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));
}
li.append(head, el('div', { class: 'q-body' }, q.question));
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '',
});
const hasOptions = q.options && q.options.length;
const isMulti = !!q.multi && hasOptions;
const freeText = el('input', {
type: 'text', name: 'answer-free',
placeholder: hasOptions ? 'or type your own…' : 'your answer',
autocomplete: 'off',
});
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' });
buttons.append(
el('button', { type: 'submit', class: 'btn btn-approve' },
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
);
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 cancelForm = el('form', {
method: 'POST', action: '/cancel-question/' + q.id,
class: 'qform-cancel', 'data-async': '',
'data-confirm': 'cancel this question? manager 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 li = el('li', { class: 'question question-answered' });
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' }, 'asked:'),
);
li.append(
head,
el('div', { class: 'q-body' }, q.question),
el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, 'answer: '),
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
),
);
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 });
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');
li.append(
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
el('span', { class: 'msg-body' }, m.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();
}
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': '',
});
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;
}
const ul = el('ul', { class: 'approvals' });
for (const a of pending) {
const li = el('li');
const row = el('div', { class: 'row' });
if (a.kind === 'apply_commit') {
row.append(
el('span', { class: 'glyph' }, '→'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, 'apply'), ' ',
el('code', {}, a.sha_short || ''),
);
} else {
row.append(
el('span', { class: 'glyph' }, '⊕'), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind kind-spawn' }, 'spawn'), ' ',
el('span', { class: 'meta' },
'new sub-agent — container will be created on approve'),
);
}
if (a.description) {
li.append(el('div', { class: 'approval-description' }, a.description));
}
// 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-prompt': 'reason for denying (optional, sent to manager):',
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
row.append(
' ',
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
' ',
denyForm,
);
li.append(row);
if (a.diff) {
const details = el('details', {
'data-restore-key': 'approval-diff:' + a.id,
});
details.append(el('summary', {}, 'diff vs applied'));
// Server ships the raw unified diff; classify each line by its
// leading char so `.diff-add` / `.diff-del` / `.diff-hunk` /
// `.diff-file` / `.diff-ctx` colour the output. Building spans
// here (instead of innerHTML-ing pre-rendered markup) keeps
// the snapshot wire format text-only and one less HTML-escape
// surface server-side.
const pre = el('pre', { class: 'diff' });
for (const raw of a.diff.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);
}
details.append(pre);
li.append(details);
}
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': '',
'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) + '…';
}
// ─── 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',
];
// 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. The `toggle` event fires on
// programmatic open changes too, so any onload-fetch wired up via
// a toggle listener (e.g. journald) re-fires cleanly.
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();
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);
restoreOpenDetails(openDetails);
notifyDeltas(s);
// Auto-refresh: fast (2s) while a spawn or a per-container
// action is in flight, otherwise heartbeat (5s) so newly-queued
// approvals from the manager show up without the operator
// having to reload the page. Broker SSE already triggers a
// refresh on operator-bound messages; this catches the rest
// (approvals, tombstones, questions).
const anyPending = s.containers.some((c) => c.pending);
const next = (s.transients.length || anyPending) ? 2000 : 5000;
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (next) pollTimer = setTimeout(refreshState, next);
} catch (err) {
console.error('refreshState failed', err);
pollTimer = setTimeout(refreshState, 5000);
}
}
refreshState();
NOTIF.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 el = api.row('msgrow ' + ev.kind, '');
el.innerHTML =
'' + tsFmt(ev.at) + '' +
'' + glyph + '' +
'' + esc(ev.from) + '' +
'→' +
'' + esc(ev.to) + '' +
'' + esc(ev.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); },
},
// 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() {
const s = window.__hyperhive_state;
if (!s || !Array.isArray(s.containers)) return [];
// The broker uses the literal recipient `manager` for the
// manager's inbox, not the container name `hm1nd`. Swap on
// suggestion so `@manager` Just Works.
const names = s.containers.map((c) => (c.is_manager ? 'manager' : c.name));
// `*` fans out the message to every registered agent (server-side
// broadcast_send). Surface it as a suggestion so operators can
// type `@*` from the dashboard the same way the manager does.
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();
})();
})();