Compare commits

..
41 changed files with 3519 additions and 1553 deletions

View file

@ -87,28 +87,35 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
and meta read access; mirrors each applied repo
into `agent-configs/<n>` (core-only); agents are
read-only collaborators on `core/meta`
src/dashboard.rs axum HTTP: /api/state JSON + actions
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
+ journald viewer + bind-with-retry (SO_REUSEADDR)
+ deployed_sha chip per container +
/dashboard/{stream,history} subscribing to the
unified DashboardEvent channel. Static assets
(HTML/CSS/JS/favicon) served by
tower_http::ServeDir from $HIVE_STATIC_DIR
(= `${frontend}/dashboard` per the c0re module).
unified DashboardEvent channel
assets/ index.html, dashboard.css, app.js (include_str!)
frontend/ npm workspaces (esbuild → static dist). Built
hermetically by `nix/frontend.nix`
(`packages.${system}.frontend`).
packages/shared/ @hive/shared: terminal pane + Catppuccin palette
+ base typography (was hive-fr0nt). ES module
exporting { create, linkify }; pure JS, no IIFE
globals; consumed by dashboard + agent.
packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js,
dashboard.css} + build.mjs → dist/{index.html,
static/{app.js, dashboard.css}}.
packages/agent/ @hive/agent default per-container UI: src/
{index, stats, screen}.html + {app, stats}.js
+ agent.css → dist/{*.html, static/*}.
hive-fr0nt/ shared frontend-assets crate (browser only).
src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS /
MARKED_JS re-exports; both binaries
`include_str!` them and prepend to their per-
page serving routes.
assets/base.css Catppuccin palette + body typography (one source
of truth, no per-page redeclaration).
assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` +
`.row` / `details.row` styling for both
pages' lit log panes. Unified prefix-column
(padding-left + negative text-indent) so glyph
alignment is consistent across row kinds + a
`.md` block scope for marked-rendered bodies.
assets/terminal.js `window.HiveTerminal.create(opts)`: scroll-
sticky log + "↓ N new" pill + history
backfill + SSE subscribe-buffer-snapshot-
dedupe dance. Pages register a kind→renderer
map; the terminal owns the lifecycle.
assets/marked.umd.js vendored marked v4.0.2 UMD bundle. Per-agent
terminal uses the global `marked.parse` for
markdown bodies on send / recv / ask / answer
/ assistant text rows.
hive-ag3nt/ in-container harness crate; produces TWO binaries
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
@ -132,10 +139,8 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
src/login_session.rs drives `claude auth login` over stdio pipes
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
Static UI assets served by ServeDir from
$HIVE_STATIC_DIR (= hyperhive.frontend
.mergedDist — default agent dist + per-agent
extraFiles, set per the harness-base module).
assets/ index.html, agent.css, app.js, stats.html,
stats.js, screen.html (include_str!)
prompts/ static role/tools/settings for claude (include_str!):
agent.md — sub-agent system prompt
manager.md — manager system prompt

39
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

@ -51,31 +51,18 @@
in
{
packages = forAllSystems (
{ pkgs, naersk-lib, ... }:
{ naersk-lib, ... }:
{
default = naersk-lib.buildPackage {
src = ./.;
meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-m1nd)";
};
# Bundled browser assets — see ./nix/frontend.nix. Output is
# $out/{dashboard,agent}/ which the Rust binaries serve via
# tower_http::ServeDir (wired up in Phase 4 of #273).
frontend = pkgs.callPackage ./nix/frontend.nix {
branding-svg = ./branding/hyperhive.svg;
};
}
);
overlays = {
default = final: prev: {
hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default;
# Bundled frontend dist (see ./nix/frontend.nix). Output is
# $out/{dashboard,agent}/; consumers pick the surface they
# need. Exposed via the overlay so containers' nix evaluations
# can reach it as `pkgs.hyperhive-frontend` once the overlay
# is applied (manager + agent containers both apply it via
# `mkContainer` further down).
hyperhive-frontend = self.packages.${prev.stdenv.hostPlatform.system}.frontend;
};
claude-unstable =
final: prev:
@ -109,7 +96,6 @@
# builds (already applied internally in `nixosConfigurations`).
hive-c0re = import ./nix/modules/hive-c0re.nix {
hyperhivePackage = system: self.packages.${system}.default;
hyperhiveFrontend = system: self.packages.${system}.frontend;
hyperhiveFlake = "${self}";
};
hive-forge = ./nix/modules/hive-forge.nix;

2
frontend/.gitignore vendored
View file

@ -1,2 +0,0 @@
node_modules/
packages/*/dist/

View file

@ -1,33 +0,0 @@
# hyperhive frontend
npm workspaces project for the hyperhive browser-facing assets:
- `packages/shared/` — shared modules used by both surfaces (terminal
pane, Catppuccin palette + body typography).
- `packages/dashboard/` — the hive-c0re dashboard SPA.
- `packages/agent/` — the per-container web UI (default agent page,
stats, screen).
## Build
```
npm install # one-off; uses the checked-in package-lock.json
npm run build # builds every workspace into packages/*/dist/
```
The Rust binaries serve `packages/dashboard/dist/` and
`packages/agent/dist/` via `tower_http::ServeDir` at runtime; the
build derivation is wired up in `nix/modules/frontend.nix`. Per-agent
additions are layered on top of the default agent dist via the
`hyperhive.frontend.extraFiles` option in `agent.nix`.
## Why npm + esbuild
- **Hermetic**: dependencies vendored via the checked-in lockfile;
`buildNpmPackage` in nix uses it as the source-of-truth so the
output is reproducible without network access at build time.
- **esbuild**: vanilla-JS bundler, no framework runtime overhead.
Each workspace's `build.mjs` is ~30 lines.
- **Single-PR migration**: see issue #273 for the design proposal and
the four-commit shape (npm scaffold → nix derivations → container
plumbing → Rust cutover).

View file

@ -1,549 +0,0 @@
{
"name": "hyperhive-frontend",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hyperhive-frontend",
"version": "0.0.0",
"workspaces": [
"packages/shared",
"packages/dashboard",
"packages/agent"
],
"devDependencies": {
"esbuild": "0.25.5"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz",
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz",
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz",
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz",
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz",
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz",
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz",
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz",
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz",
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz",
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz",
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz",
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz",
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz",
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz",
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz",
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz",
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz",
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz",
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz",
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz",
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz",
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz",
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz",
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@hive/agent": {
"resolved": "packages/agent",
"link": true
},
"node_modules/@hive/dashboard": {
"resolved": "packages/dashboard",
"link": true
},
"node_modules/@hive/shared": {
"resolved": "packages/shared",
"link": true
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/chart.js": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/esbuild": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz",
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.5",
"@esbuild/android-arm": "0.25.5",
"@esbuild/android-arm64": "0.25.5",
"@esbuild/android-x64": "0.25.5",
"@esbuild/darwin-arm64": "0.25.5",
"@esbuild/darwin-x64": "0.25.5",
"@esbuild/freebsd-arm64": "0.25.5",
"@esbuild/freebsd-x64": "0.25.5",
"@esbuild/linux-arm": "0.25.5",
"@esbuild/linux-arm64": "0.25.5",
"@esbuild/linux-ia32": "0.25.5",
"@esbuild/linux-loong64": "0.25.5",
"@esbuild/linux-mips64el": "0.25.5",
"@esbuild/linux-ppc64": "0.25.5",
"@esbuild/linux-riscv64": "0.25.5",
"@esbuild/linux-s390x": "0.25.5",
"@esbuild/linux-x64": "0.25.5",
"@esbuild/netbsd-arm64": "0.25.5",
"@esbuild/netbsd-x64": "0.25.5",
"@esbuild/openbsd-arm64": "0.25.5",
"@esbuild/openbsd-x64": "0.25.5",
"@esbuild/sunos-x64": "0.25.5",
"@esbuild/win32-arm64": "0.25.5",
"@esbuild/win32-ia32": "0.25.5",
"@esbuild/win32-x64": "0.25.5"
}
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"packages/agent": {
"name": "@hive/agent",
"version": "0.0.0",
"dependencies": {
"@hive/shared": "*",
"chart.js": "4.4.4",
"marked": "4.3.0"
}
},
"packages/dashboard": {
"name": "@hive/dashboard",
"version": "0.0.0",
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0"
}
},
"packages/shared": {
"name": "@hive/shared",
"version": "0.0.0"
}
}
}

