diff --git a/CLAUDE.md b/CLAUDE.md index 6ebbb4c7..f180de78 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 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. +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. **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,66 +12,101 @@ roadmap; this file is the operator/developer reference for the *how*. ## Architecture ``` -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 +host (NixOS, hive-c0re.service) +│ +├── hive-c0re (Rust daemon — coordinator + dashboard + CLI) +│ ├── lifecycle — nixos-container CRUD (spawn/kill/rebuild/list) +│ ├── broker — sqlite message store + broadcast channel +│ ├── approvals — sqlite approval queue +│ ├── coordinator — shared state (broker/approvals/agent sockets) +│ ├── actions — approve/deny (shared between admin socket & dashboard) +│ ├── server — host admin socket (JSON line protocol) +│ ├── manager_server — manager-only privileged socket +│ ├── agent_server — per-sub-agent sockets +│ ├── dashboard — axum HTTP UI + SSE message-flow + approve/deny + T4LK +│ └── client — admin-socket client (powers `hive-c0re spawn|kill|…`) │ ├── /run/hyperhive/ │ ├── host.sock — admin CLI ↔ daemon -│ └── agents//mcp.sock — bind-mounted into each container at /run/hive +│ ├── manager.sock → hm1nd container at /run/hive/mcp.sock +│ └── agents//mcp.sock → h- container at /run/hive/mcp.sock +│ +├── /var/lib/hyperhive/ +│ ├── broker.sqlite — messages + approvals tables +│ ├── agents//config/ — proposed repo (manager-editable, RO to hive-c0re) +│ └── applied// — applied repo (hive-c0re-only, container builds here) │ └── nixos-containers ├── h- (sub-agents, hive-ag3nt binary) - └── hm1nd (manager, hive-m1nd binary — Phase 4+) + └── hm1nd (manager, hive-m1nd binary) ``` ## Crates / file map ``` -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-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-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-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-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, Message) +hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response, + ManagerRequest/Response, Message, Approval, HelperEvent) nix/ - modules/hive-c0re.nix systemd service wiring - templates/agent-base.nix nixos-container template (boot.isNspawnContainer = true) + modules/hive-c0re.nix systemd service + firewall + git path wiring + templates/agent-base.nix sub-agent nixos-container template + templates/manager.nix manager nixos-container template -tests/roundtrip.sh Phase 3 end-to-end smoke test +tests/roundtrip.sh Phase 3 messaging round-trip +tests/approval.sh Phase 5 end-to-end approval flow +tests/dashboard.sh Phase 6+7 HTTP dashboard + SSE + orphan GC + +docs/damocles-migration.md options for moving damocles onto hyperhive ``` ## Conventions - **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`. + `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). - **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. - See `hive-sh4re` for the types. (Phase 6+ may swap to real MCP stdio.) +- **Wire protocol.** JSON line-delimited over unix sockets in both directions + (host admin / manager / agent). `/messages/stream` is `text/event-stream`. - **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.** 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. +- **`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. ## Gotchas / lessons learned @@ -79,16 +114,24 @@ tests/roundtrip.sh Phase 3 end-to-end smoke test `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). Don't bother. -- **`boot.isNspawnContainer = true`**, not `boot.isContainer = true`. The - latter was renamed in nixos-25.11+. +- **`/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`. - **systemd service PATH ≠ host PATH.** Our service explicitly sets - `path = [ "/run/current-system/sw" ]` so `nixos-container` (which lives in - the system profile, not nixpkgs) is reachable. + `path = [ pkgs.git "/run/current-system/sw" ]`. Additionally, + `environment.HYPERHIVE_GIT = "${pkgs.git}/bin/git"` bakes the absolute path + in (read by `lifecycle::git_command()`) so git resolution doesn't depend on + PATH plumbing at all. - **`RuntimeDirectoryPreserve = "yes"`** keeps `/run/hyperhive/` (and the - agent sub-dirs) across `hive-c0re` restarts. Without it, every restart wipes - bind sources and existing containers can't be started. + per-agent sub-dirs) across `hive-c0re` restarts. Without it, every restart + wipes bind sources and existing containers can't be started. - **`register_agent` is idempotent** — drops any prior socket task before rebinding. Required so a `hive-c0re` restart followed by `rebuild alice` recreates the agent's socket without needing a clean reinstall. @@ -97,23 +140,31 @@ tests/roundtrip.sh Phase 3 end-to-end smoke test (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. 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. + env var path. Today's stopgap: `nixos-container root-login h-` → + `claude` (interactive) → log in once. The harness falls back to echo + replies when `claude --print` fails. **Phase 8** moves this to a per-agent + persistent dir at `/var/lib/hyperhive/agents//claude/` bind-mounted + into the container, with the interactive login driven from the agent's web + UI. Sharing one `~/.claude` across agents is NOT viable — OAuth refresh + tokens rotate, so any sibling refresh invalidates all the others. - **Echo guard.** `hive-ag3nt serve` skips auto-reply when the incoming body - starts with `"echo: "`. Prevents ping-pong loops when both sides fall back to - echo. Real conversations between claude-backed agents *will* runaway — that's - the manager's job to bound (Phase 4+). + 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. ## 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. fmt check) +# evaluate everything (incl. rust+nix+toml fmt + clippy) nix flake check # build only the workspace package @@ -124,69 +175,114 @@ 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 test (lpt2 or any host with the module enabled) -sudo bash tests/roundtrip.sh +# end-to-end tests (each idempotent; runs as root) +sudo bash tests/roundtrip.sh # alice ↔ bob echo round-trip +sudo bash tests/approval.sh # manager edit → request → user approve → rebuilt +sudo bash tests/dashboard.sh # HTTP UI, approve POST, SSE, orphan GC ``` The host config also needs `hyperhive.overlays.default` applied — the module's default `package = pkgs.hyperhive` requires the overlay to bring the package -in. +in. The `claude-unstable` overlay is applied internally to per-agent flakes +already. ## Phase status - ✅ Phase 0 — repo + Cargo workspace + flake + agent-base template -- ✅ Phase 1 — container lifecycle (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 1 — container lifecycle; `nixos-container update` hot-reload works + under the patch stack (validated on muede-lpt2) +- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips +- ✅ Phase 3 — sqlite broker (durable) + claude-or-echo turn loop +- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative + `containers.hm1nd` +- ✅ Phase 5 — git-commit approval flow + - 5a — sqlite approval queue (`request_apply_commit`/`pending`/`approve`/`deny`) + - 5b — per-agent config flakes + - 5c — manager edits `proposed`, hive-c0re writes-only `applied`; container + builds from `applied`. Approve = read `agent.nix` at the approved commit + from `proposed`, copy into `applied`, commit + rebuild. Manager cannot + move `applied/main` on its own. +- ✅ Phase 6 — per-container web UIs (`HIVE_PORT` deterministic-hash) + + hive-c0re dashboard (default 7000, vibec0re aesthetic, deep-linked) - ✅ Phase 7 — polish: - - 7a — dashboard `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`). + - 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 -## Approval flow (Phase 5) +## Phase 8 — real claude in containers + login UX (in progress) -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. +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. ``` # Inside the hm1nd container (manager has /agents bind-mounted RW): cd /agents/alice/config -$EDITOR agent.nix # add `environment.systemPackages = [ pkgs.htop ];` +$EDITOR agent.nix # e.g. environment.systemPackages = [ pkgs.htop ]; git commit -am "add htop" SHA=$(git rev-parse HEAD) hive-m1nd request-apply-commit alice $SHA exit -# On the host: -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 +# On the host (CLI): +sudo hive-c0re pending # shows queued approval with id N +sudo hive-c0re approve N # validates, applies, rebuilds +sudo nixos-container run h-alice -- which htop + +# Or on the dashboard (browser): +http://:7000/ # ◆ APPR0VE button next to the diff ``` Per-agent layout — two separate git repos: @@ -206,21 +302,33 @@ 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 setting `environment.etc."gitconfig".text` -with the agent's name as the git committer identity. +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`. -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`. +## Polish backlog -See PLAN.md for the full design and the deferred-out-of-scope list. +Not phased — pick when relevant: + +- **Operator inbox view** — drain replies addressed to `operator` and show + in the dashboard (today they accumulate in sqlite unread). +- **Per-agent UI substance** — show last N inbox messages, last turn timing, + link back to dashboard. +- **xterm.js terminal** — embed in each per-container UI, attach to a PTY + exposed by the harness. +- **`destroy` verb** — currently `nixos-container destroy` + manual `rm -rf`. + Should be one hive-c0re verb that also purges approvals + state dirs. +- **Bounded broker** — cap rows per recipient or auto-vacuum delivered + messages older than a threshold. +- **Container crash events** — watch `container@*.service` via D-Bus, + push `HelperEvent::ContainerCrash` to the manager. ## Inspirations -- **`~/Repos/bitburner-agent`** — sibling project, drives Claude Code in a turn - loop against a Bitburner CDP session. Patterns to steal as we grow: +- **`~/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 a6e74bef..8aecbbb8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1,6 +1,9 @@ # hyperhive — Plan -> **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. +> **Status.** All phases 0–7 have shipped. This file is the original design +> doc; **CLAUDE.md** is the current source of truth for what's actually built, +> the file map, gotchas, and operator runbook. Keep this file for the *why* +> and the original phase rationale; CLAUDE.md for *how things are today*. > > **Names.** > - Repo: `hyperhive` @@ -10,7 +13,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. Eventually damocles migrates onto `hyperhive` — out of v1 scope. +> **Relationship to damocles.** Damocles is a separate, currently-running setup. `hyperhive` is a new, independent system in its own repo. Damocles' existing `claude-container.nix` informs the agent-base template but is not a dependency. Migration options laid out in `docs/damocles-migration.md`; recommendation is to keep them separate for now. ## What we're building @@ -96,7 +99,13 @@ 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, bind-mounted into every container. No per-agent keys in v1. +**Anthropic credentials.** ~~Shared key on host~~ — revised in Phase 8. +Per-agent persistent `~/.claude/` dir bind-mounted from +`/var/lib/hyperhive/agents//claude/`. OAuth refresh tokens rotate, so +sharing across agents is a non-starter (any sibling refresh invalidates all +the others). One interactive login per agent, ever; creds survive +`destroy`/recreate by default. Login flow runs from the per-agent web UI +(see Phase 8). **Workdir bootstrap.** Each agent's `state/` starts empty. Initial-task message tells the agent what to clone/set up. Manager can drop big artefacts into `state/` directly (it has RW) and pass the path as a message reference. @@ -109,39 +118,43 @@ A multi-Claude-Code-agent setup on a single host: ## Phased path -### Phase 0 — repo bootstrap +All phases ✅ shipped. Each section below is the *original* design with notes +on what actually landed and what deviated. See CLAUDE.md → "Phase status" for +the canonical summary. + +### ✅ Phase 0 — repo bootstrap - Create `~/Repos/hyperhive/`, init flake. - Cargo workspace: `hive-c0re/`, `hive-sh4re/`, `hive-ag3nt/` (the last with two `[[bin]]` targets — `hive-ag3nt` and `hive-m1nd`). All compile, all do nothing useful. - NixOS module skeleton (`nix/modules/hive-c0re.nix`) that runs the daemon as a systemd service on the host. - Agent base template (`nix/templates/agent-base.nix`) that builds a nixos-container including the `hive-ag3nt` binary. - **Exit:** `nixos-container create test-agent --flake .#agent-base && nixos-container start test-agent` brings up a container whose `hive-ag3nt` prints "hello" and exits. -### Phase 1 — container lifecycle + Risk 1 +### ✅ 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. @@ -149,13 +162,13 @@ A multi-Claude-Code-agent setup on a single host: - 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). @@ -204,19 +217,85 @@ A multi-Claude-Code-agent setup on a single host: └── 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; `state-repo` records intent). +- Destroy verb on the `hive-c0re` API (use `rm` on host). - Backup / replication of `agents/` state. -- Migration of existing damocles containers (later, separate effort). +- Migration of existing damocles containers (`docs/damocles-migration.md`). - 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 6796bcac..17d76fa6 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -1,8 +1,10 @@ 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; @@ -50,12 +52,30 @@ 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).await { + if let Err(e) = web_ui::serve(label, port, ui_state).await { tracing::error!(error = ?e, "web ui failed"); } }); - serve(&cli.socket, Duration::from_millis(poll_ms)).await + 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 + } + } } Cmd::Send { to, body } => { let resp: AgentResponse = @@ -71,8 +91,32 @@ async fn main() -> Result<()> { } } -async fn serve(socket: &Path, interval: Duration) -> 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<()> { 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 a2989fc7..e3dd7aae 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -4,10 +4,12 @@ //! 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}; @@ -33,8 +35,9 @@ enum Cmd { Send { to: String, body: String }, /// Pop one message from the manager's inbox. Recv, - /// Spawn a sub-agent. - Spawn { name: String }, + /// Submit a spawn request for the user to approve (creates a pending + /// approval; on approval the host creates + starts the container). + RequestSpawn { name: String }, /// Kill a sub-agent. Kill { name: String }, /// Submit a config commit on the agent's config repo for user approval. @@ -58,16 +61,32 @@ 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).await { + if let Err(e) = web_ui::serve(label, port, ui_state).await { tracing::error!(error = ?e, "web ui failed"); } }); - serve(&cli.socket, Duration::from_millis(poll_ms)).await + 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 + } + } } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, - Cmd::Spawn { name } => one_shot(&cli.socket, ManagerRequest::Spawn { name }).await, + Cmd::RequestSpawn { name } => { + one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await + } Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await, Cmd::RequestApplyCommit { agent, commit_ref } => { one_shot( @@ -88,6 +107,25 @@ 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 a5608ad0..b601724a 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -2,6 +2,8 @@ //! `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 new file mode 100644 index 00000000..6f8ae699 --- /dev/null +++ b/hive-ag3nt/src/login.rs @@ -0,0 +1,52 @@ +//! 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 new file mode 100644 index 00000000..100b5b1c --- /dev/null +++ b/hive-ag3nt/src/login_session.rs @@ -0,0 +1,264 @@ +//! `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 132304a1..e09adb2b 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -4,18 +4,44 @@ //! `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::{Router, extract::State, response::Html, routing::get}; +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>; #[derive(Clone)] struct AppState { label: String, + login: LoginStateCell, + session: Arc>>>, } -pub async fn serve(label: String, port: u16) -> Result<()> { - let state = AppState { label }; - let app = Router::new().route("/", get(index)).with_state(state); +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); let addr = SocketAddr::from(([0, 0, 0, 0], port)); let listener = tokio::net::TcpListener::bind(addr) .await @@ -26,12 +52,121 @@ pub async fn serve(label: String, port: u16) -> 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{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", + "\n\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n{body}\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 bef28f6e..c3655575 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -3,35 +3,85 @@ //! `&Coordinator` and the request parameters; callers stitch the response //! shape they want (HTTP redirect vs JSON). -use anyhow::Result; -use hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; +use std::sync::Arc; -use crate::coordinator::Coordinator; -use crate::lifecycle; +use anyhow::{Result, bail}; +use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; -/// 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<()> { +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<()> { let approval = coord.approvals.mark_approved(id)?; - tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding"); + tracing::info!( + %approval.id, + %approval.agent, + kind = ?approval.kind, + %approval.commit_ref, + "approval: running action", + ); 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 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 + 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(()) + } } - .await; +} + +fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> { match result { Ok(()) => { notify_manager( @@ -64,6 +114,31 @@ pub async fn approve(coord: &Coordinator, id: i64) -> 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 8f6e66a7..d3e659bb 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, ApprovalStatus}; +use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus}; use rusqlite::{Connection, OptionalExtension, params}; const SCHEMA: &str = r" @@ -24,6 +24,23 @@ 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, } @@ -38,17 +55,22 @@ 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, commit_ref, requested_at, status) - VALUES (?1, ?2, ?3, 'pending')", - params![agent, commit_ref, now_unix()], + "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()], )?; Ok(conn.last_insert_rowid()) } @@ -56,7 +78,7 @@ impl Approvals { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE status = 'pending' ORDER BY id ASC", @@ -69,7 +91,7 @@ impl Approvals { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, agent, commit_ref, requested_at, status, resolved_at, note + "SELECT id, agent, kind, commit_ref, requested_at, status, resolved_at, note FROM approvals WHERE id = ?1", params![id], row_to_approval, @@ -82,14 +104,22 @@ 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, i64, String)> = conn + let current: Option<(String, String, String, i64, String)> = conn .query_row( - "SELECT agent, commit_ref, requested_at, status FROM approvals WHERE id = ?1", + "SELECT agent, kind, 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| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, ) .optional()?; - let Some((agent, commit_ref, requested_at, status)) = current else { + let Some((agent, kind, commit_ref, requested_at, status)) = current else { bail!("approval {id} not found"); }; if status != "pending" { @@ -103,6 +133,7 @@ impl Approvals { Ok(Approval { id, agent, + kind: kind_from_str(&kind)?, commit_ref, requested_at, status: ApprovalStatus::Approved, @@ -132,10 +163,35 @@ 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 { - let status: String = row.get(4)?; + // 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 = match status.as_str() { "pending" => ApprovalStatus::Pending, "approved" => ApprovalStatus::Approved, @@ -143,7 +199,7 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { "failed" => ApprovalStatus::Failed, other => { return Err(rusqlite::Error::FromSqlConversionFailure( - 4, + 5, rusqlite::types::Type::Text, format!("unknown approval status '{other}'").into(), )); @@ -152,11 +208,27 @@ fn row_to_approval(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Approval { id: row.get(0)?, agent: row.get(1)?, - commit_ref: row.get(2)?, - requested_at: row.get(3)?, + kind, + commit_ref: row.get(3)?, + requested_at: row.get(4)?, status, - resolved_at: row.get(5)?, - note: row.get(6)?, + 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}'"), }) } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 5f7a0295..6bc2e068 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -30,6 +30,24 @@ 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 { @@ -41,6 +59,7 @@ impl Coordinator { approvals: Arc::new(approvals), hyperhive_flake, agents: Mutex::new(HashMap::new()), + transient: Mutex::new(HashMap::new()), }) } @@ -64,6 +83,25 @@ 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}")) } @@ -80,10 +118,23 @@ 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 { - PathBuf::from(format!("{AGENT_STATE_ROOT}/{name}/config")) + 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") } /// Authoritative applied config repo. Hive-c0re-only. diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index b09bd5ee..14428a37 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -41,6 +41,8 @@ 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 }); @@ -61,15 +63,26 @@ async fn index(headers: HeaderMap, State(state): State) -> Html".to_owned() + }; + Html(format!( - "\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), + "\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), talk = render_talk(&containers), )) } @@ -111,7 +124,7 @@ async fn messages_stream( } async fn post_approve(State(state): State, AxumPath(id): AxumPath) -> Response { - match actions::approve(&state.coord, id).await { + match actions::approve(state.coord.clone(), id).await { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("approve {id} failed: {e:#}")), } @@ -124,6 +137,39 @@ 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, @@ -135,11 +181,36 @@ fn error_response(message: &str) -> Response { .into_response() } -fn render_containers(containers: &[String], hostname: &str) -> String { +fn render_containers( + containers: &[String], + transient: &std::collections::HashMap, + hostname: &str, +) -> String { let mut out = String::from( "

