Compare commits

...
17 changed files with 1430 additions and 235 deletions

318
CLAUDE.md
View file

@ -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/<name>/mcp.sock — bind-mounted into each container at /run/hive
│ ├── manager.sock → hm1nd container at /run/hive/mcp.sock
│ └── agents/<name>/mcp.sock → h-<name> container at /run/hive/mcp.sock
├── /var/lib/hyperhive/
│ ├── broker.sqlite — messages + approvals tables
│ ├── agents/<name>/config/ — proposed repo (manager-editable, RO to hive-c0re)
│ └── applied/<name>/ — applied repo (hive-c0re-only, container builds here)
└── nixos-containers
├── h-<name> (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<name, AgentSocket>)
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-<name>` with `<name>` ≤ 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/<C>.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/<C>.conf` (`PRIVATE_NETWORK=0`, clears
HOST_ADDRESS/LOCAL_ADDRESS, sets `EXTRA_NSPAWN_FLAGS`), regenerates
`applied/<name>/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/<NAME>.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-<name>``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-<name>`
`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/<name>/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/<nixos-config-repo>
nix flake update --update-input hyperhive
sudo nixos-rebuild switch --flake .#<host>
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 81008999; 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, 81008999 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/<id>` / `/deny/<id>` 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@<NAME>.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/<name>/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/<name>/` 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 <name>`); operator can also queue from the
dashboard or `hive-c0re request-spawn <name>`. The host's direct
`hive-c0re spawn <name>` still works as a privileged bypass for tests.
Approve runs `lifecycle::spawn` in a background task; the dashboard polls
via `<meta refresh>` 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://<host>: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 `<applied_dir>#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 <commit>:agent.nix` from `proposed/<name>`, write the bytes
into `applied/<name>/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.

121
PLAN.md
View file

@ -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 07 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/<name>/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-<name>` (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/<name>.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 (81008999); 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/<name>/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/<name>/role.txt`, `agents/<name>/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 <sha>` etc.

View file

@ -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::<u16>().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<Mutex<LoginState>>,
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<Mutex<LoginState>>) -> Result<()> {
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
let _ = state; // reserved for future state transitions (turn-loop -> needs-login)
loop {
let recv: Result<AgentResponse> = client::request(socket, &AgentRequest::Recv).await;
match recv {

View file

@ -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::<u16>().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<Mutex<LoginState>>,
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 {

View file

@ -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`.

52
hive-ag3nt/src/login.rs Normal file
View file

@ -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
}
}
}

View file