View file

@ -1,18 +0,0 @@
{
"name": "hyperhive-frontend",
"version": "0.0.0",
"private": true,
"description": "Frontend assets for the hyperhive dashboard and per-agent UIs. Built with esbuild into static dist directories that the Rust binaries serve via tower_http::ServeDir.",
"workspaces": [
"packages/shared",
"packages/dashboard",
"packages/agent"
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"clean": "rm -rf packages/*/dist"
},
"devDependencies": {
"esbuild": "0.25.5"
}
}

View file

@ -1,60 +0,0 @@
// esbuild build for @hive/agent. Output layout (`dist/`):
//
// dist/index.html served at GET /
// dist/stats.html served at GET /stats
// dist/screen.html served at GET /screen
// dist/static/app.js served at /static/app.js (ESM bundle,
// pulls in @hive/shared + marked)
// dist/static/app.js.map source map sibling
// dist/static/stats.js served at /static/stats.js (pulls in
// chart.js/auto)
// dist/static/stats.js.map source map sibling
// dist/static/agent.css served at /static/agent.css (@import
// resolved from @hive/shared)
//
// The in-container Rust binary mounts `dist/` (with per-agent
// `hyperhive.frontend.extraFiles` layered on top) as a
// `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML references reachable without rewriting paths.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
// Two JS entries: the main app + the stats page. Both bundle their
// own deps so each page can be loaded independently.
await build({
entryPoints: [src('app.js'), src('stats.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Bundle the CSS — the @import lines pull in shared/base.css and
// shared/terminal.css from the @hive/shared workspace dep.
await build({
entryPoints: [src('agent.css')],
outfile: staticDir('agent.css'),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
});
for (const html of ['index.html', 'stats.html', 'screen.html']) {
copyFileSync(src(html), dist(html));
}
console.log('agent build ok →', dist(''));

View file

@ -1,15 +0,0 @@
{
"name": "@hive/agent",
"version": "0.0.0",
"private": true,
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
"type": "module",
"scripts": {
"build": "node ./build.mjs"
},
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0",
"chart.js": "4.4.4"
}
}

View file

@ -1,53 +0,0 @@
// esbuild build for @hive/dashboard. Output layout (`dist/`):
//
// dist/index.html served by the Rust router at GET /
// dist/static/app.js served at /static/app.js (ESM bundle,
// pulls in @hive/shared + marked)
// dist/static/app.js.map source map sibling
// dist/static/dashboard.css served at /static/dashboard.css
// (@import resolved from @hive/shared)
//
// The Rust binary mounts `dist/` as a `tower_http::ServeDir` fallback;
// the layout above keeps every URL the index.html references reachable
// without rewriting paths in the HTML.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
// Bundle the JS entry. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later
// follow-up once asset sizes warrant it).
await build({
entryPoints: [src('app.js')],
outfile: staticDir('app.js'),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
sourcemap: true,
logLevel: 'info',
});
// Bundle the CSS — esbuild resolves @import including the package
// re-exports from @hive/shared.
await build({
entryPoints: [src('dashboard.css')],
outfile: staticDir('dashboard.css'),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
});
copyFileSync(src('index.html'), dist('index.html'));
console.log('dashboard build ok →', dist(''));

View file

@ -1,14 +0,0 @@
{
"name": "@hive/dashboard",
"version": "0.0.0",
"private": true,
"description": "hive-c0re dashboard SPA. Bundled by esbuild into a static dist; served by the hive-c0re Rust binary at runtime via tower_http::ServeDir.",
"type": "module",
"scripts": {
"build": "node ./build.mjs"
},
"dependencies": {
"@hive/shared": "*",
"marked": "4.3.0"
}
}

View file

@ -1,17 +0,0 @@
{
"name": "@hive/shared",
"version": "0.0.0",
"private": true,
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
"type": "module",
"main": "./src/index.js",
"exports": {
".": "./src/index.js",
"./terminal.js": "./src/terminal.js",
"./base.css": "./src/base.css",
"./terminal.css": "./src/terminal.css"
},
"files": [
"src/"
]
}

View file

@ -1,3 +0,0 @@
// Convenience re-export so consumers can `import { create, linkify }
// from '@hive/shared'` without naming the sub-module path.
export { create, linkify } from './terminal.js';

View file

@ -1,342 +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:
//
// import { create, linkify } from '@hive/shared/terminal.js';
//
// create({
// logEl: document.getElementById('msgflow'),
// historyUrl: '/messages/history?limit=200', // optional
// streamUrl: '/messages/stream',
// renderers: {
// sent: (ev, api) => api.row('msgrow sent', ...),
// delivered: (ev, api) => api.row('msgrow delivered', ...),
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
// },
// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
// both backfill replay and live — use for derived views that need
// the full picture (e.g. a per-recipient inbox built from broker
// events) */ },
// onBackfillDone: (count) => { /* one-shot after history replay */ },
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
// use to re-sync snapshot-derived state after a reconnect gap */ },
// pillAnchor: document.getElementById('msgflow').parentElement,
// });
//
// Renderers receive (ev, api) where api exposes:
//
// api.row(cls, text) → appends a flat <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.
const NEAR_BOTTOM_PX = 48;
export function create(opts) {
const log = opts.logEl;
if (!log) throw new Error('HiveTerminal.create: logEl is required');
const renderers = opts.renderers || {};
const defaultRender = renderers._default
|| ((ev, api) => api.row('note', JSON.stringify(ev)));
const pillAnchor = opts.pillAnchor || log.parentElement || log;
let placeholderEl = null;
let pill = null;
let unseen = 0;
let currentNoAnim = false;
function isNearBottom() {
return log.scrollHeight - log.scrollTop - log.clientHeight <= NEAR_BOTTOM_PX;
}
function ensurePill() {
if (pill) return pill;
pill = document.createElement('button');
pill.type = 'button';
pill.className = 'tail-pill';
pill.addEventListener('click', () => { log.scrollTop = log.scrollHeight; });
pillAnchor.appendChild(pill);
return pill;
}
function updatePill() {
if (unseen <= 0) {
if (pill) pill.classList.remove('visible');
return;
}
ensurePill();
pill.textContent = '↓ ' + unseen + ' new';
pill.classList.add('visible');
}
log.addEventListener('scroll', () => {
if (isNearBottom()) { unseen = 0; updatePill(); }
});
function afterAppend() {
if (currentNoAnim || isNearBottom()) {
log.scrollTop = log.scrollHeight;
} else {
unseen += 1;
updatePill();
}
}
function clearPlaceholder() {
if (placeholderEl && placeholderEl.parentElement === log) {
log.removeChild(placeholderEl);
}
placeholderEl = null;
}
function placeholder(text) {
clearPlaceholder();
const e = document.createElement('div');
e.className = 'row note';
e.textContent = text;
log.appendChild(e);
placeholderEl = e;
}
function row(cls, text) {
clearPlaceholder();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend();
return e;
}
function details(cls, summary, body) {
clearPlaceholder();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
const pre = document.createElement('pre');
pre.className = 'tool-body';
pre.appendChild(linkify(body));
d.appendChild(pre);
log.appendChild(d);
afterAppend();
return d;
}
function detailsDiff(cls, summary, body) {
clearPlaceholder();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
const pre = document.createElement('pre');
pre.className = 'tool-body diff-body';
for (const line of String(body).split('\n')) {
const span = document.createElement('span');
if (line.startsWith('+ ')) span.className = 'diff-add';
else if (line.startsWith('- ')) span.className = 'diff-del';
else span.className = 'diff-ctx';
span.textContent = line + '\n';
pre.appendChild(span);
}
d.appendChild(pre);
log.appendChild(d);
afterAppend();
return d;
}
function api(extra) {
return Object.assign({
row, details, detailsDiff, placeholder, linkify,
fromHistory: false,
}, extra || {});
}
function dispatch(ev, fromHistory) {
const r = renderers[ev.kind] || defaultRender;
try {
r(ev, api({ fromHistory }));
} catch (err) {
console.error('terminal renderer threw', ev, err);
row('note', '[render err] ' + (err && err.message ? err.message : err));
}
if (opts.onAnyEvent) {
try { opts.onAnyEvent(ev, { fromHistory }); }
catch (err) { console.error('onAnyEvent threw', err); }
}
}
// Subscribe → buffer → fetch history → dedupe → apply.
//
// Race the SSE subscription opens before the history fetch starts.
// Live events that land before history resolves are buffered, not
// rendered. Once the history response (`{ seq, events }`) arrives we:
// 1. Replay `events` (fromHistory=true).
// 2. Drop buffered events with `seq <= history.seq` — they're
// already reflected in the history rows above.
// 3. Apply remaining buffered events (fromHistory=false).
// 4. Switch to live mode: each new SSE event dispatches immediately.
//
// Without this dance an event that fires between history-fetch and
// SSE-subscribe goes missing; without seq dedupe the same event
// shows twice (once via history, once via live buffer). Both bugs
// were latent before.
//
// If `historyUrl` is unset we skip the dance: buffered events apply
// as live the moment the buffer flushes (no dedupe possible without
// a boundary seq).
function start() {
let live = false;
let buffered = [];
const es = new EventSource(opts.streamUrl);
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); }
catch (err) { row('note', '[parse err] ' + e.data); return; }
if (!live) { buffered.push(ev); return; }
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
}
};
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) row('note', '[reconnecting…]');
else row('note', '[disconnected]');
};
es.onopen = () => {
// Fires on the initial connect and on every automatic
// reconnect. EventSource never replays events that fired
// during a disconnect window, so a consumer with
// snapshot-derived state (the dashboard's /api/state stores)
// must re-sync here or it shows stale state until a manual
// reload (issue #163).
if (opts.onStreamOpen) {
try { opts.onStreamOpen(); }
catch (err) { console.error('onStreamOpen threw', err); }
}
};
function flushBuffered(boundarySeq, historyKinds) {
const drained = buffered;
buffered = [];
live = true;
for (const ev of drained) {
// Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could
// double (once via history, once via the live buffer).
// Mutation events (approval/question/container/…) are never
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history
// fetch (issue #163). ev.seq absent/0 → no dedupe possible.
if (boundarySeq != null
&& typeof ev.seq === 'number' && ev.seq <= boundarySeq
&& historyKinds && historyKinds.has(ev.kind)) {
continue;
}
dispatch(ev, false);
if (opts.onLiveEvent) {
try { opts.onLiveEvent(ev); }
catch (err) { console.error('onLiveEvent threw', err); }
}
}
}
async function backfill() {
if (!opts.historyUrl) {
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
try {
const resp = await fetch(opts.historyUrl);
if (!resp.ok) {
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
return;
}
const body = await resp.json();
// Accept the envelope `{ seq, events }`. A bare array means
// the server hasn't been updated to include seq yet — treat
// it as "no dedupe possible."
const events = Array.isArray(body) ? body : (body.events || []);
const boundarySeq = Array.isArray(body) ? null : (body.seq ?? null);
// Kinds present in the history replay — the only kinds that
// can double and therefore the only ones to seq-dedupe.
const historyKinds = new Set(events.map((ev) => ev.kind));
currentNoAnim = true;
for (const ev of events) dispatch(ev, true);
currentNoAnim = false;
if (events.length) row('note', '─── live (older above) ───');
else placeholder('(connected — waiting for events)');
flushBuffered(boundarySeq, historyKinds);
if (opts.onBackfillDone) opts.onBackfillDone(events.length);
} catch (err) {
console.warn('history backfill failed', err);
flushBuffered(null);
if (opts.onBackfillDone) opts.onBackfillDone(0);
}
}
return backfill();
}
const ready = start();
return { row, details, detailsDiff, placeholder, ready };
}
// Build a DocumentFragment from `text`, turning bare http(s) URLs into
// clickable links that open in a new tab. Non-URL text stays as plain
// text nodes — no innerHTML, so this is XSS-safe. Trailing sentence
// punctuation is kept out of the link. (issue #233)
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkify(text) {
const str = text == null ? '' : String(text);
const frag = document.createDocumentFragment();
if (str.indexOf('://') === -1) { // fast path: no URLs
if (str) frag.appendChild(document.createTextNode(str));
return frag;
}
let last = 0;
let m;
LINKIFY_URL_RE.lastIndex = 0;
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
// Don't swallow trailing punctuation that's really sentence text.
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) {
frag.appendChild(document.createTextNode(str.slice(last, m.index)));
}
if (!url.slice(url.indexOf('://') + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
frag.appendChild(document.createTextNode(m[0]));
} else {
const a = document.createElement('a');
a.href = url; // regex only matches https?:// — safe
a.textContent = url;
a.target = '_blank';
a.rel = 'noopener noreferrer';
frag.appendChild(a);
if (tail) frag.appendChild(document.createTextNode(tail));
}
last = m.index + m[0].length;
}
if (last < str.length) {
frag.appendChild(document.createTextNode(str.slice(last)));
}
return frag;
}

