diff --git a/CLAUDE.md b/CLAUDE.md index f180de78..6ebbb4c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,10 @@ # 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 with live SSE message-flow. +daemon spawns nspawn-isolated agent containers and brokers messages between +them. Eventually a manager agent (another Claude Code session in its own +container) coordinates the swarm and gates lifecycle changes on user approval +via git commits. **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*. @@ -12,101 +12,66 @@ roadmap; this file is the operator/developer reference for the *how*. ## 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|…`) +host +├── hive-c0re (Rust daemon, NixOS service) +│ ├── lifecycle — nixos-container CRUD +│ ├── broker — sqlite message store (/var/lib/hyperhive/broker.sqlite) +│ ├── server — host admin socket (JSON line protocol) +│ └── agent_server — per-agent MCP-ish sockets │ ├── /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) +│ └── agents//mcp.sock — bind-mounted into each container at /run/hive │ └── nixos-containers ├── h- (sub-agents, hive-ag3nt binary) - └── hm1nd (manager, hive-m1nd binary) + └── hm1nd (manager, hive-m1nd binary — Phase 4+) ``` ## 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 - 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 - src/broker.rs sqlite Message store + broadcast channel for SSE - 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-c0re/ host daemon + CLI (one binary, subcommand-dispatched) + main.rs clap setup; serve vs spawn/kill/rebuild/list + server.rs host admin socket + client.rs host admin socket client (for spawn/kill/rebuild/list) + broker.rs sqlite-backed Message store (rusqlite) + agent_server.rs per-agent socket listener + coordinator.rs shared runtime state (broker + map) + lifecycle.rs `nixos-container` shellouts (spawn/kill/rebuild/list) -hive-ag3nt/ in-container harness crate; produces TWO binaries - 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 (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-ag3nt/ in-container harness; produces TWO binaries from one crate + src/lib.rs DEFAULT_SOCKET, re-exports + src/client.rs AgentRequest/AgentResponse over /run/hive/mcp.sock + src/bin/hive-ag3nt.rs sub-agent CLI (serve/send/recv) + src/bin/hive-m1nd.rs manager placeholder (Phase 4) -hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, - ManagerRequest/Response, Message, Approval, HelperEvent) +hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, Message) nix/ - 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 + modules/hive-c0re.nix systemd service wiring + templates/agent-base.nix nixos-container template (boot.isNspawnContainer = true) -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 +tests/roundtrip.sh Phase 3 end-to-end smoke test ``` ## Conventions - **Naming.** Containers are length-bounded (`nixos-container` ≤ 11 chars). Sub-agents are `h-` with `` ≤ 9 chars; the manager is `hm1nd`. - `MAX_AGENT_NAME` enforces the cap in `lifecycle.rs`. Per-agent web UI port = - `WEB_PORT_BASE + FNV1a(name) % WEB_PORT_RANGE` (8100..8999); manager fixed - at 8000; dashboard `cfg.dashboardPort` (default 7000). + `MAX_AGENT_NAME` enforces the cap in `lifecycle.rs`. - **Identity = socket.** No auth/tokens on the per-agent sockets. The socket *path* identifies the principal; perms come from "who has the bind-mount." -- **Wire protocol.** JSON line-delimited over unix sockets in both directions - (host admin / manager / agent). `/messages/stream` is `text/event-stream`. +- **Wire protocol.** JSON line-delimited over unix sockets in both directions. + See `hive-sh4re` for the types. (Phase 6+ may swap to real MCP stdio.) - **Commit messages.** Short, lowercase, no Co-Authored-By trailer. - **Commit before test.** Stage and commit when work *looks* ready, then run validation (`cargo check`, `nix flake check`, real lpt2 deploy). Failures get a follow-up commit rather than an amend. -- **`rebuild` is the reconcile verb.** Idempotently rewrites - `/etc/nixos-containers/.conf` (`PRIVATE_NETWORK=0`, clears - HOST_ADDRESS/LOCAL_ADDRESS, sets `EXTRA_NSPAWN_FLAGS`), regenerates - `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` live in `actions.rs`; the admin - socket and the dashboard POST handlers both call into them, so the two - surfaces never drift. +- **`rebuild` is the reconcile verb.** It rewrites `/etc/nixos-containers/.conf` + EXTRA_NSPAWN_FLAGS idempotently *and* does `nixos-container update` *and* + stop+start so nspawn-level changes (bind mounts) take effect. Anything that + changes per-container state on the host should be re-applied here. ## Gotchas / lessons learned @@ -114,24 +79,16 @@ docs/damocles-migration.md options for moving damocles onto hyperhive `EXTRA_NSPAWN_FLAGS` in `/etc/nixos-containers/.conf` — the start script (`/nix/store/.../container_-start`) expands it unquoted into the `systemd-nspawn` invocation. We rewrite this line in `set_nspawn_flags()`. -- **`/run/systemd/nspawn/*.nspawn` overrides are *ignored*** by - `nixos-container`'s start script (it builds the nspawn cmd line directly). -- **`boot.isNspawnContainer = true`**, not `boot.isContainer = true`. Renamed - in nixos-25.11+. -- **`nixos-container create` auto-assigns `HOST_ADDRESS`/`LOCAL_ADDRESS`** in - the `.conf`. The start script's `if HOST_ADDRESS set → --network-veth` - branch then forces a private netns — which is silently fatal for our web - 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`. +- **`/run/systemd/nspawn/*.nspawn` overrides are *ignored*** by `nixos-container`'s + start script (it builds the nspawn cmd line directly). Don't bother. +- **`boot.isNspawnContainer = true`**, not `boot.isContainer = true`. The + latter was renamed in nixos-25.11+. - **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. + `path = [ "/run/current-system/sw" ]` so `nixos-container` (which lives in + the system profile, not nixpkgs) is reachable. - **`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. + 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. @@ -140,31 +97,23 @@ docs/damocles-migration.md options for moving damocles onto hyperhive (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. + env var path. For now: `nixos-container root-login h-` → `claude` + (interactive) → log in once. The harness falls back to echo replies when + `claude --print` fails. Future: bind-mount a shared `~/.claude` dir from the + host so creds survive container destroy/recreate. - **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 - of `pending`. They stay in sqlite for audit. + starts with `"echo: "`. Prevents ping-pong loops when both sides fall back to + echo. Real conversations between claude-backed agents *will* runaway — that's + the manager's job to bound (Phase 4+). ## 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) +# evaluate everything (incl. fmt check) nix flake check # build only the workspace package @@ -175,114 +124,69 @@ nix build .#default 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 +# end-to-end test (lpt2 or any host with the module enabled) +sudo bash tests/roundtrip.sh ``` 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. +in. ## 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 1 — container lifecycle (spawn/kill/rebuild/list); nixos-container update + hot-reload works under the patch stack (validated empirically on muede-lpt2) +- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips messages +- ✅ Phase 3 — sqlite broker (durable across restart) + 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 (proposed + applied repos) + - 5c — split: manager edits `proposed`, hive-c0re writes-only `applied`; the + container builds from `applied`. Approve = read `agent.nix` at the + approved commit from `proposed`, copy into `applied`, commit + rebuild. + Manager cannot move `main` on its own. +- ✅ Phase 6 — per-container web UIs + hive-c0re dashboard: + - Each `hive-ag3nt` / `hive-m1nd` serves an `axum` HTTP page on `HIVE_PORT` + (deterministic hash for sub-agents in 8100–8999; fixed 8000 for the manager). + Vibec0re-styled placeholder for now (status / inbox / xterm coming later). + - `hive-c0re` serves a dashboard on `cfg.dashboardPort` (default 7000) + listing containers (deep-linked to their per-container UI) + pending + approvals. Same aesthetic. + - Firewall opens 7000, 8000, 8100–8999 when the module is enabled. - ✅ 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 + - 7a — dashboard `POST /approve/` / `/deny/` buttons + unified + diff (via `similar`) of applied vs proposed `agent.nix`. + - 7b — broker broadcast channel + `/messages/stream` SSE + live message-flow + panel (cyan `→` sent / green `✓` delivered, 200 row cap). + - 7c — `ApprovalResolved` helper events into the manager's inbox + (`SYSTEM_SENDER` + `HelperEvent` JSON). Manager harness recognises and + logs them distinctly. + - 7d — default `MemoryMax=2G` + `CPUQuota=50%` applied to every managed + container via `/run/systemd/system/container@.service.d/hyperhive-limits.conf` + drop-in (regenerated on every spawn / rebuild). + - 7e — damocles migration plan (`docs/damocles-migration.md`). -## Phase 8 — real claude in containers + login UX (in progress) +## Approval flow (Phase 5) -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 /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 -file at that sha from `proposed`, applies into `applied`, commits there, runs -`nixos-container update`. Helper-event JSON lands in the manager's inbox. +End-to-end: manager edits per-agent config repo → commits → submits commit sha +for approval → user approves on host CLI → `hive-c0re` advances `main` + rebuilds. ``` # Inside the hm1nd container (manager has /agents bind-mounted RW): cd /agents/alice/config -$EDITOR agent.nix # e.g. environment.systemPackages = [ pkgs.htop ]; +$EDITOR agent.nix # add `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 +# On the host: +sudo hive-c0re pending # shows the queued approval with id N +sudo hive-c0re approve N # validates, advances main, rebuilds h-alice +sudo nixos-container run h-alice -- which htop # /run/current-system/sw/bin/htop ``` Per-agent layout — two separate git repos: @@ -302,33 +206,21 @@ Per-agent layout — two separate git repos: 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`. +with `./agent.nix` plus an inline module setting `environment.etc."gitconfig".text` +with the agent's name as the git committer identity. -## Polish backlog +On approve: `git show :agent.nix` from `proposed/`, write the bytes +into `applied//agent.nix`, commit there as `hive-c0re`, then +`nixos-container update`. The manager can only propose; only hive-c0re advances +`applied`'s `main`. -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. +See PLAN.md for the full design and the deferred-out-of-scope list. ## Inspirations -- **`~/Repos/bitburner-agent`** — sibling project, drives Claude Code in a - turn loop against a Bitburner CDP session. Patterns to steal as we grow: +- **`~/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. + `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 index 8aecbbb8..a6e74bef 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,9 +1,6 @@ # 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*. +> **Status.** Planning doc for a new project. Lives at `~/Repos/nixos-configuration-claude/agent-system/PLAN.md` during planning; **moves to `~/Repos/hyperhive/PLAN.md`** once the repo is created. > > **Names.** > - Repo: `hyperhive` @@ -13,7 +10,7 @@ > - `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. +> **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. Eventually damocles migrates onto `hyperhive` — out of v1 scope. ## What we're building @@ -99,13 +96,7 @@ A multi-Claude-Code-agent setup on a single host: **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). +**Anthropic credentials.** Shared key on host, bind-mounted into every container. No per-agent keys in v1. **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. @@ -118,43 +109,39 @@ the others). One interactive login per agent, ever; creds survive ## 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 +### 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 +### 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 +### 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 +### 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 +### 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 +### 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. @@ -162,13 +149,13 @@ the canonical summary. - 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 +### 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 +### 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). @@ -217,85 +204,19 @@ the canonical summary. └── 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 /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). +- Destroy verb on the `hive-c0re` API (use `rm` on host; `state-repo` records intent). - Backup / replication of `agents/` state. -- Migration of existing damocles containers (`docs/damocles-migration.md`). +- Migration of existing damocles containers (later, separate effort). - Anything about multiple hosts. + +## Open implementation decisions (resolve during build) + +- MCP relay wire format: stdio MCP shuttled through the socket vs custom JSON-RPC matching MCP semantics. Default: stick with MCP stdio, `hive-c0re` multiplexes. +- Per-agent web UI tech: own small `axum` server in `hive-ag3nt`'s lib with embedded HTMX, plus xterm.js over WebSocket for the terminal. Reuse same stack as the dashboard. +- `state-repo` schema: filesystem-shaped (`agents//role.txt`, `agents//allow-list.txt`) vs single declarative file (`world.toml`). Lean filesystem-shaped — git diffs read naturally per agent. +- Whether `hive-m1nd` also auto-mounts `state-repo` RW or only via the `request_apply_commit` path. Lean: only via the verb (keeps the audit trail clean). +- Whether `hive-c0re` daemon and `hive-c0re` CLI are one binary (subcommands) or two binaries sharing a crate. Default: one binary, `hive-c0re serve` vs `hive-c0re approve ` etc. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 17d76fa6..6796bcac 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -1,10 +1,8 @@ use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; -use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{AgentRequest, AgentResponse}; use tokio::process::Command; @@ -52,30 +50,12 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into()); - let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); - let initial = LoginState::from_dir(&claude_dir); - tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot"); - let login_state = Arc::new(Mutex::new(initial)); - let ui_state = login_state.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve(label, port, ui_state).await { + if let Err(e) = web_ui::serve(label, port).await { tracing::error!(error = ?e, "web ui failed"); } }); - match initial { - LoginState::Online => { - serve(&cli.socket, Duration::from_millis(poll_ms), login_state).await - } - LoginState::NeedsLogin => { - // Partial-run mode: keep the harness alive (so the web UI - // stays bound) but don't drive the turn loop. Poll the - // claude dir periodically so a successful login (whether - // from the dashboard PTY path in step 4 or via - // `root-login` + `claude /login` in the meantime) - // transitions us into the turn loop without a restart. - needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await - } - } + serve(&cli.socket, Duration::from_millis(poll_ms)).await } Cmd::Send { to, body } => { let resp: AgentResponse = @@ -91,32 +71,8 @@ async fn main() -> Result<()> { } } -/// Re-checks `claude_dir` every `poll_ms` ms. As soon as it contains a session -/// (login completed), flips `state` to `Online` and enters the turn loop. -async fn needs_login_loop( - socket: &Path, - claude_dir: &Path, - state: Arc>, - poll_ms: u64, -) -> Result<()> { - tracing::warn!( - claude_dir = %claude_dir.display(), - "no claude session — staying in partial-run mode (web UI only)" - ); - let probe = Duration::from_millis(poll_ms.max(2000)); - loop { - tokio::time::sleep(probe).await; - if login::has_session(claude_dir) { - tracing::info!("claude session detected — entering turn loop"); - *state.lock().unwrap() = LoginState::Online; - return serve(socket, Duration::from_millis(poll_ms), state).await; - } - } -} - -async fn serve(socket: &Path, interval: Duration, state: Arc>) -> Result<()> { +async fn serve(socket: &Path, interval: Duration) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-ag3nt serve"); - let _ = state; // reserved for future state transitions (turn-loop -> needs-login) loop { let recv: Result = client::request(socket, &AgentRequest::Recv).await; match recv { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index e3dd7aae..a2989fc7 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -4,12 +4,10 @@ //! plus a `serve` loop that logs the manager's inbox. use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; -use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, web_ui}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; @@ -35,9 +33,8 @@ enum Cmd { Send { to: String, body: String }, /// Pop one message from the manager's inbox. Recv, - /// Submit a spawn request for the user to approve (creates a pending - /// approval; on approval the host creates + starts the container). - RequestSpawn { name: String }, + /// Spawn a sub-agent. + Spawn { name: String }, /// Kill a sub-agent. Kill { name: String }, /// Submit a config commit on the agent's config repo for user approval. @@ -61,32 +58,16 @@ async fn main() -> Result<()> { .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); - let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); - let initial = LoginState::from_dir(&claude_dir); - tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot"); - let login_state = Arc::new(Mutex::new(initial)); - let ui_state = login_state.clone(); tokio::spawn(async move { - if let Err(e) = web_ui::serve(label, port, ui_state).await { + if let Err(e) = web_ui::serve(label, port).await { tracing::error!(error = ?e, "web ui failed"); } }); - match initial { - LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms)).await, - LoginState::NeedsLogin => { - tracing::warn!( - claude_dir = %claude_dir.display(), - "manager has no claude session — staying in partial-run mode" - ); - needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms).await - } - } + serve(&cli.socket, Duration::from_millis(poll_ms)).await } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, - Cmd::RequestSpawn { name } => { - one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await - } + Cmd::Spawn { name } => one_shot(&cli.socket, ManagerRequest::Spawn { name }).await, Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await, Cmd::RequestApplyCommit { agent, commit_ref } => { one_shot( @@ -107,25 +88,6 @@ async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> { Ok(()) } -/// Manager-side mirror of hive-ag3nt's needs-login loop: keep the web UI -/// alive, poll the claude dir, enter `serve` once login lands. -async fn needs_login_loop( - socket: &Path, - claude_dir: &Path, - state: Arc>, - poll_ms: u64, -) -> Result<()> { - let probe = Duration::from_millis(poll_ms.max(2000)); - loop { - tokio::time::sleep(probe).await; - if login::has_session(claude_dir) { - tracing::info!("manager claude session detected — entering inbox loop"); - *state.lock().unwrap() = LoginState::Online; - return serve(socket, Duration::from_millis(poll_ms)).await; - } - } -} - async fn serve(socket: &Path, interval: Duration) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-m1nd serve"); loop { diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index b601724a..a5608ad0 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -2,8 +2,6 @@ //! `hive-m1nd` (manager) binaries. pub mod client; -pub mod login; -pub mod login_session; pub mod web_ui; /// Default socket path inside the container — bind-mounted by `hive-c0re`. diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs deleted file mode 100644 index 6f8ae699..00000000 --- a/hive-ag3nt/src/login.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is -//! provided by hive-c0re (Phase 8 step 1) and persists across container -//! destroy/recreate so OAuth tokens survive. -//! -//! "Has session" today means "the dir contains at least one regular file." -//! That's a heuristic: a fresh bind-mount starts empty, and `claude /login` -//! writes credentials into the dir. We may refine later (probe for the -//! specific credentials filename, or run a no-op `claude` call) once the -//! exact layout is locked in. - -use std::path::Path; - -/// Mount point of the per-agent Claude credentials dir inside the container. -/// Matches `hive_c0re::lifecycle::CONTAINER_CLAUDE_MOUNT`. -pub const DEFAULT_CLAUDE_DIR: &str = "/root/.claude"; - -/// Returns `true` if `dir` exists and contains any regular file. Used at -/// startup to decide whether to enter the turn loop (logged in) or stay in -/// the partial-run "needs login" state. -#[must_use] -pub fn has_session(dir: &Path) -> bool { - let Ok(entries) = std::fs::read_dir(dir) else { - return false; - }; - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|t| t.is_file()) { - return true; - } - } - false -} - -/// Login state the harness reports to its web UI. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LoginState { - /// `~/.claude/` has credentials; turn loop is running. - Online, - /// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT - /// running. Operator needs to complete login from the web UI. - NeedsLogin, -} - -impl LoginState { - #[must_use] - pub fn from_dir(dir: &Path) -> Self { - if has_session(dir) { - Self::Online - } else { - Self::NeedsLogin - } - } -} diff --git a/hive-ag3nt/src/login_session.rs b/hive-ag3nt/src/login_session.rs deleted file mode 100644 index 100b5b1c..00000000 --- a/hive-ag3nt/src/login_session.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! `claude /login` driver. Spawns the login command under plain stdio pipes, -//! accumulates stdout+stderr in a shared buffer (so the web UI can show -//! whatever URL/prompt claude emits), and writes paste-back codes from the -//! UI into the child's stdin. -//! -//! No PTY — we're betting `claude` produces a parseable URL on stdout and -//! accepts a code on stdin even when not on a terminal. If it refuses or -//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md -//! Phase 8). - -use std::process::Stdio; -use std::sync::{Arc, Mutex}; - -use anyhow::{Context, Result}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; - -const DEFAULT_CMD: &str = "claude"; -const DEFAULT_ARGS: &[&str] = &["/login"]; - -#[derive(Default)] -struct State { - /// Concatenated stdout+stderr as it streams from the child. - output: String, - /// First URL-looking substring we saw in the output. Surface this on the - /// web UI as the link the operator should open. - url: Option, - /// Set when the child has exited. The web UI uses this to know whether - /// the operator can still paste a code. - finished: bool, - /// Exit status note (e.g. "exited with code 0", "killed by signal 15"), - /// shown next to a "finished" badge once the child returns. - exit_note: Option, -} - -/// A running `claude /login` subprocess. -pub struct LoginSession { - child: Mutex, - /// Tokio mutex because we hold the guard across the `write_all().await` - /// in `submit_code`. The other locks are blocking-only and stay on - /// `std::sync::Mutex`. - stdin: tokio::sync::Mutex>, - state: Arc>, -} - -impl LoginSession { - /// Spawn the login command. The exact binary/args are configurable via - /// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by - /// default we run `claude /login`. Failing to spawn returns an error - /// before any state is registered. - pub fn start() -> Result { - let (cmd, args) = resolve_command(); - tracing::info!(%cmd, ?args, "spawning login session"); - - let mut child = Command::new(&cmd) - .args(&args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - // `claude` reads $HOME for the credentials dir; the bind-mount - // puts it at /root/.claude, which is already the default home - // for uid 0 inside the container. Nothing extra to set here. - .kill_on_drop(true) - .spawn() - .with_context(|| format!("spawn `{cmd}`"))?; - - let stdin = child.stdin.take().context("child stdin")?; - let stdout = child.stdout.take().context("child stdout")?; - let stderr = child.stderr.take().context("child stderr")?; - - let state = Arc::new(Mutex::new(State::default())); - tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout")); - tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr")); - - Ok(Self { - child: Mutex::new(child), - stdin: tokio::sync::Mutex::new(Some(stdin)), - state, - }) - } - - /// Write `code` (plus a newline) to the child's stdin. Returns an error - /// if the stdin has already been closed (e.g. after the child exited or - /// after a prior submission consumed it). - pub async fn submit_code(&self, code: &str) -> Result<()> { - let mut guard = self.stdin.lock().await; - let stdin = guard.as_mut().context("login stdin already closed")?; - let line = format!("{}\n", code.trim()); - stdin - .write_all(line.as_bytes()) - .await - .context("write code to claude stdin")?; - stdin.flush().await.context("flush claude stdin")?; - Ok(()) - } - - /// Close stdin so claude sees EOF (useful if it's waiting for more input - /// after the code submit). - pub async fn close_stdin(&self) { - let _ = self.stdin.lock().await.take(); - } - - pub fn output(&self) -> String { - self.state.lock().unwrap().output.clone() - } - - pub fn url(&self) -> Option { - self.state.lock().unwrap().url.clone() - } - - pub fn finished(&self) -> bool { - self.state.lock().unwrap().finished - } - - pub fn exit_note(&self) -> Option { - self.state.lock().unwrap().exit_note.clone() - } - - /// Best-effort: poll the child once and update `finished`/`exit_note`. - /// Called by the web UI on each render so the state stays fresh without - /// running a dedicated reaper task. - pub fn poll(&self) { - let mut child = self.child.lock().unwrap(); - match child.try_wait() { - Ok(Some(status)) => { - let mut s = self.state.lock().unwrap(); - s.finished = true; - s.exit_note = Some(format!("{status}")); - } - Ok(None) => {} - Err(e) => { - let mut s = self.state.lock().unwrap(); - s.finished = true; - s.exit_note = Some(format!("try_wait error: {e}")); - } - } - } - - /// Kill the child if it's still running. Idempotent. - pub fn kill(&self) { - if let Err(e) = self.child.lock().unwrap().start_kill() { - tracing::warn!(error = ?e, "kill login child"); - } - } -} - -fn resolve_command() -> (String, Vec) { - if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") { - // Whitespace-only split — no quote handling. Fine for "claude /login" - // style overrides; if we need anything with embedded spaces we'll - // switch to shell-words. - let mut parts = raw.split_whitespace().map(str::to_owned); - if let Some(cmd) = parts.next() { - return (cmd, parts.collect()); - } - } - ( - DEFAULT_CMD.into(), - DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(), - ) -} - -async fn pump( - mut reader: BufReader, - state: Arc>, - tag: &'static str, -) { - let mut buf = String::new(); - loop { - buf.clear(); - // read_line breaks on \n; for claude's TUI output that flushes by - // line this is fine. If it ever blasts a single un-newlined blob, - // we'll miss it until EOF (acceptable for the URL surface — claude - // prints the URL on its own line). - match reader.read_line(&mut buf).await { - Ok(0) => { - state.lock().unwrap().finished = true; - break; - } - Ok(_) => { - let mut s = state.lock().unwrap(); - if s.url.is_none() - && let Some(url) = extract_url(&buf) - { - tracing::info!(%url, %tag, "login URL detected"); - s.url = Some(url); - } - s.output.push_str(&buf); - } - Err(e) => { - tracing::warn!(error = ?e, %tag, "login pump read error"); - let mut s = state.lock().unwrap(); - s.finished = true; - s.exit_note = Some(format!("pump {tag} error: {e}")); - break; - } - } - } -} - -/// Return the first `https://…` substring on the line, terminating at any -/// ASCII whitespace. Good enough for capturing claude's OAuth link without a -/// regex dependency. -fn extract_url(line: &str) -> Option { - let start = line.find("https://")?; - let tail = &line[start..]; - let end = tail - .find(|c: char| c.is_ascii_whitespace()) - .unwrap_or(tail.len()); - let url = tail[..end].trim_end_matches(['.', ',', ')', ']']); - if url.len() > "https://".len() { - Some(url.to_owned()) - } else { - None - } -} - -/// Helper used by the web UI to gate "is there a session running right now" -/// without holding both this module's mutex and the `AppState`'s at once. -pub fn drop_if_finished(slot: &Mutex>>) { - let mut guard = slot.lock().unwrap(); - if let Some(s) = guard.as_ref() { - s.poll(); - if s.finished() { - *guard = None; - } - } -} - -impl Drop for LoginSession { - fn drop(&mut self) { - // kill_on_drop on the Command also ensures the child dies, but we - // belt-and-brace it in case the runtime detaches. - let _ = self.child.lock().unwrap().start_kill(); - } -} - -#[cfg(test)] -mod tests { - use super::extract_url; - - #[test] - fn picks_first_https() { - let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n"; - assert_eq!( - extract_url(line).as_deref(), - Some("https://claude.ai/oauth/abc?xyz=1"), - ); - } - - #[test] - fn trailing_punctuation_stripped() { - let line = "Open https://example.com/abc).\n"; - assert_eq!( - extract_url(line).as_deref(), - Some("https://example.com/abc"), - ); - } - - #[test] - fn no_url() { - assert_eq!(extract_url("nothing here\n"), None); - } -} diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index e09adb2b..132304a1 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -4,44 +4,18 @@ //! `hive-c0re`'s generated per-agent flake (deterministic from agent name). use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; use anyhow::{Context, Result}; -use axum::{ - Form, Router, - extract::State, - response::{Html, IntoResponse, Redirect, Response}, - routing::{get, post}, -}; -use serde::Deserialize; - -use crate::login::LoginState; -use crate::login_session::{LoginSession, drop_if_finished}; - -/// Live login state for the web UI. The harness updates this in place as it -/// transitions between `NeedsLogin` and `Online`; the UI reads on each -/// render. -pub type LoginStateCell = Arc>; +use axum::{Router, extract::State, response::Html, routing::get}; #[derive(Clone)] struct AppState { label: String, - login: LoginStateCell, - session: Arc>>>, } -pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<()> { - let state = AppState { - label, - login, - session: Arc::new(Mutex::new(None)), - }; - let app = Router::new() - .route("/", get(index)) - .route("/login/start", post(post_login_start)) - .route("/login/code", post(post_login_code)) - .route("/login/cancel", post(post_login_cancel)) - .with_state(state); +pub async fn serve(label: String, port: u16) -> Result<()> { + let state = AppState { label }; + let app = Router::new().route("/", get(index)).with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) .await @@ -52,121 +26,12 @@ pub async fn serve(label: String, port: u16, login: LoginStateCell) -> Result<() } async fn index(State(state): State) -> Html { - drop_if_finished(&state.session); - let login = *state.login.lock().unwrap(); - let session_snapshot = state.session.lock().unwrap().clone(); - let body = match (login, session_snapshot) { - (LoginState::Online, _) => render_online(), - (LoginState::NeedsLogin, None) => render_needs_login_idle(), - (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), - }; Html(format!( - "\n\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n", + "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n

