From 8bebd78895cf58232d54bd27d8763eb9fa779398 Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 23 May 2026 12:59:20 +0200 Subject: [PATCH] frontend: add npm workspace scaffold under frontend/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the backend/frontend code split (#273). Additive — no existing code is touched; the legacy hive-c0re/assets, hive-ag3nt/ assets and hive-fr0nt/assets trees stay in place until the Rust cutover later in this branch. Layout: frontend/package.json npm workspaces root frontend/packages/shared/ @hive/shared src/{base,terminal}.css + terminal.js (ES module) src/index.js re-exports terminal.js frontend/packages/dashboard/ @hive/dashboard src/{index.html, app.js, dashboard.css} ported from hive-c0re/assets build.mjs esbuild config → dist/ frontend/packages/agent/ @hive/agent src/{index,stats,screen}.html + agent.css + {app,stats}.js ported from hive-ag3nt/assets build.mjs esbuild config → dist/ Changes vs the existing assets: - terminal.js is an ES module exporting { create, linkify } instead of assigning to window.HiveTerminal. The dashboard / agent app.js files re-expose them on window so the IIFE bodies keep working unchanged through Phase 1; the global aliases can be dropped in a follow-up once the IIFEs are unwrapped. - marked is imported from the marked@4.3.0 npm package (replacing the vendored hive-fr0nt/assets/marked.umd.js bundle). - chart.js is imported from chart.js@4.4.4 (replacing the jsDelivr CDN script tag on the per-agent stats page — page now works offline / on operator machines without internet egress). - dashboard.css and agent.css both gain @import lines at the top that pull base.css + terminal.css from @hive/shared, replacing the runtime string concatenation in serve_css. - index.html / stats.html collapse from three / two script tags to one type="module" tag pointing at the bundled output. package-lock.json is intentionally omitted from this commit — npm isn't available in the iris container yet (approval pending) and the lockfile will land in the next commit on this branch once the toolchain is in place. The PR will not be opened until it's there. Phase 2 (nix derivations), Phase 3 (container plumbing + the hyperhive.frontend.extraFiles option for per-agent layering), and Phase 4 (Rust cutover to tower_http::ServeDir, delete hive-fr0nt + legacy assets dirs) land as follow-up commits on this same branch. Refs #273. --- frontend/.gitignore | 2 + frontend/README.md | 33 + frontend/package.json | 18 + frontend/packages/agent/build.mjs | 56 + frontend/packages/agent/package.json | 15 + frontend/packages/agent/src/agent.css | 389 +++ frontend/packages/agent/src/app.js | 1168 +++++++++ frontend/packages/agent/src/index.html | 52 + frontend/packages/agent/src/screen.html | 770 ++++++ frontend/packages/agent/src/stats.html | 98 + frontend/packages/agent/src/stats.js | 346 +++ frontend/packages/dashboard/build.mjs | 54 + frontend/packages/dashboard/package.json | 14 + frontend/packages/dashboard/src/app.js | 2322 +++++++++++++++++ frontend/packages/dashboard/src/dashboard.css | 1146 ++++++++ frontend/packages/dashboard/src/index.html | 117 + frontend/packages/shared/package.json | 17 + frontend/packages/shared/src/base.css | 24 + frontend/packages/shared/src/index.js | 3 + frontend/packages/shared/src/terminal.css | 228 ++ frontend/packages/shared/src/terminal.js | 342 +++ 21 files changed, 7214 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/package.json create mode 100644 frontend/packages/agent/build.mjs create mode 100644 frontend/packages/agent/package.json create mode 100644 frontend/packages/agent/src/agent.css create mode 100644 frontend/packages/agent/src/app.js create mode 100644 frontend/packages/agent/src/index.html create mode 100644 frontend/packages/agent/src/screen.html create mode 100644 frontend/packages/agent/src/stats.html create mode 100644 frontend/packages/agent/src/stats.js create mode 100644 frontend/packages/dashboard/build.mjs create mode 100644 frontend/packages/dashboard/package.json create mode 100644 frontend/packages/dashboard/src/app.js create mode 100644 frontend/packages/dashboard/src/dashboard.css create mode 100644 frontend/packages/dashboard/src/index.html create mode 100644 frontend/packages/shared/package.json create mode 100644 frontend/packages/shared/src/base.css create mode 100644 frontend/packages/shared/src/index.js create mode 100644 frontend/packages/shared/src/terminal.css create mode 100644 frontend/packages/shared/src/terminal.js 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; +}