View file

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

View file

@ -1,8 +1,5 @@
/* Shared Catppuccin palette + body typography + terminal pane styles.
Bundled in front of the agent-only rules below via esbuild. */
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* 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;

View file

@ -2,18 +2,6 @@
// tails `/events/stream` for live claude events, drives async-form
// actions (send / login/* / dashboard rebuild).
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
import { marked } from 'marked';
// Expose the previously-script-tag-provided globals so the IIFE below
// keeps working unchanged. Pre-split these were attached by
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
// (marked) loading before app.js. The bundle now pulls them in via ES
// imports; once the IIFE is opened up these aliases can be dropped in
// favour of direct named imports.
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
window.marked = marked;
(() => {
// ─── helpers ────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);

View file

@ -45,8 +45,8 @@
<div id="term-input" class="term-input"></div>
</div>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js. -->
<script type="module" src="/static/app.js" defer></script>
<script src="/static/marked.js" defer></script>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -90,9 +90,11 @@
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
</div>
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
chart.js@4.4.4), so the page works offline / on operator
machines without internet egress. No SRI hash to maintain. -->
<script type="module" src="/static/stats.js" defer></script>
<!-- Chart.js pinned to a fixed version from jsDelivr. SRI hash is
not set yet — add an integrity="sha384-..." attribute when we
have a way to compute it deterministically in the build. -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
crossorigin="anonymous"></script>
<script src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -2,14 +2,6 @@
// once on load, then /api/stats?window=... for the chart data — re-fetches
// when the operator clicks a window tab.
import Chart from 'chart.js/auto';
// Expose for the IIFE below — pre-split this was a window global from
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
// stats.js; once the IIFE opens up we can use the imported `Chart`
// directly.
window.Chart = Chart;
(function () {
'use strict';

View file

@ -24,7 +24,6 @@ use axum::{
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
use tower_http::services::ServeDir;
use crate::client;
use crate::events::Bus;
@ -89,19 +88,6 @@ pub async fn serve(
turn_lock: TurnLock,
) -> Result<()> {
let gui_vnc_port = read_gui_json();
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from)
.context(
"HIVE_STATIC_DIR env var not set — point it at the merged \
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
)?;
if !static_dir.is_dir() {
anyhow::bail!(
"HIVE_STATIC_DIR ({}) is not a directory",
static_dir.display()
);
}
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
let state = AppState {
label,
login,
@ -113,6 +99,11 @@ pub async fn serve(
gui_vnc_port,
};
let app = Router::new()
.route("/", get(serve_index))
.route("/static/agent.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/static/hive-fr0nt.js", get(serve_shared_js))
.route("/static/marked.js", get(serve_marked_js))
.route("/api/state", get(api_state))
.route("/events/stream", get(events_stream))
.route("/events/history", get(events_history))
@ -125,17 +116,12 @@ pub async fn serve(
.route("/api/model", post(post_set_model))
.route("/api/new-session", post(post_new_session))
.route("/api/loose-ends", get(api_loose_ends))
.route("/stats", get(serve_stats))
.route("/static/stats.js", get(serve_stats_js))
.route("/api/stats", get(api_stats))
.route("/screen", get(serve_screen))
.route("/screen/ws", get(screen_ws))
.route("/icon", get(serve_icon))
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
// falls through to the merged dist. ServeDir auto-appends
// `.html` when the URL is a bare path that matches a file
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
// screen.html`). Per-agent `extraFiles` additions are
// already layered into this same directory (see
// hyperhive.frontend.mergedDist in nix).
.fallback_service(ServeDir::new(&static_dir))
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr, "web UI").await?;
@ -215,6 +201,68 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024)
}
async fn serve_index() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/index.html"),
)
}
async fn serve_css() -> impl IntoResponse {
// Prepend the shared palette/typography so per-page styles only need
// to declare what's actually page-specific. One HTTP request, no
// per-asset cache to invalidate.
let body = format!(
"{}\n{}\n{}",
hive_fr0nt::BASE_CSS,
hive_fr0nt::TERMINAL_CSS,
include_str!("../assets/agent.css"),
);
([("content-type", "text/css")], body)
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
async fn serve_shared_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::TERMINAL_JS,
)
}
async fn serve_marked_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::MARKED_JS,
)
}
async fn serve_stats() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/stats.html"),
)
}
async fn serve_stats_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/stats.js"),
)
}
async fn serve_screen() -> impl IntoResponse {
(
[("content-type", "text/html; charset=utf-8")],
include_str!("../assets/screen.html"),
)
}
/// This agent's icon. Serves the operator-configured SVG from
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
/// option) when present, otherwise the bundled default hyperhive logo.
@ -537,13 +585,8 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
let mut links = Vec::new();
// Note: the URLs are the actual HTML files served out of the
// frontend dist (`stats.html` / `screen.html`); after the #273
// backend/frontend split the harness serves these as static
// files via ServeDir rather than via Rust routes, so the URL
// has to be the on-disk filename.
links.push(AgentLink {
url: "/stats.html".to_owned(),
url: "/stats".to_owned(),
icon: "📊".to_owned(),
label: "stats".to_owned(),
kind: AgentLinkKind::Container,
@ -551,7 +594,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
if gui_enabled {
links.push(AgentLink {
url: "/screen.html".to_owned(),
url: "/screen".to_owned(),
icon: "🖥".to_owned(),
label: "screen".to_owned(),
kind: AgentLinkKind::Container,

View file

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

View file

@ -2,18 +2,6 @@
// up async-form submission (URL-encoded POST + spinner + state refresh),
// and tails the unified dashboard event channel over `/dashboard/stream`.
import { create as termCreate, linkify as termLinkify } from '@hive/shared/terminal.js';
import { marked } from 'marked';
// Expose the previously-script-tag-provided globals so the IIFE below
// keeps working unchanged. Pre-split these were attached by
// `/static/hive-fr0nt.js` (HiveTerminal) and `/static/marked.js`
// (marked) loading before app.js. The bundle now pulls them in via ES
// imports; once the IIFE is opened up these aliases can be dropped in
// favour of direct named imports.
window.HiveTerminal = { create: termCreate, linkify: termLinkify };
window.marked = marked;
(() => {
// ─── constants ──────────────────────────────────────────────────────────
// Context-window badge thresholds. Preferred source is each container's

View file

@ -1,8 +1,5 @@
/* Shared Catppuccin palette + body typography + terminal pane styles.
Bundled in front of the dashboard-only rules below via esbuild. */
@import "@hive/shared/base.css";
@import "@hive/shared/terminal.css";
/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended
to this stylesheet by `serve_css` at runtime. */
body {
max-width: 70em;
margin: 1.5em auto;

View file

@ -109,9 +109,8 @@
</aside>
</div>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js; load order is preserved by
the module bundler. -->
<script type="module" src="/static/app.js" defer></script>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/marked.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -4,7 +4,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
@ -14,7 +14,7 @@ use axum::{
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{
IntoResponse, Response,
Html, IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
@ -23,7 +23,6 @@ use hive_sh4re::Approval;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
use tower_http::services::ServeDir;
use crate::actions;
use crate::container_view::{ContainerView, claude_has_session};
@ -38,20 +37,11 @@ struct AppState {
}
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()
.route("/", get(serve_index))
.route("/static/dashboard.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/favicon.svg", get(serve_favicon))
.route("/api/state", get(api_state))
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
@ -76,11 +66,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/meta-update", post(post_meta_update))
.route("/dashboard/stream", get(dashboard_stream))
.route("/dashboard/history", get(dashboard_history))
// Anything not matched by the dynamic routes above falls
// through to the bundled dashboard dist (GET / →
// dist/index.html, /favicon.svg → dist/favicon.svg,
// /static/dashboard.css → dist/static/dashboard.css, etc.).
.fallback_service(ServeDir::new(&static_dir))
.route("/static/hive-fr0nt.js", get(serve_shared_js))
.route("/static/marked.js", get(serve_marked_js))
.with_state(AppState { coord });
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr).await?;
@ -90,14 +77,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
}
// ---------------------------------------------------------------------------
// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
// hive-c0re NixOS module to `${frontend}/dashboard`), served by the
// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
// surface — `/api/state` and the action endpoints — is owned here.
// The JS app fetches state on load, re-fetches after every async-form
// submit, and listens on `/dashboard/stream` for the unified live event
// channel.
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
// returns the current snapshot as JSON. The JS app fetches state on load,
// re-fetches after every async-form submit, and listens on
// `/dashboard/stream` for the unified live event channel.
// ---------------------------------------------------------------------------
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
@ -158,6 +142,56 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024)
}
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../assets/index.html"))
}
async fn serve_css() -> impl IntoResponse {
// Prepend the shared palette/typography so per-page styles only need
// to declare what's actually page-specific. One HTTP request, no
// per-asset cache to invalidate.
let body = format!(
"{}\n{}\n{}",
hive_fr0nt::BASE_CSS,
hive_fr0nt::TERMINAL_CSS,
include_str!("../assets/dashboard.css"),
);
([("content-type", "text/css")], body)
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
/// Dashboard favicon — the hyperhive mark. Static: the dashboard
/// represents the whole hive, so it always uses the project logo
/// (per-agent pages serve their own configurable `/icon` instead).
async fn serve_favicon() -> impl IntoResponse {
(
[("content-type", "image/svg+xml")],
include_str!("../../branding/hyperhive.svg"),
)
}
async fn serve_shared_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::TERMINAL_JS,
)
}
/// Vendored `marked` bundle — the side panel renders markdown file
/// previews with it.
async fn serve_marked_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::MARKED_JS,
)
}
#[derive(Serialize)]
struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients

7
hive-fr0nt/Cargo.toml Normal file
View file

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

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,344 @@
// 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 };
})();