▓█▓▒░ harness alive ▓█▓▒░

\n

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

\n\n\n", label = state.label, )) } -fn render_online() -> String { - "

▓█▓▒░ harness alive — turn loop running ▓█▓▒░

\n

phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+

".into() -} - -fn render_needs_login_idle() -> String { - "

▓█▓▒░ NEEDS L0G1N ▓█▓▒░

\n

No Claude session in ~/.claude/. The harness is up but the turn loop is paused until you log in.

\n
\n \n
\n

Spawns claude /login over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.

".into() -} - -fn render_login_in_progress(session: &Arc) -> String { - let url_block = match session.url() { - Some(url) => format!( - "

{url}

\n

open this URL in a browser, complete the OAuth flow, paste the resulting code below.

", - url = html_escape(&url), - ), - None => "

waiting for claude to emit an OAuth URL on stdout… (output below)

".into(), - }; - let exit_badge = if session.finished() { - let note = session.exit_note().unwrap_or_else(|| "exited".into()); - format!( - "

claude process exited: {note}. Start over if needed.

", - note = html_escape(¬e), - ) - } else { - String::new() - }; - let output = session.output(); - let code_form = if session.finished() { - String::new() - } else { - "
\n \n \n
".into() - }; - let cancel_form = "
\n \n
".to_owned(); - format!( - "

▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░

\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n

output

\n
{output}
", - output = html_escape(&output), - ) -} - -async fn post_login_start(State(state): State) -> Response { - drop_if_finished(&state.session); - { - let guard = state.session.lock().unwrap(); - if guard.is_some() { - return Redirect::to("/").into_response(); - } - } - match LoginSession::start() { - Ok(session) => { - *state.session.lock().unwrap() = Some(Arc::new(session)); - Redirect::to("/").into_response() - } - Err(e) => error_response(&format!("login start failed: {e:#}")), - } -} - -#[derive(Deserialize)] -struct CodeForm { - code: String, -} - -async fn post_login_code( - State(state): State, - Form(form): Form, -) -> Response { - let session = state.session.lock().unwrap().clone(); - let Some(session) = session else { - return error_response("no login session running"); - }; - if let Err(e) = session.submit_code(&form.code).await { - return error_response(&format!("submit code failed: {e:#}")); - } - Redirect::to("/").into_response() -} - -async fn post_login_cancel(State(state): State) -> Response { - let session = state.session.lock().unwrap().take(); - if let Some(session) = session { - session.close_stdin().await; - session.kill(); - } - Redirect::to("/").into_response() -} - -fn error_response(message: &str) -> Response { - ( - axum::http::StatusCode::INTERNAL_SERVER_ERROR, - Html(format!( - "\n{STYLE}

error

{msg}

← back

", - msg = html_escape(message), - )), - ) - .into_response() -} - -fn html_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) -} - const STYLE: &str = r#" "#; diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index c3655575..bef28f6e 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -3,85 +3,35 @@ //! `&Coordinator` and the request parameters; callers stitch the response //! shape they want (HTTP redirect vs JSON). -use std::sync::Arc; +use anyhow::Result; +use hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; -use anyhow::{Result, bail}; -use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; +use crate::coordinator::Coordinator; +use crate::lifecycle; -use crate::coordinator::{Coordinator, TransientKind}; -use crate::lifecycle::{self, MANAGER_NAME}; - -/// Approve a pending request and run the underlying action. Dispatches on the -/// approval kind: -/// - `ApplyCommit`: read agent.nix at the approval's commit from the proposed -/// repo, copy into the applied repo, commit there, rebuild the container. -/// Synchronous — returns once the rebuild completes. -/// - `Spawn`: create + start a brand-new sub-agent container. Runs in a -/// background task so the operator's approve click returns immediately; -/// the dashboard surfaces a transient `Spawning` state until the container -/// is up. On failure, the approval is marked failed. -/// -/// In all cases an `ApprovalResolved` helper event lands in the manager's -/// inbox when the work resolves. -pub async fn approve(coord: Arc, id: i64) -> Result<()> { +/// Approve a pending request: read the agent.nix at the approval's commit from +/// the proposed repo, copy into the applied repo, commit there, and rebuild +/// the agent container. On failure marks the approval failed (with the error +/// note) and returns the error. Either way, an `ApprovalResolved` helper event +/// is pushed into the manager's inbox. +pub async fn approve(coord: &Coordinator, id: i64) -> Result<()> { let approval = coord.approvals.mark_approved(id)?; - tracing::info!( - %approval.id, - %approval.agent, - kind = ?approval.kind, - %approval.commit_ref, - "approval: running action", - ); + tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); let agent_dir = coord.register_agent(&approval.agent)?; let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent); let applied_dir = Coordinator::agent_applied_dir(&approval.agent); - let claude_dir = Coordinator::agent_claude_dir(&approval.agent); - - match approval.kind { - ApprovalKind::ApplyCommit => { - let result = async { - lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; - lifecycle::rebuild( - &approval.agent, - &coord.hyperhive_flake, - &agent_dir, - &applied_dir, - &claude_dir, - ) - .await - } - .await; - finish_approval(&coord, &approval, result) - } - ApprovalKind::Spawn => { - // Run the spawn in the background so the approve POST returns - // immediately. The dashboard reads `transient` to render a spinner. - coord.set_transient(&approval.agent, TransientKind::Spawning); - let coord_bg = coord.clone(); - let approval_bg = approval.clone(); - tokio::spawn(async move { - let agent_bg = approval_bg.agent.clone(); - let result = lifecycle::spawn( - &approval_bg.agent, - &coord_bg.hyperhive_flake, - &agent_dir, - &proposed_dir, - &applied_dir, - &claude_dir, - ) - .await; - coord_bg.clear_transient(&agent_bg); - if let Err(e) = finish_approval(&coord_bg, &approval_bg, result) { - tracing::warn!(agent = %agent_bg, error = ?e, "spawn approval failed"); - } - }); - Ok(()) - } + let result: Result<()> = async { + lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?; + lifecycle::rebuild( + &approval.agent, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + ) + .await } -} - -fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> { + .await; match result { Ok(()) => { notify_manager( @@ -114,31 +64,6 @@ fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: } } -/// Tear down a sub-agent container. By default this is non-destructive to -/// persistent state: the proposed/applied config repos and the Claude -/// credentials dir under `/var/lib/hyperhive/{agents,applied}//` are -/// kept, so recreating an agent of the same name reuses prior config + creds -/// (no re-login). The ephemeral runtime dir under `/run/hyperhive/agents/` -/// is cleared because its contents (the mcp socket) don't survive restarts -/// anyway. A future `--purge` path can wipe state when the operator opts in. -/// Refuses the manager (declarative; would fight with the host's nixos config). -pub async fn destroy(coord: &Coordinator, name: &str) -> Result<()> { - if name == MANAGER_NAME || name == MANAGER_AGENT { - bail!("refusing to destroy the manager ({name})"); - } - tracing::info!(%name, "destroy"); - lifecycle::destroy(name).await?; - coord.unregister_agent(name); - let runtime = Coordinator::agent_dir(name); - if runtime.exists() { - let _ = std::fs::remove_dir_all(&runtime); - } - let _ = coord - .approvals - .fail_pending_for_agent(name, "agent destroyed"); - Ok(()) -} - pub fn deny(coord: &Coordinator, id: i64) -> Result<()> { let approval = coord.approvals.get(id)?; coord.approvals.mark_denied(id)?; diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index d3e659bb..8f6e66a7 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, bail}; -use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus}; +use hive_sh4re::{Approval, ApprovalStatus}; use rusqlite::{Connection, OptionalExtension, params}; const SCHEMA: &str = r" @@ -24,23 +24,6 @@ CREATE INDEX IF NOT EXISTS idx_approvals_pending ON approvals (id) WHERE status = 'pending'; "; -/// Add the `kind` column to pre-Phase-8 databases. ALTER TABLE ADD COLUMN is -/// idempotent here only via a column-existence check (sqlite doesn't support -/// IF NOT EXISTS on ADD COLUMN). Defaults legacy rows to `apply_commit`, -/// which matches their actual semantics. -fn ensure_kind_column(conn: &Connection) -> Result<()> { - let has_kind: bool = conn - .prepare("SELECT 1 FROM pragma_table_info('approvals') WHERE name = 'kind'")? - .exists([])?; - if !has_kind { - conn.execute_batch( - "ALTER TABLE approvals ADD COLUMN kind TEXT NOT NULL DEFAULT 'apply_commit';", - ) - .context("add approvals.kind column")?; - } - Ok(()) -} - pub struct Approvals { conn: Mutex, } @@ -55,22 +38,17 @@ impl Approvals { .with_context(|| format!("open approvals db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply approvals schema")?; - ensure_kind_column(&conn).context("migrate approvals.kind")?; Ok(Self { conn: Mutex::new(conn), }) } pub fn submit(&self, agent: &str, commit_ref: &str) -> Result { - self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref) - } - - pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( - "INSERT INTO approvals (agent, kind, commit_ref, requested_at, status) - VALUES (?1, ?2, ?3, ?4, 'pending')", - params![agent, kind_to_str(kind), commit_ref, now_unix()], + "INSERT INTO approvals (agent, commit_ref, requested_at, status) + VALUES (?1, ?2, ?3, 'pending')", + params![agent, commit_ref, now_unix()], )?; Ok(conn.last_insert_rowid()) } @@ -78,7 +56,7 @@ impl Approvals { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE status = 'pending' ORDER BY id ASC", @@ -91,7 +69,7 @@ impl Approvals { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE id = ?1", params![id], row_to_approval, @@ -104,22 +82,14 @@ impl Approvals { /// approval so the caller can run the action and pass the agent name. pub fn mark_approved(&self, id: i64) -> Result { let conn = self.conn.lock().unwrap(); - let current: Option<(String, String, String, i64, String)> = conn + let current: Option<(String, String, i64, String)> = conn .query_row( - "SELECT agent, kind, commit_ref, requested_at, status FROM approvals WHERE id = ?1", + "SELECT agent, commit_ref, requested_at, status FROM approvals WHERE id = ?1", params![id], - |row| { - Ok(( - row.get(0)?, - row.get(1)?, - row.get(2)?, - row.get(3)?, - row.get(4)?, - )) - }, + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), ) .optional()?; - let Some((agent, kind, commit_ref, requested_at, status)) = current else { + let Some((agent, commit_ref, requested_at, status)) = current else { bail!("approval {id} not found"); }; if status != "pending" { @@ -133,7 +103,6 @@ impl Approvals { Ok(Approval { id, agent, - kind: kind_from_str(&kind)?, commit_ref, requested_at, status: ApprovalStatus::Approved, @@ -163,35 +132,10 @@ impl Approvals { )?; Ok(()) } - - /// Mark every pending approval for `agent` as failed (returns rows affected). - /// Used by `destroy` to clear the queue of an agent that no longer exists. - pub fn fail_pending_for_agent(&self, agent: &str, note: &str) -> Result { - let conn = self.conn.lock().unwrap(); - let n = conn.execute( - "UPDATE approvals SET status = 'failed', resolved_at = ?1, note = ?2 - WHERE agent = ?3 AND status = 'pending'", - params![now_unix(), note, agent], - )?; - Ok(n) - } } fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { - // Column order: id, agent, kind, commit_ref, requested_at, status, resolved_at, note. - let kind: String = row.get(2)?; - let kind = match kind.as_str() { - "apply_commit" => ApprovalKind::ApplyCommit, - "spawn" => ApprovalKind::Spawn, - other => { - return Err(rusqlite::Error::FromSqlConversionFailure( - 2, - rusqlite::types::Type::Text, - format!("unknown approval kind '{other}'").into(), - )); - } - }; - let status: String = row.get(5)?; + let status: String = row.get(4)?; let status = match status.as_str() { "pending" => ApprovalStatus::Pending, "approved" => ApprovalStatus::Approved, @@ -199,7 +143,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { "failed" => ApprovalStatus::Failed, other => { return Err(rusqlite::Error::FromSqlConversionFailure( - 5, + 4, rusqlite::types::Type::Text, format!("unknown approval status '{other}'").into(), )); @@ -208,27 +152,11 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Approval { id: row.get(0)?, agent: row.get(1)?, - kind, - commit_ref: row.get(3)?, - requested_at: row.get(4)?, + commit_ref: row.get(2)?, + requested_at: row.get(3)?, status, - resolved_at: row.get(6)?, - note: row.get(7)?, - }) -} - -fn kind_to_str(kind: ApprovalKind) -> &'static str { - match kind { - ApprovalKind::ApplyCommit => "apply_commit", - ApprovalKind::Spawn => "spawn", - } -} - -fn kind_from_str(s: &str) -> Result { - Ok(match s { - "apply_commit" => ApprovalKind::ApplyCommit, - "spawn" => ApprovalKind::Spawn, - other => bail!("unknown approval kind '{other}'"), + resolved_at: row.get(5)?, + note: row.get(6)?, }) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 6bc2e068..5f7a0295 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -30,24 +30,6 @@ pub struct Coordinator { /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, agents: Mutex>, - /// Agents whose lifecycle action (currently just spawn) is in flight. - /// Read by the dashboard to render a spinner; cleared when the action - /// resolves (success or failure). - transient: Mutex>, -} - -/// Per-agent in-progress state that the dashboard surfaces between approve -/// click and container ready. -#[derive(Debug, Clone)] -pub struct TransientState { - pub kind: TransientKind, - pub since: std::time::Instant, -} - -#[derive(Debug, Clone, Copy)] -pub enum TransientKind { - /// `lifecycle::spawn` is running (nixos-container create + update + start). - Spawning, } impl Coordinator { @@ -59,7 +41,6 @@ impl Coordinator { approvals: Arc::new(approvals), hyperhive_flake, agents: Mutex::new(HashMap::new()), - transient: Mutex::new(HashMap::new()), }) } @@ -83,25 +64,6 @@ impl Coordinator { } } - /// Mark an agent as in-progress (only one state per agent for now). - pub fn set_transient(&self, name: &str, kind: TransientKind) { - self.transient.lock().unwrap().insert( - name.to_owned(), - TransientState { - kind, - since: std::time::Instant::now(), - }, - ); - } - - pub fn clear_transient(&self, name: &str) { - self.transient.lock().unwrap().remove(name); - } - - pub fn transient_snapshot(&self) -> HashMap { - self.transient.lock().unwrap().clone() - } - pub fn agent_dir(name: &str) -> PathBuf { PathBuf::from(format!("{AGENT_RUNTIME_ROOT}/{name}")) } @@ -118,23 +80,10 @@ impl Coordinator { Self::manager_dir().join("mcp.sock") } - /// Per-agent state root (parent of `config/`, future `prompts/`, etc.). - pub fn agent_state_root(name: &str) -> PathBuf { - PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}")) - } - /// Manager-editable proposed config repo. Bind-mounted into the manager /// container as `/agents//config/`. pub fn agent_proposed_dir(name: &str) -> PathBuf { - Self::agent_state_root(name).join("config") - } - - /// Per-agent Claude credentials dir. Bind-mounted RW into the agent - /// container at `/root/.claude` so OAuth state survives container - /// destroy/recreate. Each agent owns its own token lineage — sharing - /// would break on the first refresh-token rotation. - pub fn agent_claude_dir(name: &str) -> PathBuf { - Self::agent_state_root(name).join("claude") + PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) } /// Authoritative applied config repo. Hive-c0re-only. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 14428a37..b09bd5ee 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -41,8 +41,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/", get(index)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) - .route("/destroy/{name}", post(post_destroy)) - .route("/request-spawn", post(post_request_spawn)) .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); @@ -63,26 +61,15 @@ async fn index(headers: HeaderMap, State(state): State) -> Html".to_owned() - }; - Html(format!( - "\n\n\n\nhyperhive // h1ve-c0re\n{refresh}\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", - containers = render_containers(&containers, &transient, &hostname), + "\n\n\n\nhyperhive // h1ve-c0re\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", + containers = render_containers(&containers, &hostname), talk = render_talk(&containers), )) } @@ -124,7 +111,7 @@ async fn messages_stream( } async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { - match actions::approve(state.coord.clone(), id).await { + match actions::approve(&state.coord, id).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } @@ -137,39 +124,6 @@ async fn post_deny(State(state): State, AxumPath(id): AxumPath) - } } -#[derive(Deserialize)] -struct RequestSpawnForm { - name: String, -} - -async fn post_request_spawn( - State(state): State, - Form(form): Form, -) -> Response { - let name = form.name.trim().to_owned(); - if name.is_empty() { - return error_response("spawn: `name` required"); - } - match state - .coord - .approvals - .submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "") - { - Ok(id) => { - tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); - Redirect::to("/").into_response() - } - Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")), - } -} - -async fn post_destroy(State(state): State, AxumPath(name): AxumPath) -> Response { - match actions::destroy(&state.coord, &name).await { - Ok(()) => Redirect::to("/").into_response(), - Err(e) => error_response(&format!("destroy {name} failed: {e:#}")), - } -} - fn error_response(message: &str) -> Response { ( StatusCode::INTERNAL_SERVER_ERROR, @@ -181,36 +135,11 @@ fn error_response(message: &str) -> Response { .into_response() } -fn render_containers( - containers: &[String], - transient: &std::collections::HashMap, - hostname: &str, -) -> String { +fn render_containers(containers: &[String], hostname: &str) -> String { let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); - out.push_str("
\n \n \n
\n