◆ C0NTAINERS ◆

\n
══════════════════════════════════════════════════════════════
\n", ); - if containers.is_empty() { + 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() { out.push_str("

▓ no managed containers ▓

\n"); return out; } @@ -152,9 +223,15 @@ fn render_containers(containers: &[String], hostname: &str) -> String { ); } 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 {container} :{port}
  • ", + "
  • ▒░▒░░ {name} ag3nt{login_badge} {container} :{port}\n
    \n
  • ", ); } } @@ -172,15 +249,27 @@ async fn render_approvals(approvals: &[Approval]) -> String { } out.push_str("
      \n"); for a in approvals { - 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), - ); + 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, + ); + } + } } out.push_str("
    \n"); out @@ -212,6 +301,11 @@ 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 { @@ -223,6 +317,19 @@ 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() { @@ -410,6 +517,7 @@ 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; @@ -424,7 +532,40 @@ 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 dcce16c4..cc80066d 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -16,6 +16,10 @@ 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"; @@ -66,14 +70,16 @@ 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)?; + set_nspawn_flags(&container, agent_dir, claude_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["start", &container]).await @@ -85,17 +91,37 @@ 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)?; + set_nspawn_flags(&container, agent_dir, claude_dir)?; set_resource_limits(&container)?; systemd_daemon_reload().await?; run(&["update", &container, "--flake", &flake_ref]).await?; @@ -230,6 +256,23 @@ 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", @@ -329,12 +372,13 @@ 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) -> Result<()> { +fn set_nspawn_flags(container: &str, agent_dir: &Path, claude_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={}:{CONTAINER_RUNTIME_MOUNT}\"", - agent_dir.display() + "EXTRA_NSPAWN_FLAGS=\"--bind={runtime}:{CONTAINER_RUNTIME_MOUNT} --bind={claude}:{CONTAINER_CLAUDE_MOUNT}\"", + runtime = agent_dir.display(), + claude = claude_dir.display(), ); let mut lines: Vec = original .lines() diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 9e3e9772..1db132d7 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -44,10 +44,18 @@ enum Cmd { #[arg(long, default_value_t = 7000)] dashboard_port: u16, }, - /// Spawn a new agent container (`hive-agent-`). + /// 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 { 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. @@ -89,9 +97,15 @@ 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 408bc4a5..a7329daf 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -91,29 +91,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, - 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); + 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 } - 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 db382a7e..9a960306 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).await, + Ok(req) => dispatch(&req, coord.clone()).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: &Coordinator) -> HostResponse { +async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { HostRequest::Spawn { name } => { @@ -64,12 +64,14 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> 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 { @@ -79,27 +81,47 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> 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); - lifecycle::rebuild(name, &coord.hyperhive_flake, &agent_dir, &applied_dir).await?; + let claude_dir = Coordinator::agent_claude_dir(name); + lifecycle::rebuild( + name, + &coord.hyperhive_flake, + &agent_dir, + &applied_dir, + &claude_dir, + ) + .await?; HostResponse::success() } HostRequest::List => HostResponse::list(lifecycle::list().await?), HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?), HostRequest::Approve { id } => { - actions::approve(coord, *id).await?; + actions::approve(coord.clone(), *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 c5848cc7..0cdb76d7 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -12,10 +12,23 @@ 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 `hive-agent-`. + /// 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. 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. @@ -43,6 +56,9 @@ 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, @@ -52,6 +68,17 @@ 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 { @@ -176,8 +203,10 @@ pub enum ManagerRequest { body: String, }, Recv, - /// Spawn a sub-agent. Phase 5 will gate this on user approval. - Spawn { + /// 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 { name: String, }, /// Stop a sub-agent (graceful).