Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff93b603d | ||
|
|
78fae44ee5 | ||
|
|
c59fa8541c | ||
|
|
a42fdb3a5c | ||
|
|
0fc287c768 | ||
|
|
b711296460 | ||
|
|
c7b50aa5b7 |
17 changed files with 1430 additions and 235 deletions
318
CLAUDE.md
318
CLAUDE.md
|
|
@ -1,10 +1,10 @@
|
|||
# hyperhive
|
||||
|
||||
Multi-Claude-Code-agent orchestration on **nixos-containers**. A host-side Rust
|
||||
daemon spawns nspawn-isolated agent containers and brokers messages between
|
||||
them. Eventually a manager agent (another Claude Code session in its own
|
||||
container) coordinates the swarm and gates lifecycle changes on user approval
|
||||
via git commits.
|
||||
daemon (`hive-c0re`) spawns nspawn-isolated agent containers and brokers
|
||||
messages between them. A manager agent (`hm1nd`) coordinates the swarm and
|
||||
gates lifecycle changes on user approval via git commits, surfaced through a
|
||||
vibec0re-styled HTTP dashboard with live SSE message-flow.
|
||||
|
||||
**PLAN.md** is the living design doc. Read it for the *why* and the phase
|
||||
roadmap; this file is the operator/developer reference for the *how*.
|
||||
|
|
@ -12,66 +12,101 @@ roadmap; this file is the operator/developer reference for the *how*.
|
|||
## Architecture
|
||||
|
||||
```
|
||||
host
|
||||
├── hive-c0re (Rust daemon, NixOS service)
|
||||
│ ├── lifecycle — nixos-container CRUD
|
||||
│ ├── broker — sqlite message store (/var/lib/hyperhive/broker.sqlite)
|
||||
│ ├── server — host admin socket (JSON line protocol)
|
||||
│ └── agent_server — per-agent MCP-ish sockets
|
||||
host (NixOS, hive-c0re.service)
|
||||
│
|
||||
├── hive-c0re (Rust daemon — coordinator + dashboard + CLI)
|
||||
│ ├── lifecycle — nixos-container CRUD (spawn/kill/rebuild/list)
|
||||
│ ├── broker — sqlite message store + broadcast channel
|
||||
│ ├── approvals — sqlite approval queue
|
||||
│ ├── coordinator — shared state (broker/approvals/agent sockets)
|
||||
│ ├── actions — approve/deny (shared between admin socket & dashboard)
|
||||
│ ├── server — host admin socket (JSON line protocol)
|
||||
│ ├── manager_server — manager-only privileged socket
|
||||
│ ├── agent_server — per-sub-agent sockets
|
||||
│ ├── dashboard — axum HTTP UI + SSE message-flow + approve/deny + T4LK
|
||||
│ └── client — admin-socket client (powers `hive-c0re spawn|kill|…`)
|
||||
│
|
||||
├── /run/hyperhive/
|
||||
│ ├── host.sock — admin CLI ↔ daemon
|
||||
│ └── agents/<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 8100–8999; fixed 8000 for the manager).
|
||||
Vibec0re-styled placeholder for now (status / inbox / xterm coming later).
|
||||
- `hive-c0re` serves a dashboard on `cfg.dashboardPort` (default 7000)
|
||||
listing containers (deep-linked to their per-container UI) + pending
|
||||
approvals. Same aesthetic.
|
||||
- Firewall opens 7000, 8000, 8100–8999 when the module is enabled.
|
||||
- ✅ Phase 1 — container lifecycle; `nixos-container update` hot-reload works
|
||||
under the patch stack (validated on muede-lpt2)
|
||||
- ✅ Phase 2 — per-agent sockets, in-memory broker, agent harness round-trips
|
||||
- ✅ Phase 3 — sqlite broker (durable) + claude-or-echo turn loop
|
||||
- ✅ Phase 4 — `hm1nd` manager binary + manager socket + declarative
|
||||
`containers.hm1nd`
|
||||
- ✅ Phase 5 — git-commit approval flow
|
||||
- 5a — sqlite approval queue (`request_apply_commit`/`pending`/`approve`/`deny`)
|
||||
- 5b — per-agent config flakes
|
||||
- 5c — manager edits `proposed`, hive-c0re writes-only `applied`; container
|
||||
builds from `applied`. Approve = read `agent.nix` at the approved commit
|
||||
from `proposed`, copy into `applied`, commit + rebuild. Manager cannot
|
||||
move `applied/main` on its own.
|
||||
- ✅ Phase 6 — per-container web UIs (`HIVE_PORT` deterministic-hash) +
|
||||
hive-c0re dashboard (default 7000, vibec0re aesthetic, deep-linked)
|
||||
- ✅ Phase 7 — polish:
|
||||
- 7a — dashboard `POST /approve/<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
121
PLAN.md
|
|
@ -1,6 +1,9 @@
|
|||
# hyperhive — Plan
|
||||
|
||||
> **Status.** Planning doc for a new project. Lives at `~/Repos/nixos-configuration-claude/agent-system/PLAN.md` during planning; **moves to `~/Repos/hyperhive/PLAN.md`** once the repo is created.
|
||||
> **Status.** All phases 0–7 have shipped. This file is the original design
|
||||
> doc; **CLAUDE.md** is the current source of truth for what's actually built,
|
||||
> the file map, gotchas, and operator runbook. Keep this file for the *why*
|
||||
> and the original phase rationale; CLAUDE.md for *how things are today*.
|
||||
>
|
||||
> **Names.**
|
||||
> - Repo: `hyperhive`
|
||||
|
|
@ -10,7 +13,7 @@
|
|||
> - `hive-m1nd` — runs in the manager container (same crate, second `main.rs`, wires the manager tool surface).
|
||||
> - Shared crate between `hive-c0re` and `hive-ag3nt` (wire protocol, MCP verb types, message shapes): **`hive-sh4re`**.
|
||||
>
|
||||
> **Relationship to damocles.** Damocles is a separate, currently-running setup. `hyperhive` is a new, independent system in its own repo. Damocles' existing `claude-container.nix` informs the agent-base template but is not a dependency. Eventually damocles migrates onto `hyperhive` — out of v1 scope.
|
||||
> **Relationship to damocles.** Damocles is a separate, currently-running setup. `hyperhive` is a new, independent system in its own repo. Damocles' existing `claude-container.nix` informs the agent-base template but is not a dependency. Migration options laid out in `docs/damocles-migration.md`; recommendation is to keep them separate for now.
|
||||
|
||||
## What we're building
|
||||
|
||||
|
|
@ -96,7 +99,13 @@ A multi-Claude-Code-agent setup on a single host:
|
|||
|
||||
**Manager concurrency = event loop.** `hive-m1nd` pulls from a heterogeneous `next_event` stream: inbound agent messages, replies to sync sends, lifecycle events from `hive-c0re` (crash, OOM, approval-resolved), and dashboard signals. One queue, claude turn per event.
|
||||
|
||||
**Anthropic credentials.** Shared key on host, bind-mounted into every container. No per-agent keys in v1.
|
||||
**Anthropic credentials.** ~~Shared key on host~~ — revised in Phase 8.
|
||||
Per-agent persistent `~/.claude/` dir bind-mounted from
|
||||
`/var/lib/hyperhive/agents/<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 (8100–8999); manager at fixed 8000; dashboard
|
||||
at 7000. Plain HTML, no HTMX, no xterm.js yet.
|
||||
- **`state-repo` schema.** Per-agent dir with files; not a single TOML.
|
||||
Realised as two parallel git repos per agent: `proposed` (manager-editable)
|
||||
and `applied` (hive-c0re-only). Container builds from `applied`.
|
||||
- **Manager access to applied state.** *Not* RW-mounted. Manager only has
|
||||
`proposed/` bind-mounted; `applied/` is hive-c0re-only.
|
||||
- **One binary or two.** One: `hive-c0re` is daemon + CLI dispatched by
|
||||
subcommand (`serve` / `spawn` / `kill` / `rebuild` / `list` / `pending` /
|
||||
`approve` / `deny`).
|
||||
|
||||
### ⏳ Phase 8 — real claude in containers + login UX
|
||||
|
||||
Until this lands the harness falls back to the echo path; we've never run an
|
||||
end-to-end turn with a real model in a real container.
|
||||
|
||||
**Credential model.** Per-agent persistent dir at
|
||||
`/var/lib/hyperhive/agents/<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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
52
hive-ag3nt/src/login.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
264
hive-ag3nt/src/login_session.rs
Normal file
264
hive-ag3nt/src/login_session.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬e),
|
||||
)
|
||||
} 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
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>
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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}'"),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:#}"),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in a new issue