frontend: cut over Rust binaries to ServeDir; delete legacy assets

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.
This commit is contained in:
iris 2026-05-23 13:21:37 +02:00 committed by Mara
parent 2ecf15bb6f
commit 229c4292e9
24 changed files with 143 additions and 10122 deletions

View file

@ -87,35 +87,28 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
and meta read access; mirrors each applied repo and meta read access; mirrors each applied repo
into `agent-configs/<n>` (core-only); agents are into `agent-configs/<n>` (core-only); agents are
read-only collaborators on `core/meta` 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) + journald viewer + bind-with-retry (SO_REUSEADDR)
+ deployed_sha chip per container + + deployed_sha chip per container +
/dashboard/{stream,history} subscribing to the /dashboard/{stream,history} subscribing to the
unified DashboardEvent channel unified DashboardEvent channel. Static assets
assets/ index.html, dashboard.css, app.js (include_str!) (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). frontend/ npm workspaces (esbuild → static dist). Built
src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS / hermetically by `nix/frontend.nix`
MARKED_JS re-exports; both binaries (`packages.${system}.frontend`).
`include_str!` them and prepend to their per- packages/shared/ @hive/shared: terminal pane + Catppuccin palette
page serving routes. + base typography (was hive-fr0nt). ES module
assets/base.css Catppuccin palette + body typography (one source exporting { create, linkify }; pure JS, no IIFE
of truth, no per-page redeclaration). globals; consumed by dashboard + agent.
assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` + packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js,
`.row` / `details.row` styling for both dashboard.css} + build.mjs → dist/{index.html,
pages' lit log panes. Unified prefix-column static/{app.js, dashboard.css}}.
(padding-left + negative text-indent) so glyph packages/agent/ @hive/agent default per-container UI: src/
alignment is consistent across row kinds + a {index, stats, screen}.html + {app, stats}.js
`.md` block scope for marked-rendered bodies. + agent.css → dist/{*.html, static/*}.
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.
hive-ag3nt/ in-container harness crate; produces TWO binaries hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT 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/login_session.rs drives `claude auth login` over stdio pipes
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands) src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands) src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
assets/ index.html, agent.css, app.js, stats.html, Static UI assets served by ServeDir from
stats.js, screen.html (include_str!) $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!): prompts/ static role/tools/settings for claude (include_str!):
agent.md — sub-agent system prompt agent.md — sub-agent system prompt
manager.md — manager system prompt manager.md — manager system prompt

39
Cargo.lock generated
View file

@ -565,7 +565,6 @@ dependencies = [
"axum", "axum",
"clap", "clap",
"futures-util", "futures-util",
"hive-fr0nt",
"hive-sh4re", "hive-sh4re",
"reqwest", "reqwest",
"rmcp", "rmcp",
@ -575,6 +574,7 @@ dependencies = [
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
@ -587,7 +587,6 @@ dependencies = [
"axum", "axum",
"base64", "base64",
"clap", "clap",
"hive-fr0nt",
"hive-sh4re", "hive-sh4re",
"libc", "libc",
"reqwest", "reqwest",
@ -597,14 +596,11 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tower-http",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
] ]
[[package]]
name = "hive-fr0nt"
version = "0.1.0"
[[package]] [[package]]
name = "hive-sh4re" name = "hive-sh4re"
version = "0.1.0" version = "0.1.0"
@ -645,6 +641,12 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@ -954,6 +956,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 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]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.0"
@ -1723,10 +1735,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower", "tower",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@ -1835,6 +1856,12 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"

View file

@ -1,6 +1,6 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"] members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"]
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
@ -19,8 +19,8 @@ anyhow = "1"
axum = { version = "0.8", features = ["ws"] } axum = { version = "0.8", features = ["ws"] }
base64 = "0.22" base64 = "0.22"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
hive-fr0nt = { path = "hive-fr0nt" }
hive-sh4re = { path = "hive-sh4re" } hive-sh4re = { path = "hive-sh4re" }
tower-http = { version = "0.6", features = ["fs"] }
rmcp = { version = "1.7", default-features = false, features = [ rmcp = { version = "1.7", default-features = false, features = [
"server", "server",
"macros", "macros",

View file

@ -2,11 +2,11 @@
Snapshot of how the per-agent web UI's live pane renders each Snapshot of how the per-agent web UI's live pane renders each
event kind today. Source of truth lives in 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`, `renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) + `mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
`hive-fr0nt/assets/terminal.css` (the shared `.live .<class>` `frontend/packages/shared/src/terminal.css` (the shared
styling) + `hive-fr0nt/assets/marked.umd.js` (markdown). `.live .<class>` styling) + the `marked` npm package (markdown).
## Layout contract ## Layout contract
@ -82,8 +82,8 @@ parent's negative pull.
## Markdown ## Markdown
`mdNode(text)` wraps `window.marked.parse(text)` (vendored `mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
v4.0.2 UMD via `hive-fr0nt::MARKED_JS`) in a `<div dep, bundled by esbuild into the page's `app.js`) in a `<div
class="md">`. CSS in `terminal.css` scopes paragraph / code / class="md">`. CSS in `terminal.css` scopes paragraph / code /
list / blockquote / link styling under `.live .row .md` so list / blockquote / link styling under `.live .row .md` so
the markdown body doesn't bleed into the row's own the markdown body doesn't bleed into the row's own

View file

@ -7,22 +7,32 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
## Shape (shared by both) ## Shape (shared by both)
- `GET /``assets/index.html` (placeholders for state-driven - `GET /``index.html` from the bundled frontend dist (see
sections, shipped via `include_str!` so the binary has no runtime `frontend/`). Both binaries' routers declare their dynamic
file dependency). endpoints first and then `fallback_service(ServeDir::new(...))`
- `GET /static/*.css` + `GET /static/*.js` → static assets. Both pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their action route is served from the dist. Dashboard dist lives at
per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the `${frontend}/dashboard`; per-agent dist is the merged
shared `window.HiveTerminal.create` runtime. The dashboard's `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 `#msgflow` and the per-agent `#live` log are both backed by
this terminal — sticky-bottom auto-scroll, "↓ N new" pill, this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to registers a kind→renderer map; unknown kinds fall through to
a JSON-dump note row. Bare `http(s)://` URLs in row text are a JSON-dump note row. Bare `http(s)://` URLs in row text are
turned into clickable new-tab links by `HiveTerminal.linkify` turned into clickable new-tab links by `linkify` (text-node
(text-node based, no `innerHTML` — XSS-safe); markdown bodies based, no `innerHTML` — XSS-safe); markdown bodies get the
get the same treatment via `marked`'s autolink, with the same treatment via `marked`'s autolink (npm dep, replacing the
rendered `<a>`s rewritten to `target="_blank"` (issue #233). vendored UMD bundle), with the rendered `<a>`s rewritten to
`target="_blank"` (issue #233).
- `GET /api/state` → JSON snapshot the JS app renders into the - `GET /api/state` → JSON snapshot the JS app renders into the
DOM. Includes a top-level `seq` (the dashboard event channel's DOM. Includes a top-level `seq` (the dashboard event channel's
high-water mark at the moment the snapshot was assembled); high-water mark at the moment the snapshot was assembled);

View file

@ -12,7 +12,6 @@ axum.workspace = true
reqwest.workspace = true reqwest.workspace = true
futures-util = "0.3" futures-util = "0.3"
clap.workspace = true clap.workspace = true
hive-fr0nt.workspace = true
hive-sh4re.workspace = true hive-sh4re.workspace = true
rmcp.workspace = true rmcp.workspace = true
rusqlite.workspace = true rusqlite.workspace = true
@ -21,6 +20,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

View file

@ -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. */

File diff suppressed because it is too large Load diff

View file

@ -1,52 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<link rel="stylesheet" href="/static/agent.css">
</head>
<body>
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
<div class="title-row">
<img class="agent-icon" src="/icon" alt="">
<h2 id="title">◆ … ◆</h2>
</div>
<p class="meta" id="meta-links"></p>
<div id="status">
<p class="meta">loading…</p>
</div>
<div id="state-row">
<span id="alive-badge" class="status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="state-badge state-loading">… booting</span>
<span id="model-chip" class="model-chip" hidden></span>
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
<span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
<button type="button" id="new-session-btn" class="btn-new-session"
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
</div>
<details id="inbox-section" class="agent-inbox" hidden>
<summary><span id="inbox-summary">inbox</span></summary>
<ul id="inbox-list"></ul>
</details>
<details id="loose-ends-section" class="agent-inbox" hidden>
<summary><span id="loose-ends-summary">loose ends</span></summary>
<ul id="loose-ends-list"></ul>
</details>
<div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
<div id="term-input" class="term-input"></div>
</div>
<script src="/static/marked.js" defer></script>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -1,770 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<style>
/* Catppuccin Mocha palette (mirrors base.css) */
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--text: #cdd6f4;
--subtext0:#a6adc8;
--surface0:#313244;
--surface1:#45475a;
--blue: #89b4fa;
--red: #f38ba8;
--green: #a6e3a1;
--yellow: #f9e2af;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--base); color: var(--text);
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 14px; }
#toolbar {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.4rem 0.75rem; background: var(--mantle);
border-bottom: 1px solid var(--surface0);
}
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
#toolbar a:hover { text-decoration: underline; }
.tbtn {
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
background: var(--surface0); color: var(--subtext0);
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
#debug-log {
position: fixed; bottom: 0; left: 0; right: 0; max-height: 40vh;
overflow-y: auto; background: rgba(17,17,27,0.95);
border-top: 1px solid var(--surface1);
font-size: 0.72rem; font-family: ui-monospace, monospace;
padding: 0.4rem 0.6rem; z-index: 100;
display: none; /* hidden by default; toggled by toolbar button */
}
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
#debug-log .dbg-line.err { color: var(--red); }
#debug-log .dbg-line.ok { color: var(--green); }
#debug-log .dbg-line.send { color: var(--blue); }
#canvas-wrap {
display: flex; justify-content: center; align-items: flex-start;
width: 100%; height: calc(100% - 36px); overflow: auto;
background: var(--crust);
}
/* Fit mode: centre the canvas (relayoutCanvas() scales it in JS to
fit the wrap) and clip any sub-pixel rounding overflow. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; }
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
The canvas is a flex item, and flex items default to
min-width/min-height: auto — which resolves to the canvas's intrinsic
framebuffer resolution and clamps the JS-set size straight back up,
defeating the downscale (the bug behind #133 round 1). Pin the canvas
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
flex: none stops flex grow/shrink from fighting it. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#msg {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
background: var(--surface0); color: var(--yellow); border-radius: 6px;
padding: 0.4rem 0.9rem; font-size: 0.8rem;
opacity: 0; transition: opacity 0.3s;
pointer-events: none;
}
</style>
</head>
<body>
<div id="toolbar">
<strong>🖥 screen</strong>
<a href="/" title="back to agent page">← agent</a>
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
<button id="match-toggle" class="tbtn" title="Resize the remote desktop to fit this window" disabled>⤡ match size</button>
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
<span id="status">connecting…</span>
</div>
<div id="canvas-wrap"><canvas id="c"></canvas></div>
<div id="msg"></div>
<div id="debug-log"></div>
<script>
// Minimal RFB-over-WebSocket renderer.
// Connects to /screen/ws on the same host; the harness relays raw
// RFB bytes to the VNC server running inside the container.
//
// This is a deliberately thin implementation — enough to display the
// desktop and forward pointer + keyboard events. For a production-grade
// viewer, replace with noVNC (issue #52 vendors the full bundle).
(function () {
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const msg = document.getElementById('msg');
const debugLog = document.getElementById('debug-log');
const debugBtn = document.getElementById('debug-toggle');
const fitBtn = document.getElementById('fit-toggle');
const matchBtn = document.getElementById('match-toggle');
const canvasWrap = document.getElementById('canvas-wrap');
// --- Debug log ---
let debugVisible = false;
debugBtn.addEventListener('click', () => {
debugVisible = !debugVisible;
debugLog.style.display = debugVisible ? 'block' : 'none';
debugBtn.classList.toggle('active', debugVisible);
});
// --- Fit-to-window toggle ---
// Scales the canvas down so the whole desktop is visible without
// scrolling. The canvas's intrinsic resolution (width/height attrs)
// is untouched — only its CSS display size changes, set explicitly
// by relayoutCanvas(). Pointer coordinates are rescaled in
// sendPointer to stay accurate. Persisted in localStorage; default
// is fit-on.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
// Size the canvas. In fit mode, scale down (never up) to the wrap,
// preserving aspect ratio. Explicit px sizing rather than CSS
// max-width/max-height: on a flex item those are overridden by the
// automatic minimum size, so fit mode was a silent no-op — the
// oversized canvas just got centred and clipped (issue #133).
function relayoutCanvas() {
if (fitMode && canvas.width && canvas.height
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
const scale = Math.min(
canvasWrap.clientWidth / canvas.width,
canvasWrap.clientHeight / canvas.height,
1,
);
canvas.style.width = (canvas.width * scale) + 'px';
canvas.style.height = (canvas.height * scale) + 'px';
} else if (!fitMode) {
canvas.style.width = '';
canvas.style.height = '';
}
}
function applyFitMode() {
canvasWrap.classList.toggle('fit', fitMode);
fitBtn.classList.toggle('active', fitMode);
relayoutCanvas();
}
fitBtn.addEventListener('click', () => {
fitMode = !fitMode;
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
applyFitMode();
});
window.addEventListener('resize', relayoutCanvas);
applyFitMode();
// --- Match-size: resize the remote desktop to this window ---
// Sends an RFB SetDesktopSize request so the VNC server (weston)
// changes its actual output resolution to match the browser
// viewport — sharper than fit-mode's CSS downscale. The button is
// enabled only once the server has advertised the ExtendedDesktopSize
// pseudo-encoding (a -308 rect). (issue #133)
let extDesktopSupported = false;
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
matchBtn.addEventListener('click', () => {
if (!extDesktopSupported) return;
// Even dimensions — some servers reject odd ones.
const w = Math.max(2, canvasWrap.clientWidth & ~1);
const h = Math.max(2, canvasWrap.clientHeight & ~1);
dbg('→ request desktop resize to ' + w + 'x' + h, 'send');
sendSetDesktopSize(w, h);
});
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
}
function dbg(text, cls) {
console.log('[rfb]', text);
const line = document.createElement('div');
line.className = 'dbg-line' + (cls ? ' ' + cls : '');
line.textContent = text;
debugLog.appendChild(line);
debugLog.scrollTop = debugLog.scrollHeight;
}
function setStatus(text, cls) {
status.textContent = text;
status.className = cls || '';
if (cls === 'error') dbg('ERROR: ' + text, 'err');
}
function flash(text) {
msg.textContent = text;
msg.style.opacity = '1';
setTimeout(() => { msg.style.opacity = '0'; }, 2500);
}
// --- WebSocket connection ---
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`);
ws.binaryType = 'arraybuffer';
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
ws.onerror = () => setStatus('connection error', 'error');
ws.onclose = (e) => {
setStatus(`disconnected (${e.code})`, 'error');
flash('VNC disconnected — reload to reconnect');
};
// Accumulate received bytes in a simple ring queue
const chunks = [];
let totalBytes = 0;
ws.onmessage = (ev) => {
chunks.push(new Uint8Array(ev.data));
totalBytes += ev.data.byteLength;
processRfb();
};
// --- Minimal RFB state machine ---
// We implement just enough to handshake and receive FramebufferUpdate
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
// Keyboard and pointer events are forwarded.
let state = 'version';
let fbW = 0, fbH = 0;
let pixelFormat = null; // set after ServerInit
let updateRects = 0;
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
// value the rect-header encoding field is read as.
const EXT_DESKTOP_SIZE_U32 = (-308) >>> 0;
// Drain bytes from the queue into a flat buffer view
function drainTo(n) {
if (totalBytes < n) return null;
const out = new Uint8Array(n);
let off = 0;
while (off < n) {
const c = chunks[0];
const take = Math.min(c.length, n - off);
out.set(c.subarray(0, take), off);
off += take;
if (take === c.length) {
chunks.shift();
} else {
chunks[0] = c.subarray(take);
}
}
totalBytes -= n;
return out;
}
function send(data) {
if (ws.readyState === WebSocket.OPEN) {
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
dbg('→ send [' + arr.length + 'b]: ' + hex(arr.slice(0, 32)) + (arr.length > 32 ? '…' : ''), 'send');
ws.send(data);
}
}
function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; }
function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; }
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
// Protocol (from neatvnc apple-dh.c):
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
// After SecurityResult=0: normal plaintext VNC (no session encryption)
//
// BigInt mod-pow — handles 2048-bit DH arithmetic.
function modpow(base, exp, mod) {
let r = 1n;
base = base % mod;
while (exp > 0n) {
if (exp & 1n) r = r * base % mod;
exp >>= 1n;
base = base * base % mod;
}
return r;
}
function bytesToBigInt(b) {
let n = 0n;
for (const byte of b) n = (n << 8n) | BigInt(byte);
return n;
}
function bigIntToBytes(n, len) {
const out = new Uint8Array(len);
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
return out;
}
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
// Based on the RFC 1321 reference implementation, minified.
function md5(data) {
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = b.length;
// pad message
const padLen = ((len + 8) >>> 6 << 4) + 16;
const m = new Uint32Array(padLen);
for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8);
m[len>>2] |= 0x80 << ((len&3)*8);
m[padLen-2] = len*8;
const T = new Int32Array(64);
for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0;
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21];
function add(x,y){return (x+y)|0;}
function r(v,s){return (v<<s)|(v>>>(32-s));}
for (let i = 0; i < padLen; i += 16) {
let [aa,bb,cc,dd] = [a,b2,c,d];
for (let j = 0; j < 64; j++) {
let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j]
: j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16]
: j<48 ? [bb^cc^dd, (3*j+5)%16]
: [cc^(bb|(~dd)), (7*j)%16];
f = add(add(aa, f), add(m[i+g], T[j]));
// Rotation amount: round = j>>4 (changes every 16 steps),
// position-in-round = j%4. S is laid out as 4 rounds × 4.
[aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>4)*4)]))];
}
[a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)];
}
const out = new Uint8Array(16);
[a,b2,c,d].forEach((x,i) => {
out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff;
out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff;
});
return out;
}
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
async function aes128ecb(key16, data128) {
const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']);
const out = new Uint8Array(128);
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
for (let i = 0; i < 8; i++) {
const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16));
out.set(new Uint8Array(enc,0,16), i*16);
}
return out;
}
// Apple-DH state — stored between async continuations.
let appleDhState = null;
// ────────────────────────────────────────────────────────────────────────
function processRfb() {
while (true) {
if (!tryStep()) break;
}
}
function tryStep() {
switch (state) {
case 'version': {
const b = drainTo(12);
if (!b) return false;
dbg('← server version: ' + new TextDecoder().decode(b).replace('\n','\\n'));
// Send back same version (RFB 003.008)
send(new TextEncoder().encode('RFB 003.008\n'));
state = 'security-types';
return true;
}
case 'security-types': {
const b = drainTo(1);
if (!b) return false;
const n = b[0];
dbg('← security-types: count=' + n + (n === 0 ? ' (server error!)' : ''));
if (n === 0) { setStatus('server sent 0 security types', 'error'); return false; }
const types = drainTo(n);
if (!types) { chunks.unshift(b); totalBytes += 1; return false; }
dbg('← security-types offered: [' + Array.from(types).join(', ') + ']');
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
// even with --disable-transport-layer-security), else first offered.
let prefer;
if (types.indexOf(1) !== -1) prefer = 1; // plain None
else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt
else prefer = types[0];
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
if (types.indexOf(1) !== -1) prefer = 1;
else if (types.indexOf(19) !== -1) prefer = 19;
else if (types.indexOf(30) !== -1) prefer = 30;
else {
dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err');
setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error');
ws.close();
return false;
}
dbg('→ choosing security type ' + prefer +
(prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)'));
send(new Uint8Array([prefer]));
if (prefer === 1) state = 'security-result';
else if (prefer === 19) state = 'vencrypt-version';
else state = 'apple-dh-params';
return true;
}
case 'security-vnc-challenge': {
// VNC auth (type 2): we don't have the password, so send zeros.
// This will fail for password-protected servers; fine for our
// weston VNC which uses None via VeNCrypt.
const b = drainTo(16);
if (!b) return false;
dbg('← vnc-challenge (16 bytes): ' + hex(b));
send(new Uint8Array(16));
state = 'security-result';
return true;
}
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
// with --disable-transport-layer-security, offering sub-type 1 (None).
case 'vencrypt-version': {
// Server sends: major (u8), minor (u8) — e.g. 0, 2
const b = drainTo(2);
if (!b) return false;
dbg('← VeNCrypt version: ' + b[0] + '.' + b[1]);
// Echo same version back
send(new Uint8Array([b[0], b[1]]));
state = 'vencrypt-subtypes';
return true;
}
case 'vencrypt-subtypes': {
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
const nb = drainTo(1);
if (!nb) return false;
const nSub = nb[0];
dbg('← VeNCrypt nSubtypes=' + nSub);
const raw = drainTo(nSub * 4);
if (!raw) { chunks.unshift(nb); totalBytes += 1; return false; }
// Build sub-type array from big-endian u32s
const subs = [];
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
dbg('← VeNCrypt sub-types: [' + subs.join(', ') + ']');
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
// Fall back to first offered.
const sub = subs.includes(1) ? 1 : subs[0];
dbg('→ choosing VeNCrypt sub-type ' + sub);
// Send chosen sub-type as big-endian u32
send(new Uint8Array([sub>>>24, (sub>>>16)&0xff, (sub>>>8)&0xff, sub&0xff]));
state = 'vencrypt-accept';
return true;
}
case 'vencrypt-accept': {
// Server sends 1 byte: 1=accepted, 0=refused
const b = drainTo(1);
if (!b) return false;
dbg('← VeNCrypt accept byte: ' + b[0] + (b[0] === 1 ? ' (ok)' : ' (REFUSED)'));
if (b[0] !== 1) { setStatus('VeNCrypt sub-type refused', 'error'); return false; }
// Sub-type 1 (None): proceed to SecurityResult
state = 'security-result';
return true;
}
// ── Apple-DH (type 30) ────────────────────────────────────────────
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
// server_pub[key_size]
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
// No session encryption after auth — plain RFB follows.
case 'apple-dh-params': {
const hdr = drainTo(4);
if (!hdr) return false;
const generator = u16be(hdr, 0);
const keySize = u16be(hdr, 2);
dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize);
const rest = drainTo(keySize * 2);
if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; }
const prime = rest.slice(0, keySize);
const serverPub = rest.slice(keySize);
dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) +
' server_pub[0:4]=' + hex(serverPub.slice(0,4)));
// Async DH computation — pause state machine, resume when done.
appleDhState = { generator, keySize, prime, serverPub };
state = 'apple-dh-wait';
(async () => {
try {
const p = bytesToBigInt(appleDhState.prime);
const g = BigInt(appleDhState.generator);
const ks = appleDhState.keySize;
// Generate client private key: random ks bytes, then mod p
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
const priv = bytesToBigInt(privBytes) % p;
// Client public key = g ^ priv mod p
const clientPub = modpow(g, priv, p);
const clientPubBytes = bigIntToBytes(clientPub, ks);
// Shared secret = server_pub ^ priv mod p
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
const shared = modpow(serverPubInt, priv, p);
const sharedBytes = bigIntToBytes(shared, ks);
// AES key = MD5(shared_secret)
const aesKey = md5(sharedBytes);
dbg('Apple-DH: shared MD5 key=' + hex(aesKey));
// Credentials: 64 bytes username + 64 bytes password.
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
// getpwnam(username) and requires pw_uid == weston's own uid
// BEFORE PAM is ever consulted — an empty/garbage username is
// rejected outright. weston runs as root, so the username must
// be "root". The password stays empty; pam_permit.so on the
// weston-remote-access PAM service accepts it.
const creds = new Uint8Array(128);
creds.set(new TextEncoder().encode('root'), 0);
const encCreds = await aes128ecb(aesKey, creds);
// Send: encrypted_creds + client_pub
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
// at offset 0 and public_key at offset 128 (flexible array after).
const response = new Uint8Array(ks + 128);
response.set(encCreds, 0);
response.set(clientPubBytes, 128);
send(response);
dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok');
state = 'security-result';
processRfb(); // resume state machine
} catch(e) {
setStatus('Apple-DH error: ' + e.message, 'error');
}
})();
return false; // pause — async resumes
}
case 'apple-dh-wait':
// Async handshake in progress — don't consume bytes
return false;
// ──────────────────────────────────────────────────────────────────
case 'security-result': {
const b = drainTo(4);
if (!b) return false;
const code = u32be(b, 0);
dbg('← security-result: ' + code + ' (bytes: ' + hex(b) + ')' + (code === 0 ? ' ✓' : ' FAIL'), code === 0 ? 'ok' : 'err');
if (code !== 0) { setStatus('auth failed (code ' + code + ')', 'error'); return false; }
// ClientInit: shared flag = 1
send(new Uint8Array([1]));
state = 'server-init';
return true;
}
case 'server-init': {
const b = drainTo(24);
if (!b) return false;
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
fbW = u16be(b, 0); fbH = u16be(b, 2);
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
// red/green/blue max/shift at b[8..17]
pixelFormat = {
bpp: b[4], depth: b[5], bigEndian: b[6], trueColour: b[7],
redMax: u16be(b, 8), greenMax: u16be(b, 10), blueMax: u16be(b, 12),
redShift: b[14], greenShift: b[15], blueShift: b[16],
bytesPerPixel: b[4] / 8,
};
const nameLen = u32be(b, 20);
const nameBytes = drainTo(nameLen);
if (!nameBytes) { chunks.unshift(b); totalBytes += 24; return false; }
dbg('← server-init: ' + fbW + 'x' + fbH + ' bpp=' + pixelFormat.bpp, 'ok');
canvas.width = fbW;
canvas.height = fbH;
relayoutCanvas();
setStatus('connected', 'connected');
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
// server reports (and accepts) desktop-size changes. (issue #133)
sendSetEncodings([0, -308]);
// Request full framebuffer update
requestUpdate(0, 0, 0, fbW, fbH);
state = 'normal';
return true;
}
case 'normal': {
const b = drainTo(1);
if (!b) return false;
const msgType = b[0];
if (msgType === 0) {
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
// byte is already consumed above; hdr covers padding + nRects.
const hdr = drainTo(3);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
updateRects = u16be(hdr, 1);
state = 'rect-header';
} else if (msgType === 2) {
// Bell: ignore
} else if (msgType === 3) {
// ServerCutText
const hdr = drainTo(7);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
const len = u32be(hdr, 3);
const text = drainTo(len);
if (!text) { chunks.unshift(b); totalBytes += 1 + 7; return false; }
}
return true;
}
case 'rect-header': {
if (updateRects === 0) { state = 'normal'; requestUpdate(1, 0, 0, fbW, fbH); return true; }
const b = drainTo(12);
if (!b) return false;
const x = u16be(b, 0), y = u16be(b, 2), w = u16be(b, 4), h = u16be(b, 6);
const enc = (b[8]<<24|b[9]<<16|b[10]<<8|b[11])>>>0;
if (enc === 0 && pixelFormat) {
const bytes = w * h * pixelFormat.bytesPerPixel;
const pixels = drainTo(bytes);
if (!pixels) { chunks.unshift(b); totalBytes += 12; return false; }
drawRaw(x, y, w, h, pixels);
} else if (enc === EXT_DESKTOP_SIZE_U32) {
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
// header's x = change reason, y = request status. (issue #133)
const nScreens = peekByte();
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
const body = drainTo(4 + nScreens * 16);
if (!body) { chunks.unshift(b); totalBytes += 12; return false; }
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
if (!extDesktopSupported) {
extDesktopSupported = true;
matchBtn.disabled = false;
}
if (w && h && (w !== fbW || h !== fbH)) {
dbg('← desktop resized to ' + w + 'x' + h
+ ' (reason ' + x + ', status ' + y + ')', 'ok');
fbW = w; fbH = h;
canvas.width = w; canvas.height = h;
relayoutCanvas();
requestUpdate(0, 0, 0, fbW, fbH);
}
}
updateRects--;
return true;
}
default: return false;
}
}
function drawRaw(x, y, w, h, data) {
if (!pixelFormat || w === 0 || h === 0) return;
const bpp = pixelFormat.bytesPerPixel;
const img = ctx.createImageData(w, h);
const d = img.data;
const rs = pixelFormat.redShift, gs = pixelFormat.greenShift, bs = pixelFormat.blueShift;
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
let px = 0;
if (bpp === 4) px = pixelFormat.bigEndian
? (data[o]<<24|data[o+1]<<16|data[o+2]<<8|data[o+3])>>>0
: (data[o+3]<<24|data[o+2]<<16|data[o+1]<<8|data[o])>>>0;
else if (bpp === 2) px = pixelFormat.bigEndian
? (data[o]<<8|data[o+1])>>>0 : (data[o+1]<<8|data[o])>>>0;
else px = data[o];
d[i*4] = (px >> rs) & pixelFormat.redMax;
d[i*4+1] = (px >> gs) & pixelFormat.greenMax;
d[i*4+2] = (px >> bs) & pixelFormat.blueMax;
d[i*4+3] = 255;
}
ctx.putImageData(img, x, y);
}
function requestUpdate(incremental, x, y, w, h) {
const b = new Uint8Array(10);
b[0] = 3; b[1] = incremental;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
b[6] = w>>8; b[7] = w&0xff;
b[8] = h>>8; b[9] = h&0xff;
send(b);
}
// SetEncodings (msg type 2): advertise the encodings we understand.
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
function sendSetEncodings(encs) {
const b = new Uint8Array(4 + encs.length * 4);
b[0] = 2; // message-type
b[1] = 0; // padding
b[2] = encs.length >> 8; b[3] = encs.length & 0xff;
let o = 4;
for (const e of encs) {
const v = e >>> 0; // two's-complement for negatives
b[o++] = (v>>24)&0xff; b[o++] = (v>>16)&0xff;
b[o++] = (v>>8)&0xff; b[o++] = v&0xff;
}
send(b);
}
// SetDesktopSize (msg type 251): ask the server to change the desktop
// resolution. One screen at the origin, sized to the request. (#133)
function sendSetDesktopSize(w, h) {
const b = new Uint8Array(24);
b[0] = 251; b[1] = 0; // message-type + padding
b[2] = w>>8; b[3] = w&0xff;
b[4] = h>>8; b[5] = h&0xff;
b[6] = 1; b[7] = 0; // number-of-screens + padding
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
b[8] = (screenId>>>24)&0xff; b[9] = (screenId>>>16)&0xff;
b[10] = (screenId>>>8)&0xff; b[11] = screenId&0xff;
b[12] = 0; b[13] = 0; // x-position
b[14] = 0; b[15] = 0; // y-position
b[16] = w>>8; b[17] = w&0xff;
b[18] = h>>8; b[19] = h&0xff;
b[20] = 0; b[21] = 0; b[22] = 0; b[23] = 0; // flags
send(b);
}
// Peek the first unconsumed byte without draining it. -1 when empty.
function peekByte() {
for (const c of chunks) { if (c.length) return c[0]; }
return -1;
}
// --- Input forwarding ---
canvas.addEventListener('mousemove', sendPointer);
canvas.addEventListener('mousedown', sendPointer);
canvas.addEventListener('mouseup', sendPointer);
function sendPointer(ev) {
const r = canvas.getBoundingClientRect();
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
// from the intrinsic resolution — map client coords back to fb pixels.
const sx = r.width ? canvas.width / r.width : 1;
const sy = r.height ? canvas.height / r.height : 1;
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
let mask = 0;
if (ev.buttons & 1) mask |= 1;
if (ev.buttons & 4) mask |= 2;
if (ev.buttons & 2) mask |= 4;
const b = new Uint8Array(6);
b[0] = 5; b[1] = mask;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
send(b);
}
document.addEventListener('keydown', (ev) => sendKey(ev, 1));
document.addEventListener('keyup', (ev) => sendKey(ev, 0));
function sendKey(ev, down) {
ev.preventDefault();
const key = rfbKeysym(ev);
const b = new Uint8Array(8);
b[0] = 4; b[1] = down; b[2] = 0; b[3] = 0;
b[4] = key>>24; b[5] = (key>>16)&0xff; b[6] = (key>>8)&0xff; b[7] = key&0xff;
send(b);
}
function rfbKeysym(ev) {
// Map common keys to X11 keysym values
const map = {
'BackSpace': 0xff08, 'Tab': 0xff09, 'Enter': 0xff0d, 'Escape': 0xff1b,
'Delete': 0xffff, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
'PageDown': 0xff56, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52,
'ArrowRight': 0xff53, 'ArrowDown': 0xff54,
'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffe7,
'F1': 0xffbe, 'F2': 0xffbf, 'F3': 0xffc0, 'F4': 0xffc1,
'F5': 0xffc2, 'F6': 0xffc3, 'F7': 0xffc4, 'F8': 0xffc5,
'F9': 0xffc6, 'F10': 0xffc7, 'F11': 0xffc8, 'F12': 0xffc9,
};
if (map[ev.key]) return map[ev.key];
if (ev.key.length === 1) return ev.key.codePointAt(0);
return 0;
}
})();
</script>
</body>
</html>

View file

@ -1,100 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="/icon">
<link rel="stylesheet" href="/static/agent.css">
<style>
.stats-nav { display: flex; gap: 0.75rem; align-items: baseline; margin-bottom: 0.5rem; }
.stats-nav a { color: var(--cyan); text-decoration: none; }
.stats-nav a:hover { text-decoration: underline; }
.window-tabs { display: flex; gap: 0.4rem; margin: 0.5rem 0 1rem; }
.window-tabs button {
background: var(--bg-elev); color: var(--fg);
border: 1px solid var(--border); padding: 0.3rem 0.8rem;
font-family: inherit; cursor: pointer;
}
.window-tabs button.active { background: var(--purple-dim); border-color: var(--purple); color: var(--purple); }
.summary { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
.summary .chip {
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.5rem 0.9rem;
border-radius: 4px;
box-sizing: border-box;
min-width: 9rem;
height: 3.4rem;
line-height: 1.2;
}
.summary .chip .label {
color: var(--muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary .chip .value {
color: var(--cyan);
font-weight: bold;
font-size: 1.05rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
gap: 1rem;
}
.card {
background: var(--bg-elev);
border: 1px solid var(--border);
padding: 0.75rem 1rem 1rem;
border-radius: 4px;
}
.card h3 { margin: 0 0 0.5rem; color: var(--purple); font-size: 0.95rem; font-weight: normal; }
.card .chart-wrap { position: relative; height: 220px; }
.card.wide { grid-column: 1 / -1; }
.card.wide .chart-wrap { height: 260px; }
.empty-note { color: var(--muted); font-style: italic; }
</style>
</head>
<body>
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
<div class="stats-nav">
<a id="back-link" href="/">← live</a>
<a id="dashboard-link" href="#">dashboard</a>
<h2 id="title" style="margin: 0;">◆ … ◆</h2>
</div>
<div class="window-tabs" id="window-tabs">
<button data-w="1h">last 1h</button>
<button data-w="4h">last 4h</button>
<button data-w="24h" class="active">last 24h</button>
<button data-w="3d">last 3d</button>
<button data-w="7d">last 7d</button>
<button data-w="30d">last 30d</button>
</div>
<div class="summary" id="summary"></div>
<div class="grid">
<div class="card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
<div class="card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
<div class="card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
<div class="card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
<div class="card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
<div class="card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
<div class="card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
</div>
<!-- Chart.js pinned to a fixed version from jsDelivr. SRI hash is
not set yet — add an integrity="sha384-..." attribute when we
have a way to compute it deterministically in the build. -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
crossorigin="anonymous"></script>
<script src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -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();
});
})();

View file

@ -24,6 +24,7 @@ use axum::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use tower_http::services::ServeDir;
use crate::client; use crate::client;
use crate::events::Bus; use crate::events::Bus;
@ -88,6 +89,19 @@ pub async fn serve(
turn_lock: TurnLock, turn_lock: TurnLock,
) -> Result<()> { ) -> Result<()> {
let gui_vnc_port = read_gui_json(); 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 { let state = AppState {
label, label,
login, login,
@ -99,11 +113,6 @@ pub async fn serve(
gui_vnc_port, gui_vnc_port,
}; };
let app = Router::new() 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("/api/state", get(api_state))
.route("/events/stream", get(events_stream)) .route("/events/stream", get(events_stream))
.route("/events/history", get(events_history)) .route("/events/history", get(events_history))
@ -116,12 +125,17 @@ pub async fn serve(
.route("/api/model", post(post_set_model)) .route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session)) .route("/api/new-session", post(post_new_session))
.route("/api/loose-ends", get(api_loose_ends)) .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("/api/stats", get(api_stats))
.route("/screen", get(serve_screen))
.route("/screen/ws", get(screen_ws)) .route("/screen/ws", get(screen_ws))
.route("/icon", get(serve_icon)) .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); .with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?; let listener = bind_with_retry(addr, "web UI").await?;
@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024) 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 /// This agent's icon. Serves the operator-configured SVG from
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix /// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
/// option) when present, otherwise the bundled default hyperhive logo. /// option) when present, otherwise the bundled default hyperhive logo.
@ -585,8 +537,13 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> { fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
let mut links = Vec::new(); 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 { links.push(AgentLink {
url: "/stats".to_owned(), url: "/stats.html".to_owned(),
icon: "📊".to_owned(), icon: "📊".to_owned(),
label: "stats".to_owned(), label: "stats".to_owned(),
kind: AgentLinkKind::Container, kind: AgentLinkKind::Container,
@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
if gui_enabled { if gui_enabled {
links.push(AgentLink { links.push(AgentLink {
url: "/screen".to_owned(), url: "/screen.html".to_owned(),
icon: "🖥".to_owned(), icon: "🖥".to_owned(),
label: "screen".to_owned(), label: "screen".to_owned(),
kind: AgentLinkKind::Container, kind: AgentLinkKind::Container,

View file

@ -12,7 +12,6 @@ axum.workspace = true
base64.workspace = true base64.workspace = true
reqwest.workspace = true reqwest.workspace = true
clap.workspace = true clap.workspace = true
hive-fr0nt.workspace = true
hive-sh4re.workspace = true hive-sh4re.workspace = true
libc = "0.2" libc = "0.2"
rusqlite.workspace = true rusqlite.workspace = true
@ -20,6 +19,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tokio-stream.workspace = true tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
<pre class="banner">
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
</pre>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<!-- swarm: live containers, dormant state, meta input bumps that
affect the whole swarm. -->
<h2>◆ C0NTAINERS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
<h2>◆ K3PT ST4T3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<h2>◆ M3T4 1NPUTS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
<h2>◆ R3BU1LD QU3U3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">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.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- operator decisions: things waiting on you. -->
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
<div id="reminders-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<!-- messages: broker traffic + the compose box that produces it. -->
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
<div class="terminal-wrap">
<div id="msgflow" class="live"><div class="meta">connecting…</div></div>
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</div>
<footer>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel. Long content (clicked file previews,
approval diffs, journald logs, applied config) opens here
instead of expanding inline. Singleton — JS swaps the title +
body and toggles `.open`. -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/marked.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -4,7 +4,7 @@
use std::convert::Infallible; use std::convert::Infallible;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@ -14,7 +14,7 @@ use axum::{
extract::{Path as AxumPath, State}, extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{ response::{
Html, IntoResponse, Response, IntoResponse, Response,
sse::{Event, KeepAlive, Sse}, sse::{Event, KeepAlive, Sse},
}, },
routing::{get, post}, routing::{get, post},
@ -23,6 +23,7 @@ use hive_sh4re::Approval;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt}; use tokio_stream::{Stream, StreamExt};
use tower_http::services::ServeDir;
use crate::actions; use crate::actions;
use crate::container_view::{ContainerView, claude_has_session}; use crate::container_view::{ContainerView, claude_has_session};
@ -37,11 +38,20 @@ struct AppState {
} }
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> { pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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() 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("/api/state", get(api_state))
.route("/approve/{id}", post(post_approve)) .route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny)) .route("/deny/{id}", post(post_deny))
@ -66,8 +76,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/meta-update", post(post_meta_update)) .route("/meta-update", post(post_meta_update))
.route("/dashboard/stream", get(dashboard_stream)) .route("/dashboard/stream", get(dashboard_stream))
.route("/dashboard/history", get(dashboard_history)) .route("/dashboard/history", get(dashboard_history))
.route("/static/hive-fr0nt.js", get(serve_shared_js)) // Anything not matched by the dynamic routes above falls
.route("/static/marked.js", get(serve_marked_js)) // 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 }); .with_state(AppState { coord });
let addr = SocketAddr::from(([0, 0, 0, 0], port)); let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr).await?; let listener = bind_with_retry(addr).await?;
@ -77,11 +90,14 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Static asset handlers: the dashboard is an SPA. `GET /` returns the // The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state` // live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
// returns the current snapshot as JSON. The JS app fetches state on load, // hive-c0re NixOS module to `${frontend}/dashboard`), served by the
// re-fetches after every async-form submit, and listens on // `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
// `/dashboard/stream` for the unified live event channel. // 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 /// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
@ -142,56 +158,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024) 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)] #[derive(Serialize)]
struct StateSnapshot { struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients /// Broker seq at the moment this snapshot was assembled. Clients

View file

@ -1,7 +0,0 @@
[package]
name = "hive-fr0nt"
edition.workspace = true
version.workspace = true
[lints]
workspace = true

View file

@ -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;
}

File diff suppressed because one or more lines are too long

View file

@ -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);
}

View file

@ -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 <div class="row cls">
// api.details(cls, summary, body) → appends <details class="row cls">
// with a <pre.tool-body>
// 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 };
})();

View file

@ -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 `<script>` tag, leaving `window.marked`
/// undefined and markdown silently falling back to raw text (issue #244).
pub const MARKED_JS: &str = include_str!("../assets/marked.umd.js");