47
hive-fr0nt/src/lib.rs Normal file
View file

@ -0,0 +1,47 @@
//! 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");

View file

@ -1,60 +0,0 @@
{
buildNpmPackage,
lib,
branding-svg,
}:
# Hermetic build of the npm-managed frontend workspaces (see
# `frontend/README.md`). Consumes `frontend/package-lock.json` as the
# source of truth for dependency versions; `npmDepsHash` pins the
# vendor-tarball hash so a stale lockfile fails the build instead of
# silently fetching different upstream tarballs.
#
# Output layout (`$out`) — two subdirectories, one per surface, that
# the Rust binaries serve via `tower_http::ServeDir`:
#
# $out/dashboard/ the hive-c0re dashboard SPA assets
# index.html app.js app.js.map dashboard.css favicon.svg
# $out/agent/ the per-agent default UI (layered with
# hyperhive.frontend.extraFiles at activation time)
# index.html app.js stats.html stats.js agent.css screen.html
#
# The dashboard favicon lives outside the npm tree (`branding/hyperhive
# .svg` at the repo root) — we copy it in during the install phase so
# the served prefix has everything in one place.
buildNpmPackage {
pname = "hyperhive-frontend";
version = "0.0.0";
src = ../frontend;
# Computed from `frontend/package-lock.json` via
# prefetch-npm-deps frontend/package-lock.json
# Update whenever the lockfile changes. Recompute locally with the
# same command (`pkgs.prefetch-npm-deps`), or let the build fail
# and copy the actual hash from the error message.
npmDepsHash = "sha256-MHXxkZpe/5LAhpQ76ZK94znG2noTobthjUi6iNY8/K4=";
# `npm run build` recurses into all workspaces (`--workspaces
# --if-present`). The workspaces' build scripts each run their own
# `build.mjs` (esbuild).
npmBuildScript = "build";
# buildNpmPackage's default install phase copies the working dir into
# $out, which is overkill — we only want the dist trees. Hand-roll
# the install to keep $out tight.
dontNpmInstall = true;
installPhase = ''
runHook preInstall
mkdir -p $out/dashboard $out/agent
cp -r packages/dashboard/dist/. $out/dashboard/
cp -r packages/agent/dist/. $out/agent/
cp ${branding-svg} $out/dashboard/favicon.svg
runHook postInstall
'';
meta = {
description = "Bundled browser-facing assets for the hyperhive dashboard and per-agent UI";
homepage = "https://git.berlin.ccc.de/vinzenz/hyperhive";
};
}

