From 229c4292e9f399452b26838242009eab3d0a888f Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 23 May 2026 13:21:37 +0200 Subject: [PATCH] frontend: cut over Rust binaries to ServeDir; delete legacy assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of #273 — the actual switch. Both axum routers now serve their static surface via `tower_http::services::ServeDir` mounted as a fallback service, reading the dist path from `HIVE_STATIC_DIR` (set by Phase 3's NixOS module wiring). Deletes: - `hive-c0re/assets/{index.html, app.js, dashboard.css}` - `hive-ag3nt/assets/{index.html, app.js, agent.css, stats.html, stats.js, screen.html}` - The whole `hive-fr0nt/` crate (workspace member dropped, both hive-c0re and hive-ag3nt drop their `hive-fr0nt.workspace = true` dep). Its contents now live as `@hive/shared` under `frontend/packages/shared/`. Rust changes: - `hive-c0re/src/dashboard.rs`: remove `serve_index`, `serve_css`, `serve_app_js`, `serve_shared_js`, `serve_marked_js`, `serve_favicon` (all six `include_str!` handlers); replace their routes with a single `.fallback_service(ServeDir::new(static_dir))` on the router. Fail closed (anyhow::bail) if `HIVE_STATIC_DIR` is unset or not a directory at startup. - `hive-ag3nt/src/web_ui.rs`: remove `serve_index`, `serve_css`, `serve_app_js`, `serve_shared_js`, `serve_marked_js`, `serve_stats`, `serve_stats_js`, `serve_screen`; same `fallback_service` pattern. `serve_icon` stays (consumes `/etc/hyperhive/icon.svg` + `branding/hyperhive.svg` fallback, neither of which lives under the frontend dist). - `AgentLink` URLs for stats/screen switched from `/stats` / `/screen` to `/stats.html` / `/screen.html` since ServeDir doesn't auto- append the extension and the on-disk filename is the natural URL post-cutover. - `Cargo.toml` (workspace): drop `hive-fr0nt` member + workspace dep, add `tower-http = { version = "0.6", features = ["fs"] }`. - `hive-c0re/Cargo.toml` + `hive-ag3nt/Cargo.toml`: drop the `hive-fr0nt.workspace = true` dep, add `tower-http.workspace = true`. Docs updated: - `CLAUDE.md`: file map reflects `frontend/` (was `hive-fr0nt/` + `assets/`) and the ServeDir/HIVE_STATIC_DIR shape. - `docs/web-ui.md` 'Shape (shared by both)' section: describes the ServeDir fallback + bundled-by-esbuild surface, no more `include_str!` references. - `docs/terminal-rendering.md`: src paths point at `frontend/packages/{agent,shared}/src/`; marked is the npm dep, not vendored UMD. Validation: - `cargo check --workspace` — clean (5 warnings, all pre-existing in `rebuild_queue.rs`, none on changed files). - `cargo clippy --workspace --all-targets` — clean (11 warnings, same pre-existing source). - `cd frontend && npm run build` from the prior commit's lockfile produces the dist directories the new routers consume: dashboard: `dist/{index.html, static/{app.js, dashboard.css}}` agent: `dist/{index.html, stats.html, screen.html, static/{app.js, stats.js, agent.css}}` (favicon.svg lands in dashboard/ during the nix build — `nix/frontend.nix` install phase copies `branding/hyperhive.svg` there, since it's outside the npm tree.) Refs #273. --- CLAUDE.md | 49 +- Cargo.lock | 39 +- Cargo.toml | 4 +- docs/terminal-rendering.md | 10 +- docs/web-ui.md | 32 +- hive-ag3nt/Cargo.toml | 2 +- hive-ag3nt/assets/agent.css | 386 ---- hive-ag3nt/assets/app.js | 1156 ------------ hive-ag3nt/assets/index.html | 52 - hive-ag3nt/assets/screen.html | 770 -------- hive-ag3nt/assets/stats.html | 100 -- hive-ag3nt/assets/stats.js | 338 ---- hive-ag3nt/src/web_ui.rs | 101 +- hive-c0re/Cargo.toml | 2 +- hive-c0re/assets/app.js | 2310 ------------------------ hive-c0re/assets/dashboard.css | 1143 ------------ hive-c0re/assets/index.html | 116 -- hive-c0re/src/dashboard.rs | 92 +- hive-fr0nt/Cargo.toml | 7 - hive-fr0nt/assets/base.css | 24 - hive-fr0nt/assets/marked.umd.js | 2913 ------------------------------- hive-fr0nt/assets/terminal.css | 228 --- hive-fr0nt/assets/terminal.js | 344 ---- hive-fr0nt/src/lib.rs | 47 - 24 files changed, 143 insertions(+), 10122 deletions(-) delete mode 100644 hive-ag3nt/assets/agent.css delete mode 100644 hive-ag3nt/assets/app.js delete mode 100644 hive-ag3nt/assets/index.html delete mode 100644 hive-ag3nt/assets/screen.html delete mode 100644 hive-ag3nt/assets/stats.html delete mode 100644 hive-ag3nt/assets/stats.js delete mode 100644 hive-c0re/assets/app.js delete mode 100644 hive-c0re/assets/dashboard.css delete mode 100644 hive-c0re/assets/index.html delete mode 100644 hive-fr0nt/Cargo.toml delete mode 100644 hive-fr0nt/assets/base.css delete mode 100644 hive-fr0nt/assets/marked.umd.js delete mode 100644 hive-fr0nt/assets/terminal.css delete mode 100644 hive-fr0nt/assets/terminal.js delete mode 100644 hive-fr0nt/src/lib.rs diff --git a/CLAUDE.md b/CLAUDE.md index 0a6ebc6..296d77d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,35 +87,28 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) and meta read access; mirrors each applied repo into `agent-configs/` (core-only); agents are read-only collaborators on `core/meta` - src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions + src/dashboard.rs axum HTTP: /api/state JSON + actions + journald viewer + bind-with-retry (SO_REUSEADDR) + deployed_sha chip per container + /dashboard/{stream,history} subscribing to the - unified DashboardEvent channel - assets/ index.html, dashboard.css, app.js (include_str!) + unified DashboardEvent channel. Static assets + (HTML/CSS/JS/favicon) served by + tower_http::ServeDir from $HIVE_STATIC_DIR + (= `${frontend}/dashboard` per the c0re module). -hive-fr0nt/ shared frontend-assets crate (browser only). - src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS / - MARKED_JS re-exports; both binaries - `include_str!` them and prepend to their per- - page serving routes. - assets/base.css Catppuccin palette + body typography (one source - of truth, no per-page redeclaration). - assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` + - `.row` / `details.row` styling for both - pages' lit log panes. Unified prefix-column - (padding-left + negative text-indent) so glyph - alignment is consistent across row kinds + a - `.md` block scope for marked-rendered bodies. - assets/terminal.js `window.HiveTerminal.create(opts)`: scroll- - sticky log + "↓ N new" pill + history - backfill + SSE subscribe-buffer-snapshot- - dedupe dance. Pages register a kind→renderer - map; the terminal owns the lifecycle. - assets/marked.umd.js vendored marked v4.0.2 UMD bundle. Per-agent - terminal uses the global `marked.parse` for - markdown bodies on send / recv / ask / answer - / assistant text rows. +frontend/ npm workspaces (esbuild → static dist). Built + hermetically by `nix/frontend.nix` + (`packages.${system}.frontend`). + packages/shared/ @hive/shared: terminal pane + Catppuccin palette + + base typography (was hive-fr0nt). ES module + exporting { create, linkify }; pure JS, no IIFE + globals; consumed by dashboard + agent. + packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js, + dashboard.css} + build.mjs → dist/{index.html, + static/{app.js, dashboard.css}}. + packages/agent/ @hive/agent default per-container UI: src/ + {index, stats, screen}.html + {app, stats}.js + + agent.css → dist/{*.html, static/*}. hive-ag3nt/ in-container harness crate; produces TWO binaries src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT @@ -139,8 +132,10 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries src/login_session.rs drives `claude auth login` over stdio pipes src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands) src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands) - assets/ index.html, agent.css, app.js, stats.html, - stats.js, screen.html (include_str!) + Static UI assets served by ServeDir from + $HIVE_STATIC_DIR (= hyperhive.frontend + .mergedDist — default agent dist + per-agent + extraFiles, set per the harness-base module). prompts/ static role/tools/settings for claude (include_str!): agent.md — sub-agent system prompt manager.md — manager system prompt diff --git a/Cargo.lock b/Cargo.lock index 3083bdb..9e8e75b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -565,7 +565,6 @@ dependencies = [ "axum", "clap", "futures-util", - "hive-fr0nt", "hive-sh4re", "reqwest", "rmcp", @@ -575,6 +574,7 @@ dependencies = [ "serde_json", "tokio", "tokio-stream", + "tower-http", "tracing", "tracing-subscriber", ] @@ -587,7 +587,6 @@ dependencies = [ "axum", "base64", "clap", - "hive-fr0nt", "hive-sh4re", "libc", "reqwest", @@ -597,14 +596,11 @@ dependencies = [ "tempfile", "tokio", "tokio-stream", + "tower-http", "tracing", "tracing-subscriber", ] -[[package]] -name = "hive-fr0nt" -version = "0.1.0" - [[package]] name = "hive-sh4re" version = "0.1.0" @@ -645,6 +641,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -954,6 +956,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "mio" version = "1.2.0" @@ -1723,10 +1735,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -1835,6 +1856,12 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/Cargo.toml b/Cargo.toml index a53b5b9..c388004 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"] +members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"] [workspace.package] edition = "2024" @@ -19,8 +19,8 @@ anyhow = "1" axum = { version = "0.8", features = ["ws"] } base64 = "0.22" clap = { version = "4", features = ["derive"] } -hive-fr0nt = { path = "hive-fr0nt" } hive-sh4re = { path = "hive-sh4re" } +tower-http = { version = "0.6", features = ["fs"] } rmcp = { version = "1.7", default-features = false, features = [ "server", "macros", diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index c5ebaa7..fce8d21 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -2,11 +2,11 @@ Snapshot of how the per-agent web UI's live pane renders each event kind today. Source of truth lives in -`hive-ag3nt/assets/app.js` (`renderStream`, `fmtToolUse`, +`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`, `renderRichToolUse`, `renderToolResult`, `renderTaskEvent`, `mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) + -`hive-fr0nt/assets/terminal.css` (the shared `.live .` -styling) + `hive-fr0nt/assets/marked.umd.js` (markdown). +`frontend/packages/shared/src/terminal.css` (the shared +`.live .` styling) + the `marked` npm package (markdown). ## Layout contract @@ -82,8 +82,8 @@ parent's negative pull. ## Markdown -`mdNode(text)` wraps `window.marked.parse(text)` (vendored -v4.0.2 UMD via `hive-fr0nt::MARKED_JS`) in a `
`. CSS in `terminal.css` scopes paragraph / code / list / blockquote / link styling under `.live .row .md` so the markdown body doesn't bleed into the row's own diff --git a/docs/web-ui.md b/docs/web-ui.md index 61b4fa3..a0a555d 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -7,22 +7,32 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed ## Shape (shared by both) -- `GET /` → `assets/index.html` (placeholders for state-driven - sections, shipped via `include_str!` so the binary has no runtime - file dependency). -- `GET /static/*.css` + `GET /static/*.js` → static assets. Both - pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their - per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the - shared `window.HiveTerminal.create` runtime. The dashboard's +- `GET /` → `index.html` from the bundled frontend dist (see + `frontend/`). Both binaries' routers declare their dynamic + endpoints first and then `fallback_service(ServeDir::new(...))` + pointed at `HIVE_STATIC_DIR` — anything not matched by an API or + action route is served from the dist. Dashboard dist lives at + `${frontend}/dashboard`; per-agent dist is the merged + `hyperhive.frontend.mergedDist` (default agent dist + per-agent + `extraFiles` overlay). +- `GET /static/*` → bundled CSS + JS produced by esbuild + (`frontend/packages/{dashboard,agent}/build.mjs`). Both pages + pull the shared terminal pane + Catppuccin palette + typography + from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines + `base.css` + `terminal.css` via esbuild's `@import` resolution. + `terminal.js` exports `{ create, linkify }` as ES module + members (no more `window.HiveTerminal` global outside the + back-compat shim the IIFE bodies still use). The dashboard's `#msgflow` and the per-agent `#live` log are both backed by this terminal — sticky-bottom auto-scroll, "↓ N new" pill, history backfill, SSE plumbing all live there. Each page registers a kind→renderer map; unknown kinds fall through to a JSON-dump note row. Bare `http(s)://` URLs in row text are - turned into clickable new-tab links by `HiveTerminal.linkify` - (text-node based, no `innerHTML` — XSS-safe); markdown bodies - get the same treatment via `marked`'s autolink, with the - rendered ``s rewritten to `target="_blank"` (issue #233). + turned into clickable new-tab links by `linkify` (text-node + based, no `innerHTML` — XSS-safe); markdown bodies get the + same treatment via `marked`'s autolink (npm dep, replacing the + vendored UMD bundle), with the rendered ``s rewritten to + `target="_blank"` (issue #233). - `GET /api/state` → JSON snapshot the JS app renders into the DOM. Includes a top-level `seq` (the dashboard event channel's high-water mark at the moment the snapshot was assembled); diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml index e2b4a2b..3f0fba3 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -12,7 +12,6 @@ axum.workspace = true reqwest.workspace = true futures-util = "0.3" clap.workspace = true -hive-fr0nt.workspace = true hive-sh4re.workspace = true rmcp.workspace = true rusqlite.workspace = true @@ -21,6 +20,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-stream.workspace = true +tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css deleted file mode 100644 index 8b66609..0000000 --- a/hive-ag3nt/assets/agent.css +++ /dev/null @@ -1,386 +0,0 @@ -/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended - to this stylesheet by `serve_css` at runtime. */ -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/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js deleted file mode 100644 index eb47893..0000000 --- a/hive-ag3nt/assets/app.js +++ /dev/null @@ -1,1156 +0,0 @@ -// 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). - -(() => { - // ─── 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/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html deleted file mode 100644 index 8721ef9..0000000 --- a/hive-ag3nt/assets/index.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - hyperhive agent - - - - - -
- -