@ -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<String>,
/// 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<String>,
}
/// A running `claude /login` subprocess.
pub struct LoginSession {
child: Mutex<Child>,
/// 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<Option<ChildStdin>>,
state: Arc<Mutex<State>>,
}
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<Self> {
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<String> {
self.state.lock().unwrap().url.clone()
}
pub fn finished(&self) -> bool {
self.state.lock().unwrap().finished
}
pub fn exit_note(&self) -> Option<String> {
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<String>) {
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<R: tokio::io::AsyncRead + Unpin>(
mut reader: BufReader<R>,
state: Arc<Mutex<State>>,
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<String> {
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<Option<Arc<LoginSession>>>) {
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);
}
}

View file

@ -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<Mutex<LoginState>>;
#[derive(Clone)]
struct AppState {
label: String,
login: LoginStateCell,
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
}
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<AppState>) -> Html<String> {
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!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<p>▓█▓▒░ harness alive ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>\n</body>\n</html>\n",
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta http-equiv=\"refresh\" content=\"3\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n</body>\n</html>\n",
label = state.label,
))
}
fn render_online() -> String {
"<p class=\"status-online\">▓█▓▒░ harness alive — turn loop running ▓█▓▒░</p>\n<p class=\"meta\">phase 6a placeholder — turn-loop status / inbox / xterm.js coming in 6b+</p>".into()
}
fn render_needs_login_idle() -> String {
"<p class=\"status-needs-login\">▓█▓▒░ NEEDS L0G1N ▓█▓▒░</p>\n<p>No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is paused until you log in.</p>\n<form method=\"POST\" action=\"/login/start\">\n <button type=\"submit\" class=\"btn btn-login\">◆ ST4RT L0G1N</button>\n</form>\n<p class=\"meta\">Spawns <code>claude /login</code> over plain stdio pipes. The OAuth URL will appear here when claude emits it; paste the resulting code back into the form below.</p>".into()
}
fn render_login_in_progress(session: &Arc<LoginSession>) -> String {
let url_block = match session.url() {
Some(url) => format!(
"<p>▶ <a href=\"{url}\" target=\"_blank\" rel=\"noreferrer\">{url}</a></p>\n<p class=\"meta\">open this URL in a browser, complete the OAuth flow, paste the resulting code below.</p>",
url = html_escape(&url),
),
None => "<p class=\"meta\">waiting for claude to emit an OAuth URL on stdout… (output below)</p>".into(),
};
let exit_badge = if session.finished() {
let note = session.exit_note().unwrap_or_else(|| "exited".into());
format!(
"<p class=\"status-needs-login\">claude process exited: {note}. Start over if needed.</p>",
note = html_escape(&note),
)
} else {
String::new()
};
let output = session.output();
let code_form = if session.finished() {
String::new()
} else {
"<form method=\"POST\" action=\"/login/code\" class=\"loginform\">\n <input name=\"code\" placeholder=\"paste OAuth code here\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-login\">◆ S3ND C0DE</button>\n</form>".into()
};
let cancel_form = "<form method=\"POST\" action=\"/login/cancel\" style=\"margin-top: 0.4em;\">\n <button type=\"submit\" class=\"btn btn-cancel\">cancel + kill</button>\n</form>".to_owned();
format!(
"<p class=\"status-needs-login\">▓█▓▒░ L0G1N 1N PR0GRESS ▓█▓▒░</p>\n{url_block}\n{code_form}\n{cancel_form}\n{exit_badge}\n<h3>output</h3>\n<pre class=\"diff\">{output}</pre>",
output = html_escape(&output),
)
}
async fn post_login_start(State(state): State<AppState>) -> 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<AppState>,
Form(form): Form<CodeForm>,
) -> 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<AppState>) -> 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!(
"<!doctype html>\n<html><head>{STYLE}</head><body><h2>error</h2><pre class=\"diff\">{msg}</pre><p><a href=\"/\">← back</a></p></body></html>",
msg = html_escape(message),
)),
)
.into_response()
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
const STYLE: &str = r#"
<style>
:root {
@ -40,6 +175,8 @@ const STYLE: &str = r#"
--muted: #6c5c8c;
--purple: #cc66ff;
--purple-dim: #4a1a6a;
--amber: #ffb84d;
--green: #66ff99;
}
body {
background: var(--bg);
@ -58,7 +195,7 @@ const STYLE: &str = r#"
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
overflow-x: auto;
}
h2 {
h2, h3 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -71,5 +208,41 @@ const STYLE: &str = r#"
margin-bottom: 0.5em;
}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(102, 255, 153, 0.5); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(255, 184, 77, 0.6); }
code { background: rgba(204, 102, 255, 0.1); padding: 0.05em 0.3em; border-radius: 2px; }
a { color: #66e0ff; }
.btn {
font-family: inherit;
font-size: 1em;
background: var(--bg);
border: 1px solid var(--purple);
color: var(--purple);
padding: 0.25em 0.8em;
cursor: pointer;
letter-spacing: 0.1em;
}
.btn:hover { background: rgba(204, 102, 255, 0.1); }
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.loginform input {
font-family: inherit; font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.loginform input:focus { outline: 1px solid var(--purple); }
pre.diff {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--purple-dim);
padding: 0.6em 0.8em;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 30em;
}
</style>
"#;

View file

@ -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<Coordinator>, 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}/<name>/` 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)?;

View file

@ -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<Connection>,
}
@ -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<i64> {
self.submit_kind(agent, ApprovalKind::ApplyCommit, commit_ref)
}
pub fn submit_kind(&self, agent: &str, kind: ApprovalKind, commit_ref: &str) -> Result<i64> {
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<Vec<Approval>> {
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<Option<Approval>> {
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<Approval> {
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<usize> {
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<Approval> {
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<Approval> {
"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<Approval> {
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<ApprovalKind> {
Ok(match s {
"apply_commit" => ApprovalKind::ApplyCommit,
"spawn" => ApprovalKind::Spawn,
other => bail!("unknown approval kind '{other}'"),
})
}

View file

@ -30,6 +30,24 @@ pub struct Coordinator {
/// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String,
agents: Mutex<HashMap<String, AgentSocket>>,
/// 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<HashMap<String, TransientState>>,
}
/// 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<String, TransientState> {
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/<name>/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.

View file

@ -41,6 +41,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<AppState>) -> Html<String
let hostname = host.split(':').next().unwrap_or(host).to_owned();
let containers = lifecycle::list().await.unwrap_or_default();
let transient = state.coord.transient_snapshot();
let approvals = gc_orphans(
&state.coord,
state.coord.approvals.pending().unwrap_or_default(),
);
let approvals_html = render_approvals(&approvals).await;
// Auto-refresh the dashboard root while there's a spawn in flight, so the
// operator sees the new agent show up in the container list without
// having to reload manually. 2s is a reasonable poll interval for
// nixos-container create + start, which usually finishes in <30s.
let refresh = if transient.is_empty() {
String::new()
} else {
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
};
Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
containers = render_containers(&containers, &hostname),
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\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<AppState>, AxumPath(id): AxumPath<i64>) -> 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<AppState>, AxumPath(id): AxumPath<i64>) -
}
}
#[derive(Deserialize)]
struct RequestSpawnForm {
name: String,
}
async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> 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<String, crate::coordinator::TransientState>,
hostname: &str,
) -> String {
let mut out = String::from(
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
);
if containers.is_empty() {
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
// Render in-flight spawns first so the operator sees feedback immediately.
if !transient.is_empty() {
out.push_str("<ul>\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,
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
);
}
out.push_str("</ul>\n");
}
if containers.is_empty() && transient.is_empty() {
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\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 {
" <span class=\"role role-pending\">needs login</span>"
};
let _ = writeln!(
out,
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span> <span class=\"meta\">{container} :{port}</span></li>",
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
);
}
}
@ -172,15 +249,27 @@ async fn render_approvals(approvals: &[Approval]) -> String {
}
out.push_str("<ul class=\"approvals\">\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,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
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,
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
id = a.id,
agent = a.agent,
diff = html_escape(&diff),
);
}
hive_sh4re::ApprovalKind::Spawn => {
let _ = writeln!(
out,
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n</li>",
id = a.id,
agent = a.agent,
);
}
}
}
out.push_str("</ul>\n");
out
@ -212,6 +301,11 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
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<Approval>) -> Vec<Approval> {
.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;

View file

@ -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/<C>
// and /etc/nixos-containers/<C>.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<String> = original
.lines()

View file

@ -44,10 +44,18 @@ enum Cmd {
#[arg(long, default_value_t = 7000)]
dashboard_port: u16,
},
/// Spawn a new agent container (`hive-agent-<name>`).
/// Spawn a new agent container directly (`hive-agent-<name>`). 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 <id>` (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?)
}

View file

@ -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:#}"),
},

View file

@ -46,7 +46,7 @@ async fn handle(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
return Ok(());
}
let resp = match serde_json::from_str::<HostRequest>(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<Coordinator>) -> Result<()> {
}
}
async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = 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()
}
})

View file

@ -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-<name>`.
/// 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<String>,
}
/// 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).