View file

@ -1,6 +1,5 @@
{
hyperhivePackage,
hyperhiveFrontend,
hyperhiveFlake,
}:
{
@ -26,19 +25,6 @@ in
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
description = "Package that provides /bin/hive-c0re.";
};
frontend = lib.mkOption {
type = lib.types.package;
default = hyperhiveFrontend pkgs.stdenv.hostPlatform.system;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.frontend";
description = ''
Bundled frontend dist (see `./nix/frontend.nix`). Output has
`dashboard/` and `agent/` subdirectories hive-c0re serves
`dashboard/` via `tower_http::ServeDir` from the path passed
in `HIVE_STATIC_DIR`. Override to ship a custom dashboard SPA;
the JSON contract (`/api/state`, the SSE streams, the action
endpoints) is the source of truth for any replacement.
'';
};
hyperhiveFlake = lib.mkOption {
type = lib.types.str;
default = hyperhiveFlake;
@ -128,10 +114,6 @@ in
];
environment = {
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
# Path to the dashboard static dist. The hive-c0re axum router
# serves this via `tower_http::ServeDir` for any path it doesn't
# match against an API/action route.
HIVE_STATIC_DIR = "${cfg.frontend}/dashboard";
} // lib.optionalAttrs config.hyperhive.forge.enable {
# Agents poll this URL for Forgejo notifications. Derived from
# hyperhive.forge.{domain,httpPort} so it tracks forge config changes.

