/state/...` in agent
+ // outputs and the link will resolve.
+ async function fetchStateFile(path) {
+ const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
+ const text = await resp.text();
+ if (!resp.ok) throw new Error(text || ('HTTP ' + resp.status));
+ return text;
+ }
+ // A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
+ // `renderRendered()` produces the rendered-tab node fresh on each
+ // switch; `plainText` backs the raw tab; `plainLabel` names it.
+ function buildTabbedPreview(renderRendered, plainText, plainLabel) {
+ const tabs = el('div', { class: 'diff-base-tabs' });
+ const host = el('div', { class: 'preview-host' });
+ function show(mode) {
+ for (const b of tabs.children) {
+ b.classList.toggle('active', b.dataset.mode === mode);
+ }
+ host.replaceChildren(mode === 'plain'
+ ? el('pre', { class: 'path-preview-body' }, plainText)
+ : renderRendered());
+ }
+ for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
+ const b = el('button',
+ { type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
+ b.addEventListener('click', () => show(mode));
+ tabs.append(b);
+ }
+ show('rendered');
+ return el('div', {}, tabs, host);
+ }
+ // Rendered for an SVG, loaded via an data: URI —
+ // -loaded SVG runs in the browser's secure static mode (no
+ // scripts, no external fetches), so an untrusted SVG from an
+ // agent's state dir can't execute code in the dashboard.
+ function svgImage(text) {
+ const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
+ img.addEventListener('error', () => {
+ img.replaceWith(el('div', { class: 'meta' },
+ '(could not render — see the source tab)'));
+ });
+ img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
+ return img;
+ }
+ // Marked-rendered markdown node (raw text fallback if `marked`
+ // failed to load).
+ function mdNode(text) {
+ const div = el('div', { class: 'md' });
+ if (window.marked && typeof window.marked.parse === 'function') {
+ marked.setOptions({ breaks: true, gfm: true });
+ div.innerHTML = marked.parse(text);
+ // marked autolinks URLs but leaves them same-tab — open externally
+ // so a click never navigates away from the dashboard. (issue #233)
+ div.querySelectorAll('a[href]').forEach((a) => {
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ });
+ } else {
+ div.textContent = text;
+ }
+ return div;
+ }
+ // Raster image extensions the preview renders as an pointed
+ // straight at /api/state-file (served binary with a real
+ // content-type). SVG is handled on the text path instead.
+ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
+ // Lazy-load `path` from /api/state-file into the side panel.
+ // Markdown + SVG get a rendered/plain tabbed view; raster images
+ // render as an ; every other file stays raw text in a .
+ async function openFilePanel(path) {
+ if (RASTER_RE.test(path)) {
+ const img = el('img', { class: 'img-preview', alt: path });
+ img.addEventListener('error', () => {
+ img.replaceWith(el('pre', { class: 'path-preview-body' },
+ '(could not load image — it may be missing or over the preview size cap)'));
+ });
+ img.src = '/api/state-file?path=' + encodeURIComponent(path);
+ Panel.open('↳ ' + path, img);
+ return;
+ }
+ const isMd = /\.(md|markdown)$/i.test(path);
+ const isSvg = /\.svg$/i.test(path);
+ const view = el('div');
+ view.textContent = '(fetching…)';
+ Panel.open('↳ ' + path, view);
+ try {
+ const text = await fetchStateFile(path);
+ if (isSvg) {
+ view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
+ } else if (isMd) {
+ view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
+ } else {
+ view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
+ }
+ } catch (e) {
+ view.textContent = 'error: ' + (e.message || e);
+ }
+ }
+ function makePathLink(path) {
+ const anchor = el('a', {
+ href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
+ }, path);
+ anchor.addEventListener('click', (e) => {
+ e.preventDefault();
+ openFilePanel(path);
+ });
+ return anchor;
+ }
+ // Append `text` to `parent` as a mix of text nodes + path anchors.
+ // `refs` is the server-attached `file_refs` array (verified-file
+ // tokens that appear in `text`); each occurrence of a ref becomes a
+ // clickable anchor that opens the file in the side panel. Anything
+ // not in `refs` stays plain text. No client-side regex, no probe
+ // endpoint — the server saw the body first and made the call. When
+ // `refs` is empty/missing we just emit plain text.
+ // Append a plain-text run, with bare http(s) URLs turned into clickable
+ // links via the shared terminal linkifier. Falls back to a plain text
+ // node if the terminal module hasn't loaded. (issue #233)
+ function appendText(parent, s) {
+ if (!s) return;
+ if (window.HiveTerminal && typeof HiveTerminal.linkify === 'function') {
+ parent.appendChild(HiveTerminal.linkify(s));
+ } else {
+ parent.appendChild(document.createTextNode(s));
+ }
+ }
+ function appendLinkified(parent, text, refs) {
+ if (text == null) return;
+ const str = String(text);
+ const tokens = (refs || []).slice();
+ if (!tokens.length) {
+ appendText(parent, str);
+ return;
+ }
+ // Walk the string left-to-right, at each step looking for the
+ // next occurrence of any token. Longest-first tie-break so a
+ // ref like `/agents/foo/state/x.md` wins over a (hypothetical)
+ // shorter token that prefixes it. O(text * refs) worst case;
+ // refs is bounded server-side to whatever fits in a body, so
+ // this stays cheap.
+ tokens.sort((a, b) => b.length - a.length);
+ let i = 0;
+ while (i < str.length) {
+ let bestStart = -1;
+ let bestToken = null;
+ for (const t of tokens) {
+ const idx = str.indexOf(t, i);
+ if (idx === -1) continue;
+ if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
+ bestStart = idx;
+ bestToken = t;
+ }
+ }
+ if (bestStart === -1) {
+ appendText(parent, str.slice(i));
+ break;
+ }
+ if (bestStart > i) {
+ appendText(parent, str.slice(i, bestStart));
+ }
+ parent.appendChild(makePathLink(bestToken));
+ i = bestStart + bestToken.length;
+ }
+ }
+
+ // ─── browser notifications ──────────────────────────────────────────────
+ // Fires OS notifications on three operator-bound signals:
+ // - new approval landed in the queue
+ // - new operator question queued (ask, target IS NULL)
+ // - broker message sent `to: "operator"`
+ // permission grant is per-browser; a localStorage "muted" toggle lets
+ // the operator silence without revoking. Secure-context only (HTTPS /
+ // localhost) — on other origins the API is unavailable and we hide
+ // the controls.
+ const NOTIF = (() => {
+ const supported = typeof Notification !== 'undefined';
+ const MUTED_KEY = 'hyperhive.notify.muted';
+ const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
+ const setMuted = (v) => v
+ ? localStorage.setItem(MUTED_KEY, '1')
+ : localStorage.removeItem(MUTED_KEY);
+ function renderControls() {
+ const enable = $('notif-enable');
+ const mute = $('notif-mute');
+ const unmute = $('notif-unmute');
+ const status = $('notif-status');
+ if (!enable || !mute || !unmute || !status) return;
+ if (!supported) {
+ enable.hidden = mute.hidden = unmute.hidden = true;
+ status.hidden = false;
+ status.textContent = 'notifications unsupported in this browser';
+ return;
+ }
+ const perm = Notification.permission;
+ enable.hidden = perm === 'granted';
+ mute.hidden = perm !== 'granted' || isMuted();
+ unmute.hidden = perm !== 'granted' || !isMuted();
+ status.hidden = perm !== 'denied';
+ if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
+ }
+ function bind() {
+ const enable = $('notif-enable');
+ const mute = $('notif-mute');
+ const unmute = $('notif-unmute');
+ if (!supported || !enable || !mute || !unmute) return;
+ enable.addEventListener('click', async () => {
+ await Notification.requestPermission();
+ renderControls();
+ });
+ mute.addEventListener('click', () => { setMuted(true); renderControls(); });
+ unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
+ renderControls();
+ }
+ function show(title, body, tag) {
+ if (!supported) {
+ console.debug('notify: Notification API not supported');
+ return;
+ }
+ if (Notification.permission !== 'granted') {
+ console.debug('notify: permission not granted', Notification.permission);
+ return;
+ }
+ if (isMuted()) {
+ console.debug('notify: muted');
+ return;
+ }
+ try {
+ // Per-event tag so distinct messages stack instead of
+ // collapsing into one slot. Caller passes a unique tag per
+ // notification kind/id; we don't fall back to 'hyperhive'
+ // because that one tag would replace itself on every fire.
+ const n = new Notification(title, {
+ body,
+ tag: tag || ('hyperhive:' + Date.now()),
+ });
+ n.onclick = () => { window.focus(); n.close(); };
+ console.debug('notify: shown', title, 'tag=', tag);
+ } catch (err) {
+ console.warn('notification show failed', err);
+ }
+ }
+ return { bind, show, renderControls };
+ })();
+
+ // Track which items we've already notified about so a re-render
+ // doesn't re-fire for the same row. Keyed by stable ids; reset only
+ // when the page reloads.
+ const seenApprovals = new Set();
+ const seenQuestions = new Set();
+ let seededNotify = false;
+
+ function notifyDeltas(s) {
+ const approvals = s.approvals || [];
+ const questions = s.questions || [];
+ if (!seededNotify) {
+ // First render after page load — fill the "seen" sets without
+ // firing notifications. We only want to notify on NEW items
+ // that arrived while the page is open. The inbox no longer
+ // needs seeding here: it's derived from the broker stream which
+ // does its own per-event notification on live arrival, and
+ // history-replayed events are silent by virtue of `fromHistory`.
+ for (const a of approvals) seenApprovals.add(a.id);
+ for (const q of questions) seenQuestions.add(q.id);
+ seededNotify = true;
+ return;
+ }
+ for (const a of approvals) {
+ if (seenApprovals.has(a.id)) continue;
+ seenApprovals.add(a.id);
+ const verb = a.kind === 'spawn' ? 'spawn approval'
+ : a.kind === 'init_config' ? 'config-init approval'
+ : 'config commit';
+ NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
+ 'hyperhive:approval:' + a.id);
+ }
+ for (const q of questions) {
+ if (seenQuestions.has(q.id)) continue;
+ seenQuestions.add(q.id);
+ const targetLabel = q.target || 'operator';
+ NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
+ q.question.slice(0, 120),
+ 'hyperhive:question:' + q.id);
+ }
+ }
+
+ // ─── async forms ────────────────────────────────────────────────────────
+ document.addEventListener('submit', async (e) => {
+ const f = e.target;
+ if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
+ e.preventDefault();
+ if (f.dataset.confirm && !confirm(f.dataset.confirm)) return;
+ if (f.dataset.prompt) {
+ const ans = prompt(f.dataset.prompt, '');
+ if (ans === null) return; // operator hit Cancel
+ // Drop into a hidden input named after `data-prompt-field` (or
+ // 'note' by default) so the value rides along on the POST.
+ const field = f.dataset.promptField || 'note';
+ let input = f.querySelector(`input[name="${field}"]`);
+ if (!input) {
+ input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = field;
+ f.append(input);
+ }
+ input.value = ans;
+ }
+ const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
+ const original = btn ? btn.innerHTML : '';
+ if (btn) { btn.disabled = true; btn.innerHTML = '◐ '; }
+ try {
+ const resp = await fetch(f.action, {
+ method: f.method || 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams(new FormData(f)),
+ redirect: 'manual',
+ });
+ const ok = resp.ok || resp.type === 'opaqueredirect'
+ || (resp.status >= 200 && resp.status < 400);
+ if (!ok) {
+ const text = await resp.text().catch(() => '');
+ alert('action failed: ' + resp.status + (text ? '\n\n' + text : ''));
+ if (btn) { btn.disabled = false; btn.innerHTML = original; }
+ return;
+ }
+ // Re-enable the button — refreshState() rebuilds most lists but
+ // skips forms that didn't change (e.g. the spawn form), so without
+ // this the spinner sticks and the button can't be clicked again.
+ if (btn) { btn.disabled = false; btn.innerHTML = original; }
+ // Clear text inputs whose value was just submitted.
+ f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
+ // Forms whose endpoint already emits a DashboardEvent that
+ // updates the derived store can opt out of the post-submit
+ // /api/state refetch (the event delivers the new row faster
+ // than the snapshot poll anyway). Container-lifecycle forms
+ // still rely on the refresh since `ContainerView` isn't yet
+ // event-derivable.
+ if (!f.hasAttribute('data-no-refresh')) {
+ refreshState();
+ }
+ } catch (err) {
+ alert('action failed: ' + err);
+ if (btn) { btn.disabled = false; btn.innerHTML = original; }
+ }
+ });
+
+ // Derived container state — cold-loaded from /api/state.containers,
+ // then mutated live by `container_state_changed` (upsert by name)
+ // and `container_removed` (drop by name). The coordinator's rescan
+ // helper fires these after every mutation site + on a periodic poll
+ // in crash_watch. Keyed by ContainerView.name so the lifecycle
+ // forms' POST → 200 → matching event flips the row without a
+ // snapshot refetch.
+ const containersState = new Map();
+ function syncContainersFromSnapshot(s) {
+ containersState.clear();
+ for (const c of s.containers || []) containersState.set(c.name, c);
+ }
+ function applyContainerStateChanged(ev) {
+ if (!ev.container || !ev.container.name) return;
+ containersState.set(ev.container.name, ev.container);
+ renderContainersFromState();
+ }
+ function applyContainerRemoved(ev) {
+ if (containersState.delete(ev.name)) renderContainersFromState();
+ }
+
+ // Derived tombstones + meta_inputs. Both are emitted as full
+ // snapshots (not diffs) — the lists are tiny and recomputing
+ // avoids ordering races between a same-tick destroy + purge.
+ let tombstonesState = [];
+ let metaInputsState = [];
+ // True while a dashboard-triggered meta-update (flake lock bump +
+ // agent rebuild ripple) runs in the background. Cold-loaded from
+ // `s.meta_update_running`, then flipped live by the
+ // `meta_update_running` event. Drives the META INPUTS panel's
+ // disabled "updating…" state (issue #259).
+ let metaUpdateRunning = false;
+ function syncTombstonesFromSnapshot(s) {
+ tombstonesState = (s.tombstones || []).slice();
+ }
+ function syncMetaInputsFromSnapshot(s) {
+ metaInputsState = (s.meta_inputs || []).slice();
+ metaUpdateRunning = !!s.meta_update_running;
+ }
+ function applyTombstonesChanged(ev) {
+ tombstonesState = (ev.tombstones || []).slice();
+ renderTombstonesFromState();
+ }
+ function applyMetaInputsChanged(ev) {
+ metaInputsState = (ev.inputs || []).slice();
+ renderMetaInputsFromState();
+ }
+ function applyMetaUpdateRunning(ev) {
+ metaUpdateRunning = !!ev.running;
+ renderMetaInputsFromState();
+ }
+ function renderTombstonesFromState() {
+ renderTombstones({ tombstones: tombstonesState });
+ }
+ function renderMetaInputsFromState() {
+ renderMetaInputs({ meta_inputs: metaInputsState });
+ }
+
+ // Derived rebuild queue state — cold-loaded from
+ // `/api/state.rebuild_queue`, then mutated live by the
+ // `rebuild_queue_changed` snapshot event. Same shape as the meta-
+ // inputs panel (full snapshot per change, no diff).
+ let rebuildQueueState = [];
+ function syncRebuildQueueFromSnapshot(s) {
+ rebuildQueueState = (s.rebuild_queue || []).slice();
+ }
+ function applyRebuildQueueChanged(ev) {
+ rebuildQueueState = (ev.queue || []).slice();
+ renderRebuildQueueFromState();
+ }
+ function renderRebuildQueueFromState() {
+ renderRebuildQueue({ rebuild_queue: rebuildQueueState });
+ }
+
+ // Derived transient state — cold-loaded from /api/state.transients,
+ // then mutated live by `transient_set` / `transient_cleared`. Keyed
+ // by agent name so add/remove are O(1). `since_unix` is wall-clock so
+ // the elapsed-seconds badge ticks without polling.
+ const transientsState = new Map();
+ function syncTransientsFromSnapshot(s) {
+ transientsState.clear();
+ for (const t of s.transients || []) {
+ // Snapshot ships `secs` (server-computed); reconstruct an
+ // approximate since_unix so the live ticker keeps progressing
+ // without surprising jumps when the next snapshot lands.
+ const nowUnix = Math.floor(Date.now() / 1000);
+ transientsState.set(t.name, {
+ kind: t.kind,
+ since_unix: t.since_unix ?? (nowUnix - (t.secs || 0)),
+ });
+ }
+ }
+ function applyTransientSet(ev) {
+ transientsState.set(ev.name, {
+ kind: ev.transient_kind,
+ since_unix: ev.since_unix,
+ });
+ renderContainersFromState();
+ }
+ function applyTransientCleared(ev) {
+ if (transientsState.delete(ev.name)) renderContainersFromState();
+ }
+ // Re-render using the last cached snapshot (containers come from
+ // /api/state, transients overlay from the derived map). The snapshot
+ // is stashed on window.__hyperhive_state by refreshState; on cold
+ // load before the first snapshot we just skip.
+ function renderContainersFromState() {
+ const s = window.__hyperhive_state;
+ if (s) renderContainers(s);
+ }
+
+ // 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' : '') });
+
+ // Full-height square agent icon, left of the card body. The
+ // icon is an absolutely positioned inside a wrapper div:
+ // the div is the flex child and sizes itself via aspect-ratio +
+ // stretch, the is out of flow so its load state — pending,
+ // loaded or broken — can never contribute intrinsic size or
+ // reflow the row. (issue #177)
+ //
+ // The icon points straight at the agent's `/icon`. We don't
+ // guess whether the agent is reachable from the container row —
+ // we just let the try, and if it actually fails to load
+ // (agent stopped, restarting, rebuilding — web server not
+ // answering) the error handler falls it back to the dimmed
+ // hyperhive mark (`/favicon.svg`, served by the dashboard
+ // itself, always reachable). (issues #195, #202)
+ const iconImg = el('img', { class: 'container-icon-img', src: `${url}icon`, alt: '' });
+ const icon = el('div', { class: 'container-icon' }, iconImg);
+ iconImg.addEventListener('error', () => {
+ if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s
+ iconImg.dataset.fallback = '1';
+ icon.classList.add('icon-unreachable');
+ iconImg.src = '/favicon.svg';
+ });
+ // Card body: the three stacked content lines, right of the icon.
+ const body = el('div', { class: 'card-body' });
+
+ // ── identity ─────────────────────────────────────────────────
+ const head = el('div', { class: 'head' });
+ head.append(
+ el('a', { class: 'name', href: url, target: '_blank', rel: 'noopener' }, c.name),
+ el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' },
+ c.is_manager ? 'm1nd' : 'ag3nt'),
+ );
+ // Icon-only nav strip — populated async from `/api/agent/{name}/links`,
+ // a same-origin proxy that forwards the agent backend's own link list
+ // (stats / screen-if-gui / forge profile / agent-configs / extras).
+ // The agent backend is the single source of truth; no hardcoded link
+ // list here (issue #262). DOM-built — link strings come from the
+ // agent's process and must never reach the HTML parser.
+ const navStrip = el('span', { class: 'nav-strip' });
+ head.append(navStrip);
+ const forgeBase = `http://${hostname}:3000`;
+ const containerBase = `http://${hostname}:${c.port}`;
+ fetch(`/api/agent/${encodeURIComponent(c.name)}/links`)
+ .then((r) => (r.ok ? r.json() : []))
+ .then((links) => {
+ if (!Array.isArray(links)) return;
+ for (const lnk of links) {
+ const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '')
+ : lnk.kind === 'external' ? (lnk.url || '')
+ : /* container */ containerBase + (lnk.url || '');
+ const a = el('a', {
+ class: 'nav-link',
+ href,
+ target: '_blank',
+ rel: 'noopener',
+ title: lnk.label || '',
+ });
+ // Plain text — agent-controlled strings stay out of innerHTML.
+ a.textContent = lnk.icon || lnk.label || '';
+ navStrip.append(a);
+ }
+ })
+ .catch(() => { /* graceful: agent down → no strip */ });
+ if (pending) {
+ head.append(el('span', { class: 'pending-state' },
+ el('span', { class: 'spinner' }, '◐'), ' ', pending + '…'));
+ } else if (c.rate_limited) {
+ head.append(el('span',
+ { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' },
+ '⊘ rate limited'));
+ } else if (c.needs_login) {
+ head.append(el('a',
+ { class: 'badge badge-warn', href: url, target: '_blank', rel: 'noopener' },
+ 'needs login →'));
+ }
+ if (c.needs_update) {
+ head.append(form(
+ '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻',
+ 'rebuild ' + c.name + '? hot-reloads the container.',
+ {}, { noRefresh: true },
+ ));
+ }
+ head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`));
+ if (c.deployed_sha) {
+ head.append(el('span',
+ { class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
+ `deployed:${c.deployed_sha}`));
+ }
+ if (c.pending_reminders && c.pending_reminders > 0) {
+ head.append(el('span',
+ {
+ class: 'badge badge-reminder',
+ title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
+ },
+ `⏰ ${c.pending_reminders}`));
+ }
+ if (c.ctx_tokens != null) {
+ const k = Math.round(c.ctx_tokens / 1000);
+ // Thresholds track the model's real context window when the
+ // backend supplies it; otherwise fall back to fixed constants.
+ const win = c.context_window_tokens;
+ const warn = win != null ? win * CTX_WARN_FRACTION : CTX_WARN_TOKENS;
+ const caution = win != null ? win * CTX_CAUTION_FRACTION : CTX_CAUTION_TOKENS;
+ const ctxClass = c.ctx_tokens >= warn ? 'badge-ctx-warn'
+ : c.ctx_tokens >= caution ? 'badge-ctx-caution'
+ : 'badge-ctx-ok';
+ const title = win != null
+ ? `last turn context: ${c.ctx_tokens.toLocaleString()} / ${win.toLocaleString()} `
+ + `tokens (${Math.round((c.ctx_tokens / win) * 100)}% of the window)`
+ : `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`;
+ head.append(el('span',
+ { class: `badge ${ctxClass}`, title },
+ `ctx·${k}k`));
+ }
+ body.append(head);
+
+ // ── agent status text ─────────────────────────────────────────
+ if (c.status_text) {
+ const nowUnix = Math.floor(Date.now() / 1000);
+ const ageStr = c.status_set_at != null
+ ? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';
+ body.append(el('div', {
+ class: 'agent-status',
+ title: `agent self-reported status${ageStr}`,
+ },
+ el('span', { class: 'status-icon' }, '◈ '),
+ c.status_text,
+ el('span', { class: 'status-age' }, ageStr),
+ ));
+ }
+
+ // ── action buttons ───────────────────────────────────────────
+ const actions = el('div', { class: 'actions' });
+ if (c.running) {
+ actions.append(
+ form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT',
+ 'restart ' + c.name + '?', {}, { noRefresh: true }),
+ );
+ if (!c.is_manager) {
+ actions.append(
+ form('/kill/' + c.name, 'btn-stop', '■ ST0P',
+ 'stop ' + c.name + '?', {}, { noRefresh: true }),
+ );
+ }
+ } else {
+ actions.append(
+ form('/start/' + c.name, 'btn-start', '▶ ST4RT',
+ 'start ' + c.name + '?', {}, { noRefresh: true }),
+ );
+ }
+ actions.append(
+ form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD',
+ 'rebuild ' + c.name + '? hot-reloads the container.',
+ {}, { noRefresh: true }),
+ );
+ if (!c.is_manager) {
+ // DESTR0Y is event-covered (ContainerRemoved); PURG3 also
+ // wipes tombstone state which isn't event-derived yet, so it
+ // Both event-covered now (ContainerRemoved +
+ // TombstonesChanged); no /api/state refetch needed.
+ actions.append(
+ form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
+ 'destroy ' + c.name + '? container is removed; state + creds kept.',
+ {}, { noRefresh: true }),
+ form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
+ 'PURGE ' + c.name + '? container, config history, claude creds, '
+ + 'and notes are all WIPED. no undo.',
+ { purge: 'on' }, { noRefresh: true }),
+ );
+ }
+ body.append(actions);
+
+ // ── drill-ins ────────────────────────────────────────────────
+ const drill = el('div', { class: 'drill-ins' });
+ // Per-container journald viewer. Opens the side panel and
+ // fetches the last N lines; refresh re-fetches; unit selector
+ // narrows to the harness service (or empty = full machine).
+ const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
+ drill.append(buildJournalTrigger(c.container, journalUnit));
+ // The hardcoded config-repo trigger and the agent-declared
+ // extras block both moved into the unified nav strip in the
+ // head row above (sourced from the agent backend via
+ // `/api/agent/{name}/links` — issue #262). Only the journald
+ // trigger stays here since it opens the side panel rather
+ // than a link.
+ body.append(drill);
+
+ li.append(icon, body);
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
+ // Per-container journald viewer. Returns an inline trigger; the
+ // click opens the side panel and fetches the last N lines. Refresh
+ // re-fetches; the unit toggle switches between the harness service
+ // and the full machine journal.
+ function buildJournalTrigger(containerName, defaultUnit) {
+ const trigger = el('button', { type: 'button', class: 'panel-trigger' },
+ '↳ logs · ' + containerName);
+ trigger.addEventListener('click', () => {
+ const body = el('div', { class: 'journal-body' });
+ const controls = el('div', { class: 'journal-controls' });
+ const unitSelect = el('select', { class: 'journal-unit' });
+ unitSelect.append(
+ el('option', { value: defaultUnit }, defaultUnit),
+ el('option', { value: '' }, '(full machine journal)'),
+ );
+ const refresh = el('button', { type: 'button', class: 'btn btn-restart journal-refresh' },
+ '↻ refresh');
+ const pre = el('pre', { class: 'journal-output' }, 'fetching…');
+ let fetching = false;
+ async function fetchLogs() {
+ if (fetching) return;
+ fetching = true;
+ pre.textContent = 'fetching…';
+ const unit = unitSelect.value;
+ const params = new URLSearchParams({ lines: '500' });
+ if (unit) params.set('unit', unit);
+ try {
+ const resp = await fetch('/api/journal/' + containerName + '?' + params);
+ const text = await resp.text();
+ if (!resp.ok) {
+ pre.textContent = 'error: ' + resp.status + '\n' + text;
+ } else {
+ pre.textContent = text || '(empty)';
+ // Auto-scroll the panel to the newest lines on fresh fetch.
+ const sb = $('side-panel-body');
+ if (sb) sb.scrollTop = sb.scrollHeight;
+ }
+ } catch (err) {
+ pre.textContent = 'fetch failed: ' + err;
+ } finally {
+ fetching = false;
+ }
+ }
+ refresh.addEventListener('click', (e) => { e.preventDefault(); fetchLogs(); });
+ unitSelect.addEventListener('change', fetchLogs);
+ controls.append(unitSelect, refresh);
+ body.append(controls, pre);
+ Panel.open('logs · ' + containerName, body);
+ fetchLogs();
+ });
+ return trigger;
+ }
+
+ function renderTombstones(s) {
+ const root = $('tombstones-section');
+ root.innerHTML = '';
+ if (!s.tombstones || !s.tombstones.length) {
+ root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
+ return;
+ }
+ const fmtBytes = (n) => {
+ if (n < 1024) return n + ' B';
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
+ if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
+ return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
+ };
+ const fmtAge = (ts) => {
+ if (!ts) return '?';
+ const d = Math.floor((Date.now() / 1000 - ts) / 86400);
+ if (d <= 0) return 'today';
+ if (d === 1) return '1 day ago';
+ return d + ' days ago';
+ };
+ const ul = el('ul', { class: 'containers' });
+ for (const t of s.tombstones) {
+ const li = el('li', { class: 'container-row tombstone' });
+ const head = el('div', { class: 'head' });
+ head.append(
+ el('span', { class: 'name' }, t.name),
+ el('span', { class: 'badge badge-muted' }, 'destroyed'),
+ );
+ if (t.has_creds) {
+ head.append(el('span', { class: 'badge badge-muted' }, 'creds kept'));
+ }
+ head.append(el('span', { class: 'meta' },
+ `${fmtBytes(t.state_bytes)} · ${fmtAge(t.last_seen)}`));
+ li.append(head);
+
+ const actions = el('div', { class: 'actions' });
+ // Reuse the existing spawn form pattern via /request-spawn — operator
+ // can queue an approval that recreates the agent with the same name
+ // and reuses the kept state.
+ const respawn = el('form', {
+ method: 'POST', action: '/request-spawn',
+ class: 'inline', 'data-async': '',
+ 'data-confirm': 'queue spawn approval for ' + t.name + '? state will be reused.',
+ });
+ respawn.append(
+ el('input', { type: 'hidden', name: 'name', value: t.name }),
+ el('button', { type: 'submit', class: 'btn btn-start' }, '⊕ R3V1V3'),
+ );
+ actions.append(respawn);
+ actions.append(form(
+ '/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
+ 'PURGE ' + t.name + '? config history, claude creds, '
+ + 'and notes are all WIPED. no undo.',
+ {}, { noRefresh: true },
+ ));
+ li.append(actions);
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
+ // Derived question state — cold-loaded from /api/state, then mutated
+ // live by `question_added` / `question_resolved` dashboard events.
+ const QUESTION_HISTORY_LIMIT = 20;
+ const questionsState = { pending: [], history: [] };
+ function syncQuestionsFromSnapshot(s) {
+ questionsState.pending = (s.questions || []).slice();
+ questionsState.history = (s.question_history || []).slice();
+ }
+ function applyQuestionAdded(ev) {
+ if (questionsState.pending.some((q) => q.id === ev.id)) return;
+ questionsState.pending.push({
+ id: ev.id,
+ asker: ev.asker,
+ question: ev.question,
+ options: ev.options || [],
+ multi: !!ev.multi,
+ asked_at: ev.asked_at,
+ deadline_at: ev.deadline_at ?? null,
+ target: ev.target || null,
+ question_refs: ev.question_refs || [],
+ });
+ renderQuestions();
+ }
+ function applyQuestionResolved(ev) {
+ const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
+ const existing = idx >= 0 ? questionsState.pending[idx] : null;
+ if (idx >= 0) questionsState.pending.splice(idx, 1);
+ // Idempotent: a snapshot re-sync (issue #163) can carry this same
+ // answered row in `question_history` while a live event also
+ // delivers it — guard the unshift so history can't double a row.
+ if (!questionsState.history.some((h) => h.id === ev.id)) {
+ questionsState.history.unshift({
+ id: ev.id,
+ asker: existing?.asker || '?',
+ question: existing?.question || '',
+ options: existing?.options || [],
+ multi: existing?.multi || false,
+ asked_at: existing?.asked_at || ev.answered_at,
+ answered_at: ev.answered_at,
+ answer: ev.answer,
+ answerer: ev.answerer,
+ target: existing?.target ?? ev.target ?? null,
+ question_refs: existing?.question_refs || [],
+ answer_refs: ev.answer_refs || [],
+ });
+ if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
+ questionsState.history.length = QUESTION_HISTORY_LIMIT;
+ }
+ }
+ renderQuestions();
+ }
+ // Filter selection for the questions section. Persisted so the
+ // operator's preferred view (all / operator-targeted / peer)
+ // survives a reload.
+ const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
+ function getQuestionsFilter() {
+ return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
+ }
+ function setQuestionsFilter(v) {
+ localStorage.setItem(QUESTIONS_FILTER_KEY, v);
+ renderQuestions();
+ }
+ function questionMatchesFilter(q, filter) {
+ if (filter === 'all') return true;
+ if (filter === 'operator') return !q.target;
+ if (filter === 'peer') return !!q.target;
+ // `agent:` 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) {
+ // Tag the chip with its deadline so the global 1s ticker
+ // (set up just below this function) can refresh the text
+ // without re-rendering the whole questions section
+ // (issue #335).
+ const ttlEl = el('span', {
+ class: 'q-ttl', 'data-deadline': String(q.deadline_at),
+ });
+ ttlEl.textContent = formatTtl(
+ q.deadline_at - Math.floor(Date.now() / 1000),
+ );
+ head.append(' ', ttlEl);
+ }
+ const qBody = el('div', { class: 'q-body' });
+ appendLinkified(qBody, q.question, q.question_refs);
+ li.append(head, qBody);
+ const f = el('form', {
+ method: 'POST', action: '/answer-question/' + q.id,
+ class: 'qform', 'data-async': '', 'data-no-refresh': '',
+ });
+ const hasOptions = q.options && q.options.length;
+ const isMulti = !!q.multi && hasOptions;
+ const freeText = el('textarea', {
+ name: 'answer-free', rows: '2', autocomplete: 'off',
+ placeholder: (hasOptions ? 'or type your own…' : 'your answer')
+ + ' (shift+enter for newline)',
+ });
+ // Enter submits; shift+enter inserts a newline (textarea default).
+ freeText.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ f.requestSubmit();
+ }
+ });
+ const optionGroup = el('div', { class: 'q-options' });
+ if (hasOptions) {
+ for (const opt of q.options) {
+ const inputType = isMulti ? 'checkbox' : 'radio';
+ const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
+ const input = el('input', { type: inputType, name: 'choice', value: opt, id });
+ const label = el('label', { for: id }, ' ' + opt);
+ optionGroup.append(el('div', { class: 'q-option' }, input, label));
+ }
+ }
+ // On submit, build the final `answer` field from selected
+ // options + free-text, joined by ', '. This lets the operator
+ // pick options AND add free text in the same form.
+ f.addEventListener('submit', (ev) => {
+ const parts = [];
+ for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
+ parts.push(cb.value);
+ }
+ const ft = (freeText.value || '').trim();
+ if (ft) parts.push(ft);
+ const merged = parts.join(', ');
+ // Replace the existing hidden `answer` (if any) with the merged value.
+ const existing = f.querySelector('input[name="answer"]');
+ if (existing) existing.remove();
+ f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
+ if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
+ }, true);
+ if (hasOptions) f.append(optionGroup);
+ const buttons = el('div', { class: 'q-buttons' });
+ // On peer threads the operator's answer is an override —
+ // mark the button so it's clear what the click does (the
+ // backend permits it via OperatorQuestions::answer's
+ // answerer-auth rule).
+ const answerLabel = q.target
+ ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3')
+ : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R');
+ buttons.append(
+ el('button', {
+ type: 'submit',
+ class: 'btn btn-approve' + (q.target ? ' btn-override' : ''),
+ title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '',
+ }, answerLabel),
+ );
+ f.append(
+ el('div', { class: 'q-free' }, freeText),
+ buttons,
+ );
+ li.append(f);
+ // Separate form so the cancel button doesn't get the answer
+ // merge-on-submit handler attached to the main form.
+ const cancelTargetLabel = q.target ? q.target : 'asker';
+ const cancelForm = el('form', {
+ method: 'POST', action: '/cancel-question/' + q.id,
+ class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '',
+ 'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ + '"[cancelled]" as the answer.',
+ });
+ cancelForm.append(
+ el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
+ );
+ li.append(cancelForm);
+ ul.append(li);
+ }
+ if (pending.length) root.append(ul);
+
+ // Answered question history
+ const hist = questionsState.history;
+ if (hist.length) {
+ const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
+ details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
+ const hul = el('ul', { class: 'questions questions-answered' });
+ for (const q of hist) {
+ const targetLabel = q.target || 'operator';
+ const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
+ const head = el('div', { class: 'q-head' },
+ el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ',
+ el('span', { class: 'msg-from' }, q.asker), ' ',
+ el('span', { class: 'msg-sep' }, '→'), ' ',
+ el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ',
+ el('span', { class: 'msg-sep' }, 'asked:'),
+ );
+ const histBody = el('div', { class: 'q-body' });
+ appendLinkified(histBody, q.question, q.question_refs);
+ const ansText = el('span', { class: 'q-answer-text' });
+ appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
+ const ansLine = el('div', { class: 'q-answer' },
+ el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
+ ansText,
+ );
+ li.append(head, histBody, ansLine);
+ hul.append(li);
+ }
+ details.append(hul);
+ root.append(details);
+ }
+ }
+
+ // Format a remaining-seconds count as the `⏳ …` TTL chip text on a
+ // question card. Bucketed at minutes / hours so a long deadline stays
+ // readable; "expiring…" once the deadline has passed (the host-side
+ // ttl-watchdog will fire shortly).
+ function formatTtl(remaining) {
+ if (remaining <= 0) return 'expiring…';
+ if (remaining < 60) return '⏳ ' + remaining + 's';
+ if (remaining < 3600) {
+ return '⏳ ' + Math.floor(remaining / 60) + 'm '
+ + (remaining % 60) + 's';
+ }
+ return '⏳ ' + Math.floor(remaining / 3600) + 'h '
+ + Math.floor((remaining % 3600) / 60) + 'm';
+ }
+
+ // Single page-wide ticker that refreshes every TTL chip in place
+ // each second (issue #335). Renderers stamp `data-deadline` on the
+ // chip; this just updates `textContent`, no re-render of the
+ // questions section. No-op when no chips are on screen, so the
+ // cost is negligible.
+ setInterval(() => {
+ const now = Math.floor(Date.now() / 1000);
+ document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
+ const deadline = Number(node.getAttribute('data-deadline'));
+ if (!Number.isFinite(deadline)) return;
+ node.textContent = formatTtl(deadline - now);
+ });
+ }, 1000);
+
+ // ─── operator inbox (derived from the broker message stream) ───────────
+ // No longer shipped on `/api/state.operator_inbox`. The dashboard
+ // terminal's HiveTerminal feeds this via `onAnyEvent` — backfill from
+ // `/dashboard/history` populates on load, live SSE keeps it current.
+ // Newest-first to match the previous behaviour.
+ const INBOX_LIMIT = 50;
+ const operatorInbox = [];
+ function inboxAppendFromEvent(ev) {
+ if (ev.kind !== 'sent' || ev.to !== 'operator') return false;
+ operatorInbox.unshift({
+ from: ev.from,
+ body: ev.body,
+ at: ev.at,
+ file_refs: ev.file_refs || [],
+ });
+ if (operatorInbox.length > INBOX_LIMIT) operatorInbox.length = INBOX_LIMIT;
+ return true;
+ }
+ function renderInbox() {
+ const root = $('inbox-section');
+ if (!root) return;
+ root.innerHTML = '';
+ if (!operatorInbox.length) {
+ root.append(el('p', { class: 'empty' }, 'no messages'));
+ return;
+ }
+ const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
+ const ul = el('ul', { class: 'inbox' });
+ for (const m of operatorInbox) {
+ const li = el('li');
+ const body = el('span', { class: 'msg-body' });
+ appendLinkified(body, m.body, m.file_refs);
+ li.append(
+ el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
+ el('span', { class: 'msg-from' }, m.from), ' ',
+ el('span', { class: 'msg-sep' }, '→ '),
+ body,
+ );
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
+ const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
+ // Derived approval state — cold-loaded from /api/state, then mutated
+ // live by `approval_added` / `approval_resolved` dashboard events.
+ // `pending` is the open queue (newest-first); `history` is the last
+ // 30 resolved rows.
+ const APPROVAL_HISTORY_LIMIT = 30;
+ const approvalsState = { pending: [], history: [] };
+ function syncApprovalsFromSnapshot(s) {
+ approvalsState.pending = (s.approvals || []).slice();
+ approvalsState.history = (s.approval_history || []).slice();
+ }
+ function applyApprovalAdded(ev) {
+ // Upsert by id so a snapshot that already included the row (cold
+ // load + event lands at the same tick) doesn't double it.
+ const existing = approvalsState.pending.findIndex((a) => a.id === ev.id);
+ const row = {
+ id: ev.id,
+ agent: ev.agent,
+ kind: ev.approval_kind,
+ sha_short: ev.sha_short || null,
+ diff: ev.diff || null,
+ description: ev.description || null,
+ // The ApprovalAdded event carries no requested_at; a live-added
+ // approval was queued just now, so client-now is accurate — and
+ // consistent with how fmtAgo compares everything to client-now.
+ // A later /api/state cold-load swaps in the server value. (#272)
+ requested_at: ev.requested_at != null
+ ? ev.requested_at : Math.floor(Date.now() / 1000),
+ };
+ 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);
+ // Idempotent: a snapshot re-sync (issue #163) can carry this same
+ // resolved row in `approval_history` while a live event also
+ // delivers it — guard the unshift so history can't double a row.
+ if (!approvalsState.history.some((h) => h.id === ev.id)) {
+ approvalsState.history.unshift({
+ id: ev.id,
+ agent: ev.agent,
+ kind: ev.approval_kind,
+ sha_short: ev.sha_short || null,
+ status: ev.status,
+ resolved_at: ev.resolved_at,
+ note: ev.note || null,
+ description: ev.description || null,
+ });
+ if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
+ approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
+ }
+ }
+ renderApprovals();
+ }
+ // Classify each unified-diff line by its leading char so
+ // `.diff-add` / `.diff-del` / `.diff-hunk` / `.diff-file` /
+ // `.diff-ctx` colour the output. Built as text-only spans (no
+ // innerHTML) so there's no HTML-escape surface.
+ function buildDiffPre(text) {
+ const pre = el('pre', { class: 'diff' });
+ for (const raw of String(text).split('\n')) {
+ let cls = 'diff-ctx';
+ if (raw.startsWith('--- ') || raw.startsWith('+++ ')) cls = 'diff-file';
+ else if (raw.startsWith('@')) cls = 'diff-hunk';
+ else if (raw.startsWith('+')) cls = 'diff-add';
+ else if (raw.startsWith('-')) cls = 'diff-del';
+ const span = document.createElement('span');
+ span.className = cls;
+ span.textContent = raw + '\n';
+ pre.appendChild(span);
+ }
+ return pre;
+ }
+
+ // Open an approval's diff in the side panel with a 3-way base
+ // toggle: vs applied (running tree), vs last-approved, vs previous
+ // proposal. `applied` uses the diff already shipped on the approval
+ // for instant paint; the other two fetch /api/approval-diff.
+ function openDiffPanel(a) {
+ const bases = [
+ ['applied', 'vs applied'],
+ ['approved', 'vs last-approved'],
+ ['previous', 'vs previous proposal'],
+ ];
+ const tabs = el('div', { class: 'diff-base-tabs' });
+ const host = el('div', { class: 'diff-host' });
+ async function selectBase(base) {
+ for (const btn of tabs.children) {
+ btn.classList.toggle('active', btn.dataset.base === base);
+ }
+ if (base === 'applied' && a.diff != null) {
+ host.replaceChildren(buildDiffPre(a.diff));
+ return;
+ }
+ host.replaceChildren(el('div', { class: 'meta' }, 'loading…'));
+ try {
+ const resp = await fetch('/api/approval-diff/' + a.id + '?base=' + base);
+ const text = await resp.text();
+ host.replaceChildren(resp.ok
+ ? buildDiffPre(text)
+ : el('div', { class: 'meta' }, 'error: ' + text));
+ } catch (e) {
+ host.replaceChildren(el('div', { class: 'meta' }, 'error: ' + e));
+ }
+ }
+ for (const [base, label] of bases) {
+ const btn = el('button',
+ { type: 'button', class: 'diff-base-tab', 'data-base': base }, label);
+ btn.addEventListener('click', () => selectBase(base));
+ tabs.append(btn);
+ }
+ const wrap = el('div', { class: 'diff-panel' }, tabs, host);
+ Panel.open('diff · ' + a.agent + ' #' + a.id, wrap);
+ selectBase('applied');
+ }
+
+ function renderApprovals() {
+ const root = $('approvals-section');
+ root.innerHTML = '';
+
+ // Spawn request form: submitting it queues a Spawn approval that
+ // lands in this same list, so the form belongs here rather than on
+ // the containers list (the agent doesn't exist yet).
+ const spawn = el('form', {
+ method: 'POST', action: '/request-spawn',
+ class: 'spawnform', 'data-async': '', 'data-no-refresh': '',
+ });
+ spawn.append(
+ el('input', {
+ name: 'name',
+ placeholder: 'new agent name (≤9 chars)',
+ maxlength: '9', required: '', autocomplete: 'off',
+ }),
+ el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
+ );
+ root.append(spawn);
+
+ const pending = approvalsState.pending;
+ const history = approvalsState.history;
+ const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
+ const tabs = el('div', { class: 'approval-tabs' });
+ const pendingTab = el(
+ 'button',
+ {
+ type: 'button',
+ class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
+ },
+ `pending · ${pending.length}`,
+ );
+ const historyTab = el(
+ 'button',
+ {
+ type: 'button',
+ class: 'approval-tab' + (active === 'history' ? ' active' : ''),
+ },
+ `history · ${history.length}`,
+ );
+ pendingTab.addEventListener('click', () => {
+ localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
+ renderApprovals();
+ });
+ historyTab.addEventListener('click', () => {
+ localStorage.setItem(APPROVAL_TAB_KEY, 'history');
+ renderApprovals();
+ });
+ tabs.append(pendingTab, historyTab);
+ root.append(tabs);
+
+ if (active === 'history') {
+ renderApprovalHistory(root, history);
+ return;
+ }
+
+ if (!pending.length) {
+ root.append(el('p', { class: 'empty' }, 'queue empty'));
+ return;
+ }
+ // forge link base — only when the hive-forge container is up.
+ const fs = window.__hyperhive_state;
+ const hostname = (fs && fs.hostname) || window.location.hostname;
+ const forgeBase = (fs && fs.forge_present) ? `http://${hostname}:3000` : null;
+
+ const ul = el('ul', { class: 'approvals' });
+ for (const a of pending) {
+ const isApply = a.kind === 'apply_commit';
+ const isInit = a.kind === 'init_config';
+ const li = el('li', { class: 'approval-card' });
+
+ // ── identity header ──────────────────────────────────────────
+ const head = el('div', { class: 'approval-head' },
+ el('span', { class: 'glyph' }, isApply ? '→' : '⊕'),
+ el('span', { class: 'id' }, '#' + a.id),
+ el('span', { class: 'agent' }, a.agent),
+ el('span', { class: 'kind' + (isApply ? '' : ' kind-spawn') },
+ isApply ? 'apply' : isInit ? 'init' : 'spawn'),
+ );
+ if (isApply && a.sha_short) head.append(el('code', {}, a.sha_short));
+ // When the approval was requested — relative time, right-aligned.
+ // Goes amber once it's been pending an hour so a stale request is
+ // obvious at a glance. (issue #272)
+ if (a.requested_at != null) {
+ const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - a.requested_at));
+ head.append(el('span', {
+ class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
+ title: 'requested ' + new Date(a.requested_at * 1000).toLocaleString(),
+ }, 'requested ' + fmtAgo(a.requested_at)));
+ }
+ li.append(head);
+
+ // ── what-changed body ────────────────────────────────────────
+ const body = el('div', { class: 'approval-body' });
+ if (a.description) {
+ body.append(el('div', { class: 'approval-description' }, a.description));
+ }
+ if (isApply) {
+ const drill = el('div', { class: 'drill-ins' });
+ const diffBtn = el('button', { type: 'button', class: 'panel-trigger' },
+ '↳ view diff');
+ diffBtn.addEventListener('click', () => openDiffPanel(a));
+ drill.append(diffBtn);
+ if (forgeBase && a.sha_short) {
+ drill.append(el('a', {
+ class: 'panel-trigger', target: '_blank', rel: 'noopener',
+ href: `${forgeBase}/agent-configs/${a.agent}/commit/${a.sha_short}`,
+ title: 'this proposal commit on the hive forge',
+ }, '↳ commit on forge ↗'));
+ }
+ body.append(drill);
+ } else {
+ body.append(el('span', { class: 'meta' },
+ isInit
+ ? 'scaffold proposed config repo — manager customises agent.nix before spawn'
+ : 'new sub-agent — container will be created on approve'));
+ }
+ li.append(body);
+
+ // ── decision actions ─────────────────────────────────────────
+ // Deny prompts the operator for an optional reason; the submit
+ // handler stashes it into a hidden `note` input that rides along
+ // on the POST and is surfaced to the manager via
+ // HelperEvent::ApprovalResolved { note }.
+ const denyForm = el('form', {
+ method: 'POST', action: '/deny/' + a.id,
+ class: 'inline', 'data-async': '', 'data-no-refresh': '',
+ 'data-prompt': 'reason for denying (optional, sent to manager):',
+ });
+ denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
+ li.append(el('div', { class: 'approval-actions' },
+ form('/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
+ denyForm,
+ ));
+
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
+ function renderApprovalHistory(root, history) {
+ if (!history.length) {
+ root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
+ return;
+ }
+ const ul = el('ul', { class: 'approvals approvals-history' });
+ for (const a of history) {
+ const li = el('li');
+ const row = el('div', { class: 'row' });
+ const glyph = a.status === 'approved' ? '✓'
+ : a.status === 'denied' ? '✗'
+ : '⚠';
+ row.append(
+ el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
+ el('span', { class: 'id' }, '#' + a.id), ' ',
+ el('span', { class: 'agent' }, a.agent), ' ',
+ el('span', { class: 'kind' }, a.kind === 'apply_commit' ? 'apply' : 'spawn'), ' ',
+ );
+ if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
+ row.append(
+ el('span', { class: 'status status-' + a.status }, a.status), ' ',
+ el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
+ );
+ li.append(row);
+ if (a.note) {
+ li.append(el('div', { class: 'history-note' }, a.note));
+ }
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+
+ // Relative time, anchored to now. resolved_at is unix seconds (server-
+ // authored), so we don't have to worry about client/server clock skew
+ // for sub-minute precision.
+ function fmtAgo(unixSecs) {
+ const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
+ if (ageSec < 60) return ageSec + 's ago';
+ if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
+ if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
+ return Math.floor(ageSec / 86400) + 'd ago';
+ }
+
+ function renderMetaInputs(s) {
+ const root = $('meta-inputs-section');
+ if (!root) return;
+ root.innerHTML = '';
+ const inputs = s.meta_inputs || [];
+ if (!inputs.length) {
+ root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
+ return;
+ }
+ if (metaUpdateRunning) {
+ root.append(el('p', { class: 'meta-update-running' },
+ '⏳ meta-update running — flake lock bump + affected agents rebuilding. '
+ + 'watch the agent cards for per-rebuild progress.'));
+ }
+ const form = el('form', {
+ method: 'POST',
+ action: '/meta-update',
+ class: 'meta-inputs-form',
+ 'data-async': '',
+ // run_meta_update emits MetaInputsChanged once the lock
+ // bump finishes; per-agent rebuilds fire their own
+ // ContainerStateChanged. No /api/state refetch needed.
+ 'data-no-refresh': '',
+ 'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
+ });
+ // Bulk select — the full input tree gets long; ticking each box
+ // one by one is tedious (issue #275).
+ const bulk = el('div', { class: 'meta-inputs-bulk' });
+ const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
+ const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
+ bulk.append('bulk: ', selAll, ' ', selNone);
+ form.append(bulk);
+ const ul = el('ul', { class: 'meta-inputs' });
+ for (const inp of inputs) {
+ // `name` is a slash-path from the meta root. Indent depth = its
+ // segment count; the row label shows just the leaf segment, the
+ // full path stays as the checkbox value + the label title.
+ const depth = (inp.name.match(/\//g) || []).length;
+ const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
+ const li = el('li');
+ if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
+ 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, title: inp.name });
+ label.append(cb);
+ if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
+ label.append(
+ el('span', { class: 'meta-input-name' }, leaf), ' ',
+ 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: '',
+ }, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
+ form.append(btn);
+ function refreshDisabled() {
+ const any = form.querySelectorAll('input[data-meta-input]:checked').length > 0;
+ // Stay disabled while an update is already in flight — no
+ // stacking a second run on top of the rebuild ripple.
+ if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
+ else btn.setAttribute('disabled', '');
+ }
+ form.addEventListener('change', refreshDisabled);
+ function setAllChecked(val) {
+ for (const b of form.querySelectorAll('input[data-meta-input]')) {
+ b.checked = val;
+ }
+ refreshDisabled();
+ }
+ selAll.addEventListener('click', () => setAllChecked(true));
+ selNone.addEventListener('click', () => setAllChecked(false));
+ 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) + '…';
+ }
+
+ // ─── rebuild queue ──────────────────────────────────────────────────────
+ // Glyph + verb per QueueKind. Mirrors the labels used in
+ // hive-c0re::rebuild_queue::QueueKind::as_str.
+ const QUEUE_KIND_GLYPH = {
+ rebuild: '↻',
+ meta_update: '◆',
+ spawn: '✨',
+ destroy: '🗑',
+ };
+ const QUEUE_STATE_GLYPH = {
+ queued: '⏸',
+ running: '▶',
+ done: '✔',
+ failed: '✖',
+ cancelled: '⊘',
+ };
+
+ function renderRebuildQueue(s) {
+ const root = $('rebuild-queue-section');
+ if (!root) return;
+ root.innerHTML = '';
+ const queue = s.rebuild_queue || [];
+ if (!queue.length) {
+ root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
+ return;
+ }
+ // Index by id for parent lookup.
+ const byId = new Map(queue.map((e) => [e.id, e]));
+ // Top-level entries first; children render nested under their parent.
+ const tops = queue.filter((e) => e.parent_id == null);
+ const childrenOf = new Map();
+ for (const e of queue) {
+ if (e.parent_id != null) {
+ if (!childrenOf.has(e.parent_id)) childrenOf.set(e.parent_id, []);
+ childrenOf.get(e.parent_id).push(e);
+ }
+ }
+ const ul = el('ul', { class: 'rebuild-queue' });
+ for (const top of tops) {
+ ul.append(renderQueueEntry(top, byId));
+ for (const child of childrenOf.get(top.id) || []) {
+ ul.append(renderQueueEntry(child, byId, true));
+ }
+ }
+ // Children whose parent isn't in the snapshot (history-evicted) still render flat.
+ const orphans = queue.filter(
+ (e) => e.parent_id != null && !byId.has(e.parent_id),
+ );
+ for (const o of orphans) {
+ ul.append(renderQueueEntry(o, byId, true));
+ }
+ root.append(ul);
+ }
+
+ function renderQueueEntry(entry, _byId, isChild) {
+ const li = el('li', {
+ class: 'rebuild-queue-entry rqe-' + entry.state,
+ 'data-id': String(entry.id),
+ });
+ if (isChild) li.classList.add('rqe-child');
+ // State glyph + kind + agent.
+ li.append(
+ el('span', { class: 'rqe-state', title: entry.state }, QUEUE_STATE_GLYPH[entry.state] || '?'),
+ ' ',
+ el('span', { class: 'rqe-kind', title: entry.kind },
+ (QUEUE_KIND_GLYPH[entry.kind] || '?') + ' ' + entry.kind),
+ ' ',
+ el('code', { class: 'rqe-agent' }, entry.agent),
+ );
+ // Source chip (manual / meta_update / auto_update / crash_recover).
+ li.append(' ', el('span', { class: 'rqe-source rqe-source-' + entry.source }, entry.source));
+ // Timing: queued Xs ago when pending, elapsed when running,
+ // finished Xs ago for terminal.
+ if (entry.state === 'queued') {
+ li.append(' ', el('span', { class: 'rqe-when' }, '· queued ' + fmtAgo(entry.enqueued_at)));
+ } else if (entry.state === 'running' && entry.started_at) {
+ const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - entry.started_at));
+ li.append(' ', el('span', {
+ class: 'rqe-when',
+ 'data-rqe-elapsed': String(entry.started_at),
+ }, '· ' + fmtElapsed(elapsed)));
+ } else if (entry.finished_at) {
+ li.append(' ', el('span', { class: 'rqe-when' }, '· ' + entry.state + ' ' + fmtAgo(entry.finished_at)));
+ }
+ // Reason (truncated; full text on hover).
+ if (entry.reason) {
+ const r = entry.reason.split('\n')[0];
+ li.append(' ', el('span', { class: 'rqe-reason', title: entry.reason }, '— ' + truncate(r, 60)));
+ }
+ // Error block, when failed.
+ if (entry.error) {
+ li.append(el('pre', { class: 'rqe-error', title: entry.error }, truncate(entry.error, 200)));
+ }
+ return li;
+ }
+
+ function fmtElapsed(secs) {
+ if (secs < 60) return secs + 's running';
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
+ return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
+ }
+
+ // Tick once per second to refresh "running Xs" badges in place
+ // (mirrors the question-TTL ticker pattern from #335).
+ setInterval(() => {
+ for (const span of document.querySelectorAll('.rqe-when[data-rqe-elapsed]')) {
+ const started = parseInt(span.dataset.rqeElapsed, 10);
+ if (!started) continue;
+ const elapsed = Math.max(0, Math.floor(Date.now() / 1000 - started));
+ span.textContent = '· ' + fmtElapsed(elapsed);
+ }
+ }, 1000);
+
+ // ─── reminders ──────────────────────────────────────────────────────────
+ // Reminders aren't part of /api/state (separate sqlite table, separate
+ // mutation cadence). Refresh fires alongside refreshState() so a
+ // cancel POST or a cold load both reflect within the same tick. A
+ // periodic poll isn't necessary — new reminders are queued by the
+ // agents themselves and the operator already sees them next time
+ // they interact with the page.
+ async function refreshReminders() {
+ const root = $('reminders-section');
+ if (!root) return;
+ try {
+ const resp = await fetch('/api/reminders');
+ if (!resp.ok) {
+ root.innerHTML = '';
+ root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status));
+ return;
+ }
+ const rows = await resp.json();
+ renderReminders(rows);
+ } catch (err) {
+ root.innerHTML = '';
+ root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err));
+ }
+ }
+ function renderReminders(rows) {
+ const root = $('reminders-section');
+ if (!root) return;
+ root.innerHTML = '';
+ if (!rows.length) {
+ root.append(el('p', { class: 'empty' }, 'no queued reminders'));
+ return;
+ }
+ const ul = el('ul', { class: 'reminders' });
+ for (const r of rows) {
+ const failed = (r.attempt_count || 0) > 0;
+ const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') });
+ const dueIn = r.due_at - Math.floor(Date.now() / 1000);
+ const dueLabel = dueIn <= 0
+ ? `overdue ${fmtAgo(r.due_at)}`
+ : `in ${fmtDuration(dueIn)}`;
+ const head = el('div', { class: 'reminder-head' },
+ el('span', { class: 'agent' }, r.agent), ' ',
+ el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel),
+ ' ',
+ el('span', { class: 'meta' }, `· id ${r.id}`),
+ );
+ if (r.file_path) {
+ head.append(' ', el('span', { class: 'meta' }, '· payload → '));
+ appendLinkified(head, r.file_path);
+ }
+ if (failed) {
+ head.append(' ', el('span',
+ {
+ class: 'badge badge-warn',
+ title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)',
+ },
+ `⚠ ${r.attempt_count} failed`));
+ }
+ const body = el('div', { class: 'reminder-body' });
+ appendLinkified(body, r.message);
+ li.append(head, body);
+ if (r.last_error) {
+ li.append(el('div', { class: 'reminder-error' },
+ el('span', { class: 'msg-sep' }, 'error: '),
+ r.last_error,
+ ));
+ }
+ const actions = el('div', { class: 'reminder-actions' });
+ if (failed) {
+ // Retry resets the failure counters so the scheduler picks
+ // the row up again on its next 5s tick. No data-no-refresh
+ // — the resulting refreshState re-fires refreshReminders.
+ const retryForm = el('form', {
+ method: 'POST', action: '/retry-reminder/' + r.id,
+ class: 'inline', 'data-async': '',
+ });
+ retryForm.append(el('button',
+ { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY'));
+ actions.append(retryForm);
+ }
+ const cancelForm = el('form', {
+ method: 'POST', action: '/cancel-reminder/' + r.id,
+ class: 'inline', 'data-async': '',
+ 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`,
+ });
+ cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L'));
+ actions.append(cancelForm);
+ li.append(actions);
+ ul.append(li);
+ }
+ root.append(ul);
+ }
+ function fmtDuration(secs) {
+ if (secs < 60) return secs + 's';
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
+ if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
+ return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
+ }
+
+ // ─── state polling ──────────────────────────────────────────────────────
+ let pollTimer = null;
+ // Sections whose innerHTML gets blown away on each refresh. If the
+ // operator is typing in one of them, skip the refresh — the next
+ // tick (or a manual action) will pick it up after they blur.
+ const MANAGED_SECTION_IDS = [
+ 'containers-section',
+ 'tombstones-section',
+ 'questions-section',
+ 'inbox-section',
+ 'approvals-section',
+ 'meta-inputs-section',
+ 'rebuild-queue-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. (Long-content drill-ins — file
+ // previews, diffs, logs, config — open in the side panel instead,
+ // which lives outside the managed sections and survives re-render
+ // on its own.)
+ function snapshotOpenDetails() {
+ const open = new Set();
+ for (const id of MANAGED_SECTION_IDS) {
+ const sect = document.getElementById(id);
+ if (!sect) continue;
+ for (const d of sect.querySelectorAll('details[data-restore-key]')) {
+ if (d.open) open.add(d.dataset.restoreKey);
+ }
+ }
+ return open;
+ }
+ function restoreOpenDetails(open) {
+ if (!open.size) return;
+ for (const id of MANAGED_SECTION_IDS) {
+ const sect = document.getElementById(id);
+ if (!sect) continue;
+ for (const d of sect.querySelectorAll('details[data-restore-key]')) {
+ if (open.has(d.dataset.restoreKey)) d.open = true;
+ }
+ }
+ }
+
+ function operatorIsTyping() {
+ const el_ = document.activeElement;
+ if (!el_ || el_ === document.body) return false;
+ const tag = el_.tagName;
+ if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
+ return MANAGED_SECTION_IDS.some((id) => {
+ const sect = document.getElementById(id);
+ return sect && sect.contains(el_);
+ });
+ }
+ async function refreshState() {
+ // Don't yank the form out from under the operator. Try again
+ // shortly on the next tick; eventually they'll blur and the
+ // refresh lands.
+ if (operatorIsTyping()) {
+ if (pollTimer) clearTimeout(pollTimer);
+ pollTimer = setTimeout(refreshState, 2000);
+ return;
+ }
+ try {
+ const resp = await fetch('/api/state');
+ if (!resp.ok) throw new Error('http ' + resp.status);
+ const s = await resp.json();
+ // Stash the latest snapshot for any sub-widget that wants a
+ // synchronous read (e.g. the compose autocomplete pulls agent
+ // names from here instead of refetching on every keystroke).
+ window.__hyperhive_state = s;
+ const openDetails = snapshotOpenDetails();
+ // Sync transients + containers first so renderContainers below
+ // sees the current derived maps (it reads from
+ // `transientsState` + `containersState`, not from `s.*`).
+ syncTransientsFromSnapshot(s);
+ syncContainersFromSnapshot(s);
+ syncTombstonesFromSnapshot(s);
+ syncMetaInputsFromSnapshot(s);
+ syncRebuildQueueFromSnapshot(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);
+ renderRebuildQueue(s);
+ refreshReminders();
+ restoreOpenDetails(openDetails);
+ notifyDeltas(s);
+ // No periodic refresh timer. Phase 6 covers every container
+ // mutation with `ContainerStateChanged` / `ContainerRemoved`
+ // (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
+ // approvals + questions + transients have their own events;
+ // broker traffic flows through the SSE channel. The only
+ // /api/state fetches are the initial cold load and the
+ // post-submit refetch on forms without `data-no-refresh`
+ // (tombstones, meta-input updates).
+ if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
+ } catch (err) {
+ console.error('refreshState failed', err);
+ // Schedule a single retry on transient errors so the page
+ // recovers from a brief network blip without making the
+ // operator reload.
+ pollTimer = setTimeout(refreshState, 5000);
+ }
+ }
+ refreshState();
+ NOTIF.bind();
+ Panel.bind();
+
+ // ─── message flow: shared terminal pane ────────────────────────────────
+ // Scroll, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS
+ // (window.HiveTerminal). What stays here is the broker-message
+ // renderer + the page-local side effects (banner pulse, inbox refresh
+ // on operator-bound traffic, OS notifications).
+ (() => {
+ const flow = $('msgflow');
+ if (!flow || !window.HiveTerminal) return;
+ flow.innerHTML = '';
+ const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
+ // Pulse the page banner whenever a broker event lands. Each event
+ // nudges the shimmer window; if traffic stops, the class falls off
+ // after the grace timer.
+ const banner = document.querySelector('.banner');
+ let bannerOffTimer = null;
+ function pulseBanner() {
+ if (!banner) return;
+ banner.classList.add('active');
+ if (bannerOffTimer) clearTimeout(bannerOffTimer);
+ bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
+ }
+ // Map of broker row id → rendered row element. Lets reply rows add
+ // a visual "↳ in reply to" indicator that links back to the parent.
+ // Bounded by the history window (~200 msgs from /dashboard/history),
+ // well within normal memory.
+ const msgRowMap = new Map();
+
+ function renderMsg(ev, api, glyph) {
+ const isReply = ev.in_reply_to != null;
+ const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
+ const row = api.row(cls, '');
+ // Build via DOM so path anchors stay live + escape rules are
+ // automatic (text nodes don't need esc()).
+ const ts = document.createElement('span');
+ ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
+ const arrow = document.createElement('span');
+ arrow.className = 'msg-arrow'; arrow.textContent = glyph;
+ const from = document.createElement('span');
+ from.className = 'msg-from'; from.textContent = ev.from;
+ const sep = document.createElement('span');
+ sep.className = 'msg-sep'; sep.textContent = '→';
+ const to = document.createElement('span');
+ to.className = 'msg-to'; to.textContent = ev.to;
+ const body = document.createElement('span');
+ body.className = 'msg-body';
+ appendLinkified(body, ev.body, ev.file_refs);
+ // Reply thread indicator: a small "↳ reply to " hint that
+ // shows which message this is responding to. If we have the parent
+ // in our row map, clicking scrolls it into view.
+ if (isReply) {
+ const replyTag = document.createElement('span');
+ replyTag.className = 'msg-reply-tag';
+ const parentRow = msgRowMap.get(ev.in_reply_to);
+ if (parentRow) {
+ const link = document.createElement('a');
+ link.href = '#';
+ link.textContent = '↳ reply';
+ link.title = 'scroll to parent message';
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ parentRow.classList.add('msg-highlight');
+ setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
+ });
+ replyTag.append(link);
+ } else {
+ replyTag.textContent = '↳ reply';
+ }
+ row.prepend(replyTag);
+ row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
+ } else {
+ row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
+ }
+ // Register this row so future replies can reference it.
+ if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
+ }
+ HiveTerminal.create({
+ logEl: flow,
+ historyUrl: '/dashboard/history',
+ streamUrl: '/dashboard/stream',
+ renderers: {
+ sent: (ev, api) => renderMsg(ev, api, '→'),
+ delivered: (ev, api) => renderMsg(ev, api, '✓'),
+ // Mutation events update derived state and trigger a
+ // section re-render — no terminal log row (the terminal is
+ // for broker traffic, not state-change chatter).
+ approval_added: (ev) => { applyApprovalAdded(ev); },
+ approval_resolved: (ev) => { applyApprovalResolved(ev); },
+ question_added: (ev) => { applyQuestionAdded(ev); },
+ question_resolved: (ev) => { applyQuestionResolved(ev); },
+ transient_set: (ev) => { applyTransientSet(ev); },
+ transient_cleared: (ev) => { applyTransientCleared(ev); },
+ container_state_changed: (ev) => { applyContainerStateChanged(ev); },
+ container_removed: (ev) => { applyContainerRemoved(ev); },
+ tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
+ meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
+ meta_update_running: (ev) => { applyMetaUpdateRunning(ev); },
+ rebuild_queue_changed: (ev) => { applyRebuildQueueChanged(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();
+ },
+ // Re-sync the full /api/state snapshot on every SSE (re)connect.
+ // Live mutation events that fired during a disconnect window are
+ // never replayed, so without this the derived stores (approvals,
+ // questions, containers, …) would drift stale until a manual
+ // reload (issue #163). refreshState() replaces every store from
+ // the snapshot, so a missed event self-heals on reconnect.
+ onStreamOpen: () => { refreshState(); },
+ 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();
+ })();
+})();
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
new file mode 100644
index 0000000..39d6cbe
--- /dev/null
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -0,0 +1,1146 @@
+/* Shared Catppuccin palette + body typography + terminal pane styles.
+ Bundled in front of the dashboard-only rules below via esbuild. */
+@import "@hive/shared/base.css";
+@import "@hive/shared/terminal.css";
+
+body {
+ max-width: 70em;
+ margin: 1.5em auto;
+ padding: 0 1.5em;
+}
+.banner {
+ text-align: center;
+ margin: 0 0 1em 0;
+ font-size: 0.95em;
+ overflow-x: auto;
+ background: linear-gradient(
+ 90deg,
+ var(--purple-dim) 0%,
+ var(--purple) 50%,
+ var(--purple-dim) 100%
+ );
+ background-size: 200% 100%;
+ background-position: 50% 0;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
+}
+.banner.active {
+ animation: banner-shimmer 1.8s linear infinite;
+}
+@keyframes banner-shimmer {
+ from { background-position: 200% 0; }
+ to { background-position: -100% 0; }
+}
+h1, h2 {
+ color: var(--purple);
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+ margin-top: 2em;
+ text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
+}
+.divider {
+ color: var(--purple-dim);
+ overflow: hidden;
+ white-space: nowrap;
+ margin-bottom: 0.5em;
+}
+ul { list-style: none; padding-left: 0; }
+li { padding: 0.5em 0; }
+.glyph { color: var(--purple); margin-right: 0.5em; }
+a {
+ color: var(--cyan);
+ text-decoration: none;
+ font-weight: bold;
+ text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
+}
+a:hover {
+ color: var(--fg);
+ text-shadow: 0 0 12px rgba(137, 220, 235, 0.9);
+}
+.role {
+ display: inline-block;
+ margin-left: 0.4em;
+ padding: 0.05em 0.5em;
+ border: 1px solid;
+ border-radius: 2px;
+ font-size: 0.8em;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(245, 194, 231, 0.08); }
+.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(250, 179, 135, 0.08); }
+/* Container rows: a full-height square agent icon on the left, the
+ identity / actions / drill-in lines stacked in the card body on the
+ right. Pending rows dim everything except the pending indicator. */
+.containers { display: flex; flex-direction: column; gap: 0.4em; }
+.container-row {
+ padding: 0.6em 0.8em;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: rgba(24, 24, 37, 0.55);
+ transition: opacity 200ms ease, border-color 200ms ease;
+}
+/* Live cards get the icon-left / body-right split; tombstone rows keep
+ the plain stacked block layout. The icon is a background-image div
+ with no intrinsic size, so its load state can never reflow the row
+ (issue #177). It used to `align-self: stretch` to fill the body
+ height, but with state badges / rate-limit pills / etc. wrapping the
+ head row, the body grew taller and the square icon grew with it —
+ so two cards with different content showed different-sized icons
+ (issue #344). Fixed at 5em now; height follows from aspect-ratio. */
+.container-row:not(.tombstone) {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.7em;
+}
+.container-row:not(.tombstone) > .container-icon {
+ position: relative;
+ overflow: hidden;
+ flex: none;
+ width: 5em;
+ aspect-ratio: 1;
+ border-radius: 6px;
+ background-color: rgba(17, 17, 27, 0.6);
+}
+/* The icon image fills the square wrapper and is taken out of flow
+ (absolute) so its load state — pending, loaded, broken — can never
+ contribute intrinsic size or reflow the row. (issue #177) */
+.container-row:not(.tombstone) > .container-icon > .container-icon-img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+/* When the fails to load it falls back to the dimmed hyperhive
+ mark, standing in for the unreachable agent icon (issues #195, #202). */
+.container-row:not(.tombstone) > .container-icon.icon-unreachable {
+ filter: grayscale(1);
+ opacity: 0.4;
+}
+.container-row .card-body {
+ flex: 1;
+ min-width: 0;
+}
+.container-row.pending {
+ border-color: var(--amber);
+ background: rgba(250, 179, 135, 0.05);
+}
+.container-row.pending .actions { opacity: 0.4; pointer-events: none; }
+.container-row .head {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ margin-bottom: 0.4em;
+}
+.container-row .head .name {
+ font-size: 1.05em;
+ font-weight: bold;
+}
+.container-row .head .meta { margin-left: auto; }
+/* Icon-only nav strip in the head row — the per-container backend-
+ supplied link list (issue #262). Inline-flex + gap so a longer list
+ (e.g. with `dashboardLinks` extras) doesn't cram (issue #333). Each
+ link gets a comfortable hit target with a subtle hover so the
+ icons read as interactive rather than decorative. */
+.container-row .head .nav-strip {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.35em;
+}
+.nav-link {
+ color: var(--muted);
+ font-size: 0.95em;
+ line-height: 1;
+ padding: 0.15em 0.35em;
+ border-radius: 3px;
+ text-decoration: none;
+ transition: background 0.12s ease, color 0.12s ease;
+}
+.nav-link:hover {
+ background: rgba(203, 166, 247, 0.12);
+ color: var(--cyan);
+ text-shadow: 0 0 6px rgba(137, 220, 235, 0.5);
+}
+.container-row .actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4em;
+}
+.container-row .actions form.inline { display: inline-block; margin: 0; }
+.badge {
+ display: inline-block;
+ padding: 0.05em 0.5em;
+ border: 1px solid;
+ border-radius: 2px;
+ font-size: 0.75em;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.badge-warn {
+ color: var(--amber); border-color: var(--amber);
+ text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
+}
+.badge-rate-limited {
+ color: var(--red); border-color: var(--red);
+ text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
+}
+.badge-muted {
+ color: var(--muted); border-color: var(--purple-dim);
+ background: rgba(127, 132, 156, 0.08);
+}
+.badge-reminder {
+ color: var(--cyan); border-color: var(--cyan);
+ text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
+}
+/* Context-window usage badges on dashboard container rows. Thresholds
+ are derived per-container: yellow ≥ 50% and red ≥ 75% of the model's
+ context window (`ContainerView.context_window_tokens`), mirroring the
+ harness compaction watermarks. Falls back to fixed 100k / 150k when
+ the window is unknown. (issue #66) */
+.badge-ctx-ok {
+ color: var(--green); border-color: var(--green);
+ opacity: 0.85;
+}
+.badge-ctx-caution {
+ color: var(--amber); border-color: var(--amber);
+ text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
+}
+.badge-ctx-warn {
+ color: var(--red); border-color: var(--red);
+ text-shadow: 0 0 6px rgba(243, 139, 168, 0.5);
+}
+.agent-status {
+ font-size: 0.82em;
+ color: var(--subtext0, #a6adc8);
+ padding: 0.1em 0.3em 0.25em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.agent-status .status-icon { opacity: 0.65; }
+.agent-status .status-age { opacity: 0.5; font-size: 0.9em; margin-left: 0.2em; }
+
+.container-row.tombstone {
+ border-style: dashed;
+ background: rgba(24, 24, 37, 0.35);
+ opacity: 0.85;
+}
+.container-row.tombstone .name { color: var(--muted); }
+/* Per-container journald viewer + applied-config viewer. Both open
+ in the side panel and lazy-fetch on open; output is monospace
+ inside a bordered , controls (unit select + refresh) above. */
+.journal-controls {
+ display: flex;
+ gap: 0.5em;
+ margin-bottom: 0.4em;
+ align-items: center;
+}
+.journal-unit {
+ font-family: inherit;
+ font-size: 0.9em;
+ background: var(--bg-elev);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ padding: 0.2em 0.4em;
+}
+.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
+.journal-output {
+ margin: 0;
+ background: #11111b;
+ color: var(--fg);
+ border: 1px solid var(--purple-dim);
+ padding: 0.5em 0.7em;
+ overflow-x: auto;
+ font-size: 0.85em;
+ line-height: 1.4;
+ white-space: pre;
+ word-break: normal;
+}
+
+/* Notification controls — sit between the banner and the
+ containers section. Hidden by JS when notifications are
+ unsupported, denied, or already in the right state. */
+/* Port-collision banner: appears above the containers list when
+ two sub-agents hash to the same web UI port. Critical — without
+ resolution, one of the harnesses will restart-loop on
+ AddrInUse. */
+.port-conflict {
+ background: rgba(243, 139, 168, 0.08);
+ border: 1px solid var(--red);
+ color: var(--red);
+ padding: 0.5em 0.8em;
+ margin-bottom: 0.6em;
+ border-radius: 4px;
+ text-shadow: 0 0 6px rgba(243, 139, 168, 0.4);
+ animation: questions-pulse 2.4s ease-in-out infinite;
+}
+.port-conflict strong { color: var(--red); }
+
+.notif-row {
+ display: flex;
+ gap: 0.5em;
+ align-items: center;
+ margin: 0.5em 0;
+ font-size: 0.85em;
+}
+.btn-notif {
+ font-family: inherit;
+ font-size: 0.85em;
+ background: transparent;
+ color: var(--cyan);
+ border: 1px solid var(--cyan);
+ padding: 0.2em 0.7em;
+ border-radius: 999px;
+ cursor: pointer;
+ text-shadow: 0 0 4px currentColor;
+}
+.btn-notif:hover {
+ background: rgba(137, 220, 235, 0.1);
+ box-shadow: 0 0 10px -2px currentColor;
+}
+
+.pending-state {
+ color: var(--amber);
+ font-size: 0.85em;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
+ animation: badge-pulse 1.6s ease-in-out infinite;
+}
+@keyframes badge-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.7; }
+}
+.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
+.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
+.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
+.empty { color: var(--muted); font-style: italic; }
+code {
+ color: var(--amber);
+ background: var(--bg-elev);
+ padding: 0.1em 0.4em;
+ border: 1px solid var(--border);
+ border-radius: 2px;
+ font-size: 0.9em;
+}
+/* Pending approval: a card with three stacked sections — identity
+ header, what-changed body, decision actions. */
+.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
+.approval-card {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--purple);
+ border-radius: 4px;
+ padding: 0.6em 0.8em;
+ margin-bottom: 0.6em;
+}
+.approval-head {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.3em;
+}
+/* When the approval was requested — right-aligned in the head row;
+ goes amber once it has been pending ≥ 1h so a stale request stands
+ out at a glance (issue #272). */
+.approval-ts {
+ margin-left: auto;
+ color: var(--muted);
+ font-size: 0.85em;
+}
+.approval-ts.stale {
+ color: var(--amber);
+ text-shadow: 0 0 6px rgba(250, 179, 135, 0.5);
+}
+.approval-body {
+ margin: 0.45em 0;
+ padding-left: 1.3em;
+}
+.approval-description {
+ font-size: 0.9em;
+ color: var(--fg);
+ white-space: pre-wrap;
+ margin-bottom: 0.35em;
+}
+.approval-actions {
+ display: flex;
+ gap: 0.5em;
+ padding-top: 0.45em;
+ border-top: 1px solid var(--border);
+}
+.approval-actions form.inline { display: inline; }
+/* Inline drill-in triggers (logs / config repo / view diff). */
+.drill-ins {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.15em 1.1em;
+ margin-top: 0.4em;
+}
+.drill-ins .panel-trigger { margin-top: 0; }
+/* Diff side-panel: base-toggle tabs above the diff host. */
+.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
+.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
+.diff-base-tab {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.85em;
+ padding: 0.2em 0.7em;
+ cursor: pointer;
+}
+.diff-base-tab:hover { color: var(--fg); }
+.diff-base-tab.active {
+ color: var(--purple);
+ border-color: var(--purple);
+ background: rgba(203, 166, 247, 0.08);
+}
+/* Image / tabbed file preview (issues #188, #192) */
+.preview-host { margin-top: 0.5em; }
+.img-preview {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ margin: 0 auto;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ /* checkerboard so transparent regions of the image read clearly */
+ background: repeating-conic-gradient(#313244 0% 25%, #1e1e2e 0% 50%) 50% / 18px 18px;
+}
+.approval-tabs {
+ display: flex;
+ gap: 0.4em;
+ margin: 0.6em 0 0.4em;
+}
+.approval-tab {
+ background: transparent;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ font: inherit;
+ font-size: 0.85em;
+ letter-spacing: 0.08em;
+ padding: 0.25em 0.9em;
+ cursor: pointer;
+ transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
+}
+.approval-tab:hover { color: var(--fg); }
+.approval-tab.active {
+ color: var(--purple);
+ border-color: var(--purple);
+ background: rgba(203, 166, 247, 0.08);
+ text-shadow: 0 0 4px currentColor;
+}
+.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; }
+.status-approved { color: var(--green); }
+.status-denied { color: var(--red); }
+.status-failed { color: var(--amber); }
+.glyph-approved { color: var(--green); }
+.glyph-denied { color: var(--red); }
+.glyph-failed { color: var(--amber); }
+.meta-inputs {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 0.8em;
+ display: grid;
+ gap: 0.2em;
+}
+.meta-inputs li {
+ padding: 0.25em 0.6em;
+ border: 1px solid var(--border);
+ background: rgba(24, 24, 37, 0.6);
+}
+.meta-inputs label {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5em;
+ cursor: pointer;
+ font-size: 0.9em;
+}
+.meta-input-name { color: var(--amber); font-weight: bold; }
+.meta-input-rev { color: var(--muted); }
+.meta-input-ts { color: var(--muted); font-size: 0.85em; }
+.meta-input-url {
+ color: var(--muted);
+ font-size: 0.85em;
+ margin-left: auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+/* Bulk select-all / -none control above the meta-inputs tree (#275). */
+.meta-inputs-bulk {
+ margin: 0 0 0.5em;
+ font-size: 0.8em;
+ color: var(--muted);
+}
+.meta-bulk-btn {
+ font: inherit;
+ font-size: 1em;
+ background: transparent;
+ border: 1px solid var(--purple-dim);
+ color: var(--cyan);
+ padding: 0.1em 0.6em;
+ margin-right: 0.2em;
+ cursor: pointer;
+}
+.meta-bulk-btn:hover {
+ border-color: var(--cyan);
+ text-shadow: 0 0 6px currentColor;
+}
+/* Tree twig glyph prefixing a nested (sub-)input row (#275). */
+.meta-input-twig {
+ color: var(--purple-dim);
+ margin-right: 0.1em;
+}
+.btn-meta-update {
+ background: rgba(203, 166, 247, 0.12);
+ border: 1px solid var(--purple);
+ color: var(--purple);
+ text-shadow: 0 0 4px currentColor;
+ padding: 0.3em 1em;
+ font: inherit;
+ font-size: 0.85em;
+ letter-spacing: 0.08em;
+ cursor: pointer;
+ transition: box-shadow 0.15s ease, background 0.15s ease;
+}
+.btn-meta-update:hover:not([disabled]) {
+ background: rgba(203, 166, 247, 0.22);
+ box-shadow: 0 0 10px -2px currentColor;
+}
+.btn-meta-update[disabled] {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+/* In-progress banner for the META INPUTS panel: shown while a
+ dashboard-triggered meta-update runs in the background (issue #259). */
+.meta-update-running {
+ margin: 0 0 0.7em;
+ padding: 0.4em 0.7em;
+ border: 1px solid var(--purple);
+ background: rgba(203, 166, 247, 0.12);
+ color: var(--purple);
+ font-size: 0.85em;
+ animation: badge-pulse 1.6s ease-in-out infinite;
+}
+/* ─── rebuild queue panel ──────────────────────────────────────────────── */
+.rebuild-queue {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: grid;
+ gap: 0.2em;
+}
+.rebuild-queue-entry {
+ padding: 0.3em 0.6em;
+ border: 1px solid var(--border);
+ background: rgba(24, 24, 37, 0.6);
+ font-size: 0.9em;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.4em;
+}
+.rebuild-queue-entry.rqe-child { margin-left: 1.6em; border-color: var(--purple-dim); }
+.rebuild-queue-entry.rqe-running {
+ border-color: var(--purple);
+ background: rgba(203, 166, 247, 0.12);
+ animation: badge-pulse 1.6s ease-in-out infinite;
+}
+.rebuild-queue-entry.rqe-failed { border-color: var(--red); color: var(--red); }
+.rebuild-queue-entry.rqe-cancelled { opacity: 0.6; }
+.rebuild-queue-entry.rqe-done { opacity: 0.7; color: var(--green); }
+.rqe-state { font-weight: bold; min-width: 1.2em; text-align: center; }
+.rqe-kind { color: var(--cyan); }
+.rqe-agent { color: var(--amber); font-weight: bold; }
+.rqe-source {
+ font-size: 0.75em;
+ padding: 0.05em 0.45em;
+ border-radius: 0.7em;
+ border: 1px solid var(--border);
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); }
+.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); }
+.rqe-source-auto_update { color: var(--muted); }
+.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
+.rqe-when { color: var(--muted); font-size: 0.85em; }
+.rqe-reason { color: var(--muted); font-size: 0.85em; flex: 1 1 auto; }
+.rqe-error {
+ flex-basis: 100%;
+ margin: 0.3em 0 0;
+ padding: 0.3em 0.5em;
+ background: rgba(243, 139, 168, 0.1);
+ border-left: 2px solid var(--red);
+ color: var(--red);
+ font-size: 0.8em;
+ white-space: pre-wrap;
+}
+.history-note {
+ margin-left: 1.8em;
+ margin-top: 0.2em;
+ color: var(--muted);
+ font-size: 0.85em;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+ul form.inline { display: inline-block; }
+.btn {
+ font-family: inherit;
+ font-weight: bold;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ background: transparent;
+ border: 1px solid;
+ padding: 0.25em 0.8em;
+ cursor: pointer;
+ text-shadow: 0 0 4px currentColor;
+ box-shadow: 0 0 0 0 currentColor;
+ transition: box-shadow 0.15s ease;
+}
+.btn:hover {
+ background: rgba(205, 214, 244, 0.06);
+ text-shadow: 0 0 10px currentColor;
+ box-shadow: 0 0 10px -2px currentColor;
+}
+.btn-approve { color: var(--green); border-color: var(--green); }
+.btn-deny { color: var(--red); border-color: var(--red); }
+.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
+.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
+.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
+.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
+.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
+.btn-talk { color: var(--cyan); border-color: var(--cyan); }
+.btn-spawn { color: var(--amber); border-color: var(--amber); }
+.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
+.spawnform input {
+ font-family: inherit;
+ font-size: 1em;
+ background: var(--bg-elev);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ padding: 0.4em 0.6em;
+ flex: 1;
+}
+.spawnform input::placeholder { color: var(--muted); }
+.spawnform input:focus { outline: 1px solid var(--purple); }
+.role-pending { color: var(--amber); border-color: var(--amber); }
+.btn-inline {
+ font-family: inherit;
+ background: transparent;
+ cursor: pointer;
+ margin-left: 0.4em;
+}
+.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
+.kind {
+ display: inline-block;
+ margin-left: 0.4em;
+ padding: 0.05em 0.5em;
+ border: 1px solid var(--purple-dim);
+ color: var(--purple-dim);
+ border-radius: 2px;
+ font-size: 0.75em;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.kind-spawn { color: var(--amber); border-color: var(--amber); }
+.spinner {
+ display: inline-block;
+ animation: spin 1s linear infinite;
+ color: var(--amber);
+}
+@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+.talkform {
+ display: flex;
+ gap: 0.6em;
+ align-items: stretch;
+ margin-top: 0.5em;
+}
+.talkform select, .talkform input {
+ font-family: inherit;
+ font-size: 1em;
+ background: var(--bg-elev);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ padding: 0.4em 0.6em;
+}
+.talkform select { color: var(--amber); }
+.talkform input { flex: 1; }
+.talkform input::placeholder { color: var(--muted); }
+.talkform input:focus, .talkform select:focus { outline: 1px solid var(--purple); }
+details { margin-top: 0.5em; }
+summary {
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 0.85em;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+}
+summary:hover { color: var(--purple); }
+.diff {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ padding: 0.8em;
+ margin-top: 0.4em;
+ overflow-x: auto;
+ font-size: 0.85em;
+ line-height: 1.4;
+ color: var(--muted);
+ white-space: pre;
+}
+.diff span { display: block; }
+.diff .diff-add { color: var(--green); }
+.diff .diff-del { color: var(--red); }
+.diff .diff-hunk { color: var(--cyan); }
+.diff .diff-file { color: var(--purple); font-weight: bold; }
+.diff .diff-ctx { color: var(--fg); }
+.questions {
+ background: var(--bg-elev);
+ border: 1px solid var(--amber);
+ box-shadow: 0 0 12px -4px var(--amber);
+ padding: 0.6em 0.9em;
+ animation: questions-pulse 2.4s ease-in-out infinite;
+}
+@keyframes questions-pulse {
+ 0%, 100% { box-shadow: 0 0 12px -4px rgba(250, 179, 135, 0.55); }
+ 50% { box-shadow: 0 0 22px -2px rgba(250, 179, 135, 0.95); }
+}
+/* Reminders list — rendered from /api/reminders, separate from the
+ main /api/state snapshot. Each row stacks identity, head meta,
+ body, and a small cancel form. */
+.reminders {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.reminder-row {
+ padding: 0.4em 0;
+ border-bottom: 1px solid var(--border);
+}
+.reminder-row:last-child { border-bottom: 0; }
+.reminder-head { font-size: 0.9em; }
+.reminder-body {
+ color: var(--fg);
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0.3em 0;
+}
+.reminder-row.reminder-failed {
+ border-left: 2px solid var(--red, #f38ba8);
+ padding-left: 0.5em;
+}
+.reminder-error {
+ color: var(--red, #f38ba8);
+ background: rgba(243, 139, 168, 0.06);
+ border: 1px solid rgba(243, 139, 168, 0.25);
+ padding: 0.3em 0.5em;
+ font-size: 0.85em;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 0.2em 0;
+}
+.reminder-actions {
+ display: flex;
+ gap: 0.4em;
+ margin-top: 0.3em;
+}
+
+/* Path linkification — agents drop pointer strings into messages
+ constantly; clicking the anchor opens the file in the side panel,
+ lazy-loaded from /api/state-file. */
+.path-link {
+ color: var(--blue, #89b4fa);
+ text-decoration: underline dotted;
+ cursor: pointer;
+}
+.path-link:hover { color: var(--amber); }
+/* File-preview body — rendered inside the side panel. */
+.path-preview-body {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ padding: 0.5em 0.7em;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-size: 0.85em;
+ color: var(--fg);
+}
+
+/* Filter chip row above the questions list. The active chip lights
+ up amber to match the rest of the dashboard's selection accents. */
+.questions-filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3em;
+ margin-bottom: 0.5em;
+}
+.q-filter-chip {
+ background: var(--bg);
+ color: var(--muted);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0.15em 0.7em;
+ font: inherit;
+ font-size: 0.85em;
+ cursor: pointer;
+}
+.q-filter-chip:hover { color: var(--fg); }
+.q-filter-chip.active {
+ color: var(--amber);
+ border-color: var(--amber);
+}
+/* Peer (agent-to-agent) question rows get a left rule + dim
+ target-name styling so they read distinctly from operator-bound
+ threads at a glance. */
+.questions li.question-peer {
+ border-left: 2px solid var(--mauve, #cba6f7);
+ padding-left: 0.6em;
+}
+.questions .msg-to-peer { color: var(--mauve, #cba6f7); }
+/* The override button on peer threads picks up a non-default colour
+ so the operator notices they're answering on someone's behalf. */
+.btn-override { background: var(--mauve, #cba6f7) !important; color: var(--bg) !important; }
+.questions li.question {
+ padding: 0.4em 0;
+ border-bottom: 1px solid var(--border);
+}
+.questions li.question:last-child { border-bottom: 0; }
+.questions .q-head { font-size: 0.9em; }
+.questions .q-ttl {
+ color: var(--amber);
+ margin-left: 0.4em;
+ font-size: 0.95em;
+ letter-spacing: 0.05em;
+}
+.questions .q-body {
+ color: var(--fg);
+ margin: 0.3em 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.qform {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+ margin-top: 0.4em;
+}
+.qform .q-options {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25em;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.4em 0.6em;
+}
+.qform .q-option label { cursor: pointer; user-select: none; }
+.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
+.qform .q-free { display: flex; }
+.qform .q-free textarea {
+ flex: 1;
+ font-family: inherit;
+ font-size: 1em;
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ padding: 0.4em 0.6em;
+ resize: vertical;
+ line-height: 1.4;
+}
+.qform .q-free textarea::placeholder { color: var(--muted); }
+.qform .q-free textarea:focus { outline: 1px solid var(--amber); }
+.qform button { align-self: flex-start; }
+.qform-cancel { margin-top: 0.3em; }
+.q-history {
+ margin-top: 0.8em;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 0.4em 0.7em;
+}
+.q-history summary { cursor: pointer; color: var(--muted); font-size: 0.9em; user-select: none; }
+.questions-answered {
+ border: none;
+ box-shadow: none;
+ animation: none;
+ padding: 0;
+ margin-top: 0.5em;
+}
+.question-answered { opacity: 0.7; }
+.question-answered .q-body { color: var(--muted); margin-bottom: 0.15em; }
+.q-answer { font-size: 0.9em; color: var(--green, #a6e3a1); padding: 0.1em 0 0.4em 0; }
+.q-answer-text { font-style: italic; }
+.inbox {
+ background: var(--bg-elev);
+ border: 1px solid var(--border);
+ padding: 0.5em 0.8em;
+ max-height: 24em;
+ overflow-y: auto;
+}
+.inbox li {
+ padding: 0.25em 0;
+ border-bottom: 1px solid var(--border);
+ display: grid;
+ grid-template-columns: auto auto auto 1fr;
+ gap: 0.5em;
+ align-items: baseline;
+}
+.inbox li:last-child { border-bottom: 0; }
+.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
+.inbox .msg-from { color: var(--amber); }
+.inbox .msg-sep { color: var(--muted); }
+.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
+/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap` (see
+ hive-fr0nt::TERMINAL_CSS). The msgrow / msg-* rules below are
+ dashboard-specific: each broker event becomes a grid of timestamp +
+ arrow + from/sep/to + body inside the `.row` shell. */
+/* Flex (not grid): the row carries the header chips (ts / arrow /
+ from / → / to / body) inline. Flex collapses whitespace-only text
+ nodes between items and gives `body` the remaining width via
+ `flex: 1`. Path references inside `body` are inline anchors that
+ open the side panel — no full-width sibling rows. */
+.live .msgrow {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5em;
+ padding: 0.1em 0;
+ /* Override the per-agent-terminal's hanging-indent metrics from
+ TERMINAL_CSS — the dashboard's broker rows are flex grids, not
+ glyph-prefixed text, and don't want the prefix column. */
+ text-indent: 0;
+}
+.live .msgrow .msg-body {
+ flex: 1 1 0;
+ /* min-width: 0 lets the body shrink below its longest token so
+ `word-break: break-word` actually kicks in instead of forcing
+ the whole flex line wider than the container. */
+ min-width: 0;
+}
+.live .msgrow.sent .msg-arrow { color: var(--cyan); }
+.live .msgrow.delivered .msg-arrow { color: var(--green); }
+/* Reply-thread rendering: indented border-left + muted reply tag. */
+.live .msgrow.msg-reply {
+ padding-left: 1.2em;
+ border-left: 2px solid var(--border);
+ margin-left: 0.6em;
+}
+.msg-reply-tag {
+ color: var(--muted);
+ font-size: 0.8em;
+ white-space: nowrap;
+ order: -1; /* prepend before other flex items */
+}
+.msg-reply-tag a {
+ color: var(--muted);
+ text-shadow: none;
+ font-weight: normal;
+}
+.msg-reply-tag a:hover { color: var(--fg); }
+/* Flash highlight when scrolled to from a reply link. */
+@keyframes msg-highlight-fade {
+ from { background: rgba(203, 166, 247, 0.18); }
+ to { background: transparent; }
+}
+.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
+.msg-ts { color: var(--muted); font-size: 0.85em; }
+.msg-arrow { font-weight: bold; }
+.msg-from { color: var(--amber); }
+.msg-sep { color: var(--muted); }
+.msg-to { color: var(--pink); }
+.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
+/* Compose box sits inside `.terminal-wrap`, below the `.live` log. The
+ dashed separator mirrors the agent terminal's prompt divider. */
+.op-compose {
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ gap: 0.6em;
+ padding: 0.55em 0.8em;
+ border-top: 1px dashed var(--purple-dim);
+}
+.op-compose-prompt {
+ color: var(--purple);
+ text-shadow: 0 0 4px currentColor;
+ font-weight: bold;
+ white-space: nowrap;
+ user-select: none;
+ padding-top: 0.15em;
+}
+.op-compose-input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: var(--fg);
+ font: inherit;
+ font-size: 0.85em;
+ line-height: 1.5;
+ resize: none;
+ overflow: hidden;
+ min-height: 1.5em;
+ caret-color: var(--purple);
+}
+.op-compose-input::placeholder { color: var(--muted); }
+.op-compose-suggest {
+ position: absolute;
+ bottom: 100%;
+ left: 0.8em;
+ margin-bottom: 0.2em;
+ background: rgba(24, 24, 37, 0.95);
+ border: 1px solid var(--border);
+ font-size: 0.85em;
+ min-width: 12em;
+ max-height: 12em;
+ overflow-y: auto;
+ z-index: 10;
+}
+.op-compose-suggest .item {
+ padding: 0.2em 0.8em;
+ cursor: pointer;
+ color: var(--fg);
+}
+.op-compose-suggest .item.active,
+.op-compose-suggest .item:hover {
+ background: rgba(203, 166, 247, 0.18);
+ color: var(--purple);
+}
+footer {
+ margin-top: 4em;
+ text-align: center;
+ color: var(--muted);
+ font-size: 0.9em;
+}
+footer a { color: var(--purple); }
+
+/* ─── side panel ─────────────────────────────────────────────────
+ Long content (file previews, diffs, journald, applied config)
+ opens in a drawer that swipes in from the right instead of
+ expanding inline. `.panel-trigger` is the inline affordance that
+ opens it. */
+.panel-trigger {
+ background: none;
+ border: none;
+ color: var(--muted);
+ font-family: inherit;
+ font-size: 0.85em;
+ letter-spacing: 0.05em;
+ cursor: pointer;
+ padding: 0;
+ margin-top: 0.5em;
+ display: inline-block;
+ text-align: left;
+ text-decoration: none;
+}
+.panel-trigger:hover { color: var(--cyan); }
+
+.side-panel {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ /* Closed: the wrapper ignores pointer events so the dashboard
+ underneath stays interactive; `.open` flips it back on. */
+ pointer-events: none;
+}
+.side-panel-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+.side-panel-drawer {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ width: min(760px, 94vw);
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-elev);
+ border-left: 2px solid var(--purple);
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.45);
+ transform: translateX(100%);
+ transition: transform 0.25s ease;
+}
+.side-panel.open { pointer-events: auto; }
+.side-panel.open .side-panel-backdrop { opacity: 1; }
+.side-panel.open .side-panel-drawer { transform: translateX(0); }
+.side-panel-head {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1em;
+ padding: 0.7em 1em;
+ border-bottom: 1px solid var(--border);
+}
+.side-panel-title {
+ color: var(--purple);
+ font-weight: bold;
+ letter-spacing: 0.05em;
+ word-break: break-all;
+}
+.side-panel-close {
+ flex: 0 0 auto;
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ font-family: inherit;
+ font-size: 1em;
+ line-height: 1;
+ padding: 0.25em 0.55em;
+ cursor: pointer;
+}
+.side-panel-close:hover { border-color: var(--red); color: var(--red); }
+.side-panel-body {
+ flex: 1 1 auto;
+ overflow: auto;
+ padding: 1em;
+}
+/* Markdown file previews rendered by `marked`. TERMINAL_CSS scopes
+ its own `.md` rules to `.live .row`, so the panel needs its own. */
+.side-panel-body .md { color: var(--fg); line-height: 1.5; }
+.side-panel-body .md > :first-child { margin-top: 0; }
+.side-panel-body .md > :last-child { margin-bottom: 0; }
+.side-panel-body .md p { margin: 0.5em 0; }
+.side-panel-body .md h1,
+.side-panel-body .md h2,
+.side-panel-body .md h3,
+.side-panel-body .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
+.side-panel-body .md code {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 0.05em 0.3em;
+ font-size: 0.9em;
+}
+.side-panel-body .md pre {
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 0.6em 0.8em;
+ overflow-x: auto;
+}
+.side-panel-body .md pre code { background: none; border: none; padding: 0; }
+.side-panel-body .md a { color: var(--cyan); }
+.side-panel-body .md ul,
+.side-panel-body .md ol { margin: 0.4em 0; padding-left: 1.5em; }
+.side-panel-body .md blockquote {
+ margin: 0.5em 0;
+ padding-left: 0.8em;
+ border-left: 2px solid var(--border);
+ color: var(--muted);
+}
+.side-panel-body .md table { border-collapse: collapse; margin: 0.5em 0; }
+.side-panel-body .md th,
+.side-panel-body .md td {
+ border: 1px solid var(--border);
+ padding: 0.2em 0.5em;
+}
diff --git a/frontend/packages/dashboard/src/index.html b/frontend/packages/dashboard/src/index.html
new file mode 100644
index 0000000..be99833
--- /dev/null
+++ b/frontend/packages/dashboard/src/index.html
@@ -0,0 +1,117 @@
+
+
+
+
+ hyperhive // h1ve-c0re
+
+
+
+
+
+░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
+
+
+
+ 🔔 enable notifications
+ 🔕 mute
+ 🔔 unmute
+
+
+
+
+ ◆ C0NTAINERS ◆
+ ══════════════════════════════════════════════════════════════
+
+
+ ◆ K3PT ST4T3 ◆
+ ══════════════════════════════════════════════════════════════
+
+
+ ◆ M3T4 1NPUTS ◆
+ ══════════════════════════════════════════════════════════════
+ select inputs to nix flake update in /meta/. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual rebuilt system event.
+
+
+ ◆ R3BU1LD QU3U3 ◆
+ ══════════════════════════════════════════════════════════════
+ pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.
+
+
+
+ ◆ M1ND H4S QU3STI0NS ◆
+ ══════════════════════════════════════════════════════════════
+
+
+ ◆ QU3U3D R3M1ND3RS ◆
+ ══════════════════════════════════════════════════════════════
+ reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.
+
+
+ ◆ P3NDING APPR0VALS ◆
+ ══════════════════════════════════════════════════════════════
+
+
+
+ ◆ 0PER4T0R 1NB0X ◆
+ ══════════════════════════════════════════════════════════════
+
+
+ ◆ MESS4GE FL0W ◆
+ ══════════════════════════════════════════════════════════════
+ live tail — newest at the top. tap on every send / recv through the broker. compose below: @name picks the recipient (sticky until you @ someone else); tab completes.
+
+
+
+ ══════════════════════════════════════════════════════════════
+ ▲△▲ hyperhive ▲△▲ hive-c0re on this host ▲△▲
+
+
+
+
+
+
+
+
+
diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json
new file mode 100644
index 0000000..795ed42
--- /dev/null
+++ b/frontend/packages/shared/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "@hive/shared",
+ "version": "0.0.0",
+ "private": true,
+ "description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
+ "type": "module",
+ "main": "./src/index.js",
+ "exports": {
+ ".": "./src/index.js",
+ "./terminal.js": "./src/terminal.js",
+ "./base.css": "./src/base.css",
+ "./terminal.css": "./src/terminal.css"
+ },
+ "files": [
+ "src/"
+ ]
+}
diff --git a/frontend/packages/shared/src/base.css b/frontend/packages/shared/src/base.css
new file mode 100644
index 0000000..ee7b64e
--- /dev/null
+++ b/frontend/packages/shared/src/base.css
@@ -0,0 +1,24 @@
+/* Base palette + typography shared by the hive-c0re dashboard and the
+ hive-ag3nt web UI. Catppuccin Mocha. Per-page stylesheets append on
+ top of this and must NOT redeclare the colour variables — the whole
+ point of pulling them out is one source of truth. */
+:root {
+ --bg: #1e1e2e; /* base */
+ --bg-elev: #181825; /* mantle */
+ --fg: #cdd6f4; /* text */
+ --muted: #7f849c; /* overlay1 */
+ --purple: #cba6f7; /* mauve */
+ --purple-dim: #45475a;/* surface1 */
+ --cyan: #89dceb; /* sky */
+ --pink: #f5c2e7; /* pink */
+ --amber: #fab387; /* peach */
+ --green: #a6e3a1; /* green */
+ --red: #f38ba8; /* red */
+ --border: #313244; /* surface0 */
+}
+body {
+ background: var(--bg);
+ color: var(--fg);
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
+ line-height: 1.6;
+}
diff --git a/frontend/packages/shared/src/index.js b/frontend/packages/shared/src/index.js
new file mode 100644
index 0000000..49f5c1d
--- /dev/null
+++ b/frontend/packages/shared/src/index.js
@@ -0,0 +1,3 @@
+// Convenience re-export so consumers can `import { create, linkify }
+// from '@hive/shared'` without naming the sub-module path.
+export { create, linkify } from './terminal.js';
diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css
new file mode 100644
index 0000000..b28449a
--- /dev/null
+++ b/frontend/packages/shared/src/terminal.css
@@ -0,0 +1,228 @@
+/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill.
+ Pages wrap their stream container in `.terminal-wrap` and give the log
+ itself the `.live` class; renderer JS appends `.row` (flat line) or
+ `details.row` (collapsible body) elements. Row-kind classes
+ (`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event
+ colour; pages that don't emit a given kind simply never produce that
+ class — the unused rule sits in the bundle harmlessly.
+
+ `.terminal-wrap` provides the crust-on-black phosphor chrome that makes
+ the agent page feel like a terminal. Pages can opt in by wrapping a
+ block in this class; or skip it and the rows still render with their
+ class colours, just without the frame.
+
+ No `.term-input` here — composers are a separate concern (see
+ hive-fr0nt::COMPOSER_CSS / COMPOSER_JS once introduced). */
+
+.terminal-wrap {
+ position: relative;
+ background: rgba(17, 17, 27, 0.78);
+ -webkit-backdrop-filter: blur(8px) saturate(120%);
+ backdrop-filter: blur(8px) saturate(120%);
+ border: 1px solid var(--purple-dim);
+ box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
+ border-radius: 4px;
+ font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
+ font-size: 0.92em;
+ color: var(--fg);
+ margin-top: 0.6em;
+}
+.live {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--purple-dim);
+ padding: 0.4em 0.6em;
+ overflow-y: auto;
+ max-height: 32em;
+ font-family: inherit;
+}
+.live.terminal {
+ background: transparent;
+ border: 0;
+ box-shadow: none;
+ border-radius: 0;
+ padding: 0.8em 1em 0.4em;
+ overflow-y: auto;
+ height: min(72vh, 60em);
+ max-height: none;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+}
+.live .row,
+.live details.row {
+ animation: row-fade-in 220ms ease-out both;
+}
+.live .row.no-anim,
+.live details.row.no-anim {
+ animation: none;
+}
+@keyframes row-fade-in {
+ from { opacity: 0; transform: translateY(4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+/* Unified prefix column for every row kind. The glyph (`→ ← · ◆ ✓ ✗ ⌁ !`)
+ is the first character of the row's text content; `padding-left` reserves
+ the column and `text-indent: -1.4em` pulls the glyph back into it. Wrapped
+ continuation lines then start under the body, not under the glyph, so
+ wraps don't blur into the next row. `details.row` summaries reuse the
+ same metrics below. */
+.live .row {
+ white-space: pre-wrap;
+ word-break: break-word;
+ padding: 0.05em 0;
+ line-height: 1.45;
+ border-left: 2px solid transparent;
+ padding-left: 1.9em;
+ text-indent: -1.4em;
+ margin: 0.1em 0;
+}
+.live .row + .row { border-top: 0; }
+/* Row-kind colours. Pages register renderers that emit these classes;
+ any class no page emits is just dead CSS, which is fine. Turn-framing
+ classes carry their signal entirely on the coloured border-left rule —
+ no bold, no top/bottom margins, no background tint. The chrome was
+ overweight for what's just a "this is a boundary" marker. */
+.live .turn-start { color: var(--amber); border-left-color: var(--amber); }
+/* turn-body is a child block under turn-start carrying the wake-prompt
+ body; reset text-indent so wrapped content stays under its own column
+ instead of pulling back into the parent's prefix. */
+.live .turn-body { color: var(--fg); text-indent: 0; margin-top: 0.15em; }
+/* Any child block (markdown body, nested details) resets the parent
+ row's hanging indent so the content lays out from column 0 of the
+ body area. */
+.live .row .md, .live .row > details { text-indent: 0; }
+.live .turn-end-ok { color: var(--green); border-left-color: var(--green); }
+.live .turn-end-fail { color: var(--red); border-left-color: var(--red); }
+.live .text { color: var(--fg); }
+.live .thinking { color: var(--muted); font-style: italic; }
+.live .tool-use { color: var(--cyan); }
+.live .tool-result { color: var(--muted); }
+.live .result { color: var(--green); }
+.live .note { color: var(--muted); }
+/* Distinguish stderr lines (orange) and operator-initiated notes
+ (mauve, lightly emphasised) from ambient harness chatter so the
+ eye picks out anomalies + operator actions in the scrollback. */
+.live .note.stderr { color: var(--amber); }
+.live .note.op { color: var(--purple); font-style: italic; }
+/* The .sys catch-all fires when renderStream landed an event shape it
+ couldn't classify. Make it visually loud so silently-dropped event
+ types surface for follow-up. */
+.live .sys { color: var(--amber); }
+.live .unread-badge {
+ color: var(--amber);
+ font-weight: normal;
+ margin-left: 0.6em;
+ font-size: 0.85em;
+ text-shadow: 0 0 6px rgba(250, 179, 135, 0.55);
+ animation: badge-pulse 1.4s ease-in-out infinite;
+}
+@keyframes badge-pulse {
+ 0%, 100% { opacity: 1; text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
+ 50% { opacity: 0.7; text-shadow: 0 0 14px rgba(250, 179, 135, 0.95); }
+}
+/* "↓ N new" pill: shown when new rows arrive while the operator is
+ scrolled up; click to jump to bottom. Positioned by the wrapper's
+ `position: relative` (terminal-wrap supplies it; pages that skip the
+ wrapper must add their own positioned ancestor). */
+.tail-pill {
+ position: absolute;
+ right: 1em;
+ bottom: 4.2em;
+ background: var(--amber);
+ color: #11111b;
+ font-family: inherit;
+ font-size: 0.8em;
+ font-weight: bold;
+ letter-spacing: 0.08em;
+ border: 0;
+ border-radius: 999px;
+ padding: 0.35em 0.9em;
+ cursor: pointer;
+ box-shadow: 0 0 14px -2px rgba(250, 179, 135, 0.85);
+ opacity: 0;
+ transform: translateY(6px);
+ pointer-events: none;
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+.tail-pill.visible {
+ opacity: 1;
+ transform: translateY(0);
+ pointer-events: auto;
+}
+.tail-pill:hover { filter: brightness(1.1); }
+/* Expandable rows reuse the flat-row prefix metrics (padding-left +
+ negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
+ exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
+ Summary text omits the per-row directional glyph (the row colour
+ already carries cyan = outbound tool, muted = inbound result) so
+ the prefix column doesn't have to fit two glyphs side-by-side. */
+details.row {
+ white-space: normal;
+}
+details.row > summary {
+ cursor: pointer;
+ list-style: none;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+details.row > summary::before {
+ content: '▸ ';
+ color: inherit;
+}
+details.row[open] > summary::before { content: '▾ '; }
+details.row > pre.diff-body,
+details.row > pre.tool-body {
+ margin: 0.3em 0 0.4em 0;
+ padding: 0.4em 0.6em;
+ text-indent: 0;
+ background: rgba(255, 255, 255, 0.02);
+ border-left: 2px solid var(--purple-dim);
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 22em;
+ overflow-y: auto;
+}
+details.row > pre.tool-body { color: var(--fg); }
+details.row > pre.diff-body .diff-add { color: var(--green); }
+details.row > pre.diff-body .diff-del { color: var(--red); }
+details.row > pre.diff-body .diff-ctx { color: var(--fg); }
+/* Markdown body inside a row (assistant text, send/recv/ask/answer
+ message bodies). Inline elements get muted accents; block elements
+ reset the parent row's hanging indent so content lays out cleanly. */
+.live .row .md p { margin: 0.2em 0; }
+.live .row .md p:first-child { margin-top: 0; }
+.live .row .md p:last-child { margin-bottom: 0; }
+.live .row .md code {
+ background: rgba(255, 255, 255, 0.06);
+ padding: 0.05em 0.3em;
+ border-radius: 3px;
+ font-size: 0.95em;
+}
+.live .row .md pre {
+ margin: 0.3em 0;
+ padding: 0.4em 0.6em;
+ background: rgba(255, 255, 255, 0.04);
+ border-left: 2px solid var(--purple-dim);
+ text-indent: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+.live .row .md pre code {
+ background: transparent;
+ padding: 0;
+ border-radius: 0;
+}
+.live .row .md a { color: var(--cyan); text-decoration: underline; }
+/* Auto-linkified bare URLs in plain rows + tool-body blocks (issue #233). */
+.live .row a { color: var(--cyan); text-decoration: underline; }
+.live .row a:hover { color: var(--fg); }
+.live .row .md strong { color: inherit; font-weight: bold; }
+.live .row .md em { color: inherit; font-style: italic; }
+.live .row .md ul, .live .row .md ol { margin: 0.2em 0 0.2em 1.4em; padding: 0; }
+.live .row .md li { margin: 0.05em 0; }
+.live .row .md blockquote {
+ margin: 0.2em 0;
+ padding-left: 0.6em;
+ border-left: 2px solid var(--purple-dim);
+ color: var(--muted);
+}
diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js
new file mode 100644
index 0000000..0726a8c
--- /dev/null
+++ b/frontend/packages/shared/src/terminal.js
@@ -0,0 +1,342 @@
+// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
+// backfill + live SSE. Pages provide a kind→renderer map; this module
+// owns scroll behaviour, animation suppression on backfill, and the
+// EventSource lifecycle.
+//
+// Usage:
+//
+// import { create, linkify } from '@hive/shared/terminal.js';
+//
+// create({
+// logEl: document.getElementById('msgflow'),
+// historyUrl: '/messages/history?limit=200', // optional
+// streamUrl: '/messages/stream',
+// renderers: {
+// sent: (ev, api) => api.row('msgrow sent', ...),
+// delivered: (ev, api) => api.row('msgrow delivered', ...),
+// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
+// },
+// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
+// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
+// both backfill replay and live — use for derived views that need
+// the full picture (e.g. a per-recipient inbox built from broker
+// events) */ },
+// onBackfillDone: (count) => { /* one-shot after history replay */ },
+// onStreamOpen: () => { /* fires on every EventSource (re)connect —
+// use to re-sync snapshot-derived state after a reconnect gap */ },
+// pillAnchor: document.getElementById('msgflow').parentElement,
+// });
+//
+// Renderers receive (ev, api) where api exposes:
+//
+// api.row(cls, text) → appends a flat
+// api.details(cls, summary, body) → appends
+// with a
+// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
+// leading "+ " / "- " prefix
+// api.placeholder(text) → replaces log content with a single
+// muted "(placeholder)" row, cleared
+// on the next real row
+// api.fromHistory → true while backfill is replaying
+//
+// Each kind is dispatched to `renderers[ev.kind]`; unknown kinds fall
+// through to `renderers._default` (which itself defaults to a JSON-dump
+// note row). The convention is that the SSE/history endpoints emit
+// objects with a `kind` field.
+//
+// Backfill is best-effort: if `historyUrl` is unset or the fetch fails,
+// we skip straight to SSE. The optional `onBackfillDone(count)` hook
+// fires after replay finishes (or after a failed/skipped fetch with
+// count=0); pages use it to set state flags from the replayed history.
+
+const NEAR_BOTTOM_PX = 48;
+
+export function create(opts) {
+ const log = opts.logEl;
+ if (!log) throw new Error('HiveTerminal.create: logEl is required');
+ const renderers = opts.renderers || {};
+ const defaultRender = renderers._default
+ || ((ev, api) => api.row('note', JSON.stringify(ev)));
+ const pillAnchor = opts.pillAnchor || log.parentElement || log;
+
+ let placeholderEl = null;
+ let pill = null;
+ let unseen = 0;
+ let currentNoAnim = false;
+
+ function isNearBottom() {
+ return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
+ }
+ function ensurePill() {
+ if (pill) return pill;
+ pill = document.createElement('button');
+ pill.type = 'button';
+ pill.className = 'tail-pill';
+ pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; });
+ pillAnchor.appendChild(pill);
+ return pill;
+ }
+ function updatePill() {
+ if (unseen <= 0) {
+ if (pill) pill.classList.remove('visible');
+ return;
+ }
+ ensurePill();
+ pill.textContent = '↓ ' + unseen + ' new';
+ pill.classList.add('visible');
+ }
+ log.addEventListener('scroll', () => {
+ if (isNearBottom()) { unseen = 0; updatePill(); }
+ });
+ function afterAppend() {
+ if (currentNoAnim || isNearBottom()) {
+ log.scrollTop = log.scrollHeight;
+ } else {
+ unseen += 1;
+ updatePill();
+ }
+ }
+ function clearPlaceholder() {
+ if (placeholderEl && placeholderEl.parentElement === log) {
+ log.removeChild(placeholderEl);
+ }
+ placeholderEl = null;
+ }
+ function placeholder(text) {
+ clearPlaceholder();
+ const e = document.createElement('div');
+ e.className = 'row note';
+ e.textContent = text;
+ log.appendChild(e);
+ placeholderEl = e;
+ }
+ function row(cls, text) {
+ clearPlaceholder();
+ const e = document.createElement('div');
+ e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
+ e.appendChild(linkify(text));
+ log.appendChild(e);
+ afterAppend();
+ return e;
+ }
+ function details(cls, summary, body) {
+ clearPlaceholder();
+ const d = document.createElement('details');
+ d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
+ const s = document.createElement('summary');
+ s.textContent = summary;
+ d.appendChild(s);
+ const pre = document.createElement('pre');
+ pre.className = 'tool-body';
+ pre.appendChild(linkify(body));
+ d.appendChild(pre);
+ log.appendChild(d);
+ afterAppend();
+ return d;
+ }
+ function detailsDiff(cls, summary, body) {
+ clearPlaceholder();
+ const d = document.createElement('details');
+ d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
+ const s = document.createElement('summary');
+ s.textContent = summary;
+ d.appendChild(s);
+ const pre = document.createElement('pre');
+ pre.className = 'tool-body diff-body';
+ for (const line of String(body).split('\n')) {
+ const span = document.createElement('span');
+ if (line.startsWith('+ ')) span.className = 'diff-add';
+ else if (line.startsWith('- ')) span.className = 'diff-del';
+ else span.className = 'diff-ctx';
+ span.textContent = line + '\n';
+ pre.appendChild(span);
+ }
+ d.appendChild(pre);
+ log.appendChild(d);
+ afterAppend();
+ return d;
+ }
+
+ function api(extra) {
+ return Object.assign({
+ row, details, detailsDiff, placeholder, linkify,
+ fromHistory: false,
+ }, extra || {});
+ }
+ function dispatch(ev, fromHistory) {
+ const r = renderers[ev.kind] || defaultRender;
+ try {
+ r(ev, api({ fromHistory }));
+ } catch (err) {
+ console.error('terminal renderer threw', ev, err);
+ row('note', '[render err] ' + (err && err.message ? err.message : err));
+ }
+ if (opts.onAnyEvent) {
+ try { opts.onAnyEvent(ev, { fromHistory }); }
+ catch (err) { console.error('onAnyEvent threw', err); }
+ }
+ }
+
+ // Subscribe → buffer → fetch history → dedupe → apply.
+ //
+ // Race the SSE subscription opens before the history fetch starts.
+ // Live events that land before history resolves are buffered, not
+ // rendered. Once the history response (`{ seq, events }`) arrives we:
+ // 1. Replay `events` (fromHistory=true).
+ // 2. Drop buffered events with `seq <= history.seq` — they're
+ // already reflected in the history rows above.
+ // 3. Apply remaining buffered events (fromHistory=false).
+ // 4. Switch to live mode: each new SSE event dispatches immediately.
+ //
+ // Without this dance an event that fires between history-fetch and
+ // SSE-subscribe goes missing; without seq dedupe the same event
+ // shows twice (once via history, once via live buffer). Both bugs
+ // were latent before.
+ //
+ // If `historyUrl` is unset we skip the dance: buffered events apply
+ // as live the moment the buffer flushes (no dedupe possible without
+ // a boundary seq).
+ function start() {
+ let live = false;
+ let buffered = [];
+
+ const es = new EventSource(opts.streamUrl);
+ es.onmessage = (e) => {
+ let ev;
+ try { ev = JSON.parse(e.data); }
+ catch (err) { row('note', '[parse err] ' + e.data); return; }
+ if (!live) { buffered.push(ev); return; }
+ dispatch(ev, false);
+ if (opts.onLiveEvent) {
+ try { opts.onLiveEvent(ev); }
+ catch (err) { console.error('onLiveEvent threw', err); }
+ }
+ };
+ es.onerror = () => {
+ if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]');
+ else row('note', '[disconnected]');
+ };
+ es.onopen = () => {
+ // Fires on the initial connect and on every automatic
+ // reconnect. EventSource never replays events that fired
+ // during a disconnect window, so a consumer with
+ // snapshot-derived state (the dashboard's /api/state stores)
+ // must re-sync here or it shows stale state until a manual
+ // reload (issue #163).
+ if (opts.onStreamOpen) {
+ try { opts.onStreamOpen(); }
+ catch (err) { console.error('onStreamOpen threw', err); }
+ }
+ };
+
+ function flushBuffered(boundarySeq, historyKinds) {
+ const drained = buffered;
+ buffered = [];
+ live = true;
+ for (const ev of drained) {
+ // Seq-dedupe only events of a kind that actually appeared in
+ // the history replay — those are the only ones that could
+ // double (once via history, once via the live buffer).
+ // Mutation events (approval/question/container/…) are never
+ // carried by the history endpoint; deduping them against the
+ // broker-history seq would wrongly drop ones that fired
+ // between a consumer's own snapshot read and this history
+ // fetch (issue #163). ev.seq absent/0 → no dedupe possible.
+ if (boundarySeq != null
+ && typeof ev.seq === 'number' && ev.seq <= boundarySeq
+ && historyKinds && historyKinds.has(ev.kind)) {
+ continue;
+ }
+ dispatch(ev, false);
+ if (opts.onLiveEvent) {
+ try { opts.onLiveEvent(ev); }
+ catch (err) { console.error('onLiveEvent threw', err); }
+ }
+ }
+ }
+
+ async function backfill() {
+ if (!opts.historyUrl) {
+ flushBuffered(null);
+ if (opts.onBackfillDone) opts.onBackfillDone(0);
+ return;
+ }
+ try {
+ const resp = await fetch(opts.historyUrl);
+ if (!resp.ok) {
+ flushBuffered(null);
+ if (opts.onBackfillDone) opts.onBackfillDone(0);
+ return;
+ }
+ const body = await resp.json();
+ // Accept the envelope `{ seq, events }`. A bare array means
+ // the server hasn't been updated to include seq yet — treat
+ // it as "no dedupe possible."
+ const events = Array.isArray(body) ? body : (body.events || []);
+ const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
+ // Kinds present in the history replay — the only kinds that
+ // can double and therefore the only ones to seq-dedupe.
+ const historyKinds = new Set(events.map((ev) => ev.kind));
+ currentNoAnim = true;
+ for (const ev of events) dispatch(ev, true);
+ currentNoAnim = false;
+ if (events.length) row('note', '─── live (older above) ───');
+ else placeholder('(connected — waiting for events)');
+ flushBuffered(boundarySeq, historyKinds);
+ if (opts.onBackfillDone) opts.onBackfillDone(events.length);
+ } catch (err) {
+ console.warn('history backfill failed', err);
+ flushBuffered(null);
+ if (opts.onBackfillDone) opts.onBackfillDone(0);
+ }
+ }
+ return backfill();
+ }
+
+ const ready = start();
+ return { row, details, detailsDiff, placeholder, ready };
+}
+
+// Build a DocumentFragment from `text`, turning bare http(s) URLs into
+// clickable links that open in a new tab. Non-URL text stays as plain
+// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
+// punctuation is kept out of the link. (issue #233)
+const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
+export function linkify(text) {
+ const str = text == null ? '' : String(text);
+ const frag = document.createDocumentFragment();
+ if (str.indexOf('://') === -1) { // fast path: no URLs
+ if (str) frag.appendChild(document.createTextNode(str));
+ return frag;
+ }
+ let last = 0;
+ let m;
+ LINKIFY_URL_RE.lastIndex = 0;
+ while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
+ let url = m[0];
+ // Don't swallow trailing punctuation that's really sentence text.
+ const trail = url.match(/[.,;:!?)\]}'"]+$/);
+ const tail = trail ? trail[0] : '';
+ if (tail) url = url.slice(0, -tail.length);
+ if (m.index > last) {
+ frag.appendChild(document.createTextNode(str.slice(last, m.index)));
+ }
+ if (!url.slice(url.indexOf('://') + 3)) {
+ // Nothing past the scheme — not a real URL, emit verbatim.
+ frag.appendChild(document.createTextNode(m[0]));
+ } else {
+ const a = document.createElement('a');
+ a.href = url; // regex only matches https?:// — safe
+ a.textContent = url;
+ a.target = '_blank';
+ a.rel = 'noopener noreferrer';
+ frag.appendChild(a);
+ if (tail) frag.appendChild(document.createTextNode(tail));
+ }
+ last = m.index + m[0].length;
+ }
+ if (last < str.length) {
+ frag.appendChild(document.createTextNode(str.slice(last)));
+ }
+ return frag;
+}