diff --git a/CLAUDE.md b/CLAUDE.md index 2f3c1c60..c94311c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1,86 @@ -# hyperhive — developer reference +# hyperhive -Operator + dev notes: conventions, gotchas, per-subsystem design. +Multi-Claude-Code-agent orchestration on **nixos-containers**. A host-side Rust +daemon (`hive-c0re`) spawns nspawn-isolated agent containers and brokers +messages between them. A manager agent (`hm1nd`) coordinates the swarm and +gates lifecycle changes on user approval via git commits, surfaced through a +vibec0re-styled HTTP dashboard with live SSE message-flow. -- High-level project intro: **[README.md](README.md)**. -- Open work + backlog: **[TODO.md](TODO.md)**. +**PLAN.md** is the living design doc. Read it for the *why* and the phase +roadmap; this file is the operator/developer reference for the *how*. -## File map +## Architecture + +``` +host (NixOS, hive-c0re.service) +│ +├── hive-c0re (Rust daemon — coordinator + dashboard + CLI) +│ ├── lifecycle — nixos-container CRUD (spawn/kill/rebuild/list) +│ ├── broker — sqlite message store + broadcast channel +│ ├── approvals — sqlite approval queue +│ ├── coordinator — shared state (broker/approvals/agent sockets) +│ ├── actions — approve/deny (shared between admin socket & dashboard) +│ ├── server — host admin socket (JSON line protocol) +│ ├── manager_server — manager-only privileged socket +│ ├── agent_server — per-sub-agent sockets +│ ├── dashboard — axum HTTP UI + SSE message-flow + approve/deny + T4LK +│ └── client — admin-socket client (powers `hive-c0re spawn|kill|…`) +│ +├── /run/hyperhive/ +│ ├── host.sock — admin CLI ↔ daemon +│ ├── manager.sock → hm1nd container at /run/hive/mcp.sock +│ └── agents//mcp.sock → h- container at /run/hive/mcp.sock +│ +├── /var/lib/hyperhive/ +│ ├── broker.sqlite — messages + approvals tables +│ ├── agents//config/ — proposed repo (manager-editable, RO to hive-c0re) +│ └── applied// — applied repo (hive-c0re-only, container builds here) +│ +└── nixos-containers + ├── h- (sub-agents, hive-ag3nt binary) + └── hm1nd (manager, hive-m1nd binary) +``` + +## Crates / file map ``` hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched) src/main.rs clap setup; serve / spawn / kill / rebuild / list / - pending / approve / deny / destroy / request-spawn + pending / approve / deny src/server.rs host admin socket (HostRequest → dispatch) src/client.rs admin-socket client src/manager_server.rs manager-privileged socket (ManagerRequest) - src/agent_server.rs per-sub-agent socket listener (long-poll Recv) + src/agent_server.rs per-sub-agent socket listener src/broker.rs sqlite Message store + broadcast channel for SSE - src/approvals.rs sqlite Approval queue + kinds - src/coordinator.rs shared state (broker/approvals/transient/sockets) - src/actions.rs approve/deny/destroy - src/auto_update.rs startup rebuild scan + ensure_manager - src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator - src/dashboard.rs axum HTTP UI: containers, approvals, async-form actions - assets/ CSS + JS shipped via include_str! + src/approvals.rs sqlite Approval queue + src/coordinator.rs shared state (broker/approvals/agent_flake/sockets) + src/actions.rs approve/deny (admin socket + dashboard both call in) + src/lifecycle.rs `nixos-container` shellouts, per-agent flake generator, + systemd drop-ins, git helpers, agent_web_port hash + src/dashboard.rs axum HTTP UI: containers list, T4LK form, approvals + (diff + Approve/Deny buttons), SSE message flow hive-ag3nt/ in-container harness crate; produces TWO binaries - src/lib.rs re-exports + DEFAULT_SOCKET, DEFAULT_WEB_PORT + src/lib.rs DEFAULT_SOCKET, DEFAULT_WEB_PORT, re-exports src/client.rs generic JSON-line request/response over unix socket - src/web_ui.rs per-container axum HTTP page - src/events.rs LiveEvent + broadcast Bus for the SSE stream - src/turn.rs claude --print + stream-json pump; --compact retry - src/mcp.rs embedded MCP server (rmcp): AgentServer + ManagerServer - src/login.rs probe /root/.claude/ for a valid session - src/login_session.rs drives `claude auth login` over stdio pipes - src/bin/hive-ag3nt.rs sub-agent main - src/bin/hive-m1nd.rs manager main - assets/ CSS + JS for the per-agent UI + src/web_ui.rs per-container axum HTTP page (label + placeholder) + src/bin/hive-ag3nt.rs sub-agent CLI (serve/send/recv); turn loop + web UI + src/bin/hive-m1nd.rs manager CLI (serve/send/recv/spawn/kill/ + request-apply-commit); recognises HelperEvent hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, ManagerRequest/Response, Message, Approval, HelperEvent) nix/ - modules/hive-c0re.nix systemd service + firewall + git wiring - templates/harness-base.nix shared scaffolding for sub-agents + manager - templates/agent-base.nix sub-agent nixosConfiguration - templates/manager.nix manager nixosConfiguration + modules/hive-c0re.nix systemd service + firewall + git path wiring + templates/agent-base.nix sub-agent nixos-container template + templates/manager.nix manager nixos-container template + +tests/roundtrip.sh Phase 3 messaging round-trip +tests/approval.sh Phase 5 end-to-end approval flow +tests/dashboard.sh Phase 6+7 HTTP dashboard + SSE + orphan GC + +docs/damocles-migration.md options for moving damocles onto hyperhive ``` ## Conventions @@ -68,14 +104,9 @@ nix/ `applied//flake.nix`, writes the systemd limits drop-in, then `nixos-container update` + stop + start. Anything that changes per-container state on the host should be re-applied here. -- **Actions are factored.** `approve` / `deny` / `destroy` live in - `actions.rs`; the admin socket and the dashboard POST handlers both call - into them so the two surfaces never drift. -- **Async forms.** Dashboard mutating forms carry `data-async`; the - `assets/async_forms.js` helper intercepts submit, shows a spinner, and - fetches with `application/x-www-form-urlencoded` (axum `Form` extractor - rejects multipart). New mutating forms should add `data-async` and - optionally `data-confirm`. +- **Actions are factored.** `approve` / `deny` live in `actions.rs`; the admin + socket and the dashboard POST handlers both call into them, so the two + surfaces never drift. ## Gotchas / lessons learned @@ -93,28 +124,33 @@ nix/ UIs (the bind is invisible from the host). We force-clear those vars (and `HOST_ADDRESS6` / `LOCAL_ADDRESS6` / `HOST_BRIDGE`) plus set `PRIVATE_NETWORK=0`. -- **systemd service PATH ≠ host PATH.** The hive-c0re service sets - `path = [ pkgs.git "/run/current-system/sw" ]`. In-container harness - services do the same so anything an agent adds to its own `agent.nix` - (`environment.systemPackages`) is visible to claude's Bash tool without - editing the service definition. `environment.HYPERHIVE_GIT` bakes git's - absolute path in (read by `lifecycle::git_command()`) for the host. +- **systemd service PATH ≠ host PATH.** Our service explicitly sets + `path = [ pkgs.git "/run/current-system/sw" ]`. Additionally, + `environment.HYPERHIVE_GIT = "${pkgs.git}/bin/git"` bakes the absolute path + in (read by `lifecycle::git_command()`) so git resolution doesn't depend on + PATH plumbing at all. - **`RuntimeDirectoryPreserve = "yes"`** keeps `/run/hyperhive/` (and the per-agent sub-dirs) across `hive-c0re` restarts. Without it, every restart wipes bind sources and existing containers can't be started. - **`register_agent` is idempotent** — drops any prior socket task before rebinding. Required so a `hive-c0re` restart followed by `rebuild alice` recreates the agent's socket without needing a clean reinstall. -- **`claude-code` is unfree.** `harness-base.nix` allow-list's it - specifically. The flake pins it to **nixpkgs-unstable** via - `overlays.claude-unstable` (stable lags too far). The overlay imports - unstable with its own `allowUnfreePredicate` so the access inside the - overlay doesn't itself trip. -- **Claude credentials are per-agent.** `/var/lib/hyperhive/agents//claude/` - bind-mounts to `/root/.claude` (RW). Sharing one dir across agents is NOT - viable — OAuth refresh tokens rotate, so any sibling refresh invalidates - all the others. Login flow runs from the per-agent web UI; creds persist - across `destroy`/recreate. +- **`claude-code` is unfree.** `agent-base.nix` allow-list's it specifically. + The flake pins it to **nixpkgs-unstable** via `overlays.claude-unstable` + (stable lags too far). The overlay imports unstable with its own + `allowUnfreePredicate` so the access inside the overlay doesn't itself trip. +- **Claude credentials are stateful and per-container.** No `ANTHROPIC_API_KEY` + env var path. Today's stopgap: `nixos-container root-login h-` → + `claude` (interactive) → log in once. The harness falls back to echo + replies when `claude --print` fails. **Phase 8** moves this to a per-agent + persistent dir at `/var/lib/hyperhive/agents//claude/` bind-mounted + into the container, with the interactive login driven from the agent's web + UI. Sharing one `~/.claude` across agents is NOT viable — OAuth refresh + tokens rotate, so any sibling refresh invalidates all the others. +- **Echo guard.** `hive-ag3nt serve` skips auto-reply when the incoming body + starts with `"echo: "`. Prevents ping-pong loops when both sides fall back + to echo. Real conversations between claude-backed agents *will* runaway — + bounding them is the manager's job. - **Orphan approvals.** If state dirs are wiped out from under a pending approval (test scripts, manual `rm -rf`), the dashboard's next render marks them `failed` with note `"agent state dir missing"` so they fall out @@ -123,130 +159,339 @@ nix/ ## Agent MCP surface + turn loop The harness ships an embedded MCP server (rmcp 1.7) that claude launches as -a stdio child via `--mcp-config`. Subcommand: `hive-ag3nt mcp` (or -`hive-m1nd mcp` for the manager surface). - -Sub-agent tools: +a stdio child via `--mcp-config`. Subcommand: `hive-ag3nt mcp`. Tools: - `mcp__hyperhive__send(to, body)` — message a peer or the operator. - `mcp__hyperhive__recv()` — drain one inbox message. -Manager additionally: -- `mcp__hyperhive__request_spawn(name)` — queue Spawn approval. -- `mcp__hyperhive__kill(name)` — graceful stop. -- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a - config change for any agent (including `hm1nd` for self-mods). +Both translate to `AgentRequest::Send`/`Recv` against the agent's own +`/run/hive/mcp.sock` (the existing hyperhive socket). The MCP surface is +just claude's view of that socket — same authority, friendlier protocol. -The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, -run_turn, drive_turn, emit_turn_end, wait_for_login}` so the two binaries -can't drift. - -Each turn: +The turn loop in `hive-ag3nt serve` writes +`/run/hive/claude-mcp-config.json` at boot pointing at +`/proc/self/exe mcp` (the running hive-ag3nt binary's nix store path). +Each turn invokes: ``` -claude --print --verbose --output-format stream-json --model haiku \ - --continue --settings '{"autoCompactEnabled":false,"autoMemoryEnabled":false}' \ - --mcp-config --strict-mcp-config \ - --tools --allowedTools -# prompt piped over stdin +claude --print --model haiku --mcp-config --tools --allowedTools ``` -`--continue` keeps a persistent session per agent (claude stores sessions in -`~/.claude/projects/`, which is bind-mounted persistently). Auto-compact and -auto-memory are disabled because hyperhive owns compaction (`/compact` on -overflow, retry once). +**Loop control.** The harness pops one inbox message (the wake signal) per +cycle and hands claude a prompt naming the agent, the sender, the body, +and the MCP tools. Claude drives any further `recv`/`send` itself — +harness no longer relays claude's stdout as a reply. Stdout is logged for +debugging; the side effects (sends via MCP) are what matter. -**Loop control.** The harness pops one inbox message per cycle (the wake -signal — Recv long-polls server-side for up to 30s waking instantly on a new -broker `Sent` event for this agent) and hands claude a prompt naming the -agent, the sender, the body, and the MCP tools. Claude drives any further -`recv`/`send` itself. +**Operator input** moved from the hive-c0re dashboard's T4LK form to each +per-agent page. The per-agent `/send` POST hits the new +`AgentRequest::OperatorMsg` / `ManagerRequest::OperatorMsg` wire verb, +which enqueues `Message { from: "operator", to: , body }` directly +into the broker. No more global recipient dropdown — one input per agent +page, scoped to that agent. -**Tool envelope** (`mcp::run_tool_envelope`): every MCP tool handler logs -the request, runs the body, appends a status line (e.g. -`[status] 3 unread message(s) in inbox` from a non-mutating `Status` peek), -logs the result. New tools call this helper. +**Live view.** Each agent runs a `hive_ag3nt::events::Bus` (a +`tokio::sync::broadcast` wrapper). The harness emits: +- `TurnStart { from, body }` when a wake-up message is popped. +- `Stream(value)` for every line claude prints on stdout (parsed + stream-json; flattened under `{kind: "stream", type: ...}` via serde + internal tagging). +- `Note(text)` for stderr lines and non-JSON stdout (so nothing's lost). +- `TurnEnd { ok, note }` when claude exits. -**Tool whitelist** (`mcp::ALLOWED_BUILTIN_TOOLS`): +The web UI subscribes via `/events/stream` (SSE) and a small JS panel on +`/` appends rows. No full-page reload — the login form (and anything else +the operator is typing into) stays put. + +claude is invoked with `--print --verbose --output-format stream-json` so +tool calls + assistant text + tool results all land as structured events. +The harness no longer reads claude's text stdout into a reply; claude +calls `mcp__hyperhive__send` itself. + +**Tool envelope.** Every MCP tool handler in `hive_ag3nt::mcp::AgentServer` +wraps its logic in `run_tool(name, args_debug, async { ... })`. The +envelope guarantees: +1. Pre-log of the request (tool + args). +2. The tool's own logic runs. +3. A status line is appended to the result body + (`[status] N unread message(s) in inbox`) so claude always sees the + current inbox depth without an extra tool call. +4. Post-log of the full result. + +`AgentRequest::Status` is the non-mutating peek that powers the status +line (broker's `count_pending`). When adding new tools (manager surface, +notes/state, etc.), use `run_tool` and they pick up the envelope for free. + +**Tool whitelist** (see `ALLOWED_BUILTIN_TOOLS` in `hive-ag3nt::mcp`): - Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `TodoWrite`, `Write`. -- Denied by omission: `WebFetch`, `WebSearch`, `Task`, `NotebookEdit`. -- Allowed MCP tools: as listed above per flavor. +- Denied by omission: `WebFetch`, `WebSearch`, `Task`, `NotebookEdit` — + no external egress, nested-agent spawning, or Jupyter handling until we + have a real policy story. +- Allowed MCP tools: `mcp__hyperhive__send`, `mcp__hyperhive__recv`. -`Bash` is on the allow-list pending a finer-grained pattern allow-list -(`Bash(git *)`-style) — see [TODO.md](TODO.md). +`Bash` is on the allow-list "for now" — pending a finer-grained allow-list +system for command patterns (`Bash(git *)`-style). When that lands, the +`builtin_tools_arg` shape will probably change to a setting / hooks +combo per claude-code's permissions plumbing. -**Live view.** Each agent runs an `events::Bus` (a -`tokio::sync::broadcast` wrapper). The harness emits -`TurnStart`, `Stream(value)` (one per parsed stream-json line), `Note`, -`TurnEnd`. The web UI subscribes via `/events/stream` (SSE) and a JS panel -appends rows. No full-page reload — operator input stays put. +The manager (`hive-m1nd`) runs the same loop with a `ManagerServer` MCP +flavor: +- `mcp__hyperhive__send`, `recv` — agent surface. +- `mcp__hyperhive__request_spawn(name)` — queue Spawn approval. +- `mcp__hyperhive__kill(name)` — graceful stop of a sub-agent. +- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a + config change for any agent (`hm1nd` for self-modification). + +The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, +run_turn}` so both binaries can't drift apart. ## Manager (hm1nd) is hive-c0re-managed -The manager container runs through the **same lifecycle as sub-agents**. -On `hive-c0re serve` startup, if `hm1nd` is missing, hive-c0re creates it. -The manager's flake lives at `/var/lib/hyperhive/applied/hm1nd/`; its -proposed config at `/var/lib/hyperhive/agents/hm1nd/config/`. Manager can -edit its own `agent.nix` (visible inside the container at -`/agents/hm1nd/config/`) and submit `request-apply-commit hm1nd ` for -operator approval. +The manager container runs through the **same lifecycle as sub-agents** — +no separate code path. On `hive-c0re serve` startup, if `nixos-container +list` doesn't include `hm1nd`, hive-c0re creates it. The manager's flake +lives at `/var/lib/hyperhive/applied/hm1nd/`; its proposed (manager-editable) +config at `/var/lib/hyperhive/agents/hm1nd/config/`. Manager can edit its +own `agent.nix` (visible inside the container at `/agents/hm1nd/config/`), +commit, and submit `request-apply-commit hm1nd ` for operator +approval — same flow as for sub-agents. Differences from sub-agents: -- `flake.nix` extends `hyperhive.nixosConfigurations.manager` - (vs `agent-base`). +- `flake.nix` extends `hyperhive.nixosConfigurations.manager` (vs + `agent-base`). - Container name is `hm1nd` (no `h-` prefix). - Fixed web UI port (`MANAGER_PORT = 8000`). - `set_nspawn_flags` adds an extra bind: `/var/lib/hyperhive/agents` → `/agents` (RW), so the manager can edit per-agent proposed repos. - First-deploy spawn bypasses the approval queue (manager is required infrastructure). -- Per-agent socket lives at `/run/hyperhive/manager/`, owned by - `manager_server::start`. +- Per-agent socket is the manager socket at `/run/hyperhive/manager/`, owned + by `manager_server::start`. `coordinator::ensure_runtime` returns that + path for manager and the usual `/run/hyperhive/agents//` for the + rest. -**Migration note (for older hosts):** drop any `containers.hm1nd = { ... }` -block from your host NixOS config. hyperhive creates and updates the -manager itself now. +**Migration note:** drop any `containers.hm1nd = { ... }` block from your +host NixOS config. hyperhive creates and updates the manager itself now. ## Auto-update on startup `hive-c0re serve` runs `auto_update::run` in a background task right after opening the coordinator. It enumerates managed containers and rebuilds any -whose recorded hyperhive rev differs from the current one — sub-agents and -manager go through the same `lifecycle::rebuild` path. "Rev" = canonical -filesystem path of `cfg.hyperhiveFlake`. Marker file: -`/var/lib/hyperhive/applied/..hyperhive-rev`. +whose recorded hyperhive rev differs from the current one: -If the flake input has no canonical path (e.g. a `github:` URL), -auto-update is a no-op — rebuild manually. +- **Sub-agents** rebuild via `lifecycle::rebuild` (regenerates + `applied//flake.nix`, sets nspawn flags, `nixos-container update --flake`). +- **Manager** runs `nixos-container update hm1nd` (no `--flake`). The + manager's config lives in the host's NixOS module; this is belt-and-braces + on top of NixOS's own container activation. Idempotent when nothing has + actually changed. + +"Rev" = canonical filesystem path of `cfg.hyperhiveFlake` (so `/etc/hyperhive` +resolving to a new `/nix/store/...-source` triggers a rebuild). Marker file: +`/var/lib/hyperhive/applied/..hyperhive-rev`. If the flake input has +no canonical path (e.g. a `github:` URL), auto-update is a no-op — rebuild +manually. The task is async and never blocks the admin socket; failures are +logged and don't take the daemon down. The dashboard surfaces pending updates per agent: a clickable "needs update ↻" badge appears whenever the marker differs from current rev. The badge -POSTs `/rebuild/`, calling the same `auto_update::rebuild_agent` -path so manual triggers and the startup scan can't drift. +POSTs `/rebuild/`, calling the same `auto_update::rebuild_agent` / +`rebuild_manager` path so manual triggers and the startup scan can't drift. + +## Build / deploy / test + +```sh +# inside the repo (devshell first; no global cargo) +nix develop -c cargo check +nix develop -c cargo clippy --workspace --all-targets -- -D warnings +nix develop -c cargo build + +# evaluate everything (incl. rust+nix+toml fmt + clippy) +nix flake check + +# build only the workspace package +nix build .#default +./result/bin/{hive-c0re,hive-ag3nt,hive-m1nd} + +# deploy to an existing host that imports hyperhive.nixosModules.hive-c0re +cd ~/Repos/ +nix flake update --update-input hyperhive +sudo nixos-rebuild switch --flake .# +sudo systemctl restart hive-c0re # if only env/options changed + +# end-to-end tests (each idempotent; runs as root) +sudo bash tests/roundtrip.sh # alice ↔ bob echo round-trip +sudo bash tests/approval.sh # manager edit → request → user approve → rebuilt +sudo bash tests/dashboard.sh # HTTP UI, approve POST, SSE, orphan GC +``` + +The host config also needs `hyperhive.overlays.default` applied — the module's +default `package = pkgs.hyperhive` requires the overlay to bring the package +in. The `claude-unstable` overlay is applied internally to per-agent flakes +already. + +## Phase status + +- ✅ Phase 0 — repo + Cargo workspace + flake + agent-base template +- ✅ Phase 1 — container lifecycle; `nixos-container update` hot-reload works + under the patch stack (validated on muede-lpt2) +- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips +- ✅ Phase 3 — sqlite broker (durable) + claude-or-echo turn loop +- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative + `containers.hm1nd` +- ✅ Phase 5 — git-commit approval flow + - 5a — sqlite approval queue (`request_apply_commit`/`pending`/`approve`/`deny`) + - 5b — per-agent config flakes + - 5c — manager edits `proposed`, hive-c0re writes-only `applied`; container + builds from `applied`. Approve = read `agent.nix` at the approved commit + from `proposed`, copy into `applied`, commit + rebuild. Manager cannot + move `applied/main` on its own. +- ✅ Phase 6 — per-container web UIs (`HIVE_PORT` deterministic-hash) + + hive-c0re dashboard (default 7000, vibec0re aesthetic, deep-linked) +- ✅ Phase 7 — polish: + - 7a — dashboard Approve/Deny buttons + unified diff (`similar` crate) + - 7b — broker broadcast + `/messages/stream` SSE + live message-flow panel + - 7c — `ApprovalResolved` helper events into manager inbox + - 7d — `MemoryMax=2G` + `CPUQuota=50%` systemd drop-in per container + - 7e — damocles migration plan (`docs/damocles-migration.md`) +- ✅ Phase 7 follow-ups: + - Dashboard **T4LK** form — operator can send messages from the browser + (`POST /send`, becomes `from: "operator"` broker message) + - Orphan-approval GC on dashboard render (stale entries auto-failed) + - `PRIVATE_NETWORK=0` + `HOST_ADDRESS=`/`LOCAL_ADDRESS=` cleared in + `set_nspawn_flags` so sub-agent web UI ports are reachable on the host + - `HYPERHIVE_GIT` env var (absolute path) bypasses PATH ambiguity + +## Phase 8 — real claude in containers + login UX (in progress) + +See PLAN.md → "Phase 8" for the full design. Summary: + +- **Per-agent persistent creds dir.** Bind + `/var/lib/hyperhive/agents//claude/` → `/root/.claude` (RW) in + `set_nspawn_flags`. One OAuth lineage per agent; refresh rotations stay + contained to that agent. +- **State dirs persist by default.** `destroy` keeps + `/var/lib/hyperhive/agents//` unless the operator passes an explicit + wipe flag. Recreating an agent of the same name reuses prior creds. +- **First spawn is approval-gated.** New agent names go through the same + approval queue as config edits. Manager calls `RequestSpawn` (CLI: + `hive-m1nd request-spawn `); operator can also queue from the + dashboard or `hive-c0re request-spawn `. The host's direct + `hive-c0re spawn ` still works as a privileged bypass for tests. + Approve runs `lifecycle::spawn` in a background task; the dashboard polls + via `` and renders a spinner row while + `nixos-container create` + `update` + `start` is in flight. +- **"needs login" partial-run state.** No valid session in `~/.claude/` → + harness binds the web UI but does NOT start the turn loop. The harness + polls the dir; as soon as a login lands it transitions into the turn loop + without a restart. Dashboard surfaces the state per-agent via a `needs + login` badge in the container list. "Valid session" today is a heuristic + (any regular file inside `/root/.claude/`); we may refine once the + filename layout claude writes is locked in. +- **Login from the per-agent web UI.** Spawn `claude auth login` with plain + stdio pipes (no PTY initially), surface the OAuth URL from stdout on the + page, accept the resulting code via a paste field, write it to the process + stdin. Once `~/.claude/` populates, the existing needs-login polling loop + flips state to Online and starts the turn loop — no separate signaling + needed. The exact command is overridable via `HYPERHIVE_LOGIN_CMD` so we + can adjust without rebuilding. If pipes turn out to be insufficient + (claude refuses without a TTY, raw-mode input, ANSI-only output) we redo + the backend with a PTY (e.g. `portable-pty`). + +Implementation order: bind-mount/dir creation → approval-gated spawn + +spinner → "needs login" partial run → PTY login endpoint. The login UI has +nowhere to live until the partial-run mode exists, so don't ship it earlier. ## Approval flow End-to-end: manager edits per-agent `proposed` repo → commits → submits commit -sha → user approves on host CLI or dashboard button → `hive-c0re` reads the +sha → user approves on host CLI **or** dashboard button → `hive-c0re` reads the file at that sha from `proposed`, applies into `applied`, commits there, runs -`nixos-container update`. Helper-event JSON (`ApprovalResolved`) lands in the -manager's inbox. +`nixos-container update`. Helper-event JSON lands in the manager's inbox. -Two separate git repos per agent: +``` +# Inside the hm1nd container (manager has /agents bind-mounted RW): +cd /agents/alice/config +$EDITOR agent.nix # e.g. environment.systemPackages = [ pkgs.htop ]; +git commit -am "add htop" +SHA=$(git rev-parse HEAD) +hive-m1nd request-apply-commit alice $SHA +exit + +# On the host (CLI): +sudo hive-c0re pending # shows queued approval with id N +sudo hive-c0re approve N # validates, applies, rebuilds +sudo nixos-container run h-alice -- which htop + +# Or on the dashboard (browser): +http://:7000/ # ◆ APPR0VE button next to the diff +``` + +Per-agent layout — two separate git repos: ``` /var/lib/hyperhive/agents//config/ # proposed — manager edits, hive-c0re reads only +├── .git/ └── agent.nix # the only file the manager can change # (initial commit by hive-c0re on first spawn, # never touched by hive-c0re again) /var/lib/hyperhive/applied// # applied — hive-c0re-only; container builds here -├── flake.nix # auto-generated; references hyperhive_flake +├── .git/ +├── flake.nix # hive-c0re-managed; references hyperhive_flake └── agent.nix # overwritten by approve from the proposed commit ``` -The container's `--flake` ref is `#default`. The flake extends -`hyperhive.nixosConfigurations.{agent-base|manager}` with `./agent.nix` plus -an inline module setting `programs.git.config.user` (committer identity = -the agent's name) and `systemd.services..environment` (HIVE_PORT, -HIVE_LABEL, HIVE_DASHBOARD_PORT). +The container's `--flake` ref is `#default`. The flake's +`nixosConfigurations.default` extends `hyperhive.nixosConfigurations.agent-base` +with `./agent.nix` plus an inline module that sets +`environment.etc."gitconfig".text` (committer identity = the agent's name) and +`systemd.services.hive-ag3nt.environment.HIVE_PORT`/`HIVE_LABEL`. + +## Security backlog + +- **Unprivileged containers (userns mapping).** Today the nspawn container + runs as a fully privileged root. Goal: `PrivateUsersChown=yes` (or the + nixos-container equivalent) so uid 0 inside maps to an unprivileged uid + on the host, and a container-root compromise lands the attacker on an + ordinary user account, not the host's root. Requires per-agent state + dirs to be chown'd to that uid on the host side. +- **Bash command allow-list.** Replace the blanket `Bash` allow with a + pattern allow-list (`Bash(git *)`, `Bash(nix build .*)`, etc.) per + claude-code's `--allowedTools` extended grammar. Likely lives in + `agent.nix` so each agent can scope its own shell surface. + +## Per-agent settings backlog + +- **Model override.** Hard-coded to `claude-haiku-4-5-20251001` in the turn + loop right now. Surface it as a per-agent override: operator via + dashboard, manager via `request_apply_commit` setting an attr on the + agent's flake (most natural place since the flake already carries + per-agent env/identity). + +## Polish backlog + +Not phased — pick when relevant: + +- **Operator inbox view** — drain replies addressed to `operator` and show + in the dashboard (today they accumulate in sqlite unread). +- **Per-agent UI substance** — show last N inbox messages, last turn timing, + link back to dashboard. +- **xterm.js terminal** — embed in each per-container UI, attach to a PTY + exposed by the harness. +- **`destroy` verb** — currently `nixos-container destroy` + manual `rm -rf`. + Should be one hive-c0re verb that also purges approvals + state dirs. +- **Bounded broker** — cap rows per recipient or auto-vacuum delivered + messages older than a threshold. +- **Container crash events** — watch `container@*.service` via D-Bus, + push `HelperEvent::ContainerCrash` to the manager. + +## Inspirations + +- **`~/Repos/bitburner-agent`** — sibling project, drives Claude Code in a + turn loop against a Bitburner CDP session. Patterns to steal as we grow: + per-cycle prompt diffing (vs full state), notes compaction as a separate + short-lived Claude session, MCP server registering tools from a single + `TOOLS` array, dashboard with SSE + xterm.js + sqlite stats sampler, + opaque "terminal event" stream that unifies tool-call / sleep / op-notice + / etc. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..e0f8002e --- /dev/null +++ b/PLAN.md @@ -0,0 +1,301 @@ +# hyperhive — Plan + +> **Status.** All phases 0–7 have shipped. This file is the original design +> doc; **CLAUDE.md** is the current source of truth for what's actually built, +> the file map, gotchas, and operator runbook. Keep this file for the *why* +> and the original phase rationale; CLAUDE.md for *how things are today*. +> +> **Names.** +> - Repo: `hyperhive` +> - Host-side daemon (helper): **`hive-c0re`** — its own crate (daemon + CLI in one binary). +> - In-container harness: **`hive-ag3nt`** crate, with **two `[[bin]]` targets**: +> - `hive-ag3nt` — runs in each sub-agent container. +> - `hive-m1nd` — runs in the manager container (same crate, second `main.rs`, wires the manager tool surface). +> - Shared crate between `hive-c0re` and `hive-ag3nt` (wire protocol, MCP verb types, message shapes): **`hive-sh4re`**. +> +> **Relationship to damocles.** Damocles is a separate, currently-running setup. `hyperhive` is a new, independent system in its own repo. Damocles' existing `claude-container.nix` informs the agent-base template but is not a dependency. Migration options laid out in `docs/damocles-migration.md`; recommendation is to keep them separate for now. + +## What we're building + +A multi-Claude-Code-agent setup on a single host: + +- Each agent runs in its own **nixos-container** (the NixOS wrapper around `systemd-nspawn` — gives us `nixos-container update`, declarative configs, etc.). +- A **manager agent** (itself a nixos-container) coordinates: spawns/kills agents, routes inter-agent messages, filters relevance, summarises, gates lifecycle changes on user approval. +- Host-side Rust daemon **`hive-c0re`** owns container lifecycle, hosts the MCP transport, brokers messages, runs the dashboard, and orchestrates approval-via-git-commit. +- Inside each agent container, **`hive-ag3nt`** runs the agent turn loop (drives `claude` as a subprocess), provides the per-agent web UI, and is the MCP client for that agent. +- Inside the manager container, **`hive-m1nd`** runs the analogous loop with the manager's tool surface and `/agents/**` RW access. It's a second binary in the `hive-ag3nt` crate — same lib code, different `main.rs`. +- Wire protocol and types shared between `hive-c0re` and the harness live in **`hive-sh4re`**. + +## Architecture + +``` +┌────────────────── host ────────────────────────────────────┐ +│ │ +│ hive-c0re (Rust daemon, NixOS service) │ +│ ├── lifecycle : nixos-container CRUD + nixos-container │ +│ │ update on approved config commits │ +│ ├── broker : sqlite message store + routing │ +│ ├── mcp : per-socket MCP servers (see Sockets) │ +│ ├── approvals : git-commit-based change requests │ +│ └── dashboard : HTMX/SSE web UI │ +│ │ +│ /var/lib/hyperhive/ │ +│ ├── state-repo/ (world: who exists, etc.) │ +│ ├── shared-instructions/ (RO into every agent) │ +│ └── agents/ │ +│ ├── manager/ │ +│ │ ├── config/ (flake repo, manager's own setup) │ +│ │ ├── prompts/ (manager's role/CLAUDE.md) │ +│ │ └── state/ (RW for manager) │ +│ └── / │ +│ ├── config/ (flake repo, RW for manager, RO ag) │ +│ ├── prompts/ (RO inside agent) │ +│ └── state/ (RW for agent + manager) │ +│ │ +│ Sockets (one per principal — perms by mount location): │ +│ ├── /run/hyperhive/host.sock admin/CLI on host │ +│ ├── /run/hyperhive/manager.sock → manager container │ +│ └── /run/hyperhive/agents/.sock → that agent │ +│ │ +│ ┌─ nixos-container: hm1nd ──────────────────────┐ │ +│ │ hive-m1nd (Rust, hive-ag3nt crate) │ │ +│ │ ├ MCP client → /run/hyperhive/manager.sock │ │ +│ │ ├ turn loop driving `claude` │ │ +│ │ └ per-container web UI │ │ +│ │ /var/lib/hyperhive/agents/** bind RW │ │ +│ │ MCP tools (manager surface): │ │ +│ │ send(to, body, wait_for_reply: bool), │ │ +│ │ recv / next_event, │ │ +│ │ request_spawn, request_kill, │ │ +│ │ request_apply_commit, inject_peer_info, │ │ +│ │ update_shared_instructions │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ┌─ nixos-container: h- ───────────────────┐ │ +│ │ hive-ag3nt (Rust) │ │ +│ │ MCP client → /run/hyperhive/agents/.sock │ +│ │ state/ RW, config/ + prompts/ + shared RO │ │ +│ │ MCP tools (agent surface): │ │ +│ │ send, recv, request_install │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key model decisions + +**Sockets, not identity gating.** One unix socket per principal, bind-mounted into the right place. The socket *is* the principal — no `SO_PEERCRED` lookups, no token plumbing. Perms are filesystem perms on the host side, plus the fact that only the matching container has the bind-mount. Each socket runs the MCP tool surface appropriate to its principal. + +**Container naming.** `nixos-container` caps the total container name at 11 chars (it gets encoded into network interface names). The manager runs as `hm1nd` (a compressed form of `hive-m1nd`). Sub-agents run as `h-` with `` capped to 9 chars (`MAX_AGENT_NAME`). The two namespaces don't collide. On-disk paths (`/var/lib/hyperhive/agents/foo/`) and socket paths (`/run/hyperhive/agents/foo/mcp.sock`) use the bare logical name; the prefixing lives only at the nixos-container layer. + +**Approvals are git commits.** `hive-c0re` maintains a `state-repo` on host that records the world (which agents exist, their roles, etc.). Per-agent flake configs (`agents//config/`) are themselves git repos. The manager edits clones with plain `git` CLI inside its container and asks `hive-c0re` to apply a commit via `request_apply_commit(agent, sha)`. `hive-c0re` queues it; once approved, fast-forwards `main` and reconciles state (rebuild containers, etc.). **No abstract "approval token" — the commit hash is the token.** + +**Approval UX evolves.** Early phases approve by CLI (`hive-c0re approve `) or even direct `git merge` on the host. The dashboard's commit-view UI is one of the last features built (so the system runs end-to-end on CLI long before the browser does anything useful). + +**Manager is a second binary in the same crate.** `hive-m1nd` lives in the `hive-ag3nt` crate as a second `[[bin]]`, sharing the crate's lib code. The two binaries differ in: +- Tool surface (manager's `main.rs` wires `request_spawn`/`request_kill`/`request_apply_commit`/`inject_peer_info`/`update_shared_instructions`). +- Bind-mounts (manager gets `agents/**` RW; agents get only their own `state/`). +- Auto-restart (manager is auto-restarted by `hive-c0re`; agents stay down once killed). +- The manager container is declared in the host NixOS module as a known container. + +**Manager concurrency = event loop.** `hive-m1nd` pulls from a heterogeneous `next_event` stream: inbound agent messages, replies to sync sends, lifecycle events from `hive-c0re` (crash, OOM, approval-resolved), and dashboard signals. One queue, claude turn per event. + +**Anthropic credentials.** ~~Shared key on host~~ — revised in Phase 8. +Per-agent persistent `~/.claude/` dir bind-mounted from +`/var/lib/hyperhive/agents//claude/`. OAuth refresh tokens rotate, so +sharing across agents is a non-starter (any sibling refresh invalidates all +the others). One interactive login per agent, ever; creds survive +`destroy`/recreate by default. Login flow runs from the per-agent web UI +(see Phase 8). + +**Workdir bootstrap.** Each agent's `state/` starts empty. Initial-task message tells the agent what to clone/set up. Manager can drop big artefacts into `state/` directly (it has RW) and pass the path as a message reference. + +## Riskiest assumptions (test in this order) + +1. **`nixos-container update` hot-reloads under the in-flight `nsresourced` / `mountfsd` / privateUsers patch stack** without nuking the running harness (and thus the `claude` session). Validated in Phase 1. +2. **The harness driving `claude` as a long-running turn loop is stable** — claude as a subprocess streaming output back; harness deciding when a turn ends; injecting inbox messages as new user turns. Validated in Phase 3. +3. **`hive-m1nd` (as a claude session) sensibly drives spawn / route / peer-injection.** Behavioural; only knowable by running it. Phase 4. +4. **`hive-c0re` throughput under multiple agents** — bursty messaging, MCP relay, sqlite writes. Likely fine; flag for measurement. + +## Phased path + +All phases ✅ shipped. Each section below is the *original* design with notes +on what actually landed and what deviated. See CLAUDE.md → "Phase status" for +the canonical summary. + +### ✅ Phase 0 — repo bootstrap +- Create `~/Repos/hyperhive/`, init flake. +- Cargo workspace: `hive-c0re/`, `hive-sh4re/`, `hive-ag3nt/` (the last with two `[[bin]]` targets — `hive-ag3nt` and `hive-m1nd`). All compile, all do nothing useful. +- NixOS module skeleton (`nix/modules/hive-c0re.nix`) that runs the daemon as a systemd service on the host. +- Agent base template (`nix/templates/agent-base.nix`) that builds a nixos-container including the `hive-ag3nt` binary. +- **Exit:** `nixos-container create test-agent --flake .#agent-base && nixos-container start test-agent` brings up a container whose `hive-ag3nt` prints "hello" and exits. + +### ✅ Phase 1 — container lifecycle + Risk 1 +- `hive-c0re`: open host admin socket (`/run/hyperhive/host.sock`); verbs `spawn(name)`, `kill(name)`, `rebuild(name)`, `list()`. Uses `nixos-container` underneath; container name on the host is `h-` (sub-agents) or `hm1nd` (manager). +- CLI tool talking to the admin socket (same `hive-c0re` binary, subcommand-driven). +- Manually mutate an agent's config flake, call `rebuild`, observe whether `hive-ag3nt` survives. +- **Decision:** if hot-reload doesn't preserve the harness, that becomes a hard requirement of `hive-ag3nt`'s design (resume from disk state). Document the outcome. +- **Exit:** spawn / rebuild / kill via CLI is reliable; known behaviour for in-flight rebuilds. + +### ✅ Phase 2 — sockets + minimal MCP +- `hive-c0re` opens `manager.sock` and `agents/.sock` (one per spawned agent). Per-socket MCP server with the right tool surface baked in. Types from `hive-sh4re`. +- `hive-ag3nt`: MCP client (types from `hive-sh4re`), connects to its socket on startup, exchanges hello. +- Tools: agent gets `send(to, body)`, `recv()`. No persistence yet (in-memory). +- **Exit:** two test agents exchange messages through `hive-c0re` manually-driven. + +### ✅ Phase 3 — broker + turn loop +- `hive-c0re`: sqlite-backed message store (`messages` table; `id, sender, recipient, body, sent_at, delivered_at`). Survives `hive-c0re` restart. +- `hive-ag3nt` (lib): real turn loop. Reads from `recv`; feeds new messages as user turns to `claude`; captures output; calls `send` for outbound. Long-running. +- **Exit:** two `hive-ag3nt`-driven agents have a back-and-forth conversation through `hive-c0re`. + +### ✅ Phase 4 — `hive-m1nd` + privileged surface +- `hive-m1nd` binary (second `[[bin]]` in `hive-ag3nt`) wires the manager tool surface. +- Manager container (`hm1nd`) declared in host NixOS module (auto-restart). Bind-mount `agents/**` RW. +- Manager socket gets the privileged tool surface: `request_spawn`/`request_kill`, `request_apply_commit`, `inject_peer_info`, `send(..., wait_for_reply=true)`. +- Smoke: attach a terminal to the manager container (`nixos-container root-login`); ask `hive-m1nd` to spawn an agent and route a message to it. +- **Exit:** manager spawns, routes, kills a child agent end-to-end; lifecycle still gated by manual CLI approval (no GUI yet). + +### ✅ Phase 5 — git-commit approval flow +- `state-repo` on host tracks world (agents directory listing, allow-lists, etc.). +- Per-agent `config/` flake repos created at spawn time. +- Manager's container: bind-mounted clones; uses plain `git` CLI to edit/commit. +- `request_apply_commit(name, sha)` queues a change in `hive-c0re`. Approval = CLI `hive-c0re approve `; on approve, `hive-c0re` fast-forwards `main` and reconciles (rebuild if config changed, run `nixos-container update`). +- Per-agent allow-list for `request_install`: in-list installs become auto-applied commits; novel pkgs become pending commits. +- **Exit:** manager adds a package to an agent → user approves on CLI → agent picks it up. + +### ✅ Phase 6 — per-agent web UI + dashboard MVP +- `hive-ag3nt` web UI module (in the crate's lib): HTTP on a per-container host port (host network): status, last messages, embedded terminal (xterm.js over WebSocket). Both `hive-ag3nt` and `hive-m1nd` binaries expose it. +- Dashboard served by `hive-c0re`: agent list, per-agent status, links to each agent's UI, link to manager's UI. +- No approval UI yet; users still approve via CLI. +- **Exit:** browser is a usable navigation layer over the whole system. + +### ✅ Phase 7 — dashboard commit view + polish +- Pending-commits view in the dashboard with diff rendering and Approve/Deny buttons (replaces the CLI approve step). +- Live message-flow view (`hive-c0re` sees all MCP relay traffic). +- `hive-c0re` event push into `hive-m1nd`'s `next_event` (crashes, OOM, approval resolved). +- Resource caps in nixos-container units (`MemoryMax`, `CPUQuota`). +- Migration plan for damocles' existing containers (separate doc). + +## Repo layout (target) + +``` +~/Repos/hyperhive/ +├── hive-c0re/ # host-side Rust crate (daemon + CLI) +│ ├── src/ +│ │ ├── main.rs +│ │ ├── lifecycle.rs # nixos-container CRUD +│ │ ├── broker.rs # sqlite message store +│ │ ├── mcp/ # MCP servers (per-socket) +│ │ ├── approvals.rs # git-commit change requests +│ │ ├── state_repo.rs # world-state git repo +│ │ └── dashboard/ # HTMX/SSE web UI +│ └── Cargo.toml +├── hive-sh4re/ # shared crate (hive-c0re ↔ hive-ag3nt) +│ ├── src/ +│ │ ├── lib.rs +│ │ ├── protocol.rs # MCP wire types, verb shapes +│ │ └── message.rs # message types +│ └── Cargo.toml +├── hive-ag3nt/ # in-container harness crate; two binaries +│ ├── src/ +│ │ ├── lib.rs # shared harness code +│ │ ├── mcp_client.rs # MCP client over unix socket +│ │ ├── turn_loop.rs # claude subprocess driver +│ │ ├── web_ui.rs # per-container UI scaffolding +│ │ └── bin/ +│ │ ├── hive-ag3nt.rs # agent tool surface wiring +│ │ └── hive-m1nd.rs # manager tool surface wiring +│ └── Cargo.toml # [[bin]] hive-ag3nt + [[bin]] hive-m1nd +├── nix/ +│ ├── modules/hive-c0re.nix # NixOS module for the host +│ ├── templates/ +│ │ ├── agent-base.nix # base agent nixos-container (pulls hive-ag3nt) +│ │ └── manager.nix # manager nixos-container (pulls hive-m1nd) +│ └── overlay.nix # exposes the three crates + both bins as pkgs +├── web/ # static assets, HTMX templates +├── flake.nix +├── Cargo.toml # workspace +└── PLAN.md # this file +``` + +## Resolved implementation decisions + +The original open-decisions list, with what we picked: + +- **Wire format.** Custom JSON-line over unix sockets (host admin / manager / + per-agent), not real MCP stdio. Simpler and good enough for now; can swap + to MCP later. SSE for the dashboard message-flow. +- **Per-agent web UI.** `axum` HTTP server inside each container at a port + hashed from the agent name (8100–8999); manager at fixed 8000; dashboard + at 7000. Plain HTML, no HTMX, no xterm.js yet. +- **`state-repo` schema.** Per-agent dir with files; not a single TOML. + Realised as two parallel git repos per agent: `proposed` (manager-editable) + and `applied` (hive-c0re-only). Container builds from `applied`. +- **Manager access to applied state.** *Not* RW-mounted. Manager only has + `proposed/` bind-mounted; `applied/` is hive-c0re-only. +- **One binary or two.** One: `hive-c0re` is daemon + CLI dispatched by + subcommand (`serve` / `spawn` / `kill` / `rebuild` / `list` / `pending` / + `approve` / `deny`). + +### ⏳ Phase 8 — real claude in containers + login UX + +Until this lands the harness falls back to the echo path; we've never run an +end-to-end turn with a real model in a real container. + +**Credential model.** Per-agent persistent dir at +`/var/lib/hyperhive/agents//claude/` bind-mounted RW to `/root/.claude` +inside the container. *Not* shared across agents: OAuth refresh tokens rotate, +and sharing one dir means the first refresh by any sibling invalidates all the +others. Each agent owns its own token lineage from first login onward. + +**State-dir persistence.** Agent state dirs (including the claude creds dir) +persist across `destroy`/recreate by default. The `destroy` verb only purges +state when given an explicit "wipe" flag from the operator — recreating an +agent of the same name reuses prior creds with no re-login. + +**First-deploy approval.** Spawning a brand-new agent name goes through the +existing approval queue (same path as config edits). The dashboard shows a +spinner while `nixos-container create` + `update` + `start` run. + +**"needs login" agent state.** If the bound `~/.claude/` has no valid session, +the harness boots in a partial mode: per-agent web UI is up, but the turn +loop does NOT start. Dashboard surfaces the state per-agent so the operator +knows where to click. + +**Login over the per-agent web UI.** No more `nixos-container root-login` for +the common case. The agent's web UI exposes a "log in" action that: +1. Spawns `claude auth login` (or equivalent) inside the container with plain + stdio pipes — no PTY unless we discover we need one. +2. Reads the OAuth URL from the process stdout and shows it on the page. +3. Provides a paste field for the resulting code; writes it to the process + stdin. +4. On success, transitions out of "needs login" and starts the turn loop. + +If `claude` turns out to require a TTY (refuses on `!isatty()`, uses raw-mode +input, or only renders the URL with ANSI styling), redo the backend with a +PTY (e.g. `portable-pty`). Don't pre-build for that — start simple. + +**Sequence.** Ship in this order — don't do (4) before (3) or there's nowhere +for the login UI to live: (1) bind-mount + per-agent dir creation in +`lifecycle::set_nspawn_flags`, (2) approval-gated first spawn + dashboard +spinner, (3) harness "needs login" partial-run mode, (4) PTY-backed login +endpoint on the per-agent UI. + +**Exit:** spawn a new agent from the dashboard → approve → wait for spinner +→ click "log in" on the agent's page → complete OAuth in the browser → +paste code → agent enters the turn loop and replies to a T4LK message via +real `claude --print`. + +## Polish backlog (not phased) + +See CLAUDE.md → "Polish backlog" for the live list. Highlights: operator +inbox drain, per-agent UI substance, xterm.js terminal embed, `destroy` verb, +bounded broker, container-crash events via D-Bus. + +## Explicitly deferred / out of v1 scope + +- Per-agent API keys, cost attribution. +- Pooled / pre-warmed containers. +- Destroy verb on the `hive-c0re` API (use `rm` on host). +- Backup / replication of `agents/` state. +- Migration of existing damocles containers (`docs/damocles-migration.md`). +- Anything about multiple hosts. diff --git a/README.md b/README.md deleted file mode 100644 index 5db00466..00000000 --- a/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# hyperhive - -Multi-Claude-Code-agent orchestration on **nixos-containers**. - -A host-side Rust daemon (`hive-c0re`) spawns nspawn-isolated agent -containers and brokers messages between them. A manager agent (`hm1nd`) -coordinates the swarm and gates lifecycle changes on user approval via git -commits, surfaced through a vibec0re-styled HTTP dashboard. - -``` - ┌────────────────────────┐ - │ hive-c0re (Rust) │ - operator ──▶ │ • lifecycle │ ─▶ nixos-containers - │ • broker (sqlite) │ ├── hm1nd (manager) - │ • approvals (sqlite) │ ├── h-alice (sub-agent) - │ • dashboard :7000 │ └── h-bob ... - │ • per-agent sockets │ - └────────────────────────┘ -``` - -Each container runs a harness binary that drives `claude --print --continue` -in a turn loop, exposes a per-agent web UI with a live event stream, and -talks to the broker over a bind-mounted unix socket via an embedded MCP -server claude calls into. - -## Read next - -- **[CLAUDE.md](CLAUDE.md)** — developer reference: conventions, gotchas, - per-subsystem design, file map. -- **[TODO.md](TODO.md)** — open work items. - -## Build / deploy - -```sh -# inside the repo (devshell first; no global cargo) -nix develop -c cargo check -nix develop -c cargo clippy --workspace --all-targets -- -D warnings - -# evaluate everything (rust+nix+toml fmt + clippy) -nix flake check - -# deploy to a host that imports `hyperhive.nixosModules.hive-c0re` -cd ~/Repos/ -nix flake update --update-input hyperhive -sudo nixos-rebuild switch --flake .# -``` - -The host config also needs `hyperhive.overlays.default` applied — the -module's default `package = pkgs.hyperhive` requires the overlay. diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 8091c35d..00000000 --- a/TODO.md +++ /dev/null @@ -1,69 +0,0 @@ -# TODO - -Pick anything from here when relevant. Cross-cutting design notes live in -[CLAUDE.md](CLAUDE.md); high-level project intro in [README.md](README.md). - -## Security - -- **Unprivileged containers (userns mapping).** Today the nspawn container - runs as a fully privileged root. Goal: `PrivateUsersChown=yes` (or the - nixos-container equivalent) so uid 0 inside maps to an unprivileged uid - on the host, and a container-root compromise lands the attacker on an - ordinary user account, not the host's root. Requires per-agent state - dirs to be chown'd to that uid on the host side. -- **Bash command allow-list.** Replace the blanket `Bash` allow with a - pattern allow-list (`Bash(git *)`, `Bash(nix build .*)`, etc.) per - claude-code's `--allowedTools` extended grammar. Likely lives in - `agent.nix` so each agent can scope its own shell surface. - -## Per-agent settings - -- **Model override.** Hard-coded to `haiku` in the turn loop right now. - Surface as a per-agent override: operator via dashboard, manager via - `request_apply_commit` setting an attr on the agent's flake (most natural - place since the flake already carries per-agent env/identity). - -## UI / UX - -- **Operator inbox view.** Drain replies addressed to `operator` and show - them on the dashboard. Today they accumulate in sqlite unread; you can - only see them by watching the live panel of the agent that sent them. -- **Per-agent UI substance.** Show last N inbox messages, last turn timing, - link back to dashboard. -- **Static-asset SPA-style web UI.** Move toward: `index.html` is static, - CSS/JS is static, all dynamic state is fetched over SSE / JSON endpoints. - Currently the index HTML is server-rendered with state-dependent - fragments inlined; the live event stream + async forms are already SSE / - fetch. Goal is a cleaner split so the UI is one HTML file + JS app + - small JSON API. -- **Background JS refresh on the live panel.** Already there for sends; - any remaining places that reload the whole page should switch to fetch + - partial updates. -- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by - the harness. Pairs well with the unprivileged-container work — would let - the operator drop into the container without `nixos-container root-login`. - -## Loop substance - -- **Notes / state persistence.** Per-agent `notes.md` for durable scratch - memory across turns. Compaction-on-overflow runs a separate short-lived - claude session (à la bitburner-agent). The `--continue` session already - gives short-term memory, but notes give cross-session durable knowledge - that isn't lost on a `/compact` boundary. - -## Lifecycle / reliability - -- **Bounded broker.** Cap rows per recipient or auto-vacuum delivered - messages older than a threshold. sqlite is growing unbounded. -- **Container crash events.** Watch `container@*.service` via D-Bus, push - `HelperEvent::ContainerCrash` to the manager's inbox so the manager can - react (restart, escalate, etc.). -- **`destroy --purge`.** Today `destroy` keeps state by design; add an - opt-in flag (CLI + dashboard) to also wipe `/var/lib/hyperhive/agents//` - and `/var/lib/hyperhive/applied//`. - -## Cleanup / docs - -- **Debug-only sub-commands.** `hive-ag3nt send/recv` and the analogous - `hive-m1nd send/recv/...` exist only for ops debugging. Move them into a - hidden `debug` sub-command to declutter `--help`, or drop entirely. diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index c33c319c..6d3a6535 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -27,79 +27,6 @@ use rmcp::{ use crate::client; -/// Wire-protocol-agnostic view of a hyperhive socket response. Both -/// `AgentResponse` and `ManagerResponse` convert into this so the tool -/// formatters can be shared between `AgentServer` and `ManagerServer`. -#[derive(Debug)] -pub enum SocketReply { - Ok, - Err(String), - Message { from: String, body: String }, - Empty, - Status(u64), -} - -impl From for SocketReply { - fn from(r: hive_sh4re::AgentResponse) -> Self { - match r { - hive_sh4re::AgentResponse::Ok => Self::Ok, - hive_sh4re::AgentResponse::Err { message } => Self::Err(message), - hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body }, - hive_sh4re::AgentResponse::Empty => Self::Empty, - hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread), - } - } -} - -impl From for SocketReply { - fn from(r: hive_sh4re::ManagerResponse) -> Self { - match r { - hive_sh4re::ManagerResponse::Ok => Self::Ok, - hive_sh4re::ManagerResponse::Err { message } => Self::Err(message), - hive_sh4re::ManagerResponse::Message { from, body } => Self::Message { from, body }, - hive_sh4re::ManagerResponse::Empty => Self::Empty, - hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread), - } - } -} - -/// Format helper for "send-like" tools (anything that expects an `Ok`). -/// `tool` and `ok_msg` only appear in the result string; they don't change -/// behavior. -pub fn format_ack( - resp: Result, - tool: &str, - ok_msg: String, -) -> String { - match resp { - Ok(SocketReply::Ok) => ok_msg, - Ok(SocketReply::Err(m)) => format!("{tool} failed: {m}"), - Ok(other) => format!("{tool} unexpected response: {other:?}"), - Err(e) => format!("{tool} transport error: {e:#}"), - } -} - -/// Format helper for `recv` tools: `Message` → from + body block; -/// `Empty` → marker; anything else surfaces as an error. -pub fn format_recv(resp: Result) -> String { - match resp { - Ok(SocketReply::Message { from, body }) => format!("from: {from}\n\n{body}"), - Ok(SocketReply::Empty) => "(empty)".into(), - Ok(SocketReply::Err(m)) => format!("recv failed: {m}"), - Ok(other) => format!("recv unexpected response: {other:?}"), - Err(e) => format!("recv transport error: {e:#}"), - } -} - -/// Format helper for the status peek used in the status line. -pub fn format_status(resp: Result) -> String { - match resp { - Ok(SocketReply::Status(unread)) => format!("{unread} unread message(s) in inbox"), - Ok(other) => format!("status: unexpected response {other:?}"), - Err(e) => format!("status: transport error: {e:#}"), - } -} - /// Common envelope around every MCP tool handler: pre-log → run → append /// a status line → post-log. Free function so both `AgentServer` and /// `ManagerServer` use the same shape; the per-server `status_line` @@ -150,13 +77,18 @@ impl AgentServer { /// note rather than failing the whole tool call when the socket /// hiccups. async fn status_line(&self) -> String { - let resp = client::request::<_, hive_sh4re::AgentResponse>( + match client::request::<_, hive_sh4re::AgentResponse>( &self.socket, &hive_sh4re::AgentRequest::Status, ) .await - .map(SocketReply::from); - format_status(resp) + { + Ok(hive_sh4re::AgentResponse::Status { unread }) => { + format!("{unread} unread message(s) in inbox") + } + Ok(other) => format!("status: unexpected response {other:?}"), + Err(e) => format!("status: transport error: {e:#}"), + } } } @@ -170,16 +102,16 @@ impl AgentServer { let log = format!("{args:?}"); let to = args.to.clone(); run_tool_envelope("send", log, self.status_line(), async move { - let resp = client::request::<_, hive_sh4re::AgentResponse>( - &self.socket, - &hive_sh4re::AgentRequest::Send { - to: args.to, - body: args.body, - }, - ) - .await - .map(SocketReply::from); - format_ack(resp, "send", format!("sent to {to}")) + let req = hive_sh4re::AgentRequest::Send { + to: args.to, + body: args.body, + }; + match client::request::<_, hive_sh4re::AgentResponse>(&self.socket, &req).await { + Ok(hive_sh4re::AgentResponse::Ok) => format!("sent to {to}"), + Ok(hive_sh4re::AgentResponse::Err { message }) => format!("send failed: {message}"), + Ok(other) => format!("send unexpected response: {other:?}"), + Err(e) => format!("send transport error: {e:#}"), + } }) .await } @@ -188,15 +120,19 @@ impl AgentServer { description = "Pop one message from this agent's inbox. Returns the sender and body, \ or an empty marker if nothing is waiting." )] - async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), self.status_line(), async move { - let resp = client::request::<_, hive_sh4re::AgentResponse>( - &self.socket, - &hive_sh4re::AgentRequest::Recv, - ) - .await - .map(SocketReply::from); - format_recv(resp) + async fn recv(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("recv", log, self.status_line(), async move { + let req = hive_sh4re::AgentRequest::Recv; + match client::request::<_, hive_sh4re::AgentResponse>(&self.socket, &req).await { + Ok(hive_sh4re::AgentResponse::Message { from, body }) => { + format!("from: {from}\n\n{body}") + } + Ok(hive_sh4re::AgentResponse::Empty) => "(empty)".into(), + Ok(hive_sh4re::AgentResponse::Err { message }) => format!("recv failed: {message}"), + Ok(other) => format!("recv unexpected response: {other:?}"), + Err(e) => format!("recv transport error: {e:#}"), + } }) .await } @@ -263,21 +199,18 @@ impl ManagerServer { } async fn status_line(&self) -> String { - let resp = client::request::<_, hive_sh4re::ManagerResponse>( + match client::request::<_, hive_sh4re::ManagerResponse>( &self.socket, &hive_sh4re::ManagerRequest::Status, ) .await - .map(SocketReply::from); - format_status(resp) - } - - /// Helper: issue any `ManagerRequest`, convert the reply through - /// `SocketReply`. Manager tools that just need an `Ok` ack share this. - async fn dispatch(&self, req: hive_sh4re::ManagerRequest) -> Result { - client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req) - .await - .map(SocketReply::from) + { + Ok(hive_sh4re::ManagerResponse::Status { unread }) => { + format!("{unread} unread message(s) in inbox") + } + Ok(other) => format!("status: unexpected response {other:?}"), + Err(e) => format!("status: transport error: {e:#}"), + } } } @@ -291,13 +224,18 @@ impl ManagerServer { let log = format!("{args:?}"); let to = args.to.clone(); run_tool_envelope("send", log, self.status_line(), async move { - let resp = self - .dispatch(hive_sh4re::ManagerRequest::Send { - to: args.to, - body: args.body, - }) - .await; - format_ack(resp, "send", format!("sent to {to}")) + let req = hive_sh4re::ManagerRequest::Send { + to: args.to, + body: args.body, + }; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Ok) => format!("sent to {to}"), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("send failed: {message}") + } + Ok(other) => format!("send unexpected response: {other:?}"), + Err(e) => format!("send transport error: {e:#}"), + } }) .await } @@ -306,10 +244,21 @@ impl ManagerServer { description = "Pop one message from the manager inbox. Returns sender + body, or \ empty." )] - async fn recv(&self, Parameters(_args): Parameters) -> String { - run_tool_envelope("recv", String::new(), self.status_line(), async move { - let resp = self.dispatch(hive_sh4re::ManagerRequest::Recv).await; - format_recv(resp) + async fn recv(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("recv", log, self.status_line(), async move { + let req = hive_sh4re::ManagerRequest::Recv; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Message { from, body }) => { + format!("from: {from}\n\n{body}") + } + Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("recv failed: {message}") + } + Ok(other) => format!("recv unexpected response: {other:?}"), + Err(e) => format!("recv transport error: {e:#}"), + } }) .await } @@ -322,14 +271,15 @@ impl ManagerServer { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("request_spawn", log, self.status_line(), async move { - let resp = self - .dispatch(hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }) - .await; - format_ack( - resp, - "request_spawn", - format!("spawn approval queued for {name}"), - ) + let req = hive_sh4re::ManagerRequest::RequestSpawn { name: args.name }; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Ok) => format!("spawn approval queued for {name}"), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("request_spawn failed: {message}") + } + Ok(other) => format!("request_spawn unexpected response: {other:?}"), + Err(e) => format!("request_spawn transport error: {e:#}"), + } }) .await } @@ -342,10 +292,15 @@ impl ManagerServer { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("kill", log, self.status_line(), async move { - let resp = self - .dispatch(hive_sh4re::ManagerRequest::Kill { name: args.name }) - .await; - format_ack(resp, "kill", format!("killed {name}")) + let req = hive_sh4re::ManagerRequest::Kill { name: args.name }; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"), + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("kill failed: {message}") + } + Ok(other) => format!("kill unexpected response: {other:?}"), + Err(e) => format!("kill transport error: {e:#}"), + } }) .await } @@ -367,17 +322,20 @@ impl ManagerServer { log, self.status_line(), async move { - let resp = self - .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit { - agent: args.agent, - commit_ref: args.commit_ref, - }) - .await; - format_ack( - resp, - "request_apply_commit", - format!("apply approval queued for {agent} @ {commit_ref}"), - ) + let req = hive_sh4re::ManagerRequest::RequestApplyCommit { + agent: args.agent, + commit_ref: args.commit_ref, + }; + match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { + Ok(hive_sh4re::ManagerResponse::Ok) => { + format!("apply approval queued for {agent} @ {commit_ref}") + } + Ok(hive_sh4re::ManagerResponse::Err { message }) => { + format!("request_apply_commit failed: {message}") + } + Ok(other) => format!("request_apply_commit unexpected response: {other:?}"), + Err(e) => format!("request_apply_commit transport error: {e:#}"), + } }, ) .await diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index f7c8611b..df88324a 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -115,12 +115,9 @@ fn render_online(label: &str) -> String { const input = document.getElementById('sendbody');\n \ const body = input.value.trim();\n \ if (!body) return;\n \ - const resp = await fetch('/send', {{\n \ - method: 'POST',\n \ - headers: {{ 'Content-Type': 'application/x-www-form-urlencoded' }},\n \ - body: new URLSearchParams({{ body }}),\n \ - redirect: 'manual',\n \ - }});\n \ + const fd = new FormData();\n \ + fd.set('body', body);\n \ + const resp = await fetch('/send', {{ method: 'POST', body: fd, redirect: 'manual' }});\n \ if (resp.type === 'opaqueredirect' || (resp.ok && resp.status < 400)) {{\n \ input.value = '';\n \ }} else {{\n \ diff --git a/hive-c0re/assets/async_forms.js b/hive-c0re/assets/async_forms.js index f063b7c4..1fe78344 100644 --- a/hive-c0re/assets/async_forms.js +++ b/hive-c0re/assets/async_forms.js @@ -14,12 +14,9 @@ btn.innerHTML = ''; } try { - // axum's `Form` extractor wants application/x-www-form-urlencoded; - // FormData would send multipart/form-data and bounce with 415. const resp = await fetch(form.action, { method: form.method || 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams(new FormData(form)), + body: new FormData(form), redirect: 'manual', }); const ok = resp.ok