View file

@ -1,4 +1,4 @@
{ pkgs, config, ... }:
{ pkgs, ... }:
{
imports = [ ./harness-base.nix ];
@ -13,15 +13,7 @@
# anything an agent adds to its own `agent.nix` — without having to
# touch the service definition.
path = [ "/run/current-system/sw" ];
environment = {
SHELL = "${pkgs.bashInteractive}/bin/bash";
# Path to the merged agent static dist. The harness serves this
# via `tower_http::ServeDir` for any request it doesn't route to
# an API endpoint. `mergedDist` is the agent-default dist with
# `hyperhive.frontend.extraFiles` layered on top — both come
# from harness-base.nix.
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
};
environment.SHELL = "${pkgs.bashInteractive}/bin/bash";
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
Restart = "on-failure";

View file

@ -149,110 +149,6 @@
'';
};
options.hyperhive.frontend.dist = lib.mkOption {
type = lib.types.package;
default = pkgs.hyperhive-frontend;
defaultText = lib.literalExpression "pkgs.hyperhive-frontend";
description = ''
The shipped frontend dist (built by `nix/frontend.nix`). Output
layout: `dashboard/` (used by hive-c0re on the host) and
`agent/` (used here, layered with `extraFiles` below at
activation time). Override to ship a fully custom per-agent SPA;
the JSON contract (`/api/state`, `/events/stream`, the action
endpoints) is the source of truth for any replacement.
'';
};
options.hyperhive.frontend.mergedDist = lib.mkOption {
type = lib.types.package;
readOnly = true;
description = ''
Computed: the merged static tree consumed by the harness via
`HIVE_STATIC_DIR`. Composed at evaluation time by copying
`hyperhive.frontend.dist`'s `agent/` subdir as the base, then
layering each `extraFiles` entry on top. Read-only
consumers (`agent-base.nix`, `manager.nix`) reference this in
their systemd service environment; do not set directly.
'';
};
options.hyperhive.frontend.extraFiles = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule (
{ name, ... }:
{
options = {
source = lib.mkOption {
type = lib.types.path;
description = ''
Source file or directory to layer over the default
agent dist. A path (relative to `agent.nix` or
absolute) nix copies its contents into the merged
static tree.
'';
};
target = lib.mkOption {
# First char must be alphanumeric/underscore (rules out
# leading `/`, leading `.`, leading `-`); inner chars
# include `.` and `/` so nested layouts like
# `"games/bitburner"` work. This is the shape check —
# the `..`-segment traversal check is the assertion in
# `config.assertions` below (regex alone can't reject
# mid-path `..` segments without lookahead, which nix
# POSIX regex doesn't support).
type = lib.types.strMatching "^[A-Za-z0-9_][A-Za-z0-9_./-]*$";
default = name;
defaultText = lib.literalMD "the attribute name";
description = ''
Destination path within the merged static tree, used
as both the served URL prefix (`/<target>/...`) and
the on-disk layout in the merged derivation. Defaults
to the attribute name. Use forward slashes for
nested layouts (e.g. `"games/bitburner"`).
Constrained shape: must start with an alphanumeric or
`_`, and only contain alphanumerics, `_`, `.`, `/`,
`-`. `..` segments are separately rejected at config
eval time.
'';
};
};
}
)
);
default = { };
example = lib.literalExpression ''
{
bitburner = {
source = ./bitburner-dist;
# served at GET /bitburner/...
};
}
'';
description = ''
Per-agent additions layered on top of the default frontend
dist. Each entry copies its `source` into the served static
tree under `target`. Useful for shipping a self-contained
agent-specific surface alongside the standard agent UI (e.g.
the bitburner agent's game page at `/bitburner/`).
The default agent UI remains served at `/`; entries here only
add new routes and never replace the default. Overwrite
semantics are **hard-fail**: if `target` collides with an
existing file or directory in the default dist (or with a
prior entry's target), the `mergedDist` build aborts with
`refusing to overwrite existing path '<target>' in the
default dist`. To override a default file, fork the dist via
`hyperhive.frontend.dist` instead `extraFiles` is for
pure additions.
`target` must be a relative path inside the static dir. An
assertion rejects leading `/` and `..` segments at config
eval time (string-concat-into-paths safety, even though
agent.nix goes through operator review before deploy).
'';
};
options.hyperhive.forge.url = lib.mkOption {
type = lib.types.str;
default = "http://localhost:3000";
@ -464,23 +360,6 @@
|| lib.hasSuffix ".svg" (toString config.hyperhive.icon);
message = "hyperhive.icon must point to an .svg file";
}
# hyperhive.frontend.extraFiles[*].target is concatenated into
# $out during the mergedDist build. The option's strMatching
# type already rejects leading `/`, leading `.`, and the
# weirder characters; this assertion catches mid-path `..`
# segments (e.g. `foo/../etc/passwd`) that the type's regex
# can't easily express without lookahead. agent.nix is
# operator-reviewed, so this is belt-and-braces — but it's the
# kind of mistake that's easy to make and hard to spot.
{
assertion = lib.all (
entry: !(builtins.any (seg: seg == "..") (lib.splitString "/" entry.target))
) (lib.attrValues config.hyperhive.frontend.extraFiles);
message = ''
hyperhive.frontend.extraFiles: `target` must not contain
`..` path segments.
'';
}
];
environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers;
@ -506,28 +385,6 @@
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
# Merged frontend static tree. Base = `${frontend.dist}/agent/`,
# then each `extraFiles` entry is laid on top at its `target`
# path. The runCommand derivation aborts on overwrite so a
# filename collision with the default dist surfaces as a build
# failure rather than a silent override (operator gets a clear
# nix error rather than a confusing 404 / silent dist swap).
hyperhive.frontend.mergedDist = pkgs.runCommand "hyperhive-agent-frontend-merged" { } (
''
mkdir -p $out
cp -r ${config.hyperhive.frontend.dist}/agent/. $out/
chmod -R u+w $out
''
+ lib.concatMapStrings (entry: ''
mkdir -p $(dirname $out/${entry.target})
if [ -e $out/${entry.target} ]; then
echo "hyperhive.frontend.extraFiles: refusing to overwrite existing path '${entry.target}' in the default dist" >&2
exit 1
fi
cp -r ${entry.source} $out/${entry.target}
'') (lib.attrValues config.hyperhive.frontend.extraFiles)
);
# HIVE_DEFAULT_MODEL seeds the initial model selection when no persisted
# model choice exists in the state dir. SHELL must be set so claude's
# Bash tool finds a POSIX shell.

View file

@ -1,4 +1,4 @@
{ pkgs, config, ... }:
{ pkgs, ... }:
{
imports = [ ./harness-base.nix ];
@ -24,10 +24,6 @@
HIVE_PORT = "8000";
HIVE_LABEL = "hm1nd";
SHELL = "${pkgs.bashInteractive}/bin/bash";
# Manager runs the same hive-m1nd harness binary that serves
# the per-agent web UI; point it at the merged agent static dist
# (same shape as for sub-agents).
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
};
# See note in agent-base.nix — `/run/current-system/sw` makes the
# harness service PATH track `environment.systemPackages` so anything