Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2319d206 | ||
|
|
65532e8387 | ||
|
|
2951da32e7 | ||
|
|
229c4292e9 | ||
|
|
2ecf15bb6f | ||
|
|
892e034908 | ||
|
|
c8af7bc70c | ||
|
|
9c7d4df08c | ||
|
|
8bebd78895 |
41 changed files with 1553 additions and 3519 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
|
||||
into `agent-configs/<n>` (core-only); agents are
|
||||
read-only collaborators on `core/meta`
|
||||
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
|
||||
src/dashboard.rs axum HTTP: /api/state JSON + actions
|
||||
+ journald viewer + bind-with-retry (SO_REUSEADDR)
|
||||
+ deployed_sha chip per container +
|
||||
/dashboard/{stream,history} subscribing to the
|
||||
unified DashboardEvent channel
|
||||
assets/ index.html, dashboard.css, app.js (include_str!)
|
||||
unified DashboardEvent channel. Static assets
|
||||
(HTML/CSS/JS/favicon) served by
|
||||
tower_http::ServeDir from $HIVE_STATIC_DIR
|
||||
(= `${frontend}/dashboard` per the c0re module).
|
||||
|
||||
hive-fr0nt/ shared frontend-assets crate (browser only).
|
||||
src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS /
|
||||
MARKED_JS re-exports; both binaries
|
||||
`include_str!` them and prepend to their per-
|
||||
page serving routes.
|
||||
assets/base.css Catppuccin palette + body typography (one source
|
||||
of truth, no per-page redeclaration).
|
||||
assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` +
|
||||
`.row` / `details.row` styling for both
|
||||
pages' lit log panes. Unified prefix-column
|
||||
(padding-left + negative text-indent) so glyph
|
||||
alignment is consistent across row kinds + a
|
||||
`.md` block scope for marked-rendered bodies.
|
||||
assets/terminal.js `window.HiveTerminal.create(opts)`: scroll-
|
||||
sticky log + "↓ N new" pill + history
|
||||
backfill + SSE subscribe-buffer-snapshot-
|
||||
dedupe dance. Pages register a kind→renderer
|
||||
map; the terminal owns the lifecycle.
|
||||
assets/marked.umd.js vendored marked v4.0.2 UMD bundle. Per-agent
|
||||
terminal uses the global `marked.parse` for
|
||||
markdown bodies on send / recv / ask / answer
|
||||
/ assistant text rows.
|
||||
frontend/ npm workspaces (esbuild → static dist). Built
|
||||
hermetically by `nix/frontend.nix`
|
||||
(`packages.${system}.frontend`).
|
||||
packages/shared/ @hive/shared: terminal pane + Catppuccin palette
|
||||
+ base typography (was hive-fr0nt). ES module
|
||||
exporting { create, linkify }; pure JS, no IIFE
|
||||
globals; consumed by dashboard + agent.
|
||||
packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js,
|
||||
dashboard.css} + build.mjs → dist/{index.html,
|
||||
static/{app.js, dashboard.css}}.
|
||||
packages/agent/ @hive/agent default per-container UI: src/
|
||||
{index, stats, screen}.html + {app, stats}.js
|
||||
+ agent.css → dist/{*.html, static/*}.
|
||||
|
||||
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
||||
|
|
@ -139,8 +132,10 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
|
|||
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
|
||||
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
|
||||
assets/ index.html, agent.css, app.js, stats.html,
|
||||
stats.js, screen.html (include_str!)
|
||||
Static UI assets served by ServeDir from
|
||||
$HIVE_STATIC_DIR (= hyperhive.frontend
|
||||
.mergedDist — default agent dist + per-agent
|
||||
extraFiles, set per the harness-base module).
|
||||
prompts/ static role/tools/settings for claude (include_str!):
|
||||
agent.md — sub-agent system prompt
|
||||
manager.md — manager system prompt
|
||||
|
|
|
|||
39
Cargo.lock
generated
39
Cargo.lock
generated
|
|
@ -565,7 +565,6 @@ dependencies = [
|
|||
"axum",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"hive-fr0nt",
|
||||
"hive-sh4re",
|
||||
"reqwest",
|
||||
"rmcp",
|
||||
|
|
@ -575,6 +574,7 @@ dependencies = [
|
|||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
|
@ -587,7 +587,6 @@ dependencies = [
|
|||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"hive-fr0nt",
|
||||
"hive-sh4re",
|
||||
"libc",
|
||||
"reqwest",
|
||||
|
|
@ -597,14 +596,11 @@ dependencies = [
|
|||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hive-fr0nt"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "hive-sh4re"
|
||||
version = "0.1.0"
|
||||
|
|
@ -645,6 +641,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
|
|
@ -954,6 +956,16 @@ version = "0.3.17"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
|
|
@ -1723,10 +1735,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
|||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -1835,6 +1856,12 @@ version = "1.20.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"]
|
||||
members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
|
|
@ -19,8 +19,8 @@ anyhow = "1"
|
|||
axum = { version = "0.8", features = ["ws"] }
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
hive-fr0nt = { path = "hive-fr0nt" }
|
||||
hive-sh4re = { path = "hive-sh4re" }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
rmcp = { version = "1.7", default-features = false, features = [
|
||||
"server",
|
||||
"macros",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
Snapshot of how the per-agent web UI's live pane renders each
|
||||
event kind today. Source of truth lives in
|
||||
`hive-ag3nt/assets/app.js` (`renderStream`, `fmtToolUse`,
|
||||
`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`,
|
||||
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
|
||||
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
|
||||
`hive-fr0nt/assets/terminal.css` (the shared `.live .<class>`
|
||||
styling) + `hive-fr0nt/assets/marked.umd.js` (markdown).
|
||||
`frontend/packages/shared/src/terminal.css` (the shared
|
||||
`.live .<class>` styling) + the `marked` npm package (markdown).
|
||||
|
||||
## Layout contract
|
||||
|
||||
|
|
@ -82,8 +82,8 @@ parent's negative pull.
|
|||
|
||||
## Markdown
|
||||
|
||||
`mdNode(text)` wraps `window.marked.parse(text)` (vendored
|
||||
v4.0.2 UMD via `hive-fr0nt::MARKED_JS`) in a `<div
|
||||
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
|
||||
dep, bundled by esbuild into the page's `app.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
|
||||
|
|
|
|||
|
|
@ -7,22 +7,32 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
|
|||
|
||||
## Shape (shared by both)
|
||||
|
||||
- `GET /` → `assets/index.html` (placeholders for state-driven
|
||||
sections, shipped via `include_str!` so the binary has no runtime
|
||||
file dependency).
|
||||
- `GET /static/*.css` + `GET /static/*.js` → static assets. Both
|
||||
pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their
|
||||
per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the
|
||||
shared `window.HiveTerminal.create` runtime. The dashboard's
|
||||
- `GET /` → `index.html` from the bundled frontend dist (see
|
||||
`frontend/`). Both binaries' routers declare their dynamic
|
||||
endpoints first and then `fallback_service(ServeDir::new(...))`
|
||||
pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
|
||||
action route is served from the dist. Dashboard dist lives at
|
||||
`${frontend}/dashboard`; per-agent dist is the merged
|
||||
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
|
||||
`extraFiles` overlay).
|
||||
- `GET /static/*` → bundled CSS + JS produced by esbuild
|
||||
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
|
||||
pull the shared terminal pane + Catppuccin palette + typography
|
||||
from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines
|
||||
`base.css` + `terminal.css` via esbuild's `@import` resolution.
|
||||
`terminal.js` exports `{ create, linkify }` as ES module
|
||||
members (no more `window.HiveTerminal` global outside the
|
||||
back-compat shim the IIFE bodies still use). The dashboard's
|
||||
`#msgflow` and the per-agent `#live` log are both backed by
|
||||
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
||||
history backfill, SSE plumbing all live there. Each page
|
||||
registers a kind→renderer map; unknown kinds fall through to
|
||||
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
||||
turned into clickable new-tab links by `HiveTerminal.linkify`
|
||||
(text-node based, no `innerHTML` — XSS-safe); markdown bodies
|
||||
get the same treatment via `marked`'s autolink, with the
|
||||
rendered `<a>`s rewritten to `target="_blank"` (issue #233).
|
||||
turned into clickable new-tab links by `linkify` (text-node
|
||||
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
||||
same treatment via `marked`'s autolink (npm dep, replacing the
|
||||
vendored UMD bundle), with the rendered `<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);
|
||||
|
|
|
|||
16
flake.nix
16
flake.nix
|
|
@ -51,18 +51,31 @@
|
|||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
{ naersk-lib, ... }:
|
||||
{ pkgs, 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:
|
||||
|
|
@ -96,6 +109,7 @@
|
|||
# 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
Normal file
2
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
packages/*/dist/
|
||||
33
frontend/README.md
Normal file
33
frontend/README.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# hyperhive frontend
|
||||
|
||||
npm workspaces project for the hyperhive browser-facing assets:
|
||||
|
||||
- `packages/shared/` — shared modules used by both surfaces (terminal
|
||||
pane, Catppuccin palette + body typography).
|
||||
- `packages/dashboard/` — the hive-c0re dashboard SPA.
|
||||
- `packages/agent/` — the per-container web UI (default agent page,
|
||||
stats, screen).
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
npm install # one-off; uses the checked-in package-lock.json
|
||||
npm run build # builds every workspace into packages/*/dist/
|
||||
```
|
||||
|
||||
The Rust binaries serve `packages/dashboard/dist/` and
|
||||
`packages/agent/dist/` via `tower_http::ServeDir` at runtime; the
|
||||
build derivation is wired up in `nix/modules/frontend.nix`. Per-agent
|
||||
additions are layered on top of the default agent dist via the
|
||||
`hyperhive.frontend.extraFiles` option in `agent.nix`.
|
||||
|
||||
## Why npm + esbuild
|
||||
|
||||
- **Hermetic**: dependencies vendored via the checked-in lockfile;
|
||||
`buildNpmPackage` in nix uses it as the source-of-truth so the
|
||||
output is reproducible without network access at build time.
|
||||
- **esbuild**: vanilla-JS bundler, no framework runtime overhead.
|
||||
Each workspace's `build.mjs` is ~30 lines.
|
||||
- **Single-PR migration**: see issue #273 for the design proposal and
|
||||
the four-commit shape (npm scaffold → nix derivations → container
|
||||
plumbing → Rust cutover).
|
||||
549
frontend/package-lock.json
generated
Normal file
549
frontend/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,549 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
18
frontend/package.json
Normal file
18
frontend/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "hyperhive-frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Frontend assets for the hyperhive dashboard and per-agent UIs. Built with esbuild into static dist directories that the Rust binaries serve via tower_http::ServeDir.",
|
||||
"workspaces": [
|
||||
"packages/shared",
|
||||
"packages/dashboard",
|
||||
"packages/agent"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"clean": "rm -rf packages/*/dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.5"
|
||||
}
|
||||
}
|
||||
60
frontend/packages/agent/build.mjs
Normal file
60
frontend/packages/agent/build.mjs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// 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(''));
|
||||
15
frontend/packages/agent/package.json
Normal file
15
frontend/packages/agent/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@hive/agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"marked": "4.3.0",
|
||||
"chart.js": "4.4.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended
|
||||
to this stylesheet by `serve_css` at runtime. */
|
||||
/* Shared Catppuccin palette + body typography + terminal pane styles.
|
||||
Bundled in front of the agent-only rules below via esbuild. */
|
||||
@import "@hive/shared/base.css";
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
body {
|
||||
max-width: 110em;
|
||||
margin: 1.5em auto;
|
||||
|
|
@ -2,6 +2,18 @@
|
|||
// 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);
|
||||
|
|
@ -45,8 +45,8 @@
|
|||
<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>
|
||||
<!-- 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -90,11 +90,9 @@
|
|||
<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>
|
||||
<!-- 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,6 +2,14 @@
|
|||
// 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';
|
||||
|
||||
53
frontend/packages/dashboard/build.mjs
Normal file
53
frontend/packages/dashboard/build.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// 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(''));
|
||||
14
frontend/packages/dashboard/package.json
Normal file
14
frontend/packages/dashboard/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@hive/dashboard",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "hive-c0re dashboard SPA. Bundled by esbuild into a static dist; served by the hive-c0re Rust binary at runtime via tower_http::ServeDir.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node ./build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hive/shared": "*",
|
||||
"marked": "4.3.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,18 @@
|
|||
// 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
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended
|
||||
to this stylesheet by `serve_css` at runtime. */
|
||||
/* Shared Catppuccin palette + body typography + terminal pane styles.
|
||||
Bundled in front of the dashboard-only rules below via esbuild. */
|
||||
@import "@hive/shared/base.css";
|
||||
@import "@hive/shared/terminal.css";
|
||||
|
||||
body {
|
||||
max-width: 70em;
|
||||
margin: 1.5em auto;
|
||||
|
|
@ -109,8 +109,9 @@
|
|||
</aside>
|
||||
</div>
|
||||
|
||||
<script src="/static/hive-fr0nt.js" defer></script>
|
||||
<script src="/static/marked.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
<!-- 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>
|
||||
</body>
|
||||
</html>
|
||||
17
frontend/packages/shared/package.json
Normal file
17
frontend/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@hive/shared",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Shared frontend modules used by both the dashboard and the per-agent UI: terminal log pane, Catppuccin palette, base typography. Imported by sibling workspaces; not bundled standalone.",
|
||||
"type": "module",
|
||||
"main": "./src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./terminal.js": "./src/terminal.js",
|
||||
"./base.css": "./src/base.css",
|
||||
"./terminal.css": "./src/terminal.css"
|
||||
},
|
||||
"files": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
3
frontend/packages/shared/src/index.js
Normal file
3
frontend/packages/shared/src/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Convenience re-export so consumers can `import { create, linkify }
|
||||
// from '@hive/shared'` without naming the sub-module path.
|
||||
export { create, linkify } from './terminal.js';
|
||||
342
frontend/packages/shared/src/terminal.js
Normal file
342
frontend/packages/shared/src/terminal.js
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
// Shared terminal pane: sticky-bottom log + "↓ N new" pill + history
|
||||
// backfill + live SSE. Pages provide a kind→renderer map; this module
|
||||
// owns scroll behaviour, animation suppression on backfill, and the
|
||||
// EventSource lifecycle.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// import { create, linkify } from '@hive/shared/terminal.js';
|
||||
//
|
||||
// create({
|
||||
// logEl: document.getElementById('msgflow'),
|
||||
// historyUrl: '/messages/history?limit=200', // optional
|
||||
// streamUrl: '/messages/stream',
|
||||
// renderers: {
|
||||
// sent: (ev, api) => api.row('msgrow sent', ...),
|
||||
// delivered: (ev, api) => api.row('msgrow delivered', ...),
|
||||
// _default: (ev, api) => api.row('note', JSON.stringify(ev)),
|
||||
// },
|
||||
// onLiveEvent: (ev) => { /* live-only side effects (notif, state pokes) */ },
|
||||
// onAnyEvent: (ev, { fromHistory }) => { /* runs for every event in
|
||||
// both backfill replay and live — use for derived views that need
|
||||
// the full picture (e.g. a per-recipient inbox built from broker
|
||||
// events) */ },
|
||||
// onBackfillDone: (count) => { /* one-shot after history replay */ },
|
||||
// onStreamOpen: () => { /* fires on every EventSource (re)connect —
|
||||
// use to re-sync snapshot-derived state after a reconnect gap */ },
|
||||
// pillAnchor: document.getElementById('msgflow').parentElement,
|
||||
// });
|
||||
//
|
||||
// Renderers receive (ev, api) where api exposes:
|
||||
//
|
||||
// api.row(cls, text) → appends a flat <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;
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ axum.workspace = true
|
|||
reqwest.workspace = true
|
||||
futures-util = "0.3"
|
||||
clap.workspace = true
|
||||
hive-fr0nt.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
rmcp.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
|
@ -21,6 +20,7 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ use axum::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::client;
|
||||
use crate::events::Bus;
|
||||
|
|
@ -88,6 +89,19 @@ pub async fn serve(
|
|||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_json();
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"HIVE_STATIC_DIR env var not set — point it at the merged \
|
||||
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
|
||||
)?;
|
||||
if !static_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||
static_dir.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
|
||||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
|
|
@ -99,11 +113,6 @@ pub async fn serve(
|
|||
gui_vnc_port,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/", get(serve_index))
|
||||
.route("/static/agent.css", get(serve_css))
|
||||
.route("/static/app.js", get(serve_app_js))
|
||||
.route("/static/hive-fr0nt.js", get(serve_shared_js))
|
||||
.route("/static/marked.js", get(serve_marked_js))
|
||||
.route("/api/state", get(api_state))
|
||||
.route("/events/stream", get(events_stream))
|
||||
.route("/events/history", get(events_history))
|
||||
|
|
@ -116,12 +125,17 @@ pub async fn serve(
|
|||
.route("/api/model", post(post_set_model))
|
||||
.route("/api/new-session", post(post_new_session))
|
||||
.route("/api/loose-ends", get(api_loose_ends))
|
||||
.route("/stats", get(serve_stats))
|
||||
.route("/static/stats.js", get(serve_stats_js))
|
||||
.route("/api/stats", get(api_stats))
|
||||
.route("/screen", get(serve_screen))
|
||||
.route("/screen/ws", get(screen_ws))
|
||||
.route("/icon", get(serve_icon))
|
||||
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||
// falls through to the merged dist. ServeDir auto-appends
|
||||
// `.html` when the URL is a bare path that matches a file
|
||||
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
|
||||
// screen.html`). Per-agent `extraFiles` additions are
|
||||
// already layered into this same directory (see
|
||||
// hyperhive.frontend.mergedDist in nix).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
|
|
@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<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.
|
||||
|
|
@ -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> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
// Note: the URLs are the actual HTML files served out of the
|
||||
// frontend dist (`stats.html` / `screen.html`); after the #273
|
||||
// backend/frontend split the harness serves these as static
|
||||
// files via ServeDir rather than via Rust routes, so the URL
|
||||
// has to be the on-disk filename.
|
||||
links.push(AgentLink {
|
||||
url: "/stats".to_owned(),
|
||||
url: "/stats.html".to_owned(),
|
||||
icon: "📊".to_owned(),
|
||||
label: "stats".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
|
|
@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|||
|
||||
if gui_enabled {
|
||||
links.push(AgentLink {
|
||||
url: "/screen".to_owned(),
|
||||
url: "/screen.html".to_owned(),
|
||||
icon: "🖥".to_owned(),
|
||||
label: "screen".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ axum.workspace = true
|
|||
base64.workspace = true
|
||||
reqwest.workspace = true
|
||||
clap.workspace = true
|
||||
hive-fr0nt.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
libc = "0.2"
|
||||
rusqlite.workspace = true
|
||||
|
|
@ -20,6 +19,7 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
|
@ -14,7 +14,7 @@ use axum::{
|
|||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{
|
||||
Html, IntoResponse, Response,
|
||||
IntoResponse, Response,
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
},
|
||||
routing::{get, post},
|
||||
|
|
@ -23,6 +23,7 @@ use hive_sh4re::Approval;
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::{Stream, StreamExt};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::actions;
|
||||
use crate::container_view::{ContainerView, claude_has_session};
|
||||
|
|
@ -37,11 +38,20 @@ struct AppState {
|
|||
}
|
||||
|
||||
pub async fn serve(port: u16, coord: Arc<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))
|
||||
|
|
@ -66,8 +76,11 @@ 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))
|
||||
.route("/static/hive-fr0nt.js", get(serve_shared_js))
|
||||
.route("/static/marked.js", get(serve_marked_js))
|
||||
// Anything not matched by the dynamic routes above falls
|
||||
// through to the bundled dashboard dist (GET / →
|
||||
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
||||
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(AppState { coord });
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr).await?;
|
||||
|
|
@ -77,11 +90,14 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
|
||||
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
|
||||
// returns the current snapshot as JSON. The JS app fetches state on load,
|
||||
// re-fetches after every async-form submit, and listens on
|
||||
// `/dashboard/stream` for the unified live event channel.
|
||||
// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
|
||||
// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
|
||||
// hive-c0re NixOS module to `${frontend}/dashboard`), served by the
|
||||
// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
|
||||
// surface — `/api/state` and the action endpoints — is owned here.
|
||||
// The JS app fetches state on load, re-fetches after every async-form
|
||||
// submit, and listens on `/dashboard/stream` for the unified live event
|
||||
// channel.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
|
||||
|
|
@ -142,56 +158,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<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
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
[package]
|
||||
name = "hive-fr0nt"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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");
|
||||
60
nix/frontend.nix
Normal file
60
nix/frontend.nix
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
hyperhivePackage,
|
||||
hyperhiveFrontend,
|
||||
hyperhiveFlake,
|
||||
}:
|
||||
{
|
||||
|
|
@ -25,6 +26,19 @@ 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;
|
||||
|
|
@ -114,6 +128,10 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{ pkgs, ... }:
|
||||
{ pkgs, config, ... }:
|
||||
{
|
||||
imports = [ ./harness-base.nix ];
|
||||
|
||||
|
|
@ -13,7 +13,15 @@
|
|||
# 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";
|
||||
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}";
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
|
||||
Restart = "on-failure";
|
||||
|
|
|
|||
|
|
@ -149,6 +149,110 @@
|
|||
'';
|
||||
};
|
||||
|
||||
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";
|
||||
|
|
@ -360,6 +464,23 @@
|
|||
|| 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;
|
||||
|
|
@ -385,6 +506,28 @@
|
|||
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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{ pkgs, ... }:
|
||||
{ pkgs, config, ... }:
|
||||
{
|
||||
imports = [ ./harness-base.nix ];
|
||||
|
||||
|
|
@ -24,6 +24,10 @@
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue