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
|
and meta read access; mirrors each applied repo
|
||||||
into `agent-configs/<n>` (core-only); agents are
|
into `agent-configs/<n>` (core-only); agents are
|
||||||
read-only collaborators on `core/meta`
|
read-only collaborators on `core/meta`
|
||||||
src/dashboard.rs axum HTTP: static shell + /api/state JSON + actions
|
src/dashboard.rs axum HTTP: /api/state JSON + actions
|
||||||
+ journald viewer + bind-with-retry (SO_REUSEADDR)
|
+ journald viewer + bind-with-retry (SO_REUSEADDR)
|
||||||
+ deployed_sha chip per container +
|
+ deployed_sha chip per container +
|
||||||
/dashboard/{stream,history} subscribing to the
|
/dashboard/{stream,history} subscribing to the
|
||||||
unified DashboardEvent channel
|
unified DashboardEvent channel. Static assets
|
||||||
assets/ index.html, dashboard.css, app.js (include_str!)
|
(HTML/CSS/JS/favicon) served by
|
||||||
|
tower_http::ServeDir from $HIVE_STATIC_DIR
|
||||||
|
(= `${frontend}/dashboard` per the c0re module).
|
||||||
|
|
||||||
hive-fr0nt/ shared frontend-assets crate (browser only).
|
frontend/ npm workspaces (esbuild → static dist). Built
|
||||||
src/lib.rs pub const BASE_CSS / TERMINAL_CSS / TERMINAL_JS /
|
hermetically by `nix/frontend.nix`
|
||||||
MARKED_JS re-exports; both binaries
|
(`packages.${system}.frontend`).
|
||||||
`include_str!` them and prepend to their per-
|
packages/shared/ @hive/shared: terminal pane + Catppuccin palette
|
||||||
page serving routes.
|
+ base typography (was hive-fr0nt). ES module
|
||||||
assets/base.css Catppuccin palette + body typography (one source
|
exporting { create, linkify }; pure JS, no IIFE
|
||||||
of truth, no per-page redeclaration).
|
globals; consumed by dashboard + agent.
|
||||||
assets/terminal.css `.terminal-wrap` + `.live` + `.tail-pill` +
|
packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js,
|
||||||
`.row` / `details.row` styling for both
|
dashboard.css} + build.mjs → dist/{index.html,
|
||||||
pages' lit log panes. Unified prefix-column
|
static/{app.js, dashboard.css}}.
|
||||||
(padding-left + negative text-indent) so glyph
|
packages/agent/ @hive/agent default per-container UI: src/
|
||||||
alignment is consistent across row kinds + a
|
{index, stats, screen}.html + {app, stats}.js
|
||||||
`.md` block scope for marked-rendered bodies.
|
+ agent.css → dist/{*.html, static/*}.
|
||||||
assets/terminal.js `window.HiveTerminal.create(opts)`: scroll-
|
|
||||||
sticky log + "↓ N new" pill + history
|
|
||||||
backfill + SSE subscribe-buffer-snapshot-
|
|
||||||
dedupe dance. Pages register a kind→renderer
|
|
||||||
map; the terminal owns the lifecycle.
|
|
||||||
assets/marked.umd.js vendored marked v4.0.2 UMD bundle. Per-agent
|
|
||||||
terminal uses the global `marked.parse` for
|
|
||||||
markdown bodies on send / recv / ask / answer
|
|
||||||
/ assistant text rows.
|
|
||||||
|
|
||||||
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||||
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT
|
||||||
|
|
@ -139,8 +132,10 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||||
src/login_session.rs drives `claude auth login` over stdio pipes
|
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||||
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
|
src/bin/hive-ag3nt.rs sub-agent main (Serve + Mcp subcommands)
|
||||||
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
|
src/bin/hive-m1nd.rs manager main (Serve + Mcp subcommands)
|
||||||
assets/ index.html, agent.css, app.js, stats.html,
|
Static UI assets served by ServeDir from
|
||||||
stats.js, screen.html (include_str!)
|
$HIVE_STATIC_DIR (= hyperhive.frontend
|
||||||
|
.mergedDist — default agent dist + per-agent
|
||||||
|
extraFiles, set per the harness-base module).
|
||||||
prompts/ static role/tools/settings for claude (include_str!):
|
prompts/ static role/tools/settings for claude (include_str!):
|
||||||
agent.md — sub-agent system prompt
|
agent.md — sub-agent system prompt
|
||||||
manager.md — manager system prompt
|
manager.md — manager system prompt
|
||||||
|
|
|
||||||
39
Cargo.lock
generated
39
Cargo.lock
generated
|
|
@ -565,7 +565,6 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"clap",
|
"clap",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hive-fr0nt",
|
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
|
|
@ -575,6 +574,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
@ -587,7 +587,6 @@ dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64",
|
"base64",
|
||||||
"clap",
|
"clap",
|
||||||
"hive-fr0nt",
|
|
||||||
"hive-sh4re",
|
"hive-sh4re",
|
||||||
"libc",
|
"libc",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|
@ -597,14 +596,11 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hive-fr0nt"
|
|
||||||
version = "0.1.0"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hive-sh4re"
|
name = "hive-sh4re"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -645,6 +641,12 @@ dependencies = [
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
|
|
@ -954,6 +956,16 @@ version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
|
|
@ -1723,10 +1735,19 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -1835,6 +1856,12 @@ version = "1.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.24"
|
version = "1.0.24"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = ["hive-ag3nt", "hive-c0re", "hive-fr0nt", "hive-sh4re"]
|
members = ["hive-ag3nt", "hive-c0re", "hive-sh4re"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
@ -19,8 +19,8 @@ anyhow = "1"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
hive-fr0nt = { path = "hive-fr0nt" }
|
|
||||||
hive-sh4re = { path = "hive-sh4re" }
|
hive-sh4re = { path = "hive-sh4re" }
|
||||||
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
rmcp = { version = "1.7", default-features = false, features = [
|
rmcp = { version = "1.7", default-features = false, features = [
|
||||||
"server",
|
"server",
|
||||||
"macros",
|
"macros",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
|
|
||||||
Snapshot of how the per-agent web UI's live pane renders each
|
Snapshot of how the per-agent web UI's live pane renders each
|
||||||
event kind today. Source of truth lives in
|
event kind today. Source of truth lives in
|
||||||
`hive-ag3nt/assets/app.js` (`renderStream`, `fmtToolUse`,
|
`frontend/packages/agent/src/app.js` (`renderStream`, `fmtToolUse`,
|
||||||
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
|
`renderRichToolUse`, `renderToolResult`, `renderTaskEvent`,
|
||||||
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
|
`mdNode`, `detailsOpenMd`, `fmtArgsGeneric`) +
|
||||||
`hive-fr0nt/assets/terminal.css` (the shared `.live .<class>`
|
`frontend/packages/shared/src/terminal.css` (the shared
|
||||||
styling) + `hive-fr0nt/assets/marked.umd.js` (markdown).
|
`.live .<class>` styling) + the `marked` npm package (markdown).
|
||||||
|
|
||||||
## Layout contract
|
## Layout contract
|
||||||
|
|
||||||
|
|
@ -82,8 +82,8 @@ parent's negative pull.
|
||||||
|
|
||||||
## Markdown
|
## Markdown
|
||||||
|
|
||||||
`mdNode(text)` wraps `window.marked.parse(text)` (vendored
|
`mdNode(text)` wraps `marked.parse(text)` (the `marked` v4.x npm
|
||||||
v4.0.2 UMD via `hive-fr0nt::MARKED_JS`) in a `<div
|
dep, bundled by esbuild into the page's `app.js`) in a `<div
|
||||||
class="md">`. CSS in `terminal.css` scopes paragraph / code /
|
class="md">`. CSS in `terminal.css` scopes paragraph / code /
|
||||||
list / blockquote / link styling under `.live .row .md` so
|
list / blockquote / link styling under `.live .row .md` so
|
||||||
the markdown body doesn't bleed into the row's own
|
the markdown body doesn't bleed into the row's own
|
||||||
|
|
|
||||||
|
|
@ -7,22 +7,32 @@ and the per-agent UIs (manager on :8000, sub-agents on a hashed
|
||||||
|
|
||||||
## Shape (shared by both)
|
## Shape (shared by both)
|
||||||
|
|
||||||
- `GET /` → `assets/index.html` (placeholders for state-driven
|
- `GET /` → `index.html` from the bundled frontend dist (see
|
||||||
sections, shipped via `include_str!` so the binary has no runtime
|
`frontend/`). Both binaries' routers declare their dynamic
|
||||||
file dependency).
|
endpoints first and then `fallback_service(ServeDir::new(...))`
|
||||||
- `GET /static/*.css` + `GET /static/*.js` → static assets. Both
|
pointed at `HIVE_STATIC_DIR` — anything not matched by an API or
|
||||||
pages prepend `hive_fr0nt::BASE_CSS` + `TERMINAL_CSS` to their
|
action route is served from the dist. Dashboard dist lives at
|
||||||
per-page stylesheet, and `GET /static/hive-fr0nt.js` serves the
|
`${frontend}/dashboard`; per-agent dist is the merged
|
||||||
shared `window.HiveTerminal.create` runtime. The dashboard's
|
`hyperhive.frontend.mergedDist` (default agent dist + per-agent
|
||||||
|
`extraFiles` overlay).
|
||||||
|
- `GET /static/*` → bundled CSS + JS produced by esbuild
|
||||||
|
(`frontend/packages/{dashboard,agent}/build.mjs`). Both pages
|
||||||
|
pull the shared terminal pane + Catppuccin palette + typography
|
||||||
|
from `@hive/shared` (was `hive-fr0nt`); the CSS bundle inlines
|
||||||
|
`base.css` + `terminal.css` via esbuild's `@import` resolution.
|
||||||
|
`terminal.js` exports `{ create, linkify }` as ES module
|
||||||
|
members (no more `window.HiveTerminal` global outside the
|
||||||
|
back-compat shim the IIFE bodies still use). The dashboard's
|
||||||
`#msgflow` and the per-agent `#live` log are both backed by
|
`#msgflow` and the per-agent `#live` log are both backed by
|
||||||
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
this terminal — sticky-bottom auto-scroll, "↓ N new" pill,
|
||||||
history backfill, SSE plumbing all live there. Each page
|
history backfill, SSE plumbing all live there. Each page
|
||||||
registers a kind→renderer map; unknown kinds fall through to
|
registers a kind→renderer map; unknown kinds fall through to
|
||||||
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
a JSON-dump note row. Bare `http(s)://` URLs in row text are
|
||||||
turned into clickable new-tab links by `HiveTerminal.linkify`
|
turned into clickable new-tab links by `linkify` (text-node
|
||||||
(text-node based, no `innerHTML` — XSS-safe); markdown bodies
|
based, no `innerHTML` — XSS-safe); markdown bodies get the
|
||||||
get the same treatment via `marked`'s autolink, with the
|
same treatment via `marked`'s autolink (npm dep, replacing the
|
||||||
rendered `<a>`s rewritten to `target="_blank"` (issue #233).
|
vendored UMD bundle), with the rendered `<a>`s rewritten to
|
||||||
|
`target="_blank"` (issue #233).
|
||||||
- `GET /api/state` → JSON snapshot the JS app renders into the
|
- `GET /api/state` → JSON snapshot the JS app renders into the
|
||||||
DOM. Includes a top-level `seq` (the dashboard event channel's
|
DOM. Includes a top-level `seq` (the dashboard event channel's
|
||||||
high-water mark at the moment the snapshot was assembled);
|
high-water mark at the moment the snapshot was assembled);
|
||||||
|
|
|
||||||
16
flake.nix
16
flake.nix
|
|
@ -51,18 +51,31 @@
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
packages = forAllSystems (
|
packages = forAllSystems (
|
||||||
{ naersk-lib, ... }:
|
{ pkgs, naersk-lib, ... }:
|
||||||
{
|
{
|
||||||
default = naersk-lib.buildPackage {
|
default = naersk-lib.buildPackage {
|
||||||
src = ./.;
|
src = ./.;
|
||||||
meta.description = "hyperhive workspace (hive-c0re, hive-ag3nt, hive-m1nd)";
|
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 = {
|
overlays = {
|
||||||
default = final: prev: {
|
default = final: prev: {
|
||||||
hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default;
|
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 =
|
claude-unstable =
|
||||||
final: prev:
|
final: prev:
|
||||||
|
|
@ -96,6 +109,7 @@
|
||||||
# builds (already applied internally in `nixosConfigurations`).
|
# builds (already applied internally in `nixosConfigurations`).
|
||||||
hive-c0re = import ./nix/modules/hive-c0re.nix {
|
hive-c0re = import ./nix/modules/hive-c0re.nix {
|
||||||
hyperhivePackage = system: self.packages.${system}.default;
|
hyperhivePackage = system: self.packages.${system}.default;
|
||||||
|
hyperhiveFrontend = system: self.packages.${system}.frontend;
|
||||||
hyperhiveFlake = "${self}";
|
hyperhiveFlake = "${self}";
|
||||||
};
|
};
|
||||||
hive-forge = ./nix/modules/hive-forge.nix;
|
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
|
/* Shared Catppuccin palette + body typography + terminal pane styles.
|
||||||
to this stylesheet by `serve_css` at runtime. */
|
Bundled in front of the agent-only rules below via esbuild. */
|
||||||
|
@import "@hive/shared/base.css";
|
||||||
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
max-width: 110em;
|
max-width: 110em;
|
||||||
margin: 1.5em auto;
|
margin: 1.5em auto;
|
||||||
|
|
@ -2,6 +2,18 @@
|
||||||
// tails `/events/stream` for live claude events, drives async-form
|
// tails `/events/stream` for live claude events, drives async-form
|
||||||
// actions (send / login/* / dashboard rebuild).
|
// 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 ────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
@ -45,8 +45,8 @@
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/marked.js" defer></script>
|
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||||
<script src="/static/hive-fr0nt.js" defer></script>
|
the marked npm package into app.js. -->
|
||||||
<script src="/static/app.js" defer></script>
|
<script type="module" src="/static/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -90,11 +90,9 @@
|
||||||
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart.js pinned to a fixed version from jsDelivr. SRI hash is
|
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
|
||||||
not set yet — add an integrity="sha384-..." attribute when we
|
chart.js@4.4.4), so the page works offline / on operator
|
||||||
have a way to compute it deterministically in the build. -->
|
machines without internet egress. No SRI hash to maintain. -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
|
<script type="module" src="/static/stats.js" defer></script>
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="/static/stats.js" defer></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -2,6 +2,14 @@
|
||||||
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
||||||
// when the operator clicks a window tab.
|
// 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 () {
|
(function () {
|
||||||
'use strict';
|
'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),
|
// up async-form submission (URL-encoded POST + spinner + state refresh),
|
||||||
// and tails the unified dashboard event channel over `/dashboard/stream`.
|
// 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 ──────────────────────────────────────────────────────────
|
// ─── constants ──────────────────────────────────────────────────────────
|
||||||
// Context-window badge thresholds. Preferred source is each container's
|
// 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
|
/* Shared Catppuccin palette + body typography + terminal pane styles.
|
||||||
to this stylesheet by `serve_css` at runtime. */
|
Bundled in front of the dashboard-only rules below via esbuild. */
|
||||||
|
@import "@hive/shared/base.css";
|
||||||
|
@import "@hive/shared/terminal.css";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
max-width: 70em;
|
max-width: 70em;
|
||||||
margin: 1.5em auto;
|
margin: 1.5em auto;
|
||||||
|
|
@ -109,8 +109,9 @@
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/hive-fr0nt.js" defer></script>
|
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||||
<script src="/static/marked.js" defer></script>
|
the marked npm package into app.js; load order is preserved by
|
||||||
<script src="/static/app.js" defer></script>
|
the module bundler. -->
|
||||||
|
<script type="module" src="/static/app.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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
|
reqwest.workspace = true
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hive-fr0nt.workspace = true
|
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
rmcp.workspace = true
|
rmcp.workspace = true
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
|
|
@ -21,6 +20,7 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-stream.workspace = true
|
tokio-stream.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ use axum::{
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::client;
|
use crate::client;
|
||||||
use crate::events::Bus;
|
use crate::events::Bus;
|
||||||
|
|
@ -88,6 +89,19 @@ pub async fn serve(
|
||||||
turn_lock: TurnLock,
|
turn_lock: TurnLock,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let gui_vnc_port = read_gui_json();
|
let gui_vnc_port = read_gui_json();
|
||||||
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.context(
|
||||||
|
"HIVE_STATIC_DIR env var not set — point it at the merged \
|
||||||
|
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
|
||||||
|
)?;
|
||||||
|
if !static_dir.is_dir() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||||
|
static_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
label,
|
label,
|
||||||
login,
|
login,
|
||||||
|
|
@ -99,11 +113,6 @@ pub async fn serve(
|
||||||
gui_vnc_port,
|
gui_vnc_port,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(serve_index))
|
|
||||||
.route("/static/agent.css", get(serve_css))
|
|
||||||
.route("/static/app.js", get(serve_app_js))
|
|
||||||
.route("/static/hive-fr0nt.js", get(serve_shared_js))
|
|
||||||
.route("/static/marked.js", get(serve_marked_js))
|
|
||||||
.route("/api/state", get(api_state))
|
.route("/api/state", get(api_state))
|
||||||
.route("/events/stream", get(events_stream))
|
.route("/events/stream", get(events_stream))
|
||||||
.route("/events/history", get(events_history))
|
.route("/events/history", get(events_history))
|
||||||
|
|
@ -116,12 +125,17 @@ pub async fn serve(
|
||||||
.route("/api/model", post(post_set_model))
|
.route("/api/model", post(post_set_model))
|
||||||
.route("/api/new-session", post(post_new_session))
|
.route("/api/new-session", post(post_new_session))
|
||||||
.route("/api/loose-ends", get(api_loose_ends))
|
.route("/api/loose-ends", get(api_loose_ends))
|
||||||
.route("/stats", get(serve_stats))
|
|
||||||
.route("/static/stats.js", get(serve_stats_js))
|
|
||||||
.route("/api/stats", get(api_stats))
|
.route("/api/stats", get(api_stats))
|
||||||
.route("/screen", get(serve_screen))
|
|
||||||
.route("/screen/ws", get(screen_ws))
|
.route("/screen/ws", get(screen_ws))
|
||||||
.route("/icon", get(serve_icon))
|
.route("/icon", get(serve_icon))
|
||||||
|
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||||
|
// falls through to the merged dist. ServeDir auto-appends
|
||||||
|
// `.html` when the URL is a bare path that matches a file
|
||||||
|
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
|
||||||
|
// screen.html`). Per-agent `extraFiles` additions are
|
||||||
|
// already layered into this same directory (see
|
||||||
|
// hyperhive.frontend.mergedDist in nix).
|
||||||
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = bind_with_retry(addr, "web UI").await?;
|
let listener = bind_with_retry(addr, "web UI").await?;
|
||||||
|
|
@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||||
sock.listen(1024)
|
sock.listen(1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_index() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "text/html; charset=utf-8")],
|
|
||||||
include_str!("../assets/index.html"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_css() -> impl IntoResponse {
|
|
||||||
// Prepend the shared palette/typography so per-page styles only need
|
|
||||||
// to declare what's actually page-specific. One HTTP request, no
|
|
||||||
// per-asset cache to invalidate.
|
|
||||||
let body = format!(
|
|
||||||
"{}\n{}\n{}",
|
|
||||||
hive_fr0nt::BASE_CSS,
|
|
||||||
hive_fr0nt::TERMINAL_CSS,
|
|
||||||
include_str!("../assets/agent.css"),
|
|
||||||
);
|
|
||||||
([("content-type", "text/css")], body)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_app_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
include_str!("../assets/app.js"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_shared_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
hive_fr0nt::TERMINAL_JS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_marked_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
hive_fr0nt::MARKED_JS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_stats() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "text/html; charset=utf-8")],
|
|
||||||
include_str!("../assets/stats.html"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_stats_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
include_str!("../assets/stats.js"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_screen() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "text/html; charset=utf-8")],
|
|
||||||
include_str!("../assets/screen.html"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This agent's icon. Serves the operator-configured SVG from
|
/// This agent's icon. Serves the operator-configured SVG from
|
||||||
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
|
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
|
||||||
/// option) when present, otherwise the bundled default hyperhive logo.
|
/// option) when present, otherwise the bundled default hyperhive logo.
|
||||||
|
|
@ -585,8 +537,13 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
|
|
||||||
|
// Note: the URLs are the actual HTML files served out of the
|
||||||
|
// frontend dist (`stats.html` / `screen.html`); after the #273
|
||||||
|
// backend/frontend split the harness serves these as static
|
||||||
|
// files via ServeDir rather than via Rust routes, so the URL
|
||||||
|
// has to be the on-disk filename.
|
||||||
links.push(AgentLink {
|
links.push(AgentLink {
|
||||||
url: "/stats".to_owned(),
|
url: "/stats.html".to_owned(),
|
||||||
icon: "📊".to_owned(),
|
icon: "📊".to_owned(),
|
||||||
label: "stats".to_owned(),
|
label: "stats".to_owned(),
|
||||||
kind: AgentLinkKind::Container,
|
kind: AgentLinkKind::Container,
|
||||||
|
|
@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||||
|
|
||||||
if gui_enabled {
|
if gui_enabled {
|
||||||
links.push(AgentLink {
|
links.push(AgentLink {
|
||||||
url: "/screen".to_owned(),
|
url: "/screen.html".to_owned(),
|
||||||
icon: "🖥".to_owned(),
|
icon: "🖥".to_owned(),
|
||||||
label: "screen".to_owned(),
|
label: "screen".to_owned(),
|
||||||
kind: AgentLinkKind::Container,
|
kind: AgentLinkKind::Container,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ axum.workspace = true
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hive-fr0nt.workspace = true
|
|
||||||
hive-sh4re.workspace = true
|
hive-sh4re.workspace = true
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
rusqlite.workspace = true
|
rusqlite.workspace = true
|
||||||
|
|
@ -20,6 +19,7 @@ serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tokio-stream.workspace = true
|
tokio-stream.workspace = true
|
||||||
|
tower-http.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
|
@ -14,7 +14,7 @@ use axum::{
|
||||||
extract::{Path as AxumPath, State},
|
extract::{Path as AxumPath, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::{
|
response::{
|
||||||
Html, IntoResponse, Response,
|
IntoResponse, Response,
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
},
|
},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
|
|
@ -23,6 +23,7 @@ use hive_sh4re::Approval;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
use tokio_stream::{Stream, StreamExt};
|
use tokio_stream::{Stream, StreamExt};
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
use crate::actions;
|
use crate::actions;
|
||||||
use crate::container_view::{ContainerView, claude_has_session};
|
use crate::container_view::{ContainerView, claude_has_session};
|
||||||
|
|
@ -37,11 +38,20 @@ struct AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.context(
|
||||||
|
"HIVE_STATIC_DIR env var not set — point it at the bundled \
|
||||||
|
dashboard dist (see services.hive-c0re.frontend in nix)",
|
||||||
|
)?;
|
||||||
|
if !static_dir.is_dir() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||||
|
static_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved");
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(serve_index))
|
|
||||||
.route("/static/dashboard.css", get(serve_css))
|
|
||||||
.route("/static/app.js", get(serve_app_js))
|
|
||||||
.route("/favicon.svg", get(serve_favicon))
|
|
||||||
.route("/api/state", get(api_state))
|
.route("/api/state", get(api_state))
|
||||||
.route("/approve/{id}", post(post_approve))
|
.route("/approve/{id}", post(post_approve))
|
||||||
.route("/deny/{id}", post(post_deny))
|
.route("/deny/{id}", post(post_deny))
|
||||||
|
|
@ -66,8 +76,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/meta-update", post(post_meta_update))
|
.route("/meta-update", post(post_meta_update))
|
||||||
.route("/dashboard/stream", get(dashboard_stream))
|
.route("/dashboard/stream", get(dashboard_stream))
|
||||||
.route("/dashboard/history", get(dashboard_history))
|
.route("/dashboard/history", get(dashboard_history))
|
||||||
.route("/static/hive-fr0nt.js", get(serve_shared_js))
|
// Anything not matched by the dynamic routes above falls
|
||||||
.route("/static/marked.js", get(serve_marked_js))
|
// through to the bundled dashboard dist (GET / →
|
||||||
|
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
||||||
|
// /static/dashboard.css → dist/static/dashboard.css, etc.).
|
||||||
|
.fallback_service(ServeDir::new(&static_dir))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = bind_with_retry(addr).await?;
|
let listener = bind_with_retry(addr).await?;
|
||||||
|
|
@ -77,11 +90,14 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
|
// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
|
||||||
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
|
// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
|
||||||
// returns the current snapshot as JSON. The JS app fetches state on load,
|
// hive-c0re NixOS module to `${frontend}/dashboard`), served by the
|
||||||
// re-fetches after every async-form submit, and listens on
|
// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
|
||||||
// `/dashboard/stream` for the unified live event channel.
|
// surface — `/api/state` and the action endpoints — is owned here.
|
||||||
|
// The JS app fetches state on load, re-fetches after every async-form
|
||||||
|
// submit, and listens on `/dashboard/stream` for the unified live event
|
||||||
|
// channel.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
|
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
|
||||||
|
|
@ -142,56 +158,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
||||||
sock.listen(1024)
|
sock.listen(1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_index() -> impl IntoResponse {
|
|
||||||
Html(include_str!("../assets/index.html"))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_css() -> impl IntoResponse {
|
|
||||||
// Prepend the shared palette/typography so per-page styles only need
|
|
||||||
// to declare what's actually page-specific. One HTTP request, no
|
|
||||||
// per-asset cache to invalidate.
|
|
||||||
let body = format!(
|
|
||||||
"{}\n{}\n{}",
|
|
||||||
hive_fr0nt::BASE_CSS,
|
|
||||||
hive_fr0nt::TERMINAL_CSS,
|
|
||||||
include_str!("../assets/dashboard.css"),
|
|
||||||
);
|
|
||||||
([("content-type", "text/css")], body)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_app_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
include_str!("../assets/app.js"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dashboard favicon — the hyperhive mark. Static: the dashboard
|
|
||||||
/// represents the whole hive, so it always uses the project logo
|
|
||||||
/// (per-agent pages serve their own configurable `/icon` instead).
|
|
||||||
async fn serve_favicon() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "image/svg+xml")],
|
|
||||||
include_str!("../../branding/hyperhive.svg"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn serve_shared_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
hive_fr0nt::TERMINAL_JS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vendored `marked` bundle — the side panel renders markdown file
|
|
||||||
/// previews with it.
|
|
||||||
async fn serve_marked_js() -> impl IntoResponse {
|
|
||||||
(
|
|
||||||
[("content-type", "application/javascript")],
|
|
||||||
hive_fr0nt::MARKED_JS,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct StateSnapshot {
|
struct StateSnapshot {
|
||||||
/// Broker seq at the moment this snapshot was assembled. Clients
|
/// Broker seq at the moment this snapshot was assembled. Clients
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "hive-fr0nt"
|
|
||||||
edition.workspace = true
|
|
||||||
version.workspace = true
|
|
||||||
|
|
||||||
[lints]
|
|
||||||
workspace = true
|
|
||||||
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,
|
hyperhivePackage,
|
||||||
|
hyperhiveFrontend,
|
||||||
hyperhiveFlake,
|
hyperhiveFlake,
|
||||||
}:
|
}:
|
||||||
{
|
{
|
||||||
|
|
@ -25,6 +26,19 @@ in
|
||||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
|
defaultText = lib.literalExpression "hyperhive.packages.\${system}.default";
|
||||||
description = "Package that provides /bin/hive-c0re.";
|
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 {
|
hyperhiveFlake = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = hyperhiveFlake;
|
default = hyperhiveFlake;
|
||||||
|
|
@ -114,6 +128,10 @@ in
|
||||||
];
|
];
|
||||||
environment = {
|
environment = {
|
||||||
HYPERHIVE_GIT = "${pkgs.git}/bin/git";
|
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 {
|
} // lib.optionalAttrs config.hyperhive.forge.enable {
|
||||||
# Agents poll this URL for Forgejo notifications. Derived from
|
# Agents poll this URL for Forgejo notifications. Derived from
|
||||||
# hyperhive.forge.{domain,httpPort} so it tracks forge config changes.
|
# hyperhive.forge.{domain,httpPort} so it tracks forge config changes.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{ pkgs, ... }:
|
{ pkgs, config, ... }:
|
||||||
{
|
{
|
||||||
imports = [ ./harness-base.nix ];
|
imports = [ ./harness-base.nix ];
|
||||||
|
|
||||||
|
|
@ -13,7 +13,15 @@
|
||||||
# anything an agent adds to its own `agent.nix` — without having to
|
# anything an agent adds to its own `agent.nix` — without having to
|
||||||
# touch the service definition.
|
# touch the service definition.
|
||||||
path = [ "/run/current-system/sw" ];
|
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 = {
|
serviceConfig = {
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
|
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
|
||||||
Restart = "on-failure";
|
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 {
|
options.hyperhive.forge.url = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "http://localhost:3000";
|
default = "http://localhost:3000";
|
||||||
|
|
@ -360,6 +464,23 @@
|
||||||
|| lib.hasSuffix ".svg" (toString config.hyperhive.icon);
|
|| lib.hasSuffix ".svg" (toString config.hyperhive.icon);
|
||||||
message = "hyperhive.icon must point to an .svg file";
|
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;
|
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 =
|
environment.etc."hyperhive/claude-plugins-auto-update.json".text =
|
||||||
builtins.toJSON config.hyperhive.claudePluginsAutoUpdate;
|
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
|
# 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
|
# model choice exists in the state dir. SHELL must be set so claude's
|
||||||
# Bash tool finds a POSIX shell.
|
# Bash tool finds a POSIX shell.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
{ pkgs, ... }:
|
{ pkgs, config, ... }:
|
||||||
{
|
{
|
||||||
imports = [ ./harness-base.nix ];
|
imports = [ ./harness-base.nix ];
|
||||||
|
|
||||||
|
|
@ -24,6 +24,10 @@
|
||||||
HIVE_PORT = "8000";
|
HIVE_PORT = "8000";
|
||||||
HIVE_LABEL = "hm1nd";
|
HIVE_LABEL = "hm1nd";
|
||||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
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
|
# See note in agent-base.nix — `/run/current-system/sw` makes the
|
||||||
# harness service PATH track `environment.systemPackages` so anything
|
# harness service PATH track `environment.systemPackages` so anything
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue