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:
parent
2ecf15bb6f
commit
229c4292e9
24 changed files with 143 additions and 10122 deletions
49
CLAUDE.md
49
CLAUDE.md
|
|
@ -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
39
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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">@—></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>
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "hive-fr0nt"
|
|
||||||
edition.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
})();
|
|
||||||
|
|
@ -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");
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue