. 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;
}
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);
}
function renderQuestions(s) {
const root = $('questions-section');
root.innerHTML = '';
if (!s.questions || !s.questions.length) {
root.append(el('p', { class: 'empty' }, 'no pending questions'));
return;
}
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'questions' });
for (const q of s.questions) {
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);
}
root.append(ul);
}
function renderInbox(s) {
const root = $('inbox-section');
root.innerHTML = '';
if (!s.operator_inbox || !s.operator_inbox.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 s.operator_inbox) {
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);
}
function renderApprovals(s) {
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);
if (!s.approvals.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
return;
}
const ul = el('ul', { class: 'approvals' });
for (const a of s.approvals) {
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'),
);
}
row.append(
' ',
form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE'),
' ',
form('/deny/' + a.id, 'btn-deny', 'DENY'),
);
li.append(row);
if (a.diff_html) {
const details = el('details', {
'data-restore-key': 'approval-diff:' + a.id,
});
details.append(el('summary', {}, 'diff vs applied'));
// diff_html is pre-rendered server-side (per-line class spans inside
// a ); inject as innerHTML.
const pre = el('pre', { class: 'diff', html: a.diff_html });
details.append(pre);
li.append(details);
}
ul.append(li);
}
root.append(ul);
}
// ─── 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',
];
// 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();
const openDetails = snapshotOpenDetails();
renderContainers(s);
renderTombstones(s);
renderQuestions(s);
renderInbox(s);
renderApprovals(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 SSE ───────────────────────────────────────────────────
(() => {
const flow = $('msgflow');
if (!flow) return;
flow.innerHTML = '';
const es = new EventSource('/messages/stream');
const MAX_ROWS = 200;
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
// Animate the 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);
}
es.onmessage = (e) => {
let m;
try { m = JSON.parse(e.data); } catch { return; }
pulseBanner();
// Live-update the inbox when claude sends to operator + ping
// the OS notification center.
if (m.kind === 'sent' && m.to === 'operator') {
refreshState();
NOTIF.show(
'◆ ' + m.from + ' → operator',
String(m.body || '').slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + m.at + ':' + Math.random().toString(36).slice(2, 6),
);
}
const row = document.createElement('div');
row.className = 'msgrow ' + m.kind;
const kind = m.kind === 'sent' ? '→' : '✓';
row.innerHTML =
'' + tsFmt(m.at) + '' +
'' + kind + '' +
'' + esc(m.from) + '' +
'→' +
'' + esc(m.to) + '' +
'' + esc(m.body) + '';
flow.insertBefore(row, flow.firstChild);
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
};
es.onerror = () => {
flow.insertBefore(Object.assign(document.createElement('div'), {
className: 'msgrow meta', textContent: '[connection lost — retrying]',
}), flow.firstChild);
};
})();
})();