spawn requests queue as approvals. operator approves below to actually create the container.

\n"); - // Render in-flight spawns first so the operator sees feedback immediately. - if !transient.is_empty() { - out.push_str("
    \n"); - for (name, state) in transient { - // Skip names that already exist in `containers` (race: spawn finished - // between transient set and list refresh). - if containers.iter().any(|c| c == &format!("h-{name}")) { - continue; - } - let secs = state.since.elapsed().as_secs(); - let label = match state.kind { - crate::coordinator::TransientKind::Spawning => "spawning…", - }; - let _ = writeln!( - out, - "
  • {name} {label} nixos-container create + start ({secs}s)
  • ", - ); - } - out.push_str("
\n"); - } - if containers.is_empty() && transient.is_empty() { + if containers.is_empty() { out.push_str("

▓ no managed containers ▓

\n"); return out; } @@ -223,15 +152,9 @@ fn render_containers( ); } else if let Some(name) = container.strip_prefix(AGENT_PREFIX) { let port = lifecycle::agent_web_port(name); - let claude_dir = Coordinator::agent_claude_dir(name); - let login_badge = if claude_has_session(&claude_dir) { - "" - } else { - " needs login" - }; let _ = writeln!( out, - "
  • ▒░▒░░ {name} ag3nt{login_badge} {container} :{port}\n
    \n
  • ", + "
  • ▒░▒░░ {name} ag3nt {container} :{port}
  • ", ); } } @@ -249,27 +172,15 @@ async fn render_approvals(approvals: &[Approval]) -> String { } out.push_str("
      \n"); for a in approvals { - match a.kind { - hive_sh4re::ApprovalKind::ApplyCommit => { - let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; - let diff = approval_diff(&a.agent, &a.commit_ref).await; - let _ = writeln!( - out, - "
    • \n
      #{id} {agent} apply {sha_short}\n
      \n
      \n
      \n
      diff vs applied
      {diff}
      \n
    • ", - id = a.id, - agent = a.agent, - diff = html_escape(&diff), - ); - } - hive_sh4re::ApprovalKind::Spawn => { - let _ = writeln!( - out, - "
    • \n
      #{id} {agent} spawn new sub-agent — container will be created on approve\n
      \n
      \n
      \n
    • ", - id = a.id, - agent = a.agent, - ); - } - } + let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)]; + let diff = approval_diff(&a.agent, &a.commit_ref).await; + let _ = writeln!( + out, + "
    • \n
      #{id} {agent} {sha_short}\n
      \n
      \n
      \n
      diff vs applied
      {diff}
      \n
    • ", + id = a.id, + agent = a.agent, + diff = html_escape(&diff), + ); } out.push_str("
    \n"); out @@ -301,11 +212,6 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { approvals .into_iter() .filter(|a| { - // Spawn approvals are for not-yet-existent agents; the proposed - // dir is supposed to be missing. - if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) { - return true; - } if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { @@ -317,19 +223,6 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { .collect() } -/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the -/// agent's bound `~/.claude/` dir on disk contains any regular file. The -/// dashboard reads this each render so logins driven from the agent web UI -/// (Phase 8 step 4) reflect within one auto-refresh cycle. -fn claude_has_session(dir: &Path) -> bool { - let Ok(entries) = std::fs::read_dir(dir) else { - return false; - }; - entries - .flatten() - .any(|e| e.file_type().is_ok_and(|t| t.is_file())) -} - async fn approval_diff(agent: &str, commit_ref: &str) -> String { let proposed = Coordinator::agent_proposed_dir(agent); if !proposed.exists() { @@ -517,7 +410,6 @@ const STYLE: &str = r#" } .approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; } .approvals form.inline { display: inline; margin-left: 0.4em; } - ul form.inline { display: inline-block; } .btn { font-family: inherit; font-weight: bold; @@ -532,40 +424,7 @@ const STYLE: &str = r#" .btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; } .btn-approve { color: var(--green); border-color: var(--green); } .btn-deny { color: var(--red); border-color: var(--red); } - .btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; } .btn-talk { color: var(--cyan); border-color: var(--cyan); } - .btn-spawn { color: var(--amber); border-color: var(--amber); } - .spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; } - .spawnform input { - font-family: inherit; - font-size: 1em; - background: var(--bg-elev); - color: var(--fg); - border: 1px solid var(--border); - padding: 0.4em 0.6em; - flex: 1; - } - .spawnform input::placeholder { color: var(--muted); } - .spawnform input:focus { outline: 1px solid var(--purple); } - .role-pending { color: var(--amber); border-color: var(--amber); } - .kind { - display: inline-block; - margin-left: 0.4em; - padding: 0.05em 0.5em; - border: 1px solid var(--purple-dim); - color: var(--purple-dim); - border-radius: 2px; - font-size: 0.75em; - letter-spacing: 0.1em; - text-transform: uppercase; - } - .kind-spawn { color: var(--amber); border-color: var(--amber); } - .spinner { - display: inline-block; - animation: spin 1s linear infinite; - color: var(--amber); - } - @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .talkform { display: flex; gap: 0.6em; diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index cc80066d..dcce16c4 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -16,10 +16,6 @@ pub const MANAGER_NAME: &str = "hm1nd"; /// Mount point of the per-agent runtime directory inside the container. pub const CONTAINER_RUNTIME_MOUNT: &str = "/run/hive"; -/// Mount point of the per-agent Claude credentials dir inside the container. -/// Persistent across destroy/recreate so OAuth login survives. -pub const CONTAINER_CLAUDE_MOUNT: &str = "/root/.claude"; - const GIT_NAME: &str = "hive-c0re"; const GIT_EMAIL: &str = "hive-c0re@hyperhive"; @@ -70,16 +66,14 @@ pub async fn spawn( agent_dir: &Path, proposed_dir: &Path, applied_dir: &Path, - claude_dir: &Path, ) -> Result<()> { validate(name)?; setup_proposed(proposed_dir, name).await?; setup_applied(applied_dir, name, hyperhive_flake).await?; - ensure_claude_dir(claude_dir)?; let container = container_name(name); let flake_ref = format!("{}#default", applied_dir.display()); run(&["create", &container, "--flake", &flake_ref]).await?; - set_nspawn_flags(&container, agent_dir, claude_dir)?; + set_nspawn_flags(&container, agent_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["start", &container]).await @@ -91,37 +85,17 @@ pub async fn kill(name: &str) -> Result<()> { run(&["stop", &container]).await } -/// Fully tear down a sub-agent's container: stop + remove via `nixos-container -/// destroy`, then clean our own systemd drop-in. Leaves it to the caller to -/// wipe `/var/lib/hyperhive/...` state and the per-agent runtime dir. -pub async fn destroy(name: &str) -> Result<()> { - validate(name)?; - let container = container_name(name); - // nixos-container destroy handles stop + removal of /var/lib/nixos-containers/ - // and /etc/nixos-containers/.conf. Tolerate "no such container". - if let Err(e) = run(&["destroy", &container]).await { - tracing::warn!(error = ?e, "nixos-container destroy returned an error; continuing cleanup"); - } - let dropin_dir = format!("/run/systemd/system/container@{container}.service.d"); - if std::path::Path::new(&dropin_dir).exists() { - std::fs::remove_dir_all(&dropin_dir).with_context(|| format!("remove {dropin_dir}"))?; - } - Ok(()) -} - pub async fn rebuild( name: &str, hyperhive_flake: &str, agent_dir: &Path, applied_dir: &Path, - claude_dir: &Path, ) -> Result<()> { validate(name)?; setup_applied(applied_dir, name, hyperhive_flake).await?; - ensure_claude_dir(claude_dir)?; let container = container_name(name); let flake_ref = format!("{}#default", applied_dir.display()); - set_nspawn_flags(&container, agent_dir, claude_dir)?; + set_nspawn_flags(&container, agent_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["update", &container, "--flake", &flake_ref]).await?; @@ -256,23 +230,6 @@ pub async fn apply_commit(applied_dir: &Path, proposed_dir: &Path, commit_ref: & Ok(()) } -/// Create the per-agent Claude credentials dir if missing. Mode 0700 — only -/// root inside the container reads/writes it. Idempotent: existing dirs are -/// left untouched (an agent's OAuth tokens survive `destroy`/recreate). -fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { - if !claude_dir.exists() { - std::fs::create_dir_all(claude_dir) - .with_context(|| format!("create {}", claude_dir.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(claude_dir, std::fs::Permissions::from_mode(0o700)) - .with_context(|| format!("chmod {}", claude_dir.display()))?; - } - } - Ok(()) -} - fn initial_agent_nix(name: &str) -> String { format!( "{{ ... }}:\n{{\n # Per-agent overrides for {name}. The manager edits this\n # file (and commits) to customise the agent's NixOS config.\n}}\n", @@ -372,13 +329,12 @@ async fn systemd_daemon_reload() -> Result<()> { /// is reachable on the host) and `EXTRA_NSPAWN_FLAGS` (the runtime-dir bind). /// The start script expands `$EXTRA_NSPAWN_FLAGS` unquoted into the /// `systemd-nspawn` command. -fn set_nspawn_flags(container: &str, agent_dir: &Path, claude_dir: &Path) -> Result<()> { +fn set_nspawn_flags(container: &str, agent_dir: &Path) -> Result<()> { let path = format!("/etc/nixos-containers/{container}.conf"); let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; let bind_flag = format!( - "EXTRA_NSPAWN_FLAGS=\"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}\"", - runtime = agent_dir.display(), - claude = claude_dir.display(), + "EXTRA_NSPAWN_FLAGS=\"--bind={}:{CONTAINER_RUNTIME_MOUNT}\"", + agent_dir.display() ); let mut lines: Vec = original .lines() diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 1db132d7..9e3e9772 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -44,18 +44,10 @@ enum Cmd { #[arg(long, default_value_t = 7000)] dashboard_port: u16, }, - /// Spawn a new agent container directly (`hive-agent-`). Bypasses - /// the approval queue — use only as an operator on the host. For - /// approval-gated spawns, use `request-spawn` instead. + /// Spawn a new agent container (`hive-agent-`). Spawn { name: String }, - /// Queue a spawn request as an approval. The container is created on - /// `approve ` (CLI) or the dashboard's APPR0VE button. - RequestSpawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, - /// Tear down a sub-agent container. Container is removed; persistent - /// state (config repos + Claude credentials) is kept by default. - Destroy { name: String }, /// Apply pending config to a managed container. Rebuild { name: String }, /// List managed containers. @@ -97,15 +89,9 @@ async fn main() -> Result<()> { Cmd::Spawn { name } => { render(client::request(&cli.socket, HostRequest::Spawn { name }).await?) } - Cmd::RequestSpawn { name } => { - render(client::request(&cli.socket, HostRequest::RequestSpawn { name }).await?) - } Cmd::Kill { name } => { render(client::request(&cli.socket, HostRequest::Kill { name }).await?) } - Cmd::Destroy { name } => { - render(client::request(&cli.socket, HostRequest::Destroy { name }).await?) - } Cmd::Rebuild { name } => { render(client::request(&cli.socket, HostRequest::Rebuild { name }).await?) } diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index a7329daf..408bc4a5 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -91,16 +91,29 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, - ManagerRequest::RequestSpawn { name } => { - tracing::info!(%name, "manager: request_spawn"); - match coord - .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "") - { - Ok(id) => { - tracing::info!(%id, %name, "spawn approval queued"); - ManagerResponse::Ok + ManagerRequest::Spawn { name } => { + tracing::info!(%name, "manager: spawn"); + let result: Result<()> = async { + let agent_dir = coord.register_agent(name)?; + let proposed_dir = Coordinator::agent_proposed_dir(name); + let applied_dir = Coordinator::agent_applied_dir(name); + if let Err(e) = lifecycle::spawn( + name, + &coord.hyperhive_flake, + &agent_dir, + &proposed_dir, + &applied_dir, + ) + .await + { + coord.unregister_agent(name); + return Err(e); } + Ok(()) + } + .await; + match result { + Ok(()) => ManagerResponse::Ok, Err(e) => ManagerResponse::Err { message: format!("{e:#}"), }, diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 9a960306..db382a7e 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -46,7 +46,7 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { return Ok(()); } let resp = match serde_json::from_str::(line.trim()) { - Ok(req) => dispatch(&req, coord.clone()).await, + Ok(req) => dispatch(&req, &coord).await, Err(e) => HostResponse::error(format!("parse error: {e}")), }; let mut payload = serde_json::to_string(&resp)?; @@ -56,7 +56,7 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { } } -async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { +async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse { let result: anyhow::Result = async { Ok(match req { HostRequest::Spawn { name } => { @@ -64,14 +64,12 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let agent_dir = coord.register_agent(name)?; let proposed_dir = Coordinator::agent_proposed_dir(name); let applied_dir = Coordinator::agent_applied_dir(name); - let claude_dir = Coordinator::agent_claude_dir(name); if let Err(e) = lifecycle::spawn( name, &coord.hyperhive_flake, &agent_dir, &proposed_dir, &applied_dir, - &claude_dir, ) .await { @@ -81,47 +79,27 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { } HostResponse::success() } - HostRequest::RequestSpawn { name } => { - tracing::info!(%name, "request_spawn"); - let id = coord - .approvals - .submit_kind(name, hive_sh4re::ApprovalKind::Spawn, "")?; - tracing::info!(%id, %name, "spawn approval queued"); - HostResponse::success() - } HostRequest::Kill { name } => { tracing::info!(%name, "kill"); lifecycle::kill(name).await?; coord.unregister_agent(name); HostResponse::success() } - HostRequest::Destroy { name } => { - actions::destroy(&coord, name).await?; - HostResponse::success() - } HostRequest::Rebuild { name } => { tracing::info!(%name, "rebuild"); let agent_dir = coord.register_agent(name)?; let applied_dir = Coordinator::agent_applied_dir(name); - let claude_dir = Coordinator::agent_claude_dir(name); - lifecycle::rebuild( - name, - &coord.hyperhive_flake, - &agent_dir, - &applied_dir, - &claude_dir, - ) - .await?; + lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?; HostResponse::success() } HostRequest::List => HostResponse::list(lifecycle::list().await?), HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?), HostRequest::Approve { id } => { - actions::approve(coord.clone(), *id).await?; + actions::approve(coord, *id).await?; HostResponse::success() } HostRequest::Deny { id } => { - actions::deny(&coord, *id)?; + actions::deny(coord, *id)?; HostResponse::success() } }) diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 0cdb76d7..c5848cc7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -12,23 +12,10 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "cmd", rename_all = "snake_case")] pub enum HostRequest { - /// Create and start a sub-agent container directly (no approval). Use - /// this from privileged contexts (operator on the host); it bypasses the - /// approval queue intentionally so test scripts and one-off recoveries - /// don't need a separate approve step. + /// Create and start a sub-agent container `hive-agent-`. Spawn { name: String }, - /// Submit a spawn request for the user to approve. On approval the host - /// creates and starts the container. Mirrors the manager's - /// `RequestSpawn` — exposed on the admin socket so the dashboard and CLI - /// can also queue spawns through the approval flow. - RequestSpawn { name: String }, /// Stop a managed container (graceful). Kill { name: String }, - /// Tear down a sub-agent container: stop + remove + drop the systemd - /// drop-in, purge pending approvals. Persistent state (proposed/applied - /// repos, Claude credentials) is KEPT by default — recreating the agent - /// with the same name reuses prior config + login. Manager not destroyable. - Destroy { name: String }, /// Apply pending config to a managed container. Rebuild { name: String }, /// List managed containers. @@ -56,9 +43,6 @@ pub struct HostResponse { pub struct Approval { pub id: i64, pub agent: String, - #[serde(default)] - pub kind: ApprovalKind, - /// For `ApplyCommit`: the git sha to apply. For `Spawn`: empty. pub commit_ref: String, pub requested_at: i64, pub status: ApprovalStatus, @@ -68,17 +52,6 @@ pub struct Approval { pub note: Option, } -/// What action the approval, when granted, will trigger. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ApprovalKind { - /// Apply a manager-proposed config commit (existing flow). - #[default] - ApplyCommit, - /// Create + start a new sub-agent container with the given name. - Spawn, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ApprovalStatus { @@ -203,10 +176,8 @@ pub enum ManagerRequest { body: String, }, Recv, - /// Submit a spawn request for the user to approve. On approval the host - /// creates and starts the container. Brand-new agent names only — if an - /// agent of the same name already exists, the approval will fail. - RequestSpawn { + /// Spawn a sub-agent. Phase 5 will gate this on user approval. + Spawn { name: String, }, /// Stop a sub-agent (graceful).