◆ … ◆

-
- - -
-

loading…

-
- -
- - … booting - - - - - - -
- - - - - -
-
connecting…
-
-
- - - - - - diff --git a/hive-ag3nt/assets/screen.html b/hive-ag3nt/assets/screen.html deleted file mode 100644 index 2b91f12..0000000 --- a/hive-ag3nt/assets/screen.html +++ /dev/null @@ -1,770 +0,0 @@ - - - - - -screen - - - - -
- 🖥 screen - ← agent - - - - connecting… -
-
-
-
- - - - diff --git a/hive-ag3nt/assets/stats.html b/hive-ag3nt/assets/stats.html deleted file mode 100644 index 446491f..0000000 --- a/hive-ag3nt/assets/stats.html +++ /dev/null @@ -1,100 +0,0 @@ - - - - - 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/hive-ag3nt/assets/stats.js b/hive-ag3nt/assets/stats.js deleted file mode 100644 index 1b524d2..0000000 --- a/hive-ag3nt/assets/stats.js +++ /dev/null @@ -1,338 +0,0 @@ -// 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. - -(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/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 3868c82..14e02e3 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -24,6 +24,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; +use tower_http::services::ServeDir; use crate::client; use crate::events::Bus; @@ -88,6 +89,19 @@ pub async fn serve( turn_lock: TurnLock, ) -> Result<()> { let gui_vnc_port = read_gui_json(); + let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") + .map(PathBuf::from) + .context( + "HIVE_STATIC_DIR env var not set — point it at the merged \ + per-agent dist (see hyperhive.frontend.mergedDist in nix)", + )?; + if !static_dir.is_dir() { + anyhow::bail!( + "HIVE_STATIC_DIR ({}) is not a directory", + static_dir.display() + ); + } + tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved"); let state = AppState { label, login, @@ -99,11 +113,6 @@ pub async fn serve( gui_vnc_port, }; let app = Router::new() - .route("/", get(serve_index)) - .route("/static/agent.css", get(serve_css)) - .route("/static/app.js", get(serve_app_js)) - .route("/static/hive-fr0nt.js", get(serve_shared_js)) - .route("/static/marked.js", get(serve_marked_js)) .route("/api/state", get(api_state)) .route("/events/stream", get(events_stream)) .route("/events/history", get(events_history)) @@ -116,12 +125,17 @@ pub async fn serve( .route("/api/model", post(post_set_model)) .route("/api/new-session", post(post_new_session)) .route("/api/loose-ends", get(api_loose_ends)) - .route("/stats", get(serve_stats)) - .route("/static/stats.js", get(serve_stats_js)) .route("/api/stats", get(api_stats)) - .route("/screen", get(serve_screen)) .route("/screen/ws", get(screen_ws)) .route("/icon", get(serve_icon)) + // Anything else (`/`, `/stats`, `/screen`, `/static/*`) + // falls through to the merged dist. ServeDir auto-appends + // `.html` when the URL is a bare path that matches a file + // (so `/stats` → `dist/stats.html`, `/screen` → `dist/ + // screen.html`). Per-agent `extraFiles` additions are + // already layered into this same directory (see + // hyperhive.frontend.mergedDist in nix). + .fallback_service(ServeDir::new(&static_dir)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr, "web UI").await?; @@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { sock.listen(1024) } -async fn serve_index() -> impl IntoResponse { - ( - [("content-type", "text/html; charset=utf-8")], - include_str!("../assets/index.html"), - ) -} - -async fn serve_css() -> impl IntoResponse { - // Prepend the shared palette/typography so per-page styles only need - // to declare what's actually page-specific. One HTTP request, no - // per-asset cache to invalidate. - let body = format!( - "{}\n{}\n{}", - hive_fr0nt::BASE_CSS, - hive_fr0nt::TERMINAL_CSS, - include_str!("../assets/agent.css"), - ); - ([("content-type", "text/css")], body) -} - -async fn serve_app_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - include_str!("../assets/app.js"), - ) -} - -async fn serve_shared_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - hive_fr0nt::TERMINAL_JS, - ) -} - -async fn serve_marked_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - hive_fr0nt::MARKED_JS, - ) -} - -async fn serve_stats() -> impl IntoResponse { - ( - [("content-type", "text/html; charset=utf-8")], - include_str!("../assets/stats.html"), - ) -} - -async fn serve_stats_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - include_str!("../assets/stats.js"), - ) -} - -async fn serve_screen() -> impl IntoResponse { - ( - [("content-type", "text/html; charset=utf-8")], - include_str!("../assets/screen.html"), - ) -} - /// This agent's icon. Serves the operator-configured SVG from /// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix /// option) when present, otherwise the bundled default hyperhive logo. @@ -585,8 +537,13 @@ async fn api_state(State(state): State) -> axum::Json { fn agent_links(label: &str, gui_enabled: bool) -> Vec { let mut links = Vec::new(); + // Note: the URLs are the actual HTML files served out of the + // frontend dist (`stats.html` / `screen.html`); after the #273 + // backend/frontend split the harness serves these as static + // files via ServeDir rather than via Rust routes, so the URL + // has to be the on-disk filename. links.push(AgentLink { - url: "/stats".to_owned(), + url: "/stats.html".to_owned(), icon: "📊".to_owned(), label: "stats".to_owned(), kind: AgentLinkKind::Container, @@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec { if gui_enabled { links.push(AgentLink { - url: "/screen".to_owned(), + url: "/screen.html".to_owned(), icon: "🖥".to_owned(), label: "screen".to_owned(), kind: AgentLinkKind::Container, diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index 169a50e..d57a777 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -12,7 +12,6 @@ axum.workspace = true base64.workspace = true reqwest.workspace = true clap.workspace = true -hive-fr0nt.workspace = true hive-sh4re.workspace = true libc = "0.2" rusqlite.workspace = true @@ -20,6 +19,7 @@ serde.workspace = true serde_json.workspace = true tokio.workspace = true tokio-stream.workspace = true +tower-http.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js deleted file mode 100644 index 53012bd..0000000 --- a/hive-c0re/assets/app.js +++ /dev/null @@ -1,2310 +0,0 @@ -// 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`. - -(() => { - // ─── 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/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css deleted file mode 100644 index 568729d..0000000 --- a/hive-c0re/assets/dashboard.css +++ /dev/null @@ -1,1143 +0,0 @@ -/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended - to this stylesheet by `serve_css` at runtime. */ -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/hive-c0re/assets/index.html b/hive-c0re/assets/index.html
deleted file mode 100644
index 846c86e..0000000
--- a/hive-c0re/assets/index.html
+++ /dev/null
@@ -1,116 +0,0 @@
-
-
-
-  
-  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/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 725ac1b..9871fc5 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -4,7 +4,7 @@ use std::convert::Infallible; use std::net::SocketAddr; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, Result}; @@ -14,7 +14,7 @@ use axum::{ extract::{Path as AxumPath, State}, http::{HeaderMap, StatusCode}, response::{ - Html, IntoResponse, Response, + IntoResponse, Response, sse::{Event, KeepAlive, Sse}, }, routing::{get, post}, @@ -23,6 +23,7 @@ use hive_sh4re::Approval; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; +use tower_http::services::ServeDir; use crate::actions; use crate::container_view::{ContainerView, claude_has_session}; @@ -37,11 +38,20 @@ struct AppState { } pub async fn serve(port: u16, coord: Arc) -> Result<()> { + let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") + .map(PathBuf::from) + .context( + "HIVE_STATIC_DIR env var not set — point it at the bundled \ + dashboard dist (see services.hive-c0re.frontend in nix)", + )?; + if !static_dir.is_dir() { + anyhow::bail!( + "HIVE_STATIC_DIR ({}) is not a directory", + static_dir.display() + ); + } + tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved"); let app = Router::new() - .route("/", get(serve_index)) - .route("/static/dashboard.css", get(serve_css)) - .route("/static/app.js", get(serve_app_js)) - .route("/favicon.svg", get(serve_favicon)) .route("/api/state", get(api_state)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) @@ -66,8 +76,11 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/meta-update", post(post_meta_update)) .route("/dashboard/stream", get(dashboard_stream)) .route("/dashboard/history", get(dashboard_history)) - .route("/static/hive-fr0nt.js", get(serve_shared_js)) - .route("/static/marked.js", get(serve_marked_js)) + // Anything not matched by the dynamic routes above falls + // through to the bundled dashboard dist (GET / → + // dist/index.html, /favicon.svg → dist/favicon.svg, + // /static/dashboard.css → dist/static/dashboard.css, etc.). + .fallback_service(ServeDir::new(&static_dir)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = bind_with_retry(addr).await?; @@ -77,11 +90,14 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { } // --------------------------------------------------------------------------- -// Static asset handlers: the dashboard is an SPA. `GET /` returns the -// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state` -// returns the current snapshot as JSON. The JS app fetches state on load, -// re-fetches after every async-form submit, and listens on -// `/dashboard/stream` for the unified live event channel. +// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon +// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the +// hive-c0re NixOS module to `${frontend}/dashboard`), served by the +// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic +// surface — `/api/state` and the action endpoints — is owned here. +// The JS app fetches state on load, re-fetches after every async-form +// submit, and listens on `/dashboard/stream` for the unified live event +// channel. // --------------------------------------------------------------------------- /// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in @@ -142,56 +158,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result { sock.listen(1024) } -async fn serve_index() -> impl IntoResponse { - Html(include_str!("../assets/index.html")) -} - -async fn serve_css() -> impl IntoResponse { - // Prepend the shared palette/typography so per-page styles only need - // to declare what's actually page-specific. One HTTP request, no - // per-asset cache to invalidate. - let body = format!( - "{}\n{}\n{}", - hive_fr0nt::BASE_CSS, - hive_fr0nt::TERMINAL_CSS, - include_str!("../assets/dashboard.css"), - ); - ([("content-type", "text/css")], body) -} - -async fn serve_app_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - include_str!("../assets/app.js"), - ) -} - -/// Dashboard favicon — the hyperhive mark. Static: the dashboard -/// represents the whole hive, so it always uses the project logo -/// (per-agent pages serve their own configurable `/icon` instead). -async fn serve_favicon() -> impl IntoResponse { - ( - [("content-type", "image/svg+xml")], - include_str!("../../branding/hyperhive.svg"), - ) -} - -async fn serve_shared_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - hive_fr0nt::TERMINAL_JS, - ) -} - -/// Vendored `marked` bundle — the side panel renders markdown file -/// previews with it. -async fn serve_marked_js() -> impl IntoResponse { - ( - [("content-type", "application/javascript")], - hive_fr0nt::MARKED_JS, - ) -} - #[derive(Serialize)] struct StateSnapshot { /// Broker seq at the moment this snapshot was assembled. Clients diff --git a/hive-fr0nt/Cargo.toml b/hive-fr0nt/Cargo.toml deleted file mode 100644 index 1bc5f00..0000000 --- a/hive-fr0nt/Cargo.toml +++ /dev/null @@ -1,7 +0,0 @@ -[package] -name = "hive-fr0nt" -edition.workspace = true -version.workspace = true - -[lints] -workspace = true diff --git a/hive-fr0nt/assets/base.css b/hive-fr0nt/assets/base.css deleted file mode 100644 index ee7b64e..0000000 --- a/hive-fr0nt/assets/base.css +++ /dev/null @@ -1,24 +0,0 @@ -/* 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/hive-fr0nt/assets/marked.umd.js b/hive-fr0nt/assets/marked.umd.js deleted file mode 100644 index 2f2ca9c..0000000 --- a/hive-fr0nt/assets/marked.umd.js +++ /dev/null @@ -1,2913 +0,0 @@ -/** - * marked - a markdown parser - * Copyright (c) 2011-2021, Christopher Jeffrey. (MIT Licensed) - * https://github.com/markedjs/marked - */ - -/** - * DO NOT EDIT THIS FILE - * The code in this file is generated from files in ./src/ - */ - -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : - typeof define === 'function' && define.amd ? define(['exports'], factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.marked = {})); -})(this, (function (exports) { 'use strict'; - - function _defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; - descriptor.enumerable = descriptor.enumerable || false; - descriptor.configurable = true; - if ("value" in descriptor) descriptor.writable = true; - Object.defineProperty(target, descriptor.key, descriptor); - } - } - - function _createClass(Constructor, protoProps, staticProps) { - if (protoProps) _defineProperties(Constructor.prototype, protoProps); - if (staticProps) _defineProperties(Constructor, staticProps); - return Constructor; - } - - function _unsupportedIterableToArray(o, minLen) { - if (!o) return; - if (typeof o === "string") return _arrayLikeToArray(o, minLen); - var n = Object.prototype.toString.call(o).slice(8, -1); - if (n === "Object" && o.constructor) n = o.constructor.name; - if (n === "Map" || n === "Set") return Array.from(o); - if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); - } - - function _arrayLikeToArray(arr, len) { - if (len == null || len > arr.length) len = arr.length; - - for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; - - return arr2; - } - - function _createForOfIteratorHelperLoose(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (it) return (it = it.call(o)).next.bind(it); - - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - return function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }; - } - - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - - function getDefaults() { - return { - baseUrl: null, - breaks: false, - extensions: null, - gfm: true, - headerIds: true, - headerPrefix: '', - highlight: null, - langPrefix: 'language-', - mangle: true, - pedantic: false, - renderer: null, - sanitize: false, - sanitizer: null, - silent: false, - smartLists: false, - smartypants: false, - tokenizer: null, - walkTokens: null, - xhtml: false - }; - } - exports.defaults = getDefaults(); - function changeDefaults(newDefaults) { - exports.defaults = newDefaults; - } - - /** - * Helpers - */ - var escapeTest = /[&<>"']/; - var escapeReplace = /[&<>"']/g; - var escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/; - var escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g; - var escapeReplacements = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - }; - - var getEscapeReplacement = function getEscapeReplacement(ch) { - return escapeReplacements[ch]; - }; - - function escape(html, encode) { - if (encode) { - if (escapeTest.test(html)) { - return html.replace(escapeReplace, getEscapeReplacement); - } - } else { - if (escapeTestNoEncode.test(html)) { - return html.replace(escapeReplaceNoEncode, getEscapeReplacement); - } - } - - return html; - } - var unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig; - function unescape(html) { - // explicitly match decimal, hex, and named HTML entities - return html.replace(unescapeTest, function (_, n) { - n = n.toLowerCase(); - if (n === 'colon') return ':'; - - if (n.charAt(0) === '#') { - return n.charAt(1) === 'x' ? String.fromCharCode(parseInt(n.substring(2), 16)) : String.fromCharCode(+n.substring(1)); - } - - return ''; - }); - } - var caret = /(^|[^\[])\^/g; - function edit(regex, opt) { - regex = regex.source || regex; - opt = opt || ''; - var obj = { - replace: function replace(name, val) { - val = val.source || val; - val = val.replace(caret, '$1'); - regex = regex.replace(name, val); - return obj; - }, - getRegex: function getRegex() { - return new RegExp(regex, opt); - } - }; - return obj; - } - var nonWordAndColonTest = /[^\w:]/g; - var originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i; - function cleanUrl(sanitize, base, href) { - if (sanitize) { - var prot; - - try { - prot = decodeURIComponent(unescape(href)).replace(nonWordAndColonTest, '').toLowerCase(); - } catch (e) { - return null; - } - - if (prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) { - return null; - } - } - - if (base && !originIndependentUrl.test(href)) { - href = resolveUrl(base, href); - } - - try { - href = encodeURI(href).replace(/%25/g, '%'); - } catch (e) { - return null; - } - - return href; - } - var baseUrls = {}; - var justDomain = /^[^:]+:\/*[^/]*$/; - var protocol = /^([^:]+:)[\s\S]*$/; - var domain = /^([^:]+:\/*[^/]*)[\s\S]*$/; - function resolveUrl(base, href) { - if (!baseUrls[' ' + base]) { - // we can ignore everything in base after the last slash of its path component, - // but we might need to add _that_ - // https://tools.ietf.org/html/rfc3986#section-3 - if (justDomain.test(base)) { - baseUrls[' ' + base] = base + '/'; - } else { - baseUrls[' ' + base] = rtrim(base, '/', true); - } - } - - base = baseUrls[' ' + base]; - var relativeBase = base.indexOf(':') === -1; - - if (href.substring(0, 2) === '//') { - if (relativeBase) { - return href; - } - - return base.replace(protocol, '$1') + href; - } else if (href.charAt(0) === '/') { - if (relativeBase) { - return href; - } - - return base.replace(domain, '$1') + href; - } else { - return base + href; - } - } - var noopTest = { - exec: function noopTest() {} - }; - function merge(obj) { - var i = 1, - target, - key; - - for (; i < arguments.length; i++) { - target = arguments[i]; - - for (key in target) { - if (Object.prototype.hasOwnProperty.call(target, key)) { - obj[key] = target[key]; - } - } - } - - return obj; - } - function splitCells(tableRow, count) { - // ensure that every cell-delimiting pipe has a space - // before it to distinguish it from an escaped pipe - var row = tableRow.replace(/\|/g, function (match, offset, str) { - var escaped = false, - curr = offset; - - while (--curr >= 0 && str[curr] === '\\') { - escaped = !escaped; - } - - if (escaped) { - // odd number of slashes means | is escaped - // so we leave it alone - return '|'; - } else { - // add space before unescaped | - return ' |'; - } - }), - cells = row.split(/ \|/); - var i = 0; // First/last cell in a row cannot be empty if it has no leading/trailing pipe - - if (!cells[0].trim()) { - cells.shift(); - } - - if (!cells[cells.length - 1].trim()) { - cells.pop(); - } - - if (cells.length > count) { - cells.splice(count); - } else { - while (cells.length < count) { - cells.push(''); - } - } - - for (; i < cells.length; i++) { - // leading or trailing whitespace is ignored per the gfm spec - cells[i] = cells[i].trim().replace(/\\\|/g, '|'); - } - - return cells; - } // Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). - // /c*$/ is vulnerable to REDOS. - // invert: Remove suffix of non-c chars instead. Default falsey. - - function rtrim(str, c, invert) { - var l = str.length; - - if (l === 0) { - return ''; - } // Length of suffix matching the invert condition. - - - var suffLen = 0; // Step left until we fail to match the invert condition. - - while (suffLen < l) { - var currChar = str.charAt(l - suffLen - 1); - - if (currChar === c && !invert) { - suffLen++; - } else if (currChar !== c && invert) { - suffLen++; - } else { - break; - } - } - - return str.substr(0, l - suffLen); - } - function findClosingBracket(str, b) { - if (str.indexOf(b[1]) === -1) { - return -1; - } - - var l = str.length; - var level = 0, - i = 0; - - for (; i < l; i++) { - if (str[i] === '\\') { - i++; - } else if (str[i] === b[0]) { - level++; - } else if (str[i] === b[1]) { - level--; - - if (level < 0) { - return i; - } - } - } - - return -1; - } - function checkSanitizeDeprecation(opt) { - if (opt && opt.sanitize && !opt.silent) { - console.warn('marked(): sanitize and sanitizer parameters are deprecated since version 0.7.0, should not be used and will be removed in the future. Read more here: https://marked.js.org/#/USING_ADVANCED.md#options'); - } - } // copied from https://stackoverflow.com/a/5450113/806777 - - function repeatString(pattern, count) { - if (count < 1) { - return ''; - } - - var result = ''; - - while (count > 1) { - if (count & 1) { - result += pattern; - } - - count >>= 1; - pattern += pattern; - } - - return result + pattern; - } - - function outputLink(cap, link, raw, lexer) { - var href = link.href; - var title = link.title ? escape(link.title) : null; - var text = cap[1].replace(/\\([\[\]])/g, '$1'); - - if (cap[0].charAt(0) !== '!') { - lexer.state.inLink = true; - var token = { - type: 'link', - raw: raw, - href: href, - title: title, - text: text, - tokens: lexer.inlineTokens(text, []) - }; - lexer.state.inLink = false; - return token; - } else { - return { - type: 'image', - raw: raw, - href: href, - title: title, - text: escape(text) - }; - } - } - - function indentCodeCompensation(raw, text) { - var matchIndentToCode = raw.match(/^(\s+)(?:```)/); - - if (matchIndentToCode === null) { - return text; - } - - var indentToCode = matchIndentToCode[1]; - return text.split('\n').map(function (node) { - var matchIndentInNode = node.match(/^\s+/); - - if (matchIndentInNode === null) { - return node; - } - - var indentInNode = matchIndentInNode[0]; - - if (indentInNode.length >= indentToCode.length) { - return node.slice(indentToCode.length); - } - - return node; - }).join('\n'); - } - /** - * Tokenizer - */ - - - var Tokenizer = /*#__PURE__*/function () { - function Tokenizer(options) { - this.options = options || exports.defaults; - } - - var _proto = Tokenizer.prototype; - - _proto.space = function space(src) { - var cap = this.rules.block.newline.exec(src); - - if (cap) { - if (cap[0].length > 1) { - return { - type: 'space', - raw: cap[0] - }; - } - - return { - raw: '\n' - }; - } - }; - - _proto.code = function code(src) { - var cap = this.rules.block.code.exec(src); - - if (cap) { - var text = cap[0].replace(/^ {1,4}/gm, ''); - return { - type: 'code', - raw: cap[0], - codeBlockStyle: 'indented', - text: !this.options.pedantic ? rtrim(text, '\n') : text - }; - } - }; - - _proto.fences = function fences(src) { - var cap = this.rules.block.fences.exec(src); - - if (cap) { - var raw = cap[0]; - var text = indentCodeCompensation(raw, cap[3] || ''); - return { - type: 'code', - raw: raw, - lang: cap[2] ? cap[2].trim() : cap[2], - text: text - }; - } - }; - - _proto.heading = function heading(src) { - var cap = this.rules.block.heading.exec(src); - - if (cap) { - var text = cap[2].trim(); // remove trailing #s - - if (/#$/.test(text)) { - var trimmed = rtrim(text, '#'); - - if (this.options.pedantic) { - text = trimmed.trim(); - } else if (!trimmed || / $/.test(trimmed)) { - // CommonMark requires space before trailing #s - text = trimmed.trim(); - } - } - - var token = { - type: 'heading', - raw: cap[0], - depth: cap[1].length, - text: text, - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - }; - - _proto.hr = function hr(src) { - var cap = this.rules.block.hr.exec(src); - - if (cap) { - return { - type: 'hr', - raw: cap[0] - }; - } - }; - - _proto.blockquote = function blockquote(src) { - var cap = this.rules.block.blockquote.exec(src); - - if (cap) { - var text = cap[0].replace(/^ *> ?/gm, ''); - return { - type: 'blockquote', - raw: cap[0], - tokens: this.lexer.blockTokens(text, []), - text: text - }; - } - }; - - _proto.list = function list(src) { - var cap = this.rules.block.list.exec(src); - - if (cap) { - var raw, istask, ischecked, indent, i, blankLine, endsWithBlankLine, line, lines, itemContents; - var bull = cap[1].trim(); - var isordered = bull.length > 1; - var list = { - type: 'list', - raw: '', - ordered: isordered, - start: isordered ? +bull.slice(0, -1) : '', - loose: false, - items: [] - }; - bull = isordered ? "\\d{1,9}\\" + bull.slice(-1) : "\\" + bull; - - if (this.options.pedantic) { - bull = isordered ? bull : '[*+-]'; - } // Get next list item - - - var itemRegex = new RegExp("^( {0,3}" + bull + ")((?: [^\\n]*| *)(?:\\n[^\\n]*)*(?:\\n|$))"); // Get each top-level item - - while (src) { - if (this.rules.block.hr.test(src)) { - // End list if we encounter an HR (possibly move into itemRegex?) - break; - } - - if (!(cap = itemRegex.exec(src))) { - break; - } - - lines = cap[2].split('\n'); - - if (this.options.pedantic) { - indent = 2; - itemContents = lines[0].trimLeft(); - } else { - indent = cap[2].search(/[^ ]/); // Find first non-space char - - indent = cap[1].length + (indent > 4 ? 1 : indent); // intented code blocks after 4 spaces; indent is always 1 - - itemContents = lines[0].slice(indent - cap[1].length); - } - - blankLine = false; - raw = cap[0]; - - if (!lines[0] && /^ *$/.test(lines[1])) { - // items begin with at most one blank line - raw = cap[1] + lines.slice(0, 2).join('\n') + '\n'; - list.loose = true; - lines = []; - } - - var nextBulletRegex = new RegExp("^ {0," + Math.min(3, indent - 1) + "}(?:[*+-]|\\d{1,9}[.)])"); - - for (i = 1; i < lines.length; i++) { - line = lines[i]; - - if (this.options.pedantic) { - // Re-align to follow commonmark nesting rules - line = line.replace(/^ {1,4}(?=( {4})*[^ ])/g, ' '); - } // End list item if found start of new bullet - - - if (nextBulletRegex.test(line)) { - raw = cap[1] + lines.slice(0, i).join('\n') + '\n'; - break; - } // Until we encounter a blank line, item contents do not need indentation - - - if (!blankLine) { - if (!line.trim()) { - // Check if current line is empty - blankLine = true; - } // Dedent if possible - - - if (line.search(/[^ ]/) >= indent) { - itemContents += '\n' + line.slice(indent); - } else { - itemContents += '\n' + line; - } - - continue; - } // Dedent this line - - - if (line.search(/[^ ]/) >= indent || !line.trim()) { - itemContents += '\n' + line.slice(indent); - continue; - } else { - // Line was not properly indented; end of this item - raw = cap[1] + lines.slice(0, i).join('\n') + '\n'; - break; - } - } - - if (!list.loose) { - // If the previous item ended with a blank line, the list is loose - if (endsWithBlankLine) { - list.loose = true; - } else if (/\n *\n *$/.test(raw)) { - endsWithBlankLine = true; - } - } // Check for task list items - - - if (this.options.gfm) { - istask = /^\[[ xX]\] /.exec(itemContents); - - if (istask) { - ischecked = istask[0] !== '[ ] '; - itemContents = itemContents.replace(/^\[[ xX]\] +/, ''); - } - } - - list.items.push({ - type: 'list_item', - raw: raw, - task: !!istask, - checked: ischecked, - loose: false, - text: itemContents - }); - list.raw += raw; - src = src.slice(raw.length); - } // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic - - - list.items[list.items.length - 1].raw = raw.trimRight(); - list.items[list.items.length - 1].text = itemContents.trimRight(); - list.raw = list.raw.trimRight(); - var l = list.items.length; // Item child tokens handled here at end because we needed to have the final item to trim it first - - for (i = 0; i < l; i++) { - this.lexer.state.top = false; - list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); - - if (list.items[i].tokens.some(function (t) { - return t.type === 'space'; - })) { - list.loose = true; - list.items[i].loose = true; - } - } - - return list; - } - }; - - _proto.html = function html(src) { - var cap = this.rules.block.html.exec(src); - - if (cap) { - var token = { - type: 'html', - raw: cap[0], - pre: !this.options.sanitizer && (cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style'), - text: cap[0] - }; - - if (this.options.sanitize) { - token.type = 'paragraph'; - token.text = this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]); - token.tokens = []; - this.lexer.inline(token.text, token.tokens); - } - - return token; - } - }; - - _proto.def = function def(src) { - var cap = this.rules.block.def.exec(src); - - if (cap) { - if (cap[3]) cap[3] = cap[3].substring(1, cap[3].length - 1); - var tag = cap[1].toLowerCase().replace(/\s+/g, ' '); - return { - type: 'def', - tag: tag, - raw: cap[0], - href: cap[2], - title: cap[3] - }; - } - }; - - _proto.table = function table(src) { - var cap = this.rules.block.table.exec(src); - - if (cap) { - var item = { - type: 'table', - header: splitCells(cap[1]).map(function (c) { - return { - text: c - }; - }), - align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), - rows: cap[3] ? cap[3].replace(/\n$/, '').split('\n') : [] - }; - - if (item.header.length === item.align.length) { - item.raw = cap[0]; - var l = item.align.length; - var i, j, k, row; - - for (i = 0; i < l; i++) { - if (/^ *-+: *$/.test(item.align[i])) { - item.align[i] = 'right'; - } else if (/^ *:-+: *$/.test(item.align[i])) { - item.align[i] = 'center'; - } else if (/^ *:-+ *$/.test(item.align[i])) { - item.align[i] = 'left'; - } else { - item.align[i] = null; - } - } - - l = item.rows.length; - - for (i = 0; i < l; i++) { - item.rows[i] = splitCells(item.rows[i], item.header.length).map(function (c) { - return { - text: c - }; - }); - } // parse child tokens inside headers and cells - // header child tokens - - - l = item.header.length; - - for (j = 0; j < l; j++) { - item.header[j].tokens = []; - this.lexer.inlineTokens(item.header[j].text, item.header[j].tokens); - } // cell child tokens - - - l = item.rows.length; - - for (j = 0; j < l; j++) { - row = item.rows[j]; - - for (k = 0; k < row.length; k++) { - row[k].tokens = []; - this.lexer.inlineTokens(row[k].text, row[k].tokens); - } - } - - return item; - } - } - }; - - _proto.lheading = function lheading(src) { - var cap = this.rules.block.lheading.exec(src); - - if (cap) { - var token = { - type: 'heading', - raw: cap[0], - depth: cap[2].charAt(0) === '=' ? 1 : 2, - text: cap[1], - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - }; - - _proto.paragraph = function paragraph(src) { - var cap = this.rules.block.paragraph.exec(src); - - if (cap) { - var token = { - type: 'paragraph', - raw: cap[0], - text: cap[1].charAt(cap[1].length - 1) === '\n' ? cap[1].slice(0, -1) : cap[1], - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - }; - - _proto.text = function text(src) { - var cap = this.rules.block.text.exec(src); - - if (cap) { - var token = { - type: 'text', - raw: cap[0], - text: cap[0], - tokens: [] - }; - this.lexer.inline(token.text, token.tokens); - return token; - } - }; - - _proto.escape = function escape$1(src) { - var cap = this.rules.inline.escape.exec(src); - - if (cap) { - return { - type: 'escape', - raw: cap[0], - text: escape(cap[1]) - }; - } - }; - - _proto.tag = function tag(src) { - var cap = this.rules.inline.tag.exec(src); - - if (cap) { - if (!this.lexer.state.inLink && /^/i.test(cap[0])) { - this.lexer.state.inLink = false; - } - - if (!this.lexer.state.inRawBlock && /^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = true; - } else if (this.lexer.state.inRawBlock && /^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])) { - this.lexer.state.inRawBlock = false; - } - - return { - type: this.options.sanitize ? 'text' : 'html', - raw: cap[0], - inLink: this.lexer.state.inLink, - inRawBlock: this.lexer.state.inRawBlock, - text: this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0] - }; - } - }; - - _proto.link = function link(src) { - var cap = this.rules.inline.link.exec(src); - - if (cap) { - var trimmedUrl = cap[2].trim(); - - if (!this.options.pedantic && /^$/.test(trimmedUrl)) { - return; - } // ending angle bracket cannot be escaped - - - var rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); - - if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { - return; - } - } else { - // find closing parenthesis - var lastParenIndex = findClosingBracket(cap[2], '()'); - - if (lastParenIndex > -1) { - var start = cap[0].indexOf('!') === 0 ? 5 : 4; - var linkLen = start + cap[1].length + lastParenIndex; - cap[2] = cap[2].substring(0, lastParenIndex); - cap[0] = cap[0].substring(0, linkLen).trim(); - cap[3] = ''; - } - } - - var href = cap[2]; - var title = ''; - - if (this.options.pedantic) { - // split pedantic href and title - var link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); - - if (link) { - href = link[1]; - title = link[3]; - } - } else { - title = cap[3] ? cap[3].slice(1, -1) : ''; - } - - href = href.trim(); - - if (/^$/.test(trimmedUrl)) { - // pedantic allows starting angle bracket without ending angle bracket - href = href.slice(1); - } else { - href = href.slice(1, -1); - } - } - - return outputLink(cap, { - href: href ? href.replace(this.rules.inline._escapes, '$1') : href, - title: title ? title.replace(this.rules.inline._escapes, '$1') : title - }, cap[0], this.lexer); - } - }; - - _proto.reflink = function reflink(src, links) { - var cap; - - if ((cap = this.rules.inline.reflink.exec(src)) || (cap = this.rules.inline.nolink.exec(src))) { - var link = (cap[2] || cap[1]).replace(/\s+/g, ' '); - link = links[link.toLowerCase()]; - - if (!link || !link.href) { - var text = cap[0].charAt(0); - return { - type: 'text', - raw: text, - text: text - }; - } - - return outputLink(cap, link, cap[0], this.lexer); - } - }; - - _proto.emStrong = function emStrong(src, maskedSrc, prevChar) { - if (prevChar === void 0) { - prevChar = ''; - } - - var match = this.rules.inline.emStrong.lDelim.exec(src); - if (!match) return; // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well - - if (match[3] && prevChar.match(/(?:[0-9A-Za-z\xAA\xB2\xB3\xB5\xB9\xBA\xBC-\xBE\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u0660-\u0669\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07C0-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u0870-\u0887\u0889-\u088E\u08A0-\u08C9\u0904-\u0939\u093D\u0950\u0958-\u0961\u0966-\u096F\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09E6-\u09F1\u09F4-\u09F9\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A66-\u0A6F\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AE6-\u0AEF\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B66-\u0B6F\u0B71-\u0B77\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0BE6-\u0BF2\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C5D\u0C60\u0C61\u0C66-\u0C6F\u0C78-\u0C7E\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDD\u0CDE\u0CE0\u0CE1\u0CE6-\u0CEF\u0CF1\u0CF2\u0D04-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D58-\u0D61\u0D66-\u0D78\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0DE6-\u0DEF\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E50-\u0E59\u0E81\u0E82\u0E84\u0E86-\u0E8A\u0E8C-\u0EA3\u0EA5\u0EA7-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0ED0-\u0ED9\u0EDC-\u0EDF\u0F00\u0F20-\u0F33\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F-\u1049\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u1090-\u1099\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1369-\u137C\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F8\u1700-\u1711\u171F-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u17E0-\u17E9\u17F0-\u17F9\u1810-\u1819\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1946-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u19D0-\u19DA\u1A00-\u1A16\u1A20-\u1A54\u1A80-\u1A89\u1A90-\u1A99\u1AA7\u1B05-\u1B33\u1B45-\u1B4C\u1B50-\u1B59\u1B83-\u1BA0\u1BAE-\u1BE5\u1C00-\u1C23\u1C40-\u1C49\u1C4D-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF3\u1CF5\u1CF6\u1CFA\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2070\u2071\u2074-\u2079\u207F-\u2089\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2150-\u2189\u2460-\u249B\u24EA-\u24FF\u2776-\u2793\u2C00-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2CFD\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u3192-\u3195\u31A0-\u31BF\u31F0-\u31FF\u3220-\u3229\u3248-\u324F\u3251-\u325F\u3280-\u3289\u32B1-\u32BF\u3400-\u4DBF\u4E00-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6EF\uA717-\uA71F\uA722-\uA788\uA78B-\uA7CA\uA7D0\uA7D1\uA7D3\uA7D5-\uA7D9\uA7F2-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA830-\uA835\uA840-\uA873\uA882-\uA8B3\uA8D0-\uA8D9\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA900-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF-\uA9D9\uA9E0-\uA9E4\uA9E6-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA50-\uAA59\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB69\uAB70-\uABE2\uABF0-\uABF9\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDD07-\uDD33\uDD40-\uDD78\uDD8A\uDD8B\uDE80-\uDE9C\uDEA0-\uDED0\uDEE1-\uDEFB\uDF00-\uDF23\uDF2D-\uDF4A\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF\uDFD1-\uDFD5]|\uD801[\uDC00-\uDC9D\uDCA0-\uDCA9\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDD70-\uDD7A\uDD7C-\uDD8A\uDD8C-\uDD92\uDD94\uDD95\uDD97-\uDDA1\uDDA3-\uDDB1\uDDB3-\uDDB9\uDDBB\uDDBC\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67\uDF80-\uDF85\uDF87-\uDFB0\uDFB2-\uDFBA]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC58-\uDC76\uDC79-\uDC9E\uDCA7-\uDCAF\uDCE0-\uDCF2\uDCF4\uDCF5\uDCFB-\uDD1B\uDD20-\uDD39\uDD80-\uDDB7\uDDBC-\uDDCF\uDDD2-\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE40-\uDE48\uDE60-\uDE7E\uDE80-\uDE9F\uDEC0-\uDEC7\uDEC9-\uDEE4\uDEEB-\uDEEF\uDF00-\uDF35\uDF40-\uDF55\uDF58-\uDF72\uDF78-\uDF91\uDFA9-\uDFAF]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDCFA-\uDD23\uDD30-\uDD39\uDE60-\uDE7E\uDE80-\uDEA9\uDEB0\uDEB1\uDF00-\uDF27\uDF30-\uDF45\uDF51-\uDF54\uDF70-\uDF81\uDFB0-\uDFCB\uDFE0-\uDFF6]|\uD804[\uDC03-\uDC37\uDC52-\uDC6F\uDC71\uDC72\uDC75\uDC83-\uDCAF\uDCD0-\uDCE8\uDCF0-\uDCF9\uDD03-\uDD26\uDD36-\uDD3F\uDD44\uDD47\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDD0-\uDDDA\uDDDC\uDDE1-\uDDF4\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDEF0-\uDEF9\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC50-\uDC59\uDC5F-\uDC61\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDCD0-\uDCD9\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE50-\uDE59\uDE80-\uDEAA\uDEB8\uDEC0-\uDEC9\uDF00-\uDF1A\uDF30-\uDF3B\uDF40-\uDF46]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCF2\uDCFF-\uDD06\uDD09\uDD0C-\uDD13\uDD15\uDD16\uDD18-\uDD2F\uDD3F\uDD41\uDD50-\uDD59\uDDA0-\uDDA7\uDDAA-\uDDD0\uDDE1\uDDE3\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE89\uDE9D\uDEB0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC50-\uDC6C\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD50-\uDD59\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDDA0-\uDDA9\uDEE0-\uDEF2\uDFB0\uDFC0-\uDFD4]|\uD808[\uDC00-\uDF99]|\uD809[\uDC00-\uDC6E\uDC80-\uDD43]|\uD80B[\uDF90-\uDFF0]|[\uD80C\uD81C-\uD820\uD822\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879\uD880-\uD883][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDE60-\uDE69\uDE70-\uDEBE\uDEC0-\uDEC9\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF50-\uDF59\uDF5B-\uDF61\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE96\uDF00-\uDF4A\uDF50\uDF93-\uDF9F\uDFE0\uDFE1\uDFE3]|\uD821[\uDC00-\uDFF7]|\uD823[\uDC00-\uDCD5\uDD00-\uDD08]|\uD82B[\uDFF0-\uDFF3\uDFF5-\uDFFB\uDFFD\uDFFE]|\uD82C[\uDC00-\uDD22\uDD50-\uDD52\uDD64-\uDD67\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD834[\uDEE0-\uDEF3\uDF60-\uDF78]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB\uDFCE-\uDFFF]|\uD837[\uDF00-\uDF1E]|\uD838[\uDD00-\uDD2C\uDD37-\uDD3D\uDD40-\uDD49\uDD4E\uDE90-\uDEAD\uDEC0-\uDEEB\uDEF0-\uDEF9]|\uD839[\uDFE0-\uDFE6\uDFE8-\uDFEB\uDFED\uDFEE\uDFF0-\uDFFE]|\uD83A[\uDC00-\uDCC4\uDCC7-\uDCCF\uDD00-\uDD43\uDD4B\uDD50-\uDD59]|\uD83B[\uDC71-\uDCAB\uDCAD-\uDCAF\uDCB1-\uDCB4\uDD01-\uDD2D\uDD2F-\uDD3D\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD83C[\uDD00-\uDD0C]|\uD83E[\uDFF0-\uDFF9]|\uD869[\uDC00-\uDEDF\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF38\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D]|\uD884[\uDC00-\uDF4A])/)) return; - var nextChar = match[1] || match[2] || ''; - - if (!nextChar || nextChar && (prevChar === '' || this.rules.inline.punctuation.exec(prevChar))) { - var lLength = match[0].length - 1; - var rDelim, - rLength, - delimTotal = lLength, - midDelimTotal = 0; - var endReg = match[0][0] === '*' ? this.rules.inline.emStrong.rDelimAst : this.rules.inline.emStrong.rDelimUnd; - endReg.lastIndex = 0; // Clip maskedSrc to same section of string as src (move to lexer?) - - maskedSrc = maskedSrc.slice(-1 * src.length + lLength); - - while ((match = endReg.exec(maskedSrc)) != null) { - rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; - if (!rDelim) continue; // skip single * in __abc*abc__ - - rLength = rDelim.length; - - if (match[3] || match[4]) { - // found another Left Delim - delimTotal += rLength; - continue; - } else if (match[5] || match[6]) { - // either Left or Right Delim - if (lLength % 3 && !((lLength + rLength) % 3)) { - midDelimTotal += rLength; - continue; // CommonMark Emphasis Rules 9-10 - } - } - - delimTotal -= rLength; - if (delimTotal > 0) continue; // Haven't found enough closing delimiters - // Remove extra characters. *a*** -> *a* - - rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); // Create `em` if smallest delimiter has odd char count. *a*** - - if (Math.min(lLength, rLength) % 2) { - var _text = src.slice(1, lLength + match.index + rLength); - - return { - type: 'em', - raw: src.slice(0, lLength + match.index + rLength + 1), - text: _text, - tokens: this.lexer.inlineTokens(_text, []) - }; - } // Create 'strong' if smallest delimiter has even char count. **a*** - - - var text = src.slice(2, lLength + match.index + rLength - 1); - return { - type: 'strong', - raw: src.slice(0, lLength + match.index + rLength + 1), - text: text, - tokens: this.lexer.inlineTokens(text, []) - }; - } - } - }; - - _proto.codespan = function codespan(src) { - var cap = this.rules.inline.code.exec(src); - - if (cap) { - var text = cap[2].replace(/\n/g, ' '); - var hasNonSpaceChars = /[^ ]/.test(text); - var hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text); - - if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { - text = text.substring(1, text.length - 1); - } - - text = escape(text, true); - return { - type: 'codespan', - raw: cap[0], - text: text - }; - } - }; - - _proto.br = function br(src) { - var cap = this.rules.inline.br.exec(src); - - if (cap) { - return { - type: 'br', - raw: cap[0] - }; - } - }; - - _proto.del = function del(src) { - var cap = this.rules.inline.del.exec(src); - - if (cap) { - return { - type: 'del', - raw: cap[0], - text: cap[2], - tokens: this.lexer.inlineTokens(cap[2], []) - }; - } - }; - - _proto.autolink = function autolink(src, mangle) { - var cap = this.rules.inline.autolink.exec(src); - - if (cap) { - var text, href; - - if (cap[2] === '@') { - text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]); - href = 'mailto:' + text; - } else { - text = escape(cap[1]); - href = text; - } - - return { - type: 'link', - raw: cap[0], - text: text, - href: href, - tokens: [{ - type: 'text', - raw: text, - text: text - }] - }; - } - }; - - _proto.url = function url(src, mangle) { - var cap; - - if (cap = this.rules.inline.url.exec(src)) { - var text, href; - - if (cap[2] === '@') { - text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]); - href = 'mailto:' + text; - } else { - // do extended autolink path validation - var prevCapZero; - - do { - prevCapZero = cap[0]; - cap[0] = this.rules.inline._backpedal.exec(cap[0])[0]; - } while (prevCapZero !== cap[0]); - - text = escape(cap[0]); - - if (cap[1] === 'www.') { - href = 'http://' + text; - } else { - href = text; - } - } - - return { - type: 'link', - raw: cap[0], - text: text, - href: href, - tokens: [{ - type: 'text', - raw: text, - text: text - }] - }; - } - }; - - _proto.inlineText = function inlineText(src, smartypants) { - var cap = this.rules.inline.text.exec(src); - - if (cap) { - var text; - - if (this.lexer.state.inRawBlock) { - text = this.options.sanitize ? this.options.sanitizer ? this.options.sanitizer(cap[0]) : escape(cap[0]) : cap[0]; - } else { - text = escape(this.options.smartypants ? smartypants(cap[0]) : cap[0]); - } - - return { - type: 'text', - raw: cap[0], - text: text - }; - } - }; - - return Tokenizer; - }(); - - /** - * Block-Level Grammar - */ - - var block = { - newline: /^(?: *(?:\n|$))+/, - code: /^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/, - fences: /^ {0,3}(`{3,}(?=[^`\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?=\n|$)|$)/, - hr: /^ {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\* *){3,})(?:\n+|$)/, - heading: /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/, - blockquote: /^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/, - list: /^( {0,3}bull)( [^\n]+?)?(?:\n|$)/, - html: '^ {0,3}(?:' // optional indentation - + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)' // (1) - + '|comment[^\\n]*(\\n+|$)' // (2) - + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) - + '|\\n*|$)' // (4) - + '|\\n*|$)' // (5) - + '|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (6) - + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) open tag - + '|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)' // (7) closing tag - + ')', - def: /^ {0,3}\[(label)\]: *\n? *]+)>?(?:(?: +\n? *| *\n *)(title))? *(?:\n+|$)/, - table: noopTest, - lheading: /^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/, - // regex template, placeholders will be replaced according to different paragraph - // interruption rules of commonmark and the original markdown spec: - _paragraph: /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html| +\n)[^\n]+)*)/, - text: /^[^\n]+/ - }; - block._label = /(?!\s*\])(?:\\[\[\]]|[^\[\]])+/; - block._title = /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/; - block.def = edit(block.def).replace('label', block._label).replace('title', block._title).getRegex(); - block.bullet = /(?:[*+-]|\d{1,9}[.)])/; - block.listItemStart = edit(/^( *)(bull) */).replace('bull', block.bullet).getRegex(); - block.list = edit(block.list).replace(/bull/g, block.bullet).replace('hr', '\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))').replace('def', '\\n+(?=' + block.def.source + ')').getRegex(); - block._tag = 'address|article|aside|base|basefont|blockquote|body|caption' + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + '|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr' + '|track|ul'; - block._comment = /|$)/; - block.html = edit(block.html, 'i').replace('comment', block._comment).replace('tag', block._tag).replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(); - block.paragraph = edit(block._paragraph).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('|lheading', '') // setex headings don't interrupt commonmark paragraphs - .replace('blockquote', ' {0,3}>').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // pars can be interrupted by type (6) html blocks - .getRegex(); - block.blockquote = edit(block.blockquote).replace('paragraph', block.paragraph).getRegex(); - /** - * Normal Block Grammar - */ - - block.normal = merge({}, block); - /** - * GFM Block Grammar - */ - - block.gfm = merge({}, block.normal, { - table: '^ *([^\\n ].*\\|.*)\\n' // Header - + ' {0,3}(?:\\| *)?(:?-+:? *(?:\\| *:?-+:? *)*)(?:\\| *)?' // Align - + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)' // Cells - - }); - block.gfm.table = edit(block.gfm.table).replace('hr', block.hr).replace('heading', ' {0,3}#{1,6} ').replace('blockquote', ' {0,3}>').replace('code', ' {4}[^\\n]').replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n').replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt - .replace('html', ')|<(?:script|pre|style|textarea|!--)').replace('tag', block._tag) // tables can be interrupted by type (6) html blocks - .getRegex(); - /** - * Pedantic grammar (original John Gruber's loose markdown specification) - */ - - block.pedantic = merge({}, block.normal, { - html: edit('^ *(?:comment *(?:\\n|\\s*$)' + '|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)' // closed tag - + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))').replace('comment', block._comment).replace(/tag/g, '(?!(?:' + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b').getRegex(), - def: /^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, - heading: /^(#{1,6})(.*)(?:\n+|$)/, - fences: noopTest, - // fences not supported - paragraph: edit(block.normal._paragraph).replace('hr', block.hr).replace('heading', ' *#{1,6} *[^\n]').replace('lheading', block.lheading).replace('blockquote', ' {0,3}>').replace('|fences', '').replace('|list', '').replace('|html', '').getRegex() - }); - /** - * Inline-Level Grammar - */ - - var inline = { - escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/, - autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/, - url: noopTest, - tag: '^comment' + '|^' // self-closing tag - + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag - + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. - + '|^' // declaration, e.g. - + '|^', - // CDATA section - link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/, - reflink: /^!?\[(label)\]\[(?!\s*\])((?:\\[\[\]]?|[^\[\]\\])+)\]/, - nolink: /^!?\[(?!\s*\])((?:\[[^\[\]]*\]|\\[\[\]]|[^\[\]])*)\](?:\[\])?/, - reflinkSearch: 'reflink|nolink(?!\\()', - emStrong: { - lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/, - // (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left. (5) and (6) can be either Left or Right. - // () Skip orphan delim inside strong (1) #*** (2) a***#, a*** (3) #***a, ***a (4) ***# (5) #***# (6) a***a - rDelimAst: /^[^_*]*?\_\_[^_*]*?\*[^_*]*?(?=\_\_)|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/, - rDelimUnd: /^[^_*]*?\*\*[^_*]*?\_[^_*]*?(?=\*\*)|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/ // ^- Not allowed for _ - - }, - code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/, - br: /^( {2,}|\\)\n(?!\s*$)/, - del: noopTest, - text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\?@\\[\\]`^{|}~'; - inline.punctuation = edit(inline.punctuation).replace(/punctuation/g, inline._punctuation).getRegex(); // sequences em should skip over [title](link), `code`, - - inline.blockSkip = /\[[^\]]*?\]\([^\)]*?\)|`[^`]*?`|<[^>]*?>/g; - inline.escapedEmSt = /\\\*|\\_/g; - inline._comment = edit(block._comment).replace('(?:-->|$)', '-->').getRegex(); - inline.emStrong.lDelim = edit(inline.emStrong.lDelim).replace(/punct/g, inline._punctuation).getRegex(); - inline.emStrong.rDelimAst = edit(inline.emStrong.rDelimAst, 'g').replace(/punct/g, inline._punctuation).getRegex(); - inline.emStrong.rDelimUnd = edit(inline.emStrong.rDelimUnd, 'g').replace(/punct/g, inline._punctuation).getRegex(); - inline._escapes = /\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/g; - inline._scheme = /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/; - inline._email = /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/; - inline.autolink = edit(inline.autolink).replace('scheme', inline._scheme).replace('email', inline._email).getRegex(); - inline._attribute = /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/; - inline.tag = edit(inline.tag).replace('comment', inline._comment).replace('attribute', inline._attribute).getRegex(); - inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; - inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/; - inline._title = /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/; - inline.link = edit(inline.link).replace('label', inline._label).replace('href', inline._href).replace('title', inline._title).getRegex(); - inline.reflink = edit(inline.reflink).replace('label', inline._label).getRegex(); - inline.reflinkSearch = edit(inline.reflinkSearch, 'g').replace('reflink', inline.reflink).replace('nolink', inline.nolink).getRegex(); - /** - * Normal Inline Grammar - */ - - inline.normal = merge({}, inline); - /** - * Pedantic Inline Grammar - */ - - inline.pedantic = merge({}, inline.normal, { - strong: { - start: /^__|\*\*/, - middle: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, - endAst: /\*\*(?!\*)/g, - endUnd: /__(?!_)/g - }, - em: { - start: /^_|\*/, - middle: /^()\*(?=\S)([\s\S]*?\S)\*(?!\*)|^_(?=\S)([\s\S]*?\S)_(?!_)/, - endAst: /\*(?!\*)/g, - endUnd: /_(?!_)/g - }, - link: edit(/^!?\[(label)\]\((.*?)\)/).replace('label', inline._label).getRegex(), - reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace('label', inline._label).getRegex() - }); - /** - * GFM Inline Grammar - */ - - inline.gfm = merge({}, inline.normal, { - escape: edit(inline.escape).replace('])', '~|])').getRegex(), - _extended_email: /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/, - url: /^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, - _backpedal: /(?:[^?!.,:;*_~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_~)]+(?!$))+/, - del: /^(~~?)(?=[^\s~])([\s\S]*?[^\s~])\1(?=[^~]|$)/, - text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\ 0.5) { - ch = 'x' + ch.toString(16); - } - - out += '&#' + ch + ';'; - } - - return out; - } - /** - * Block Lexer - */ - - - var Lexer = /*#__PURE__*/function () { - function Lexer(options) { - this.tokens = []; - this.tokens.links = Object.create(null); - this.options = options || exports.defaults; - this.options.tokenizer = this.options.tokenizer || new Tokenizer(); - this.tokenizer = this.options.tokenizer; - this.tokenizer.options = this.options; - this.tokenizer.lexer = this; - this.inlineQueue = []; - this.state = { - inLink: false, - inRawBlock: false, - top: true - }; - var rules = { - block: block.normal, - inline: inline.normal - }; - - if (this.options.pedantic) { - rules.block = block.pedantic; - rules.inline = inline.pedantic; - } else if (this.options.gfm) { - rules.block = block.gfm; - - if (this.options.breaks) { - rules.inline = inline.breaks; - } else { - rules.inline = inline.gfm; - } - } - - this.tokenizer.rules = rules; - } - /** - * Expose Rules - */ - - - /** - * Static Lex Method - */ - Lexer.lex = function lex(src, options) { - var lexer = new Lexer(options); - return lexer.lex(src); - } - /** - * Static Lex Inline Method - */ - ; - - Lexer.lexInline = function lexInline(src, options) { - var lexer = new Lexer(options); - return lexer.inlineTokens(src); - } - /** - * Preprocessing - */ - ; - - var _proto = Lexer.prototype; - - _proto.lex = function lex(src) { - src = src.replace(/\r\n|\r/g, '\n').replace(/\t/g, ' '); - this.blockTokens(src, this.tokens); - var next; - - while (next = this.inlineQueue.shift()) { - this.inlineTokens(next.src, next.tokens); - } - - return this.tokens; - } - /** - * Lexing - */ - ; - - _proto.blockTokens = function blockTokens(src, tokens) { - var _this = this; - - if (tokens === void 0) { - tokens = []; - } - - if (this.options.pedantic) { - src = src.replace(/^ +$/gm, ''); - } - - var token, lastToken, cutSrc, lastParagraphClipped; - - while (src) { - if (this.options.extensions && this.options.extensions.block && this.options.extensions.block.some(function (extTokenizer) { - if (token = extTokenizer.call({ - lexer: _this - }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - - return false; - })) { - continue; - } // newline - - - if (token = this.tokenizer.space(src)) { - src = src.substring(token.raw.length); - - if (token.type) { - tokens.push(token); - } - - continue; - } // code - - - if (token = this.tokenizer.code(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; // An indented code block cannot interrupt a paragraph. - - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - - continue; - } // fences - - - if (token = this.tokenizer.fences(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // heading - - - if (token = this.tokenizer.heading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // hr - - - if (token = this.tokenizer.hr(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // blockquote - - - if (token = this.tokenizer.blockquote(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // list - - - if (token = this.tokenizer.list(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // html - - - if (token = this.tokenizer.html(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // def - - - if (token = this.tokenizer.def(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - - if (lastToken && (lastToken.type === 'paragraph' || lastToken.type === 'text')) { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.raw; - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } else if (!this.tokens.links[token.tag]) { - this.tokens.links[token.tag] = { - href: token.href, - title: token.title - }; - } - - continue; - } // table (gfm) - - - if (token = this.tokenizer.table(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // lheading - - - if (token = this.tokenizer.lheading(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // top-level paragraph - // prevent paragraph consuming extensions by clipping 'src' to extension start - - - cutSrc = src; - - if (this.options.extensions && this.options.extensions.startBlock) { - (function () { - var startIndex = Infinity; - var tempSrc = src.slice(1); - var tempStart = void 0; - - _this.options.extensions.startBlock.forEach(function (getStartIndex) { - tempStart = getStartIndex.call({ - lexer: this - }, tempSrc); - - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - })(); - } - - if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { - lastToken = tokens[tokens.length - 1]; - - if (lastParagraphClipped && lastToken.type === 'paragraph') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - - lastParagraphClipped = cutSrc.length !== src.length; - src = src.substring(token.raw.length); - continue; - } // text - - - if (token = this.tokenizer.text(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - - if (lastToken && lastToken.type === 'text') { - lastToken.raw += '\n' + token.raw; - lastToken.text += '\n' + token.text; - this.inlineQueue.pop(); - this.inlineQueue[this.inlineQueue.length - 1].src = lastToken.text; - } else { - tokens.push(token); - } - - continue; - } - - if (src) { - var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - - if (this.options.silent) { - console.error(errMsg); - break; - } else { - throw new Error(errMsg); - } - } - } - - this.state.top = true; - return tokens; - }; - - _proto.inline = function inline(src, tokens) { - this.inlineQueue.push({ - src: src, - tokens: tokens - }); - } - /** - * Lexing/Compiling - */ - ; - - _proto.inlineTokens = function inlineTokens(src, tokens) { - var _this2 = this; - - if (tokens === void 0) { - tokens = []; - } - - var token, lastToken, cutSrc; // String with links masked to avoid interference with em and strong - - var maskedSrc = src; - var match; - var keepPrevChar, prevChar; // Mask out reflinks - - if (this.tokens.links) { - var links = Object.keys(this.tokens.links); - - if (links.length > 0) { - while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { - if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); - } - } - } - } // Mask out other blocks - - - while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '[' + repeatString('a', match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); - } // Mask out escaped em & strong delimiters - - - while ((match = this.tokenizer.rules.inline.escapedEmSt.exec(maskedSrc)) != null) { - maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.escapedEmSt.lastIndex); - } - - while (src) { - if (!keepPrevChar) { - prevChar = ''; - } - - keepPrevChar = false; // extensions - - if (this.options.extensions && this.options.extensions.inline && this.options.extensions.inline.some(function (extTokenizer) { - if (token = extTokenizer.call({ - lexer: _this2 - }, src, tokens)) { - src = src.substring(token.raw.length); - tokens.push(token); - return true; - } - - return false; - })) { - continue; - } // escape - - - if (token = this.tokenizer.escape(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // tag - - - if (token = this.tokenizer.tag(src)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - - continue; - } // link - - - if (token = this.tokenizer.link(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // reflink, nolink - - - if (token = this.tokenizer.reflink(src, this.tokens.links)) { - src = src.substring(token.raw.length); - lastToken = tokens[tokens.length - 1]; - - if (lastToken && token.type === 'text' && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - - continue; - } // em & strong - - - if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // code - - - if (token = this.tokenizer.codespan(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // br - - - if (token = this.tokenizer.br(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // del (gfm) - - - if (token = this.tokenizer.del(src)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // autolink - - - if (token = this.tokenizer.autolink(src, mangle)) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // url (gfm) - - - if (!this.state.inLink && (token = this.tokenizer.url(src, mangle))) { - src = src.substring(token.raw.length); - tokens.push(token); - continue; - } // text - // prevent inlineText consuming extensions by clipping 'src' to extension start - - - cutSrc = src; - - if (this.options.extensions && this.options.extensions.startInline) { - (function () { - var startIndex = Infinity; - var tempSrc = src.slice(1); - var tempStart = void 0; - - _this2.options.extensions.startInline.forEach(function (getStartIndex) { - tempStart = getStartIndex.call({ - lexer: this - }, tempSrc); - - if (typeof tempStart === 'number' && tempStart >= 0) { - startIndex = Math.min(startIndex, tempStart); - } - }); - - if (startIndex < Infinity && startIndex >= 0) { - cutSrc = src.substring(0, startIndex + 1); - } - })(); - } - - if (token = this.tokenizer.inlineText(cutSrc, smartypants)) { - src = src.substring(token.raw.length); - - if (token.raw.slice(-1) !== '_') { - // Track prevChar before string of ____ started - prevChar = token.raw.slice(-1); - } - - keepPrevChar = true; - lastToken = tokens[tokens.length - 1]; - - if (lastToken && lastToken.type === 'text') { - lastToken.raw += token.raw; - lastToken.text += token.text; - } else { - tokens.push(token); - } - - continue; - } - - if (src) { - var errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); - - if (this.options.silent) { - console.error(errMsg); - break; - } else { - throw new Error(errMsg); - } - } - } - - return tokens; - }; - - _createClass(Lexer, null, [{ - key: "rules", - get: function get() { - return { - block: block, - inline: inline - }; - } - }]); - - return Lexer; - }(); - - /** - * Renderer - */ - - var Renderer = /*#__PURE__*/function () { - function Renderer(options) { - this.options = options || exports.defaults; - } - - var _proto = Renderer.prototype; - - _proto.code = function code(_code, infostring, escaped) { - var lang = (infostring || '').match(/\S*/)[0]; - - if (this.options.highlight) { - var out = this.options.highlight(_code, lang); - - if (out != null && out !== _code) { - escaped = true; - _code = out; - } - } - - _code = _code.replace(/\n$/, '') + '\n'; - - if (!lang) { - return '
' + (escaped ? _code : escape(_code, true)) + '
\n'; - } - - return '
' + (escaped ? _code : escape(_code, true)) + '
\n'; - }; - - _proto.blockquote = function blockquote(quote) { - return '
\n' + quote + '
\n'; - }; - - _proto.html = function html(_html) { - return _html; - }; - - _proto.heading = function heading(text, level, raw, slugger) { - if (this.options.headerIds) { - return '' + text + '\n'; - } // ignore IDs - - - return '' + text + '\n'; - }; - - _proto.hr = function hr() { - return this.options.xhtml ? '
\n' : '
\n'; - }; - - _proto.list = function list(body, ordered, start) { - var type = ordered ? 'ol' : 'ul', - startatt = ordered && start !== 1 ? ' start="' + start + '"' : ''; - return '<' + type + startatt + '>\n' + body + '\n'; - }; - - _proto.listitem = function listitem(text) { - return '
  • ' + text + '
  • \n'; - }; - - _proto.checkbox = function checkbox(checked) { - return ' '; - }; - - _proto.paragraph = function paragraph(text) { - return '

    ' + text + '

    \n'; - }; - - _proto.table = function table(header, body) { - if (body) body = '' + body + ''; - return '\n' + '\n' + header + '\n' + body + '
    \n'; - }; - - _proto.tablerow = function tablerow(content) { - return '\n' + content + '\n'; - }; - - _proto.tablecell = function tablecell(content, flags) { - var type = flags.header ? 'th' : 'td'; - var tag = flags.align ? '<' + type + ' align="' + flags.align + '">' : '<' + type + '>'; - return tag + content + '\n'; - } // span level renderer - ; - - _proto.strong = function strong(text) { - return '' + text + ''; - }; - - _proto.em = function em(text) { - return '' + text + ''; - }; - - _proto.codespan = function codespan(text) { - return '' + text + ''; - }; - - _proto.br = function br() { - return this.options.xhtml ? '
    ' : '
    '; - }; - - _proto.del = function del(text) { - return '' + text + ''; - }; - - _proto.link = function link(href, title, text) { - href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); - - if (href === null) { - return text; - } - - var out = '
    '; - return out; - }; - - _proto.image = function image(href, title, text) { - href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); - - if (href === null) { - return text; - } - - var out = '' + text + '' : '>'; - return out; - }; - - _proto.text = function text(_text) { - return _text; - }; - - return Renderer; - }(); - - /** - * TextRenderer - * returns only the textual part of the token - */ - var TextRenderer = /*#__PURE__*/function () { - function TextRenderer() {} - - var _proto = TextRenderer.prototype; - - // no need for block level renderers - _proto.strong = function strong(text) { - return text; - }; - - _proto.em = function em(text) { - return text; - }; - - _proto.codespan = function codespan(text) { - return text; - }; - - _proto.del = function del(text) { - return text; - }; - - _proto.html = function html(text) { - return text; - }; - - _proto.text = function text(_text) { - return _text; - }; - - _proto.link = function link(href, title, text) { - return '' + text; - }; - - _proto.image = function image(href, title, text) { - return '' + text; - }; - - _proto.br = function br() { - return ''; - }; - - return TextRenderer; - }(); - - /** - * Slugger generates header id - */ - var Slugger = /*#__PURE__*/function () { - function Slugger() { - this.seen = {}; - } - - var _proto = Slugger.prototype; - - _proto.serialize = function serialize(value) { - return value.toLowerCase().trim() // remove html tags - .replace(/<[!\/a-z].*?>/ig, '') // remove unwanted chars - .replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, '').replace(/\s/g, '-'); - } - /** - * Finds the next safe (unique) slug to use - */ - ; - - _proto.getNextSafeSlug = function getNextSafeSlug(originalSlug, isDryRun) { - var slug = originalSlug; - var occurenceAccumulator = 0; - - if (this.seen.hasOwnProperty(slug)) { - occurenceAccumulator = this.seen[originalSlug]; - - do { - occurenceAccumulator++; - slug = originalSlug + '-' + occurenceAccumulator; - } while (this.seen.hasOwnProperty(slug)); - } - - if (!isDryRun) { - this.seen[originalSlug] = occurenceAccumulator; - this.seen[slug] = 0; - } - - return slug; - } - /** - * Convert string to unique id - * @param {object} options - * @param {boolean} options.dryrun Generates the next unique slug without updating the internal accumulator. - */ - ; - - _proto.slug = function slug(value, options) { - if (options === void 0) { - options = {}; - } - - var slug = this.serialize(value); - return this.getNextSafeSlug(slug, options.dryrun); - }; - - return Slugger; - }(); - - /** - * Parsing & Compiling - */ - - var Parser = /*#__PURE__*/function () { - function Parser(options) { - this.options = options || exports.defaults; - this.options.renderer = this.options.renderer || new Renderer(); - this.renderer = this.options.renderer; - this.renderer.options = this.options; - this.textRenderer = new TextRenderer(); - this.slugger = new Slugger(); - } - /** - * Static Parse Method - */ - - - Parser.parse = function parse(tokens, options) { - var parser = new Parser(options); - return parser.parse(tokens); - } - /** - * Static Parse Inline Method - */ - ; - - Parser.parseInline = function parseInline(tokens, options) { - var parser = new Parser(options); - return parser.parseInline(tokens); - } - /** - * Parse Loop - */ - ; - - var _proto = Parser.prototype; - - _proto.parse = function parse(tokens, top) { - if (top === void 0) { - top = true; - } - - var out = '', - i, - j, - k, - l2, - l3, - row, - cell, - header, - body, - token, - ordered, - start, - loose, - itemBody, - item, - checked, - task, - checkbox, - ret; - var l = tokens.length; - - for (i = 0; i < l; i++) { - token = tokens[i]; // Run any renderer extensions - - if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) { - ret = this.options.extensions.renderers[token.type].call({ - parser: this - }, token); - - if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(token.type)) { - out += ret || ''; - continue; - } - } - - switch (token.type) { - case 'space': - { - continue; - } - - case 'hr': - { - out += this.renderer.hr(); - continue; - } - - case 'heading': - { - out += this.renderer.heading(this.parseInline(token.tokens), token.depth, unescape(this.parseInline(token.tokens, this.textRenderer)), this.slugger); - continue; - } - - case 'code': - { - out += this.renderer.code(token.text, token.lang, token.escaped); - continue; - } - - case 'table': - { - header = ''; // header - - cell = ''; - l2 = token.header.length; - - for (j = 0; j < l2; j++) { - cell += this.renderer.tablecell(this.parseInline(token.header[j].tokens), { - header: true, - align: token.align[j] - }); - } - - header += this.renderer.tablerow(cell); - body = ''; - l2 = token.rows.length; - - for (j = 0; j < l2; j++) { - row = token.rows[j]; - cell = ''; - l3 = row.length; - - for (k = 0; k < l3; k++) { - cell += this.renderer.tablecell(this.parseInline(row[k].tokens), { - header: false, - align: token.align[k] - }); - } - - body += this.renderer.tablerow(cell); - } - - out += this.renderer.table(header, body); - continue; - } - - case 'blockquote': - { - body = this.parse(token.tokens); - out += this.renderer.blockquote(body); - continue; - } - - case 'list': - { - ordered = token.ordered; - start = token.start; - loose = token.loose; - l2 = token.items.length; - body = ''; - - for (j = 0; j < l2; j++) { - item = token.items[j]; - checked = item.checked; - task = item.task; - itemBody = ''; - - if (item.task) { - checkbox = this.renderer.checkbox(checked); - - if (loose) { - if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') { - item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; - - if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { - item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text; - } - } else { - item.tokens.unshift({ - type: 'text', - text: checkbox - }); - } - } else { - itemBody += checkbox; - } - } - - itemBody += this.parse(item.tokens, loose); - body += this.renderer.listitem(itemBody, task, checked); - } - - out += this.renderer.list(body, ordered, start); - continue; - } - - case 'html': - { - // TODO parse inline content if parameter markdown=1 - out += this.renderer.html(token.text); - continue; - } - - case 'paragraph': - { - out += this.renderer.paragraph(this.parseInline(token.tokens)); - continue; - } - - case 'text': - { - body = token.tokens ? this.parseInline(token.tokens) : token.text; - - while (i + 1 < l && tokens[i + 1].type === 'text') { - token = tokens[++i]; - body += '\n' + (token.tokens ? this.parseInline(token.tokens) : token.text); - } - - out += top ? this.renderer.paragraph(body) : body; - continue; - } - - default: - { - var errMsg = 'Token with "' + token.type + '" type was not found.'; - - if (this.options.silent) { - console.error(errMsg); - return; - } else { - throw new Error(errMsg); - } - } - } - } - - return out; - } - /** - * Parse Inline Tokens - */ - ; - - _proto.parseInline = function parseInline(tokens, renderer) { - renderer = renderer || this.renderer; - var out = '', - i, - token, - ret; - var l = tokens.length; - - for (i = 0; i < l; i++) { - token = tokens[i]; // Run any renderer extensions - - if (this.options.extensions && this.options.extensions.renderers && this.options.extensions.renderers[token.type]) { - ret = this.options.extensions.renderers[token.type].call({ - parser: this - }, token); - - if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(token.type)) { - out += ret || ''; - continue; - } - } - - switch (token.type) { - case 'escape': - { - out += renderer.text(token.text); - break; - } - - case 'html': - { - out += renderer.html(token.text); - break; - } - - case 'link': - { - out += renderer.link(token.href, token.title, this.parseInline(token.tokens, renderer)); - break; - } - - case 'image': - { - out += renderer.image(token.href, token.title, token.text); - break; - } - - case 'strong': - { - out += renderer.strong(this.parseInline(token.tokens, renderer)); - break; - } - - case 'em': - { - out += renderer.em(this.parseInline(token.tokens, renderer)); - break; - } - - case 'codespan': - { - out += renderer.codespan(token.text); - break; - } - - case 'br': - { - out += renderer.br(); - break; - } - - case 'del': - { - out += renderer.del(this.parseInline(token.tokens, renderer)); - break; - } - - case 'text': - { - out += renderer.text(token.text); - break; - } - - default: - { - var errMsg = 'Token with "' + token.type + '" type was not found.'; - - if (this.options.silent) { - console.error(errMsg); - return; - } else { - throw new Error(errMsg); - } - } - } - } - - return out; - }; - - return Parser; - }(); - - /** - * Marked - */ - - function marked(src, opt, callback) { - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - throw new Error('marked(): input parameter is undefined or null'); - } - - if (typeof src !== 'string') { - throw new Error('marked(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected'); - } - - if (typeof opt === 'function') { - callback = opt; - opt = null; - } - - opt = merge({}, marked.defaults, opt || {}); - checkSanitizeDeprecation(opt); - - if (callback) { - var highlight = opt.highlight; - var tokens; - - try { - tokens = Lexer.lex(src, opt); - } catch (e) { - return callback(e); - } - - var done = function done(err) { - var out; - - if (!err) { - try { - if (opt.walkTokens) { - marked.walkTokens(tokens, opt.walkTokens); - } - - out = Parser.parse(tokens, opt); - } catch (e) { - err = e; - } - } - - opt.highlight = highlight; - return err ? callback(err) : callback(null, out); - }; - - if (!highlight || highlight.length < 3) { - return done(); - } - - delete opt.highlight; - if (!tokens.length) return done(); - var pending = 0; - marked.walkTokens(tokens, function (token) { - if (token.type === 'code') { - pending++; - setTimeout(function () { - highlight(token.text, token.lang, function (err, code) { - if (err) { - return done(err); - } - - if (code != null && code !== token.text) { - token.text = code; - token.escaped = true; - } - - pending--; - - if (pending === 0) { - done(); - } - }); - }, 0); - } - }); - - if (pending === 0) { - done(); - } - - return; - } - - try { - var _tokens = Lexer.lex(src, opt); - - if (opt.walkTokens) { - marked.walkTokens(_tokens, opt.walkTokens); - } - - return Parser.parse(_tokens, opt); - } catch (e) { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - - if (opt.silent) { - return '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; - } - - throw e; - } - } - /** - * Options - */ - - marked.options = marked.setOptions = function (opt) { - merge(marked.defaults, opt); - changeDefaults(marked.defaults); - return marked; - }; - - marked.getDefaults = getDefaults; - marked.defaults = exports.defaults; - /** - * Use Extension - */ - - marked.use = function () { - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - - var opts = merge.apply(void 0, [{}].concat(args)); - var extensions = marked.defaults.extensions || { - renderers: {}, - childTokens: {} - }; - var hasExtensions; - args.forEach(function (pack) { - // ==-- Parse "addon" extensions --== // - if (pack.extensions) { - hasExtensions = true; - pack.extensions.forEach(function (ext) { - if (!ext.name) { - throw new Error('extension name required'); - } - - if (ext.renderer) { - // Renderer extensions - var prevRenderer = extensions.renderers ? extensions.renderers[ext.name] : null; - - if (prevRenderer) { - // Replace extension with func to run new extension but fall back if false - extensions.renderers[ext.name] = function () { - for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { - args[_key2] = arguments[_key2]; - } - - var ret = ext.renderer.apply(this, args); - - if (ret === false) { - ret = prevRenderer.apply(this, args); - } - - return ret; - }; - } else { - extensions.renderers[ext.name] = ext.renderer; - } - } - - if (ext.tokenizer) { - // Tokenizer Extensions - if (!ext.level || ext.level !== 'block' && ext.level !== 'inline') { - throw new Error("extension level must be 'block' or 'inline'"); - } - - if (extensions[ext.level]) { - extensions[ext.level].unshift(ext.tokenizer); - } else { - extensions[ext.level] = [ext.tokenizer]; - } - - if (ext.start) { - // Function to check for start of token - if (ext.level === 'block') { - if (extensions.startBlock) { - extensions.startBlock.push(ext.start); - } else { - extensions.startBlock = [ext.start]; - } - } else if (ext.level === 'inline') { - if (extensions.startInline) { - extensions.startInline.push(ext.start); - } else { - extensions.startInline = [ext.start]; - } - } - } - } - - if (ext.childTokens) { - // Child tokens to be visited by walkTokens - extensions.childTokens[ext.name] = ext.childTokens; - } - }); - } // ==-- Parse "overwrite" extensions --== // - - - if (pack.renderer) { - (function () { - var renderer = marked.defaults.renderer || new Renderer(); - - var _loop = function _loop(prop) { - var prevRenderer = renderer[prop]; // Replace renderer with func to run extension, but fall back if false - - renderer[prop] = function () { - for (var _len3 = arguments.length, args = new Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { - args[_key3] = arguments[_key3]; - } - - var ret = pack.renderer[prop].apply(renderer, args); - - if (ret === false) { - ret = prevRenderer.apply(renderer, args); - } - - return ret; - }; - }; - - for (var prop in pack.renderer) { - _loop(prop); - } - - opts.renderer = renderer; - })(); - } - - if (pack.tokenizer) { - (function () { - var tokenizer = marked.defaults.tokenizer || new Tokenizer(); - - var _loop2 = function _loop2(prop) { - var prevTokenizer = tokenizer[prop]; // Replace tokenizer with func to run extension, but fall back if false - - tokenizer[prop] = function () { - for (var _len4 = arguments.length, args = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { - args[_key4] = arguments[_key4]; - } - - var ret = pack.tokenizer[prop].apply(tokenizer, args); - - if (ret === false) { - ret = prevTokenizer.apply(tokenizer, args); - } - - return ret; - }; - }; - - for (var prop in pack.tokenizer) { - _loop2(prop); - } - - opts.tokenizer = tokenizer; - })(); - } // ==-- Parse WalkTokens extensions --== // - - - if (pack.walkTokens) { - var _walkTokens = marked.defaults.walkTokens; - - opts.walkTokens = function (token) { - pack.walkTokens.call(this, token); - - if (_walkTokens) { - _walkTokens.call(this, token); - } - }; - } - - if (hasExtensions) { - opts.extensions = extensions; - } - - marked.setOptions(opts); - }); - }; - /** - * Run callback for every token - */ - - - marked.walkTokens = function (tokens, callback) { - var _loop3 = function _loop3() { - var token = _step.value; - callback.call(marked, token); - - switch (token.type) { - case 'table': - { - for (var _iterator2 = _createForOfIteratorHelperLoose(token.header), _step2; !(_step2 = _iterator2()).done;) { - var cell = _step2.value; - marked.walkTokens(cell.tokens, callback); - } - - for (var _iterator3 = _createForOfIteratorHelperLoose(token.rows), _step3; !(_step3 = _iterator3()).done;) { - var row = _step3.value; - - for (var _iterator4 = _createForOfIteratorHelperLoose(row), _step4; !(_step4 = _iterator4()).done;) { - var _cell = _step4.value; - marked.walkTokens(_cell.tokens, callback); - } - } - - break; - } - - case 'list': - { - marked.walkTokens(token.items, callback); - break; - } - - default: - { - if (marked.defaults.extensions && marked.defaults.extensions.childTokens && marked.defaults.extensions.childTokens[token.type]) { - // Walk any extensions - marked.defaults.extensions.childTokens[token.type].forEach(function (childTokens) { - marked.walkTokens(token[childTokens], callback); - }); - } else if (token.tokens) { - marked.walkTokens(token.tokens, callback); - } - } - } - }; - - for (var _iterator = _createForOfIteratorHelperLoose(tokens), _step; !(_step = _iterator()).done;) { - _loop3(); - } - }; - /** - * Parse Inline - */ - - - marked.parseInline = function (src, opt) { - // throw error in case of non string input - if (typeof src === 'undefined' || src === null) { - throw new Error('marked.parseInline(): input parameter is undefined or null'); - } - - if (typeof src !== 'string') { - throw new Error('marked.parseInline(): input parameter is of type ' + Object.prototype.toString.call(src) + ', string expected'); - } - - opt = merge({}, marked.defaults, opt || {}); - checkSanitizeDeprecation(opt); - - try { - var tokens = Lexer.lexInline(src, opt); - - if (opt.walkTokens) { - marked.walkTokens(tokens, opt.walkTokens); - } - - return Parser.parseInline(tokens, opt); - } catch (e) { - e.message += '\nPlease report this to https://github.com/markedjs/marked.'; - - if (opt.silent) { - return '

    An error occurred:

    ' + escape(e.message + '', true) + '
    '; - } - - throw e; - } - }; - /** - * Expose - */ - - - marked.Parser = Parser; - marked.parser = Parser.parse; - marked.Renderer = Renderer; - marked.TextRenderer = TextRenderer; - marked.Lexer = Lexer; - marked.lexer = Lexer.lex; - marked.Tokenizer = Tokenizer; - marked.Slugger = Slugger; - marked.parse = marked; - var options = marked.options; - var setOptions = marked.setOptions; - var use = marked.use; - var walkTokens = marked.walkTokens; - var parseInline = marked.parseInline; - var parse = marked; - var parser = Parser.parse; - var lexer = Lexer.lex; - - exports.Lexer = Lexer; - exports.Parser = Parser; - exports.Renderer = Renderer; - exports.Slugger = Slugger; - exports.TextRenderer = TextRenderer; - exports.Tokenizer = Tokenizer; - exports.getDefaults = getDefaults; - exports.lexer = lexer; - exports.marked = marked; - exports.options = options; - exports.parse = parse; - exports.parseInline = parseInline; - exports.parser = parser; - exports.setOptions = setOptions; - exports.use = use; - exports.walkTokens = walkTokens; - - Object.defineProperty(exports, '__esModule', { value: true }); - -})); diff --git a/hive-fr0nt/assets/terminal.css b/hive-fr0nt/assets/terminal.css deleted file mode 100644 index b28449a..0000000 --- a/hive-fr0nt/assets/terminal.css +++ /dev/null @@ -1,228 +0,0 @@ -/* 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/hive-fr0nt/assets/terminal.js b/hive-fr0nt/assets/terminal.js deleted file mode 100644 index fd30ea2..0000000 --- a/hive-fr0nt/assets/terminal.js +++ /dev/null @@ -1,344 +0,0 @@ -// 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: -// -// HiveTerminal.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. - -(function () { - const NEAR_BOTTOM_PX = 48; - - 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; - 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; - } - - window.HiveTerminal = { create, linkify }; -})(); diff --git a/hive-fr0nt/src/lib.rs b/hive-fr0nt/src/lib.rs deleted file mode 100644 index e791dee..0000000 --- a/hive-fr0nt/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Shared frontend assets for the hive-c0re dashboard and the hive-ag3nt -//! per-container web UI. Both surfaces live in different binaries (and -//! different containers at runtime) but should feel like one product — -//! same colour tokens, same terminal-style live stream, same compose-box -//! ergonomics. Keeping the CSS + JS in one crate is the dumbest way to -//! make that true: both binaries `include_str!` from -//! `hive_fr0nt::assets::*` instead of growing their own copy. -//! -//! There is no Rust code beyond these `const` re-exports. The crate is a -//! container for text files and a place to write down the contract -//! between the two surfaces. -//! -//! Conventions for sharing: -//! - **CSS variables** live in [`BASE_CSS`] (colour palette, typography). -//! Page-specific stylesheets append to it; nothing else should declare -//! `--bg` / `--purple` / etc. -//! - **Terminal pane** (sticky-bottom log + `↓ N new` pill + fade-in -//! rows) lives in [`TERMINAL_CSS`] and [`TERMINAL_JS`]. Pages provide -//! a kind→renderer map; the JS owns the scroll + backfill + SSE plumbing. -//! - **Compose box** (textarea + slash-command palette + sticky -//! recipient + `@`-mention autocomplete) lives in [`COMPOSER_JS`]. -//! Pages pass a config flagging which features they want; the dashboard -//! ships `@`-mentions without slash commands, the agent page ships -//! slash commands without `@`-mentions. Both render through the same -//! component so the keystrokes, error flashes, and async-form -//! behaviour stay identical. -//! -//! Loading new shared assets: add the file under `assets/`, expose it as -//! a `pub const`, and `include_str!` it from whichever -//! `dashboard.rs` / `web_ui.rs` route needs it. - -pub const BASE_CSS: &str = include_str!("../assets/base.css"); -pub const TERMINAL_CSS: &str = include_str!("../assets/terminal.css"); -pub const TERMINAL_JS: &str = include_str!("../assets/terminal.js"); -/// Vendored [marked](https://github.com/markedjs/marked) v4.0.2 **UMD** -/// bundle (`lib/marked.umd.js`). The UMD wrapper assigns a browser global -/// `window.marked` with `.parse(src, opts)` / `.setOptions(...)`. Used by -/// the per-agent terminal and the dashboard file-preview flyout to render -/// markdown (paragraphs, lists, fenced + inline code, bold/italic, links). -/// Vendored rather than CDN-loaded so the pages work on operator machines -/// without internet egress (the container itself never fetches it). -/// -/// NB: must be the **UMD** build, not `marked.min.js` / `lib/marked.cjs` — -/// those are `CommonJS` (`exports.parse = …`, no wrapper) and throw -/// `exports is not defined` in a `