diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..31994d8 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +packages/*/dist/ diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5564ab0 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,33 @@ +# hyperhive frontend + +npm workspaces project for the hyperhive browser-facing assets: + +- `packages/shared/` — shared modules used by both surfaces (terminal + pane, Catppuccin palette + body typography). +- `packages/dashboard/` — the hive-c0re dashboard SPA. +- `packages/agent/` — the per-container web UI (default agent page, + stats, screen). + +## Build + +``` +npm install # one-off; uses the checked-in package-lock.json +npm run build # builds every workspace into packages/*/dist/ +``` + +The Rust binaries serve `packages/dashboard/dist/` and +`packages/agent/dist/` via `tower_http::ServeDir` at runtime; the +build derivation is wired up in `nix/modules/frontend.nix`. Per-agent +additions are layered on top of the default agent dist via the +`hyperhive.frontend.extraFiles` option in `agent.nix`. + +## Why npm + esbuild + +- **Hermetic**: dependencies vendored via the checked-in lockfile; + `buildNpmPackage` in nix uses it as the source-of-truth so the + output is reproducible without network access at build time. +- **esbuild**: vanilla-JS bundler, no framework runtime overhead. + Each workspace's `build.mjs` is ~30 lines. +- **Single-PR migration**: see issue #273 for the design proposal and + the four-commit shape (npm scaffold → nix derivations → container + plumbing → Rust cutover). diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..a1fb781 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "hyperhive-frontend", + "version": "0.0.0", + "private": true, + "description": "Frontend assets for the hyperhive dashboard and per-agent UIs. Built with esbuild into static dist directories that the Rust binaries serve via tower_http::ServeDir.", + "workspaces": [ + "packages/shared", + "packages/dashboard", + "packages/agent" + ], + "scripts": { + "build": "npm run build --workspaces --if-present", + "clean": "rm -rf packages/*/dist" + }, + "devDependencies": { + "esbuild": "0.24.0" + } +} diff --git a/frontend/packages/agent/build.mjs b/frontend/packages/agent/build.mjs new file mode 100644 index 0000000..c5b1c59 --- /dev/null +++ b/frontend/packages/agent/build.mjs @@ -0,0 +1,56 @@ +// esbuild build for @hive/agent. Outputs: +// +// dist/app.js bundled ES module (entrypoint = src/app.js, +// pulls in @hive/shared + marked) +// dist/agent.css bundled stylesheet (entrypoint = src/agent.css, +// @import "@hive/shared/base.css" + terminal.css +// get inlined here) +// dist/stats.js bundled stats page (entrypoint = src/stats.js, +// pulls in chart.js/auto) +// dist/{index,stats,screen}.html copies of src/*.html (esbuild +// doesn't process HTML; the bundled siblings are +// referenced by name) +// +// The in-container Rust binary serves `dist/` via tower_http::ServeDir, +// with the per-agent `hyperhive.frontend.extraFiles` layered on top. + +import { build } from 'esbuild'; +import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = (p) => resolve(here, 'src', p); +const dist = (p) => resolve(here, 'dist', p); + +rmSync(dist(''), { recursive: true, force: true }); +mkdirSync(dist(''), { recursive: true }); + +// Two JS entries: the main app + the stats page. Both bundle their +// own deps so each page can be loaded independently. +await build({ + entryPoints: [src('app.js'), src('stats.js')], + outdir: dist(''), + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2022'], + sourcemap: true, + logLevel: 'info', +}); + +// Bundle the CSS — the @import lines pull in shared/base.css and +// shared/terminal.css from the @hive/shared workspace dep. +await build({ + entryPoints: [src('agent.css')], + outfile: dist('agent.css'), + bundle: true, + loader: { '.css': 'css' }, + logLevel: 'info', +}); + +for (const html of ['index.html', 'stats.html', 'screen.html']) { + copyFileSync(src(html), dist(html)); +} + +console.log('agent build ok →', dist('')); diff --git a/frontend/packages/agent/package.json b/frontend/packages/agent/package.json new file mode 100644 index 0000000..0d0c175 --- /dev/null +++ b/frontend/packages/agent/package.json @@ -0,0 +1,15 @@ +{ + "name": "@hive/agent", + "version": "0.0.0", + "private": true, + "description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.", + "type": "module", + "scripts": { + "build": "node ./build.mjs" + }, + "dependencies": { + "@hive/shared": "*", + "marked": "4.3.0", + "chart.js": "4.4.4" + } +} diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css new file mode 100644 index 0000000..6810320 --- /dev/null +++ b/frontend/packages/agent/src/agent.css @@ -0,0 +1,389 @@ +/* Shared Catppuccin palette + body typography + terminal pane styles. + Bundled in front of the agent-only rules below via esbuild. */ +@import "@hive/shared/base.css"; +@import "@hive/shared/terminal.css"; + +body { + max-width: 110em; + 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; } +} +h2, h3 { + color: var(--purple); + text-transform: uppercase; + letter-spacing: 0.15em; + text-shadow: 0 0 8px rgba(203, 166, 247, 0.4); +} +.title-row { + display: flex; + align-items: center; + gap: 0.6rem; +} +.title-row h2 { margin: 0; } +.agent-icon { + width: 40px; + height: 40px; + border-radius: 6px; + flex-shrink: 0; +} +.meta { color: var(--muted); font-size: 0.85em; } +.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); } +.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); } +code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; } +a { + color: var(--cyan); + 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); } +.btn { + font-family: inherit; + font-size: 1em; + background: var(--bg); + border: 1px solid var(--purple); + color: var(--purple); + padding: 0.25em 0.8em; + cursor: pointer; + letter-spacing: 0.1em; +} +.btn { + text-shadow: 0 0 4px currentColor; + transition: box-shadow 0.15s ease, text-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-login { color: var(--amber); border-color: var(--amber); } +.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; } +.btn-rebuild { + color: var(--amber); + border: 1px solid var(--amber); + padding: 0.15em 0.6em; + font-size: 0.55em; + font-family: inherit; + text-decoration: none; + letter-spacing: 0.1em; + margin-left: 0.6em; + vertical-align: middle; + cursor: pointer; +} +.btn-rebuild:hover { background: rgba(250, 179, 135, 0.1); } +.btn-send { color: var(--green); border-color: var(--green); } +.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; } +.sendform input { + font-family: inherit; font-size: 1em; + background: rgba(255, 255, 255, 0.04); + color: var(--fg); + border: 1px solid var(--purple-dim); + padding: 0.4em 0.6em; + flex: 1; +} +.sendform input:focus { outline: 1px solid var(--purple); } +.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; } +.loginform input { + font-family: inherit; font-size: 1em; + background: rgba(255, 255, 255, 0.04); + color: var(--fg); + border: 1px solid var(--purple-dim); + padding: 0.4em 0.6em; + flex: 1; +} +.loginform input:focus { outline: 1px solid var(--purple); } +pre.diff { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--purple-dim); + padding: 0.6em 0.8em; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; + max-height: 30em; +} +#state-row { + margin: 0.4em 0 0.2em; + display: flex; + align-items: center; + gap: 0.6em; +} +/* Per-agent inbox section — collapsible, dim, lives between the + state row and the terminal so the operator can peek at what + landed without scrolling through the live tail. */ +.agent-inbox { + margin: 0.4em 0; + font-size: 0.85em; + color: var(--muted); +} +.agent-inbox > summary { + cursor: pointer; + letter-spacing: 0.05em; + list-style: none; +} +.agent-inbox > summary::marker { content: ''; } +.agent-inbox[open] > summary > span::before { content: ''; } +.agent-inbox ul { + list-style: none; + padding: 0.4em 0.8em; + margin: 0.3em 0 0; + background: rgba(255, 255, 255, 0.02); + border-left: 2px solid var(--purple-dim); + max-height: 16em; + overflow-y: auto; +} +.agent-inbox li { + padding: 0.15em 0; + display: grid; + grid-template-columns: auto auto auto 1fr; + gap: 0.5em; + align-items: baseline; +} +.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; } +.agent-inbox .inbox-from { color: var(--amber); } +.agent-inbox .inbox-sep { color: var(--muted); } +.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } +.agent-inbox li.inbox-reply { + padding-left: 1em; + border-left: 2px solid var(--border); + margin-left: 0.4em; +} +.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; } + +.agent-inbox .answer-form { + grid-column: 1 / -1; + display: flex; + gap: 0.4em; + align-items: flex-start; + margin-top: 0.25em; +} +.agent-inbox .answer-form textarea { + flex: 1; + font-family: inherit; + font-size: inherit; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.3em; + resize: vertical; +} +.agent-inbox .answer-form button { + font-family: inherit; + font-size: inherit; + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.3em 0.7em; + cursor: pointer; + white-space: nowrap; +} +.agent-inbox .answer-form button:hover:not(:disabled) { + border-color: var(--purple); + color: var(--purple); +} +.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; } +.agent-inbox .answer-status { color: var(--muted); align-self: center; } + +.last-turn { + color: var(--muted); + font-size: 0.8em; + letter-spacing: 0.05em; +} +.model-chip { + display: inline-block; + padding: 0.1em 0.6em; + border: 1px solid var(--purple-dim); + border-radius: 999px; + color: var(--cyan); + font-size: 0.78em; + letter-spacing: 0.04em; +} +/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens" + chip — single primary number (total prompt tokens in use), full + breakdown on hover. Sized/coloured like a peer of model-chip so + the state row reads as one row of chrome. */ +.ctx-badge { + display: inline-block; + padding: 0.1em 0.6em; + border: 1px solid var(--purple-dim); + border-radius: 999px; + color: var(--green); + font-size: 0.78em; + letter-spacing: 0.04em; + cursor: default; + white-space: pre-line; +} + +/* Harness reachability badge. Same chip shape + sizing as + `.state-badge` / `.model-chip` so the state row stays visually + uniform; colour communicates the actual reachability state. */ +.status-badge { + display: inline-block; + padding: 0.25em 0.8em; + border: 1px solid; + border-radius: 999px; + font-size: 0.85em; + letter-spacing: 0.05em; +} +.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); } +.status-badge.status-online { color: var(--green); border-color: var(--green); + text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); } +.status-badge.status-rate-limited { color: var(--red); border-color: var(--red); + text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); } +.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); } +.status-badge.status-offline { color: var(--muted); border-color: var(--muted); } +.btn-dashlink { + color: var(--cyan); + border: 1px solid var(--cyan); + padding: 0.15em 0.6em; + font-size: 0.55em; + font-family: inherit; + text-decoration: none; + letter-spacing: 0.1em; + margin-left: 0.6em; + vertical-align: middle; +} +.btn-dashlink:hover { + background: rgba(137, 220, 235, 0.1); + box-shadow: 0 0 10px -2px currentColor; +} +.btn-cancel-turn { + font-family: inherit; + font-size: 0.8em; + letter-spacing: 0.08em; + background: transparent; + color: var(--red); + border: 1px solid var(--red); + border-radius: 999px; + padding: 0.2em 0.8em; + cursor: pointer; + text-shadow: 0 0 4px currentColor; + transition: box-shadow 0.15s ease, background 0.15s ease; +} +.btn-cancel-turn:hover { + background: rgba(243, 139, 168, 0.1); + box-shadow: 0 0 10px -2px currentColor; +} +.btn-new-session { + font-family: inherit; + font-size: 0.8em; + letter-spacing: 0.08em; + background: transparent; + color: var(--amber); + border: 1px solid var(--amber); + border-radius: 999px; + padding: 0.2em 0.8em; + cursor: pointer; + text-shadow: 0 0 4px currentColor; + transition: box-shadow 0.15s ease, background 0.15s ease; +} +.btn-new-session:hover { + background: rgba(250, 179, 135, 0.1); + box-shadow: 0 0 10px -2px currentColor; +} +.btn-new-session:disabled { + opacity: 0.4; + cursor: progress; +} +.state-badge { + display: inline-block; + padding: 0.25em 0.8em; + border: 1px solid; + border-radius: 999px; + font-size: 0.85em; + letter-spacing: 0.05em; + transition: color 280ms ease, border-color 280ms ease, + box-shadow 280ms ease, background 280ms ease; +} +.state-badge.state-loading { + color: var(--muted); border-color: var(--purple-dim); +} +.state-badge.state-offline { + color: var(--muted); border-color: var(--muted); +} +.state-badge.state-idle { + color: var(--cyan); border-color: var(--cyan); + text-shadow: 0 0 6px rgba(137, 220, 235, 0.55); +} +.state-badge.state-thinking { + color: var(--amber); border-color: var(--amber); + text-shadow: 0 0 6px rgba(250, 179, 135, 0.65); + animation: badge-pulse 1.8s ease-in-out infinite; +} +.state-badge.state-compacting { + color: var(--purple); border-color: var(--purple); + text-shadow: 0 0 6px rgba(203, 166, 247, 0.65); + animation: badge-pulse 1.8s ease-in-out infinite; +} +.state-badge.state-just-changed { + animation: state-flash 600ms ease-out; +} +@keyframes state-flash { + 0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } + 60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; } + 100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; } +} +/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details + styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css). + What stays here is the composer chrome that sits inside the wrap. */ +.term-input { padding: 0.4em 1em 0.8em; } +.term-input .sendform-term { + display: flex; + align-items: flex-start; + gap: 0.5em; + border-top: 1px dashed var(--purple-dim); + padding-top: 0.5em; +} +.term-input .prompt, .term-input .submit-hint { + padding-top: 0.25em; +} +.term-input .prompt { + color: var(--green); + text-shadow: 0 0 6px rgba(166, 227, 161, 0.6); + user-select: none; + flex: 0 0 auto; +} +.term-input textarea { + flex: 1; + background: transparent; + border: 0; + outline: 0; + color: var(--fg); + font-family: inherit; + font-size: 1em; + padding: 0.2em 0; + caret-color: var(--green); + resize: none; + overflow-y: auto; + line-height: 1.4; + min-height: 1.4em; +} +.term-input textarea::placeholder { color: var(--muted); } +.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; } +.term-input.disabled .prompt { color: var(--muted); text-shadow: none; } +.term-input.disabled textarea { color: var(--muted); } +/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */ diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js new file mode 100644 index 0000000..c2a56c5 --- /dev/null +++ b/frontend/packages/agent/src/app.js @@ -0,0 +1,1168 @@ +// Per-agent web UI. Renders title + login/online view from `/api/state`, +// tails `/events/stream` for live claude events, drives async-form +// actions (send / login/* / dashboard rebuild). + +import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; +import { marked } from 'marked'; + +// Expose the previously-script-tag-provided globals so the IIFE below +// keeps working unchanged. Pre-split these were attached by +// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js` +// (marked) loading before app.js. The bundle now pulls them in via ES +// imports; once the IIFE is opened up these aliases can be dropped in +// favour of direct named imports. +window.HiveTerminal = { create: termCreate, linkify: termLinkify }; +window.marked = marked; + +(() => { + // ─── helpers ──────────────────────────────────────────────────────────── + const $ = (id) => document.getElementById(id); + const escText = (s) => String(s).replace(/[&<>"]/g, (c) => + ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) + ); + const el = (tag, attrs = {}, ...children) => { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'html') e.innerHTML = v; + else e.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + e.append(c.nodeType ? c : document.createTextNode(c)); + } + return e; + }; + + // Base URL of the host dashboard (core backend). Set once the first + // /api/state lands. Operator-authority actions (answering a question + // as the operator) POST here rather than to this agent's own socket — + // see docs/boundary.md for why the boundary lives on the core side. + let dashboardBase = ''; + + // ─── async-form submit (shared with dashboard) ────────────────────────── + 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; + const btn = f.querySelector('button[type="submit"], button:not([type])'); + 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; + } + // Clear text inputs the operator typed into (the form value was sent). + f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; }); + // Re-enable the button — refreshState() often skips re-rendering the + // form (status unchanged), so without this the spinner sticks and + // the operator can't submit again. + if (btn) { btn.disabled = false; btn.innerHTML = original; } + refreshState(); + } catch (err) { + alert('action failed: ' + err); + if (btn) { btn.disabled = false; btn.innerHTML = original; } + } + }); + + // ─── state rendering ──────────────────────────────────────────────────── + function setHeader(label, dashboardPort) { + $('banner').textContent = + `░▒▓█▓▒░ ${label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░`; + const title = $('title'); + title.textContent = `◆ ${label} ◆ `; + // ↑ DASHB04RD — back-link to the host dashboard. Opens in a new + // tab to keep the agent page anchored where the operator is. + const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; + dashboardBase = dashUrl; + title.append( + el('a', { + href: dashUrl, target: '_blank', rel: 'noopener', + class: 'btn-dashlink', title: 'host dashboard', + }, '↑ DASHB04RD'), + ' ', + ); + const btn = el('a', { + href: '#', class: 'btn-rebuild', id: 'rebuild-btn', + }, '↻ R3BU1LD'); + btn.addEventListener('click', (e) => { + e.preventDefault(); + if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; + const f = document.createElement('form'); + f.method = 'POST'; + f.action = `${dashUrl}rebuild/${label}`; + document.body.appendChild(f); + f.submit(); + }); + title.append(btn); + document.title = `${label} // hyperhive`; + } + + function renderOnline(_label, _root) { + // Online state is conveyed by the `#alive-badge` chip in the + // state row — no longer a separate paragraph in the status + // block (keeps the terminal the star, status row stays compact). + } + + function renderNeedsLoginIdle(root) { + root.append( + el('p', { class: 'status-needs-login' }, '◌ NEEDS L0G1N'), + el('p', { html: + 'No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.', + }), + ); + const start = el('form', { + action: '/login/start', method: 'POST', 'data-async': '', + }); + start.append( + el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'), + ); + root.append(start); + root.append(el('p', { class: 'meta', html: + 'Spawns claude auth login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.', + })); + } + + function renderLoginInProgress(s, root) { + root.append(el('p', { class: 'status-needs-login' }, '◌ L0G1N 1N PR0GRESS')); + if (s.url) { + const link = el('a', { + href: s.url, target: '_blank', rel: 'noreferrer', + }, s.url); + root.append(el('p', {}, '▶ ', link)); + root.append(el('p', { class: 'meta' }, + 'open this URL in a browser, complete the OAuth flow, paste the resulting code below.', + )); + } else { + root.append(el('p', { class: 'meta' }, + 'waiting for claude to emit an OAuth URL on stdout… (output below)', + )); + } + if (!s.finished) { + const code = el('form', { + action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '', + }); + code.append( + el('input', { + name: 'code', placeholder: 'paste OAuth code here', + required: '', autocomplete: 'off', + }), + el('button', { type: 'submit', class: 'btn btn-login' }, '◆ S3ND C0DE'), + ); + root.append(code); + } + const cancel = el('form', { + action: '/login/cancel', method: 'POST', 'data-async': '', + style: 'margin-top: 0.4em;', + }); + cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill')); + root.append(cancel); + if (s.finished) { + root.append(el('p', { class: 'status-needs-login' }, + `claude process exited: ${s.exit_note || 'exited'}. Start over if needed.`, + )); + } + root.append(el('h3', {}, 'output')); + root.append(el('pre', { class: 'diff' }, s.output || '')); + } + + let headerSet = false; + let lastStatus = null; + let lastOutputLen = -1; + let pollTimer = null; + let termInputRendered = false; + // Filled in by the live-event IIFE below. Used by the slash-command + // dispatcher to print local-only rows ('help', errors) and to clear + // the terminal on `/clear`. + let termAPI = null; + // Label captured from the first /api/state cold load — used by the + // bus-driven `status_changed` handler so it can re-enable the + // composer without waiting for the next snapshot fetch. + let currentLabel = ''; + + const SLASH_COMMANDS = [ + { name: '/help', desc: 'list slash commands' }, + { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/compact', desc: 'compact the persistent claude session' }, + { name: '/model', desc: '/model — switch claude model for future turns' }, + { name: '/new-session', desc: 'next turn runs without --continue (fresh claude session)' }, + ]; + + async function postModel(name) { + try { + const resp = await fetch('/api/model', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ model: name }), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok && termAPI) { + const text = await resp.text().catch(() => ''); + termAPI.row('turn-end-fail', '✗ /model failed: ' + resp.status + + (text ? ' — ' + text : '')); + } + // No refreshState — the harness emits `model_changed` on the + // SSE bus and the chip handler picks it up live. + } catch (err) { + if (termAPI) termAPI.row('turn-end-fail', '✗ /model failed: ' + err); + } + } + + async function postSimple(url, label) { + try { + const resp = await fetch(url, { method: 'POST', redirect: 'manual' }); + const ok = resp.ok || resp.type === 'opaqueredirect' + || (resp.status >= 200 && resp.status < 400); + if (!ok && termAPI) { + termAPI.row('turn-end-fail', '✗ ' + label + ' failed: http ' + resp.status); + } + } catch (err) { + if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); + } + } + const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); + const postCompact = () => postSimple('/api/compact', '/compact'); + const postNewSession = () => postSimple('/api/new-session', '/new-session'); + + function handleSlashCommand(line) { + if (!termAPI) return false; + const trimmed = line.trim(); + if (!trimmed.startsWith('/')) return false; + const [cmd] = trimmed.split(/\s+/); + switch (cmd) { + case '/help': + termAPI.row('note', '· /help'); + for (const c of SLASH_COMMANDS) { + termAPI.row('note', ' ' + c.name.padEnd(10) + ' — ' + c.desc); + } + return true; + case '/clear': + termAPI.clear(); + termAPI.row('note', '· terminal cleared (local view only — server history kept)'); + return true; + case '/cancel': + postCancelTurn(); + return true; + case '/compact': + postCompact(); + return true; + case '/new-session': + if (window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) { + postNewSession(); + } + return true; + case '/model': { + const parts = trimmed.split(/\s+/); + if (parts.length < 2 || !parts[1]) { + termAPI.row('turn-end-fail', + '✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)'); + } else { + postModel(parts[1]); + } + return true; + } + default: + termAPI.row('turn-end-fail', '✗ unknown slash command: ' + cmd + ' — try /help'); + return true; + } + } + + // Cycle through commands when operator hits Tab on a `/…` prefix. + function completeSlash(prefix) { + const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)); + if (!matches.length) return null; + // Cycle: when the current prefix already equals a command name, + // advance to the next match. + const idx = matches.findIndex((c) => c.name === prefix); + return matches[(idx + 1) % matches.length].name; + } + + function renderTermInput(label, online) { + const slot = $('term-input'); + if (!slot) return; + if (!termInputRendered) { + slot.innerHTML = ''; + const form = el('form', { + action: '/send', method: 'POST', + class: 'sendform-term', 'data-async': '', + }); + const ta = el('textarea', { + name: 'body', placeholder: 'message ' + label + '…', + required: '', autocomplete: 'off', rows: '1', + }); + // Enter submits, Shift+Enter inserts a newline. Auto-grow up to + // ~8 rows of content, then scroll inside the textarea. + const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base + const grow = () => { + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px'; + }; + ta.addEventListener('input', grow); + ta.addEventListener('keydown', (e) => { + // Tab-complete slash commands when the buffer starts with `/`. + if (e.key === 'Tab' && ta.value.startsWith('/') && !ta.value.includes(' ')) { + const next = completeSlash(ta.value); + if (next) { e.preventDefault(); ta.value = next; return; } + } + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + const line = ta.value; + if (!line.trim()) return; + // Intercept slash commands locally; never send them to the agent. + if (line.trim().startsWith('/')) { + if (handleSlashCommand(line)) { + ta.value = ''; + grow(); + return; + } + } + form.requestSubmit(); + } + }); + // Reset height after async submit clears the value. + form.addEventListener('submit', () => setTimeout(grow, 0)); + form.append( + el('span', { class: 'prompt' }, 'operator@' + label + ' ▸'), + ta, + el('span', { class: 'submit-hint' }, '↵ send · ⇧↵ newline · /help'), + ); + slot.append(form); + termInputRendered = true; + } + slot.classList.toggle('disabled', !online); + const ta = slot.querySelector('textarea'); + if (ta) ta.disabled = !online; + } + + // Granular state badge: idle / thinking / offline. Driven from SSE + // turn_start/turn_end. Age timer ticks client-side; badge re-renders + // each second so the "· 12s" suffix stays current. State changes + // trigger a short flash animation via .state-just-changed. + const STATE_LABELS = { + loading: { glyph: '…', text: 'booting' }, + offline: { glyph: '○', text: 'offline' }, + idle: { glyph: '💤', text: 'idle' }, + thinking: { glyph: '🧠', text: 'thinking' }, + compacting: { glyph: '📦', text: 'compacting' }, + }; + let stateName = 'loading'; + let stateSince = Date.now(); + let stateTickTimer = null; + function fmtAge(ms) { + const s = Math.floor(ms / 1000); + if (s < 60) return s + 's'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ' + (s % 60) + 's'; + const h = Math.floor(m / 60); + return h + 'h ' + (m % 60) + 'm'; + } + const STATE_TOOLTIPS = { + loading: 'harness not yet contacted', + offline: 'harness unreachable or claude not logged in', + idle: 'turn loop running, no claude invocation in flight', + thinking: 'claude is executing the current turn', + compacting: 'operator-triggered /compact running on the persistent session', + }; + function renderStateBadge() { + const badge = $('state-badge'); + if (!badge) return; + const def = STATE_LABELS[stateName] || STATE_LABELS.loading; + const age = fmtAge(Date.now() - stateSince); + badge.textContent = def.glyph + ' ' + def.text + ' · ' + age; + badge.className = 'state-badge state-' + stateName; + badge.title = (STATE_TOOLTIPS[stateName] || '') + '\nin this state for ' + age; + const cancelBtn = $('cancel-btn'); + if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; + } + function setState(next) { + setStateAbs(next, Math.floor(Date.now() / 1000)); + } + /// Set state with an authoritative since-unix from the server. Lets + /// `last turn` track the actual server-side duration rather than + /// whatever the client perceived between SSE events. + function setStateAbs(next, sinceUnix) { + if (next === stateName && sinceUnix * 1000 === stateSince) return; + if (stateName === 'thinking' && next !== 'thinking') { + const elapsedMs = Date.now() - stateSince; + renderLastTurn(elapsedMs); + } + const flashing = next !== stateName; + stateName = next; + stateSince = sinceUnix * 1000; + const badge = $('state-badge'); + if (badge && flashing) { + badge.classList.remove('state-just-changed'); + void badge.offsetWidth; + badge.classList.add('state-just-changed'); + } + renderStateBadge(); + } + // Loose-ends section: same data the get_loose_ends MCP tool + // returns. Best-effort fetch on cold load + after every turn_end + // (a turn likely answered or asked something). Silent failure + // keeps the section hidden rather than surfacing an empty banner. + let lastLooseEndsCount = 0; + async function refreshLooseEnds() { + try { + const resp = await fetch('/api/loose-ends'); + if (!resp.ok) { + renderLooseEnds([]); + return; + } + const data = await resp.json(); + renderLooseEnds(data.loose_ends || []); + } catch (err) { + console.warn('loose-ends fetch failed', err); + renderLooseEnds([]); + } + } + function renderLooseEnds(threads) { + const root = $('loose-ends-section'); + const list = $('loose-ends-list'); + const summary = $('loose-ends-summary'); + if (!root || !list || !summary) return; + if (!threads.length) { + root.hidden = true; + lastLooseEndsCount = 0; + return; + } + root.hidden = false; + summary.textContent = 'loose ends · ' + threads.length; + list.innerHTML = ''; + // Auto-expand on first appearance of any open thread so the + // operator notices new loose ends; collapse only on operator + // click (sticky after that). + if (lastLooseEndsCount === 0) root.open = true; + lastLooseEndsCount = threads.length; + const fmtAge = (s) => { + if (s < 60) return s + 's'; + if (s < 3600) return Math.floor(s / 60) + 'm'; + if (s < 86400) return Math.floor(s / 3600) + 'h'; + return Math.floor(s / 86400) + 'd'; + }; + for (const t of threads) { + const li = el('li'); + if (t.kind === 'approval') { + li.append( + el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + ); + if (t.description) { + li.append(el('div', { class: 'inbox-body' }, t.description)); + } + } else if (t.kind === 'question') { + const target = t.target || 'operator'; + li.append( + el('span', { class: 'inbox-from' }, '? #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ', + el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'), + el('div', { class: 'inbox-body' }, t.question || ''), + buildAnswerForm(t.id), + ); + } else if (t.kind === 'reminder') { + // due_at is an absolute unix-seconds value; show time-until-fire + // (negative when overdue, fmtAge handles 0/positive case here). + const now = Math.floor(Date.now() / 1000); + const dueIn = (t.due_at || 0) - now; + const dueLabel = dueIn >= 0 ? 'in ' + fmtAge(dueIn) : fmtAge(-dueIn) + ' overdue'; + li.append( + el('span', { class: 'inbox-from' }, '⏰ reminder #' + t.id), ' ', + el('span', { class: 'inbox-sep' }, t.owner + ' · due ' + dueLabel), ' ', + el('span', { class: 'inbox-ts' }, 'scheduled ' + fmtAge(t.age_seconds || 0) + ' ago'), + el('div', { class: 'inbox-body' }, t.message || ''), + ); + } else { + li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t))); + } + list.append(li); + } + } + + // Inline "answer as operator" form for a question loose-end. POSTs to + // the host dashboard (core backend), never this agent's socket — the + // core is the only place that can stamp `operator` as the answerer. + function buildAnswerForm(id) { + const wrap = el('div', { class: 'answer-form' }); + const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' }); + const btn = el('button', { type: 'button' }, 'send answer'); + const status = el('span', { class: 'answer-status' }); + btn.addEventListener('click', async () => { + const answer = ta.value.trim(); + if (!answer) { status.textContent = 'answer required'; return; } + if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; } + btn.disabled = true; + status.textContent = 'sending…'; + try { + const resp = await fetch(dashboardBase + 'answer-question/' + id, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'answer=' + encodeURIComponent(answer), + }); + if (resp.ok) { + status.textContent = 'answered ✓'; + refreshLooseEnds(); + } else { + status.textContent = 'failed: ' + (await resp.text()); + btn.disabled = false; + } + } catch (err) { + status.textContent = 'failed: ' + err; + btn.disabled = false; + } + }); + wrap.append(ta, btn, status); + return wrap; + } + + function renderInbox(rows) { + const root = $('inbox-section'); + const list = $('inbox-list'); + const summary = $('inbox-summary'); + if (!root || !list || !summary) return; + if (!rows.length) { + root.hidden = true; + return; + } + root.hidden = false; + summary.textContent = 'inbox · ' + rows.length; + list.innerHTML = ''; + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); + for (const m of rows) { + const li = el('li', m.in_reply_to != null ? { class: 'inbox-reply' } : {}); + if (m.in_reply_to != null) { + li.append(el('span', { class: 'inbox-reply-tag' }, '↳ reply · ')); + } + li.append( + el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', + el('span', { class: 'inbox-from' }, m.from), ' ', + el('span', { class: 'inbox-sep' }, '→'), ' ', + el('span', { class: 'inbox-body' }, m.body), + ); + list.append(li); + } + } + // Harness reachability badge: derived from the same `s.status` the + // status block reads. Each status maps to a glyph + label + colour + // class. Lives in the state row so the operator sees boot/login/ + // online without losing terminal real-estate to a paragraph. + const ALIVE_LABELS = { + loading: { glyph: '…', text: 'connecting', cls: 'status-loading' }, + online: { glyph: '●', text: 'alive', cls: 'status-online' }, + rate_limited: { glyph: '⊘', text: 'rate limited', cls: 'status-rate-limited' }, + needs_login_idle: { glyph: '◌', text: 'needs login', cls: 'status-needs-login' }, + needs_login_in_progress: { glyph: '◌', text: 'logging in', cls: 'status-needs-login' }, + offline: { glyph: '○', text: 'offline', cls: 'status-offline' }, + }; + function renderAliveBadge(status) { + const el_ = $('alive-badge'); + if (!el_) return; + const def = ALIVE_LABELS[status] || ALIVE_LABELS.loading; + el_.textContent = def.glyph + ' ' + def.text; + el_.className = 'status-badge ' + def.cls; + } + + function renderModelChip(model) { + const el_ = $('model-chip'); + if (!el_) return; + if (!model) { el_.hidden = true; return; } + el_.hidden = false; + el_.textContent = 'model · ' + model; + el_.title = `claude --model ${model}\nset via the operator's /model command; persists across turns until changed`; + } + // Token badges — two separate chips: + // ctx · N last inference's prompt size = current context window + // utilisation (what to watch for compaction decisions) + // cost · M cumulative billed tokens across the whole last turn + // (sum across every inference; tool-heavy turns rebill + // the cached prompt per call and blow past the model's + // context window — this is a cost signal, not a size + // signal) + // Both fed by the same `token_usage_changed` SSE event (`{ ctx, cost }`). + const fmtTokens = (n) => { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1_000) return Math.round(n / 1000) + 'k'; + return String(n); + }; + function renderOneUsage(elId, label, u, blurb) { + const el_ = $(elId); + if (!el_) return; + if (!u) { el_.hidden = true; return; } + const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens; + el_.hidden = false; + el_.title = [ + blurb, + 'input: ' + u.input_tokens, + 'cache_read: ' + u.cache_read_input_tokens, + 'cache_write: ' + u.cache_creation_input_tokens, + 'output: ' + u.output_tokens, + ].join('\n'); + el_.textContent = label + ' · ' + fmtTokens(total); + } + function renderTokenUsage(ev) { + // `ev` is `{ ctx, cost }` either off /api/state cold-load (each may + // be null) or off a `token_usage_changed` SSE event (both present + // post-turn). + renderOneUsage('ctx-badge', 'ctx', ev && ev.ctx, + 'last-inference prompt size — the actual context window in use right now'); + renderOneUsage('cost-badge', 'cost', ev && ev.cost, + 'cumulative tokens billed across the last turn (sum across every inference)'); + } + function renderLastTurn(ms) { + const el_ = $('last-turn'); + if (!el_) return; + let s = ''; + if (ms < 1000) s = ms + 'ms'; + else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; + else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's'; + el_.textContent = '· last turn ' + s; + el_.title = `wall-clock duration of the last completed claude turn (${ms} ms)`; + el_.hidden = false; + } + function startStateTicker() { + if (stateTickTimer) return; + stateTickTimer = setInterval(renderStateBadge, 1000); + } + startStateTicker(); + + // Wire the cancel-turn button (visible only while state === thinking). + (() => { + const btn = $('cancel-btn'); + if (!btn) return; + btn.addEventListener('click', () => { + btn.disabled = true; + postCancelTurn().finally(() => { btn.disabled = false; }); + }); + })(); + + // Wire the new-session button (always visible; arms a one-shot for + // the next turn). Mildly destructive (drops --continue context) so + // we confirm before posting. + (() => { + const btn = $('new-session-btn'); + if (!btn) return; + btn.addEventListener('click', () => { + if (!window.confirm('arm a fresh claude session for the next turn? all prior --continue context will be dropped.')) return; + btn.disabled = true; + postNewSession().finally(() => { btn.disabled = false; }); + }); + })(); + + // Track banner activity by reference-counting in-flight turns. A turn + // can begin while the previous turn_end is still in the pipeline (rare + // but happens on tight wake cycles), so we count rather than toggle. + let activeTurns = 0; + function setBannerActive(on) { + const banner = $('banner'); + if (!banner) return; + if (on) { + activeTurns += 1; + banner.classList.add('active'); + } else { + activeTurns = Math.max(0, activeTurns - 1); + if (activeTurns === 0) banner.classList.remove('active'); + } + } + + async function refreshState() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) throw new Error('http ' + resp.status); + const s = await resp.json(); + if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } + currentLabel = s.label; + // Render server-supplied navigation links — stats, screen, the + // forge profile, the agent-configs mirror, plus any + // agent-declared `dashboardLinks` extras (issue #262). Each + // NavLink's `kind` says how to resolve `url`: Container → + // same-origin path (the agent page is itself container-local); + // Forge → `http://:3000`; External → already + // absolute. DOM-built via el() — agent-declared icon / label / + // url strings must NEVER reach innerHTML. + const metaLinks = $('meta-links'); + if (metaLinks && Array.isArray(s.links)) { + metaLinks.replaceChildren(); + const forgeBase = `http://${window.location.hostname}:3000`; + s.links.forEach((lnk, i) => { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ (lnk.url || ''); + const a = el('a', { + class: 'agent-nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + if (i > 0) a.style.marginLeft = '1em'; + a.append(((lnk.icon || '') + ' ' + (lnk.label || '')).trim() + ' →'); + metaLinks.append(a); + }); + } + renderTermInput(s.label, s.status === 'online'); + renderInbox(s.inbox || []); + // Authoritative state comes from the harness via /api/state. + // Login-not-yet → 'offline'; otherwise use the server-reported + // turn_state (idle / thinking / compacting). stateSince in + // unix-seconds is converted to a client-side Date.now() anchor. + if (s.status !== 'online') { + setState('offline'); + } else if (s.turn_state) { + setStateAbs(s.turn_state, s.turn_state_since); + } + renderAliveBadge(s.status); + renderModelChip(s.model); + renderTokenUsage({ ctx: s.ctx_usage, cost: s.cost_usage }); + // Open-threads aren't part of /api/state (kept on the broker + // db, fetched via the per-agent socket). Cold-load fetches + // it here; turn_end refreshes it via the renderer below. + refreshLooseEnds(); + // Skip the re-render if nothing structurally changed. The most + // common case is `online` polling itself — without this guard, the + // operator's gets clobbered every cycle. + const outLen = s.session?.output?.length ?? -1; + const dirty = + s.status !== lastStatus || + (s.status === 'needs_login_in_progress' && outLen !== lastOutputLen); + if (dirty) { + const root = $('status'); + root.innerHTML = ''; + if (s.status === 'online') renderOnline(s.label, root); + else if (s.status === 'needs_login_idle') renderNeedsLoginIdle(root); + else if (s.status === 'needs_login_in_progress') renderLoginInProgress(s.session || {}, root); + lastStatus = s.status; + lastOutputLen = outLen; + } + // Only poll while a login is in flight — otherwise SSE turn_end + // events trigger a refresh, and the operator can type into the + // send form without it getting cleared every few seconds. + if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } + if (s.status === 'needs_login_in_progress') { + pollTimer = setTimeout(refreshState, 1500); + } + } catch (err) { + console.error('refreshState failed', err); + pollTimer = setTimeout(refreshState, 5000); + } + } + refreshState(); + + // ─── live event stream ────────────────────────────────────────────────── + // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS + // (window.HiveTerminal). What stays here is the per-kind rendering: + // turn framing, claude stream-json interpretation, tool_use prettyprint, + // tool_result collapse, +/- diff bodies for Write/Edit. + (function() { + const log = $('live'); + if (!log || !window.HiveTerminal) return; + log.innerHTML = ''; + + function trim(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } + // Render a message body as markdown into a new
. + // Wraps `marked.parse` so the per-row body element carries the + // `.md` class (CSS in TERMINAL_CSS scopes paragraph/code/list + // styles to it). Falls back to a plain text node if marked isn't + // loaded (network glitch, asset 404) so the body still renders. + function mdNode(text) { + const div = document.createElement('div'); + div.className = 'md'; + const src = String(text || ''); + if (window.marked && typeof window.marked.parse === 'function') { + try { + marked.setOptions({ breaks: true, gfm: true }); + div.innerHTML = marked.parse(src); + // marked autolinks URLs but leaves them same-tab — open them + // externally so a click never unloads the terminal. (issue #233) + div.querySelectorAll('a[href]').forEach((a) => { + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + }); + } catch (err) { + console.warn('marked failed', err); + div.textContent = src; + } + } else { + div.textContent = src; + } + return div; + } + // Build a default-open details row whose body is markdown-rendered. + // Used by send / ask / answer tool_use renderers and by `recv` + // tool_result so message-bearing rows show their content inline + // without an extra click. + function detailsOpenMd(api, cls, summary, body) { + const d = api.details(cls, summary, ''); + d.open = true; + const pre = d.querySelector('pre.tool-body'); + if (pre) { + pre.replaceWith(mdNode(body)); + } else { + d.appendChild(mdNode(body)); + } + return d; + } + // Generic args-pretty-printer for unknown / extra-MCP tools. The + // built-in switch handles the common claude/hyperhive tools; this + // is the fallback so an `mcp__matrix__send_message` or similar + // doesn't dump raw JSON. Heuristics: single string-valued field → + // `Name field: "value"`; single dict-valued field → `Name field + // {…}`; otherwise compact JSON. Always trimmed to fit a row. + function fmtArgsGeneric(name, input) { + const keys = Object.keys(input || {}); + if (keys.length === 0) return name + '()'; + if (keys.length === 1) { + const k = keys[0]; + const v = input[k]; + if (typeof v === 'string') { + const oneline = v.replace(/\s+/g, ' ').trim(); + return name + ' ' + k + ': ' + JSON.stringify(trim(oneline, 100)); + } + if (typeof v === 'number' || typeof v === 'boolean') { + return name + ' ' + k + ': ' + JSON.stringify(v); + } + } + // Multi-field: render `k: v` pairs with strings/numbers inlined and + // anything else summarised by type so the row stays readable. + const pretty = keys.slice(0, 4).map((k) => { + const v = input[k]; + if (v == null) return k + ': null'; + if (typeof v === 'string') { + const oneline = v.replace(/\s+/g, ' ').trim(); + return k + ': ' + JSON.stringify(trim(oneline, 40)); + } + if (typeof v === 'number' || typeof v === 'boolean') return k + ': ' + v; + if (Array.isArray(v)) return k + `: [${v.length}]`; + return k + ': {…}'; + }); + const tail = keys.length > 4 ? ' …+' + (keys.length - 4) : ''; + return name + ' ' + pretty.join(' · ') + tail; + } + // Pretty-print a tool call: per-known-tool format, fallback to JSON + // for unknown tools. + function fmtToolUse(c) { + const name = c.name || ''; + const input = c.input || {}; + const short = name.startsWith('mcp__hyperhive__') + ? name.slice('mcp__hyperhive__'.length) + '*' : name; + switch (name) { + case 'Read': return short + ' ' + (input.file_path || ''); + case 'Write': return short + ' ' + (input.file_path || ''); + case 'Edit': return short + ' ' + (input.file_path || ''); + case 'Glob': return short + ' ' + (input.pattern || ''); + case 'Grep': return short + ' ' + (input.pattern || ''); + case 'Bash': return short + (input.run_in_background ? ' [bg]' : '') + + ' $ ' + (input.command || ''); + case 'TodoWrite': return short + ' (' + ((input.todos || []).length) + ' items)'; + case 'mcp__hyperhive__send': return short + ' → ' + (input.to || '?') + ': ' + + JSON.stringify(input.body || '').slice(0, 80); + case 'mcp__hyperhive__recv': { + // Surface the long-poll wait + batch size — a bare `recv()` row + // hides whether the agent is parking a turn (wait_seconds) or + // draining a burst (max). + const parts = []; + if (input.wait_seconds != null) parts.push('wait ' + input.wait_seconds + 's'); + if (input.max != null) parts.push('max ' + input.max); + return short + (parts.length ? ' ' + parts.join(' · ') : '()'); + } + case 'mcp__hyperhive__request_spawn': return short + ' ' + (input.name || ''); + case 'mcp__hyperhive__kill': return short + ' ' + (input.name || ''); + case 'mcp__hyperhive__request_apply_commit': + return short + ' ' + (input.agent || '') + ' @ ' + (input.commit_ref || '').slice(0, 12); + default: return fmtArgsGeneric(short, input); + } + } + // Build a "rich" tool_use row for tools whose input has a body we + // want the operator to see in full. Returns null for any other tool + // so the caller falls back to the flat-row path. + // Write: every input.content line is "+". + // Edit: old_string lines as "-", new_string lines as "+". + // mcp__hyperhive__send: collapsed
, full body text inside. + function renderRichToolUse(c, api) { + const name = c.name || ''; + const input = c.input || {}; + if (name === 'Write' || name === 'Edit') { + const path = input.file_path || '?'; + let body; + let plus = 0; + let minus = 0; + if (name === 'Write') { + const content = String(input.content || ''); + const lines = content.split('\n'); + plus = lines.length; + body = lines.map(l => '+ ' + l).join('\n'); + } else { + const oldLines = String(input.old_string || '').split('\n'); + const newLines = String(input.new_string || '').split('\n'); + minus = oldLines.length; + plus = newLines.length; + body = oldLines.map(l => '- ' + l).join('\n') + + '\n' + + newLines.map(l => '+ ' + l).join('\n'); + } + // Summaries on expandable rows omit the row's directional glyph + // (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the + // prefix column for every row kind, and the row's cyan colour + // already signals "outbound tool". + const summary = name + ' ' + path + ' · ' + + (minus ? '-' + minus + ' ' : '') + '+' + plus; + return api.detailsDiff('tool-use', summary, body); + } + // Message-bearing tools render default-open with a markdown body so + // the operator sees the content without an extra click. send / ask + // address a target; answer attaches to an existing question id. + if (name === 'mcp__hyperhive__send') { + const to = input.to || '?'; + const body = String(input.body || ''); + const lines = body.split('\n').length; + return detailsOpenMd(api, 'tool-use', + 'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''), + body); + } + if (name === 'mcp__hyperhive__ask') { + const to = input.to || 'operator'; + const q = String(input.question || ''); + const lines = q.split('\n').length; + return detailsOpenMd(api, 'tool-use', + 'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''), + q); + } + if (name === 'mcp__hyperhive__answer') { + const id = input.id != null ? String(input.id) : '?'; + const a = String(input.answer || ''); + const lines = a.split('\n').length; + return detailsOpenMd(api, 'tool-use', + 'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''), + a); + } + return null; + } + // Track tool_use_id → tool name so we can decide on rendering when the + // matching tool_result lands later. Lets us default-open the body for + // message-bearing tools (`recv`) while keeping shell/file tool output + // collapsed unless the operator clicks. Cleared on /clear; otherwise + // grows with the session — entries are tiny strings. + const toolNameById = new Map(); + function renderToolResult(c, api) { + const txt = Array.isArray(c.content) + ? c.content.map(p => p.text || '').join('') + : (c.content || ''); + const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null; + const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; + const trimmed = txt.replace(/\s+/g, ' ').trim(); + const summaryBody = (() => { + if (!trimmed) return '(empty)'; + if (trimmed.length <= 120) return trimmed; + const lines = txt.split('\n').filter(l => l.length).length; + const headline = trimmed.slice(0, 90) + '…'; + return `${lines}L · ${headline}`; + })(); + // Flat row: keep the `←` glyph in the prefix column. Details rows + // drop it — the `▸/▾` disclosure marker sits in that column via CSS. + if (isMessageBearing && txt.trim()) { + return detailsOpenMd(api, 'tool-result-block', + 'recv ← ' + summaryBody, txt); + } + if (!txt.trim() || txt.length <= 120) { + api.row('tool-result', '← ' + summaryBody); + } else { + api.details('tool-result-block', summaryBody, txt); + } + } + // Pretty-render claude's background-task subagent events + // (`task_started`, `task_notification`). They share the same + // task_id so the operator can correlate start ↔ result; render + // each as a peer of tool_use / tool_result with a `⌁` glyph to + // mark "this happened in a subagent" rather than the main + // session. + function renderTaskEvent(v, api) { + const id = (v.task_id || '').slice(0, 8); + const kind = v.task_type ? ` [${v.task_type}]` : ''; + const desc = v.description || v.summary || '(no description)'; + if (v.subtype === 'task_started') { + api.row('tool-use', `⌁ task ${id} started · ${desc}${kind}`); + return true; + } + if (v.subtype === 'task_notification') { + const status = v.status || 'unknown'; + const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌'; + const cls = status === 'completed' ? 'turn-end-ok' + : status === 'failed' ? 'turn-end-fail' + : 'tool-result'; + const out = v.output_file ? ` · → ${v.output_file}` : ''; + api.row(cls, `⌁ task ${id} ${glyph} ${status} · ${desc}${out}`); + return true; + } + return false; + } + function renderStream(v, api) { + // Drop session init, claude's result line, rate-limit — noise. + // TurnEnd communicates pass/fail; session init isn't actionable. + if (v.type === 'system' && v.subtype === 'init') return; + if (v.type === 'rate_limit_event') return; + if (v.type === 'result') return; + // Background-task subagent events (claude's `Task` tool spawns + // a separate session whose progress lands here as `task_*` + // subtypes). Match by subtype so we don't have to track which + // top-level `type` claude wraps them under across versions. + if (v.subtype === 'task_started' || v.subtype === 'task_notification') { + if (renderTaskEvent(v, api)) return; + } + if (v.type === 'assistant' && v.message && v.message.content) { + for (const c of v.message.content) { + if (c.type === 'text' && c.text && c.text.trim()) { + // Assistant prose renders with markdown — claude often + // emits bullets / fenced code / inline code; raw text + // loses the structure. + const row = api.row('text', ''); + row.appendChild(mdNode(c.text)); + } + else if (c.type === 'thinking') { + const txt = (c.thinking || c.text || '').trim(); + api.row('thinking', txt ? '· ' + txt : '· thinking …'); + } + else if (c.type === 'tool_use') { + if (c.id && c.name) toolNameById.set(c.id, c.name); + if (!renderRichToolUse(c, api)) { + api.row('tool-use', '→ ' + fmtToolUse(c)); + } + } + } + return; + } + if (v.type === 'user' && v.message && v.message.content) { + for (const c of v.message.content) { + if (c.type === 'tool_result') renderToolResult(c, api); + } + return; + } + // Catch-all for unrecognised stream-json shapes. Loud (orange) so + // silently-dropped event types surface in the scrollback for + // follow-up classification. + api.row('sys', '! ' + trim(JSON.stringify(v), 200)); + } + + // Count open turns across the backfill replay so the live banner + + // state badge reflect whatever the history last left running. With + // shared HiveTerminal this is computed inside each renderer instead + // of in a second walk over the events list. + let openTurnsFromHistory = 0; + + const term = HiveTerminal.create({ + logEl: log, + historyUrl: '/events/history', + streamUrl: '/events/stream', + renderers: { + turn_start(ev, api) { + if (api.fromHistory) openTurnsFromHistory += 1; + else { setBannerActive(true); setState('thinking'); } + const block = api.row('turn-start', '◆ TURN ← ' + ev.from); + if (ev.unread > 0) { + const badge = document.createElement('span'); + badge.className = 'unread-badge'; + badge.textContent = '· ' + ev.unread + ' unread'; + block.appendChild(badge); + } + const body = document.createElement('div'); + body.className = 'turn-body'; + body.textContent = ev.body; + block.appendChild(body); + }, + turn_end(ev, api) { + if (api.fromHistory) { + openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1); + } else { + setBannerActive(false); setState('idle'); + // Likely answered/asked/scheduled something — refresh. + refreshLooseEnds(); + } + const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail'; + api.row(cls, + (ev.ok ? '✓' : '✗') + ' turn ' + (ev.ok ? 'ok' : 'fail') + + (ev.note ? ' — ' + ev.note : '')); + }, + note(ev, api) { + const t = String(ev.text || ''); + // stderr lines coming off the claude pump get an orange `!` + // glyph so they're not visually fused with ambient harness + // chatter. Operator-initiated notes (/cancel, /compact, + // /model, new-session) get a mauve italic affordance so the + // scrollback distinguishes "the operator did this" from + // "the harness did this on its own." + if (t.startsWith('stderr:')) { + api.row('note stderr', '! ' + t); + } else if (t.startsWith('operator:')) { + api.row('note op', '· ' + t); + } else { + api.row('note', '· ' + t); + } + }, + stream(ev, api) { + const v = Object.assign({}, ev); delete v.kind; + renderStream(v, api); + }, + // Bus-driven state/badges. `status_changed` may also need a + // /api/state refresh to render the login `#status` block + // (which carries the OAuth URL + form), so we kick the + // existing refresh path on that transition. Online → only + // the badge updates; no /api/state fetch needed. + status_changed(ev, api) { + if (api.fromHistory) return; + renderAliveBadge(ev.status); + renderTermInput(currentLabel, ev.status === 'online'); + // Login-flow transitions need the #status block rebuilt + // (it carries the OAuth URL + form). The existing + // refreshState path also re-arms the in-progress poll for + // session output streaming. Online → only the badge moves; + // no /api/state fetch is necessary. + if (ev.status !== 'online' && ev.status !== lastStatus) { + refreshState(); + } else if (ev.status === 'online' && lastStatus !== 'online') { + // Status block stays as-is or shows the previous + // login UI; clear it so the operator sees a clean + // online state without a separate refetch. + const root = $('status'); + if (root) root.innerHTML = ''; + lastStatus = 'online'; + } + }, + model_changed(ev, api) { if (!api.fromHistory) renderModelChip(ev.model); }, + token_usage_changed(ev, api) { + if (!api.fromHistory) renderTokenUsage({ ctx: ev.ctx, cost: ev.cost }); + }, + turn_state_changed(ev, api) { + if (!api.fromHistory) setStateAbs(ev.state, ev.since_unix); + }, + }, + onBackfillDone() { + // If the last replayed turn never closed, the banner shimmer + + // thinking badge should be on. Apply in one pass after replay. + for (let i = 0; i < openTurnsFromHistory; i++) setBannerActive(true); + if (openTurnsFromHistory > 0) setState('thinking'); + }, + }); + + // Expose the panel API for slash commands (`/help`, `/clear`). + termAPI = { + row: (cls, text) => term.row(cls, text), + clear: () => { log.innerHTML = ''; }, + }; + })(); + + // Avoid unused-var lint while keeping `escText` available for future use. + void escText; +})(); diff --git a/frontend/packages/agent/src/index.html b/frontend/packages/agent/src/index.html new file mode 100644 index 0000000..dcc0d2b --- /dev/null +++ b/frontend/packages/agent/src/index.html @@ -0,0 +1,52 @@ + + + + + hyperhive agent + + + + + +
+ +

◆ … ◆

+
+ + +
+

loading…

+
+ +
+ + … booting + + + + + + +
+ + + + + +
+
connecting…
+
+
+ + + + + diff --git a/frontend/packages/agent/src/screen.html b/frontend/packages/agent/src/screen.html new file mode 100644 index 0000000..2b91f12 --- /dev/null +++ b/frontend/packages/agent/src/screen.html @@ -0,0 +1,770 @@ + + + + + +screen + + + + +
+ 🖥 screen + ← agent + + + + connecting… +
+
+
+
+ + + + diff --git a/frontend/packages/agent/src/stats.html b/frontend/packages/agent/src/stats.html new file mode 100644 index 0000000..0795310 --- /dev/null +++ b/frontend/packages/agent/src/stats.html @@ -0,0 +1,98 @@ + + + + + hyperhive agent — stats + + + + + + +
+ ← live + dashboard +

◆ … ◆

+
+ +
+ + + + + + +
+ +
+ +
+

turns per bucket

+

turn duration (ms) — p50 / p95 / avg

+

context tokens (last inference per turn) — avg / max

+

token cost per bucket (sum across inferences)

+

turns by model per bucket — model drives token cost

+

top tools

+

wake source mix

+

result mix

+
+ + + + + diff --git a/frontend/packages/agent/src/stats.js b/frontend/packages/agent/src/stats.js new file mode 100644 index 0000000..546ab19 --- /dev/null +++ b/frontend/packages/agent/src/stats.js @@ -0,0 +1,346 @@ +// Per-agent stats page. Fetches /api/state for the title + dashboard link +// once on load, then /api/stats?window=... for the chart data — re-fetches +// when the operator clicks a window tab. + +import Chart from 'chart.js/auto'; + +// Expose for the IIFE below — pre-split this was a window global from +// the jsDelivr CDN script tag. esbuild now bundles chart.js into +// stats.js; once the IIFE opens up we can use the imported `Chart` +// directly. +window.Chart = Chart; + +(function () { + 'use strict'; + + const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const palette = { + bg: cssVar('--bg'), + bgElev: cssVar('--bg-elev'), + fg: cssVar('--fg'), + muted: cssVar('--muted'), + purple: cssVar('--purple'), + cyan: cssVar('--cyan'), + pink: cssVar('--pink'), + amber: cssVar('--amber'), + green: cssVar('--green'), + red: cssVar('--red'), + border: cssVar('--border'), + }; + // Distinct hues for categorical charts (top tools / wake mix / result mix). + const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber, + palette.green, palette.red, '#94e2d5', '#f9e2af', + '#74c7ec', '#b4befe']; + + // Apply Catppuccin defaults globally so each Chart inherits without per-call + // overrides. Chart.js v4 reads these on chart construction. + Chart.defaults.color = palette.fg; + Chart.defaults.borderColor = palette.border; + Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace'; + Chart.defaults.font.size = 11; + Chart.defaults.plugins.legend.labels.color = palette.fg; + + const charts = {}; + let currentWindow = '24h'; + + function fmtMs(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '0'; + if (ms < 1000) return ms.toFixed(0) + 'ms'; + return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's'; + } + + function fmtInt(n) { + if (!Number.isFinite(n)) return '0'; + return new Intl.NumberFormat().format(Math.round(n)); + } + + function bucketLabel(ts, bucketSecs) { + const d = new Date(ts * 1000); + if (bucketSecs >= 86400) { + return d.toISOString().slice(5, 10); // MM-DD + } + return d.toISOString().slice(11, 16); // HH:MM + } + + function destroy(name) { + if (charts[name]) { + charts[name].destroy(); + delete charts[name]; + } + } + + function paintEmpty(canvasId, msg) { + destroy(canvasId); + const cv = document.getElementById(canvasId); + if (!cv) return; + const ctx = cv.getContext('2d'); + ctx.clearRect(0, 0, cv.width, cv.height); + ctx.fillStyle = palette.muted; + ctx.font = '12px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(msg, cv.width / 2, cv.height / 2); + } + + function renderSummary(s) { + const root = document.getElementById('summary'); + root.replaceChildren(); + const chips = [ + ['turns', fmtInt(s.turn_count)], + ['avg duration', fmtMs(s.duration_summary.avg_ms)], + ['p50 duration', fmtMs(s.duration_summary.p50_ms)], + ['p95 duration', fmtMs(s.duration_summary.p95_ms)], + ['window', s.window], + ]; + for (const [label, value] of chips) { + const chip = document.createElement('span'); + chip.className = 'chip'; + const l = document.createElement('span'); + l.className = 'label'; + l.textContent = label; + const v = document.createElement('span'); + v.className = 'value'; + v.textContent = value; + chip.append(l, v); + root.append(chip); + } + } + + function renderTurnsChart(s) { + const id = 'chart-turns'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + const data = s.buckets.map((b) => b.turn_count); + charts[id] = new Chart(document.getElementById(id), { + type: 'bar', + data: { + labels, + datasets: [{ + label: 'turns', + data, + backgroundColor: palette.purple, + borderColor: palette.purple, + borderWidth: 1, + }], + }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { color: palette.border } }, + y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } }, + }, + }, + }); + } + + function renderDurationChart(s) { + const id = 'chart-duration'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + const ds = (label, color, key) => ({ + label, data: s.buckets.map((b) => b[key]), + borderColor: color, backgroundColor: color + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }); + charts[id] = new Chart(document.getElementById(id), { + type: 'line', + data: { + labels, + datasets: [ + ds('p50', palette.cyan, 'p50_duration_ms'), + ds('p95', palette.pink, 'p95_duration_ms'), + ds('avg', palette.amber, 'avg_duration_ms'), + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { grid: { color: palette.border } }, + y: { + beginAtZero: true, + grid: { color: palette.border }, + ticks: { callback: (v) => fmtMs(v) }, + }, + }, + }, + }); + } + + function renderCtxChart(s) { + const id = 'chart-ctx'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + charts[id] = new Chart(document.getElementById(id), { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'avg ctx', + data: s.buckets.map((b) => b.avg_ctx_tokens), + borderColor: palette.cyan, + backgroundColor: palette.cyan + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }, + { + label: 'max ctx', + data: s.buckets.map((b) => b.max_ctx_tokens), + borderColor: palette.amber, + backgroundColor: palette.amber + '33', + tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true, + }, + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { grid: { color: palette.border } }, + y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, + }, + }, + }); + } + + function renderCostChart(s) { + const id = 'chart-cost'; + destroy(id); + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + // Stacked bars: cache_read (cheap) / cache_creation / input / output. + // Highlights "what's actually getting billed at full rate" vs cache hits. + charts[id] = new Chart(document.getElementById(id), { + type: 'bar', + data: { + labels, + datasets: [ + { label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens), + backgroundColor: palette.muted }, + { label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens), + backgroundColor: palette.cyan }, + { label: 'input', data: s.buckets.map((b) => b.input_tokens), + backgroundColor: palette.amber }, + { label: 'output', data: s.buckets.map((b) => b.output_tokens), + backgroundColor: palette.pink }, + ], + }, + options: { + responsive: true, maintainAspectRatio: false, + scales: { + x: { stacked: true, grid: { color: palette.border } }, + y: { stacked: true, beginAtZero: true, + grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } }, + }, + }, + }); + } + + function renderModelChart(s) { + const id = 'chart-model'; + destroy(id); + const models = s.models || []; + if (!models.length) { paintEmpty(id, 'no turns in window'); return; } + const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds)); + // One stacked series per model. Model choice drives token cost, + // so this lines up against the cost chart above it. + const datasets = models.map((m, i) => ({ + label: m, + data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0), + backgroundColor: wheel[i % wheel.length], + })); + charts[id] = new Chart(document.getElementById(id), { + type: 'bar', + data: { labels, datasets }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } }, + scales: { + x: { stacked: true, grid: { color: palette.border } }, + y: { stacked: true, beginAtZero: true, + grid: { color: palette.border }, ticks: { precision: 0 } }, + }, + }, + }); + } + + function renderKeyCount(canvasId, items, emptyMsg) { + destroy(canvasId); + if (!items || items.length === 0) { + paintEmpty(canvasId, emptyMsg); + return; + } + const labels = items.map((kc) => kc.key); + const data = items.map((kc) => kc.count); + const colors = items.map((_, i) => wheel[i % wheel.length]); + charts[canvasId] = new Chart(document.getElementById(canvasId), { + type: 'doughnut', + data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } }, + }, + }); + } + + function render(s) { + renderSummary(s); + if (s.turn_count === 0) { + paintEmpty('chart-turns', 'no turns in window'); + paintEmpty('chart-duration', 'no turns in window'); + paintEmpty('chart-ctx', 'no turns in window'); + paintEmpty('chart-cost', 'no turns in window'); + paintEmpty('chart-model', 'no turns in window'); + paintEmpty('chart-tools', 'no tool calls'); + paintEmpty('chart-wake', 'no wakes'); + paintEmpty('chart-result', 'no results'); + return; + } + renderTurnsChart(s); + renderDurationChart(s); + renderCtxChart(s); + renderCostChart(s); + renderModelChart(s); + renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls'); + renderKeyCount('chart-wake', s.wake_mix, 'no wakes'); + renderKeyCount('chart-result', s.result_mix, 'no results'); + } + + async function loadStats() { + try { + const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow)); + if (!resp.ok) throw new Error('http ' + resp.status); + const snap = await resp.json(); + render(snap); + } catch (e) { + document.getElementById('summary').textContent = 'stats fetch failed: ' + e; + } + } + + async function loadIdentity() { + try { + const resp = await fetch('/api/state'); + if (!resp.ok) return; + const s = await resp.json(); + document.title = 'stats · ' + s.label; + document.getElementById('title').textContent = '◆ ' + s.label + ' ◆'; + const dl = document.getElementById('dashboard-link'); + dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/'; + } catch (_) { /* non-fatal */ } + } + + function bindTabs() { + const tabs = document.getElementById('window-tabs'); + tabs.addEventListener('click', (ev) => { + const btn = ev.target.closest('button[data-w]'); + if (!btn) return; + currentWindow = btn.dataset.w; + for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn); + loadStats(); + }); + } + + document.addEventListener('DOMContentLoaded', () => { + bindTabs(); + loadIdentity(); + loadStats(); + }); +})(); diff --git a/frontend/packages/dashboard/build.mjs b/frontend/packages/dashboard/build.mjs new file mode 100644 index 0000000..2ea7db0 --- /dev/null +++ b/frontend/packages/dashboard/build.mjs @@ -0,0 +1,54 @@ +// esbuild build for @hive/dashboard. Outputs: +// +// dist/app.js bundled ES module (entrypoint = src/app.js, +// pulls in @hive/shared + marked) +// dist/dashboard.css bundled stylesheet (entrypoint = src/dashboard.css, +// @import "@hive/shared/base.css" + terminal.css +// get inlined here) +// dist/index.html copy of src/index.html (esbuild doesn't process +// HTML; the script/link tags reference the bundled +// siblings by name) +// +// Both Rust binaries serve `dist/` via tower_http::ServeDir. The output +// must keep the same filenames the existing index.html references, so +// downstream changes are mechanical. + +import { build } from 'esbuild'; +import { mkdirSync, copyFileSync, rmSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = (p) => resolve(here, 'src', p); +const dist = (p) => resolve(here, 'dist', p); + +rmSync(dist(''), { recursive: true, force: true }); +mkdirSync(dist(''), { recursive: true }); + +// Bundle the JS entry. ES-module output, browser target, no minify +// (line-aligned source aids debugging; minification belongs in a later +// follow-up once asset sizes warrant it). +await build({ + entryPoints: [src('app.js')], + outfile: dist('app.js'), + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2022'], + sourcemap: true, + logLevel: 'info', +}); + +// Bundle the CSS — esbuild resolves @import including the package +// re-exports from @hive/shared. +await build({ + entryPoints: [src('dashboard.css')], + outfile: dist('dashboard.css'), + bundle: true, + loader: { '.css': 'css' }, + logLevel: 'info', +}); + +copyFileSync(src('index.html'), dist('index.html')); + +console.log('dashboard build ok →', dist('')); diff --git a/frontend/packages/dashboard/package.json b/frontend/packages/dashboard/package.json new file mode 100644 index 0000000..de91e6d --- /dev/null +++ b/frontend/packages/dashboard/package.json @@ -0,0 +1,14 @@ +{ + "name": "@hive/dashboard", + "version": "0.0.0", + "private": true, + "description": "hive-c0re dashboard SPA. Bundled by esbuild into a static dist; served by the hive-c0re Rust binary at runtime via tower_http::ServeDir.", + "type": "module", + "scripts": { + "build": "node ./build.mjs" + }, + "dependencies": { + "@hive/shared": "*", + "marked": "4.3.0" + } +} diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js new file mode 100644 index 0000000..d7b912b --- /dev/null +++ b/frontend/packages/dashboard/src/app.js @@ -0,0 +1,2322 @@ +// Dashboard SPA. Renders containers + approvals from `/api/state`, wires +// up async-form submission (URL-encoded POST + spinner + state refresh), +// and tails the unified dashboard event channel over `/dashboard/stream`. + +import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js'; +import { marked } from 'marked'; + +// Expose the previously-script-tag-provided globals so the IIFE below +// keeps working unchanged. Pre-split these were attached by +// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js` +// (marked) loading before app.js. The bundle now pulls them in via ES +// imports; once the IIFE is opened up these aliases can be dropped in +// favour of direct named imports. +window.HiveTerminal = { create: termCreate, linkify: termLinkify }; +window.marked = marked; + +(() => { + // ─── constants ────────────────────────────────────────────────────────── + // Context-window badge thresholds. Preferred source is each container's + // `context_window_tokens` from /api/state (the real window for the model + // it last ran on) — thresholds are then 75% / 50% of it, matching the + // harness compaction watermarks (compact at 75%, auto-reset at 50%). The + // fixed token constants are the fallback for when that field is absent + // (agent has no turns yet, or no per-model config matched the model). + const CTX_WARN_FRACTION = 0.75; // ≥ this share of the window → red + const CTX_CAUTION_FRACTION = 0.50; // ≥ this share of the window → yellow + const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k) + const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k) + + // ─── helpers ──────────────────────────────────────────────────────────── + const $ = (id) => document.getElementById(id); + const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m` + : s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`; + const esc = (s) => String(s).replace(/[&<>"]/g, (c) => + ({ '&':'&', '<':'<', '>':'>', '"':'"' }[c]) + ); + const el = (tag, attrs = {}, ...children) => { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') e.className = v; + else if (k === 'html') e.innerHTML = v; + else if (k.startsWith('data-')) e.setAttribute(k, v); + else e.setAttribute(k, v); + } + for (const c of children) { + if (c == null) continue; + e.append(c.nodeType ? c : document.createTextNode(c)); + } + return e; + }; + const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => { + const f = el('form', { + method: 'POST', action, class: 'inline', 'data-async': '', + ...(confirmMsg ? { 'data-confirm': confirmMsg } : {}), + // Endpoints whose mutation fires a DashboardEvent (and whose + // derived store applies it live) opt out of the post-submit + // /api/state refetch. See the async-form handler. + ...(opts.noRefresh ? { 'data-no-refresh': '' } : {}), + }); + for (const [name, value] of Object.entries(extra)) { + f.append(el('input', { type: 'hidden', name, value })); + } + f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel)); + return f; + }; + + // ─── side panel ───────────────────────────────────────────────────────── + // Singleton drawer that swipes in from the right. Long content + // (file previews, approval diffs, journald logs, applied config) + // opens here via `Panel.open(title, node)` instead of expanding + // inline. Body is swapped on each open; closing just slides out so + // the content stays visible through the transition. + const Panel = (() => { + const root = $('side-panel'); + const titleEl = $('side-panel-title'); + const bodyEl = $('side-panel-body'); + function open(title, content) { + titleEl.textContent = title; + bodyEl.replaceChildren(...(content ? [content] : [])); + root.classList.add('open'); + root.setAttribute('aria-hidden', 'false'); + } + function close() { + root.classList.remove('open'); + root.setAttribute('aria-hidden', 'true'); + } + function bind() { + $('side-panel-close').addEventListener('click', close); + $('side-panel-backdrop').addEventListener('click', close); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && root.classList.contains('open')) close(); + }); + } + return { open, close, bind }; + })(); + + // ─── path linkification ───────────────────────────────────────────────── + // Agents constantly drop pointer strings into messages + question + // bodies (it's the 1 KiB-cap escape hatch). Anything matching the + // PATH_RE patterns becomes a clickable anchor; clicking expands an + // inline
with the file's contents, fetched lazily from + // /api/state-file. The legacy in-container `/state/...` prefix is + // deliberately not matched — it's ambiguous from the host's + // perspective (we'd need to know which agent the message is about + // to translate it). Prefer `/agents//state/...` in agent + // outputs and the link will resolve. + 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
+  
+  
+
+
+  
+
+  
+ + + + +
+ + +

◆ C0NTAINERS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ K3PT ST4T3 ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ 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.

+
+

loading…

+
+ +

◆ 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.

+
+

loading…

+
+ + +

◆ M1ND H4S QU3STI0NS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ QU3U3D R3M1ND3RS ◆

+
══════════════════════════════════════════════════════════════
+

reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.

+
+

loading…

+
+ +

◆ P3NDING APPR0VALS ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ + +

◆ 0PER4T0R 1NB0X ◆

+
══════════════════════════════════════════════════════════════
+
+

loading…

+
+ +

◆ 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.

+
+
connecting…
+
+ @—> + + +
+
+ +
+
══════════════════════════════════════════════════════════════
+

▲△▲ 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; +}