// 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 = {}, 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; }; // ─── 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
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//state/...` in agent // outputs and the link will resolve. const PATH_RE = /(\/var\/lib\/hyperhive\/agents\/[\w.-]+\/state\/[\w./-]+|\/var\/lib\/hyperhive\/shared\/[\w./-]+|\/agents\/[\w.-]+\/state\/[\w./-]+|\/shared\/[\w./-]+)/g; 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; } function makePathPreview(path) { // Inline anchor + a sibling
that lazy-loads the file // on first open. Caller appends both: the anchor inline with the // surrounding text, the details as a block sibling after the // line so the layout doesn't get awkward. const anchor = el('a', { href: '#', class: 'path-link', title: 'click to preview ' + path, }, path); const details = el('details', { class: 'path-preview' }); const summary = el('summary', {}, '↳ ' + path); const pre = el('pre', { class: 'path-preview-body' }, '(fetching…)'); details.append(summary, pre); let fetched = false; async function doFetch() { if (fetched) return; fetched = true; try { pre.textContent = await fetchStateFile(path); } catch (e) { pre.textContent = 'error: ' + (e.message || e); fetched = false; // allow retry on next open } } details.addEventListener('toggle', () => { if (details.open) doFetch(); }); anchor.addEventListener('click', (e) => { e.preventDefault(); details.open = !details.open; }); return { anchor, details }; } // Append `text` to `parent` as a mix of text nodes + path anchors. // Returns the array of generated `
` previews so the // caller can append them as block siblings under the row. function appendLinkified(parent, text) { const previews = []; if (text == null) return previews; const str = String(text); let lastIdx = 0; PATH_RE.lastIndex = 0; let m; while ((m = PATH_RE.exec(str)) !== null) { if (m.index > lastIdx) { parent.appendChild(document.createTextNode(str.slice(lastIdx, m.index))); } const { anchor, details } = makePathPreview(m[0]); parent.appendChild(anchor); previews.push(details); lastIdx = m.index + m[0].length; } if (lastIdx < str.length) { parent.appendChild(document.createTextNode(str.slice(lastIdx))); } return previews; } // ─── 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 = ''; } 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 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'), ); 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}`)); } 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 // keeps the post-submit refetch. 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' }), ); } 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, target: ev.target || 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, target: existing?.target ?? ev.target ?? null, }); 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:` 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' }); const qPreviews = appendLinkified(qBody, q.question); li.append(head, qBody); for (const d of qPreviews) li.appendChild(d); 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('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' }); // 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' }); const histBodyPreviews = appendLinkified(histBody, q.question); const ansText = el('span', { class: 'q-answer-text' }); const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)'); const ansLine = el('div', { class: 'q-answer' }, el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), ansText, ); li.append(head, histBody); for (const d of histBodyPreviews) li.appendChild(d); li.append(ansLine); for (const d of histAnsPreviews) li.appendChild(d); 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'); const body = el('span', { class: 'msg-body' }); const previews = appendLinkified(body, m.body); li.append( el('span', { class: 'msg-ts' }, fmt(m.at)), ' ', el('span', { class: 'msg-from' }, m.from), ' ', el('span', { class: 'msg-sep' }, '→ '), body, ); for (const d of previews) li.appendChild(d); 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': '', '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; } 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-no-refresh': '', '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', null, {}, { noRefresh: true }), ' ', 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) + '…'; } // ─── 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 li = el('li', { class: 'reminder-row' }); 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); } const body = el('div', { class: 'reminder-body' }); const previews = appendLinkified(body, r.message); li.append(head, body); for (const d of previews) li.appendChild(d); // Cancel form omits `data-no-refresh` — the resulting refreshState // re-fires refreshReminders so the row drops on its own. 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')); li.append(cancelForm); 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', ]; //
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(); // Sync transients + containers first so renderContainers below // sees the current derived maps (it reads from // `transientsState` + `containersState`, not from `s.*`). syncTransientsFromSnapshot(s); syncContainersFromSnapshot(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(); // ─── 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'; const previews = appendLinkified(body, ev.body); row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body); for (const d of previews) row.appendChild(d); } 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); }, }, // 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(); })(); })();