diff --git a/CLAUDE.md b/CLAUDE.md index fb26720c..5b0d3719 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,15 @@ hand-maintained per-file tree drifts out of sync with the code. directly over streamable-http (no stdio bridge), writes task files under `/harness/bash-tasks/`, and records the favorite-tools `bash_commands` stat into turn-stats.sqlite. +- **`hive-subagent-mcp/`** — per-agent claude-subagent runner daemon + (`hive-subagent-daemon`); spawns nested claude sessions on request and + serves `start`/`continue`/`status`/`interrupt` directly over + streamable-http (no stdio bridge). Independent of `hive-bash-mcp` (a + subagent is a much heavier capability than a bash command). No task + files — the daemon's only state is an in-memory map of currently-running + processes, live only as long as the process is; the actual claude + session survives a daemon restart independently (see `session.rs`'s + module doc). - **`hive-sh4re/`** — shared wire types (Agent / Manager request + response, `Message`, `Approval`, `HelperEvent`) used across the unix sockets. Host-admin-socket and hive-priv-socket wire types have been diff --git a/Cargo.lock b/Cargo.lock index bd738290..28d61b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,10 +1780,11 @@ dependencies = [ [[package]] name = "hive-claude" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540ef2338fc071b60e69cac345cb5ec04627c4ef11c49138d1c39d4f687f254" +checksum = "7784a4aeb50369f2a49f052fd3bb28eee143ec6c8af8df806d3e375b045b0661" dependencies = [ + "libc", "serde", "serde_json", "thiserror 2.0.18", @@ -1976,6 +1977,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "hive-subagent-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "hive-agent-sock", + "hive-claude", + "hive-sock-client", + "hive-types", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "hive-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8ca89bd1..c7ad7dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "hive-priv-sock", "hive-sh4re", "hive-sock-client", + "hive-subagent-mcp", "hive-types", "hivectl", "swarm-authelia-bridge", @@ -84,7 +85,7 @@ hive-jobq = { path = "hive-jobq" } hive-jobq-metrics = { path = "hive-jobq-metrics" } hive-jobq-wire = { path = "hive-jobq-wire" } hive-core-agent-sock = { path = "hive-core-agent-sock" } -hive-claude = "0.1" +hive-claude = "0.1.1" hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } diff --git a/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md index 4305eae2..756b8604 100644 --- a/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md +++ b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md @@ -3,11 +3,11 @@ name: claude-subagents description: Spin up a short-lived headless `claude` sub-instance to grind through a well-scoped, mechanical batch (bulk relabeling, a repetitive find/replace, a mechanical migration) instead of burning your own context on it inline. Use this when a task has a clear, describable recipe and is either big enough to eat your context or long enough that you'd rather not babysit it. Not for judgement-heavy work, anything needing operator back-and-forth, or a task whose blast radius you can't bound up front. --- -# Ephemeral Sub-Agents via the Claude CLI +# Ephemeral Sub-Agents -`claude` is on `PATH` in your container, and a sub-instance you spawn -inherits the same filesystem and credentials you have. This is a -first-class tool for offloading a bounded, mechanical batch - not a hack. +A sub-instance you spawn inherits the same filesystem and credentials you +have. This is a first-class tool for offloading a bounded, mechanical +batch. ## When to use it @@ -18,45 +18,28 @@ first-class tool for offloading a bounded, mechanical batch - not a hack. - **No:** judgement-heavy work, anything needing back-and-forth with a human, or a task whose blast radius you can't bound up front. -## The spawn command +## The `subagent` MCP tools -Put the task recipe in a file and pass it as a path, with a short `-p` -trigger: +Your container's `subagent` MCP server (`hive-subagent-daemon`) runs this +skill's recipe for you: -```bash -claude --name --model --dangerously-skip-permissions \ - --append-system-prompt-file task-prompt.md \ - -p "Carry out the task described in your instructions." +``` +start(name, prompt_file, model?, trigger?) +continue(name, prompt, model?) +status(name) +interrupt(name, force?) ``` -- **`--name `** - names the session so you can `--resume` it - by name for follow-ups. -- **`--model `** - think about which model the task - actually needs; don't spend a bigger model's tokens than your own on - mechanical work a cheaper one handles fine. Never use a _bigger_ model - than yourself for a sub-agent - if the task needs that much capability, - it's not the "mechanical batch" case this skill is for. -- **`--dangerously-skip-permissions`** - required in headless (`-p`) - mode. Without it, tool calls that would normally prompt for approval - are auto-**denied** non-interactively, so the sub-agent silently does - nothing. Acceptable here because the sub-instance runs inside your - already-sandboxed environment, with your already-scoped credentials, - on a bounded task - don't reach for it when the task could touch - things outside its intended blast radius. -- **`--append-system-prompt-file `** - read the recipe from a - file rather than inlining it in `-p` (which is bounded by `ARG_MAX` - - a long recipe passed as `-p "$(cat …)"` can blow past it and the exec - fails). Keeps Claude Code's default scaffolding and adds your recipe - on top. -- **`-p ""`** - headless print mode. Keep this short: just - tell the sub-agent to act on its file-supplied instructions. +`start` and `continue` return as soon as the process is confirmed +running, not once it finishes — a completion lands as a todo +(`get_loose_ends`), same as any other producer. Use `status` for a +zero-cost "is it still going" check; reach for `continue` only once you +actually have a new instruction for it, since that spends a turn. +`interrupt` genuinely stops a running turn (`force: true` for SIGKILL). -## Run it in the background, don't babysit - -Wrap the launch in a background bash task and end your turn - your -bash-task runner already captures stdout/stderr and hands you a -completion pointer, and ending the turn keeps you reachable for other -work while the sub-agent grinds. +Model choice, prompt hygiene, splitting big batches, verify-then-report — +everything else in this skill — applies exactly the same whether you're +calling the tool or thinking through the recipe by hand. ## Prompt hygiene - this is where batches succeed or fail @@ -92,10 +75,9 @@ running at once instead of handing the whole thing to one. different files never race on the same working tree or index. For a non-git batch (issue relabeling, API calls), independent items don't need filesystem isolation at all — just launch N in parallel. -- **Launch all N in the background and don't babysit any single one** — - same as the one-subagent case, just N background bash tasks instead of - one. Check on them as a batch, not by polling each individually in a - loop. +- **Launch all N and don't babysit any single one** — same as the + one-subagent case, just N `start` calls instead of one. Check on them + as a batch, not by polling each individually in a loop. - **Mind the container's memory cap before picking N.** Your whole container shares one `MemoryMax` (a few GB by default) with every subagent you spawn _and_ your own process. A `claude` process plus its @@ -118,10 +100,10 @@ threaded — the coordination overhead isn't worth it below that size. ## Resume for follow-ups -`claude --resume -p "Now run the same procedure over the second batch."` -reuses the same session, so the sub-agent keeps every constant and -gotcha it already discovered instead of re-learning the surface from a -cold prompt. Spawn a new `--name` only for a genuinely unrelated task. +`continue` reuses the same session, so the sub-agent keeps every constant +and gotcha it already discovered instead of re-learning the surface from +a cold prompt. Reach for `start` under a new name only for a genuinely +unrelated task. ## Verify, then report @@ -131,14 +113,12 @@ anything the sub-agent left ambiguous or exempted for a human call. ## Pitfalls (all observed in practice) -- No `--dangerously-skip-permissions` → headless tool calls denied, the - sub-agent burns a run doing nothing. - Using a model bigger than yourself → paying premium cost for mechanical work that didn't need it. - Skipping the one-item tuning pass → a systematic mistake smeared across the whole batch. -- Spawning fresh instead of `--resume` for a follow-up → throws away - all the context the first pass earned. +- `start`-ing fresh instead of `continue`-ing for a follow-up → throws + away all the context the first pass earned. - Baking an unverified assumption into the recipe - if a quick check suggests something "isn't possible" or "doesn't exist," confirm it before writing that conclusion into the prompt; a wrong assumption diff --git a/docs/tools/README.md b/docs/tools/README.md index fba7cc15..b3946dc0 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -28,6 +28,10 @@ debug agent behavior. - **[bash](bash.md)** — background shell execution (`mcp__bash__*`), available on every agent unconditionally. +- **[subagent](subagent.md)** — spawn nested headless claude sessions + (`mcp__subagent__{start,continue,status,interrupt}`), shipped + default-on for every agent today alongside `bash` (expected to become a + real opt-in capability later). - **[forge](forge.md)** — the `hive-forge` Forgejo CLI every agent has for issues, PRs, and comments. Not an MCP tool — a binary agents shell out to instead of ad-hoc curl. diff --git a/docs/tools/bash.md b/docs/tools/bash.md index c9ef0b52..2fa6f7c3 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -93,15 +93,14 @@ this structured path so tasks get task-id tracking and structured output. `hive-bash-daemon` is a single long-running process (one per agent container, systemd service in `nix/agent-modules/mcp.nix`) — no stdio -bridge, no separate bin. It owns subprocess management, output file -writing, todo delivery on the harness's in-agent socket, **and** serves -the `run`/`status`/`kill` MCP tools directly over streamable-http on -`hyperhive.mcp.bashHttpPort` (declared in `hyperhive.extraMcpServers.bash` -as `{ type = "http"; url = ...; }`). Same shape as the built-in -`hyperhive` surface (`hive-mcp-http`) — claude reconnects to the stable -URL every turn instead of respawning a stdio child, so there's no -per-turn MCP re-registration race and no round-trip socket hop for tool -calls. +bridge. It owns subprocess management, output file writing, todo delivery +on the harness's in-agent socket, **and** serves the `run`/`status`/`kill` +MCP tools directly over streamable-http on `hyperhive.mcp.bashHttpPort` +(declared in `hyperhive.extraMcpServers.bash` as `{ type = "http"; url = +...; }`). Same shape as the built-in `hyperhive` surface (`hive-mcp-http`) +— claude reconnects to the stable URL every turn instead of respawning a +stdio child, so there's no per-turn MCP re-registration race and no +round-trip socket hop for tool calls. ### Completion as a todo (loose-ends v2) diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md new file mode 100644 index 00000000..314bf224 --- /dev/null +++ b/docs/tools/subagent.md @@ -0,0 +1,54 @@ +# Subagent daemon + +`hive-subagent-daemon` (crate `hive-subagent-mcp`) spawns nested headless +`claude` sessions on request. Own process, own systemd unit, own MCP +server (`subagent`, not `hyperhive`) — independent of `hive-bash-daemon`: +a subagent is a full nested claude process, a materially heavier +capability than a background shell command, so it gets its own +deployable/restartable unit rather than living inside the bash daemon. + +Shipped default-on for every agent — `nix/agent-modules/mcp.nix` injects +`subagent` into `hyperhive.extraMcpServers` unconditionally (`allowedTools += ["*"]`), same as `bash`. The operator's own framing: default-on for +now, a real opt-in capability later. + +For what the tools do and when an agent should reach for them, see the +`subagent` MCP server's own tool descriptions and the +`base:claude-subagents` skill — this page covers the daemon as deployed +infrastructure, not the agent-facing API. + +## Tools + +Served under the `subagent` MCP server (`mcp__subagent__`): `start`, +`continue`, `status`, `interrupt`. + +## State + +In-memory only: a map of currently-running processes, live only as long +as the daemon process is. A daemon restart stops whatever was running +rather than adopting it. The durable record of a subagent's existence is +claude's own on-disk session (`hive_claude::SessionStore`), which +`continue` reattaches to independent of the daemon's own lifetime — a +restart loses the _in-flight turn_, not the subagent's history. + +## Compaction trade-off + +Built on `hive_claude::Claude::spawn` + `RunningClaude::wait` directly +rather than `InfiniteSession::run`, since only the low-level driver +exposes a cancel handle to stop a turn mid-flight — that's what makes +`interrupt` genuinely stop a running turn rather than only cancelling a +still-pending one. The cost: a turn that overflows the context window +surfaces as an error rather than self-healing via reactive compaction. +Subagents are meant to be bounded, single-batch work, not sessions +long-lived enough to need in-place compaction — a real follow-up if that +assumption stops holding. + +## Configuration + +`hyperhive.mcp.subagentHttpPort` — the daemon's streamable-http listen +port. Same pattern as `bashHttpPort`/`matrixHttpPort`: a per-agent default +assigned by `nix/agent-modules/mcp.nix`, only worth overriding for an +agent that needs a stable or non-default port. + +Own systemd unit, defined alongside the other per-agent MCP daemons in +`nix/agent-modules/mcp.nix`. diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index d1d63549..d3c59fe7 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -128,6 +128,9 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a - **Bash execution** (`execution`) — background shell tasks. See [`docs/tools/bash.md`](../tools/bash.md). +- **Subagent spawning** — headless claude sub-instances as background + tasks, shipped default-on like bash execution (no tool group gates it + yet). See [`docs/tools/subagent.md`](../tools/subagent.md). - **Lifecycle + config** (`lifecycle`, `approvals`) — manage child agents, spawn new ones, apply config commits. See [`docs/tools/lifecycle.md`](../tools/lifecycle.md). diff --git a/flake.nix b/flake.nix index 9e6d2ad4..a1f0b566 100644 --- a/flake.nix +++ b/flake.nix @@ -93,6 +93,7 @@ hive-matrix-daemon hive-metric hive-screen-mcp + hive-subagent-daemon assets frontend reference-docs diff --git a/hive-c0re/src/dashboard/journal.rs b/hive-c0re/src/dashboard/journal.rs index 3898b5c9..f09908c5 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -110,6 +110,7 @@ pub(super) async fn get_journal( "hive-agent.service", "hive-mcp-http.service", "hive-bash-daemon.service", + "hive-subagent-daemon.service", "hive-matrix-daemon.service", ]; let unit = if u.ends_with(".service") { diff --git a/hive-subagent-mcp/Cargo.toml b/hive-subagent-mcp/Cargo.toml new file mode 100644 index 00000000..b3315719 --- /dev/null +++ b/hive-subagent-mcp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "hive-subagent-mcp" +edition.workspace = true +version.workspace = true +readme = "README.md" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +axum.workspace = true +clap.workspace = true +hive-agent-sock.workspace = true +hive-claude.workspace = true +hive-sock-client.workspace = true +hive-types.workspace = true +rmcp.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +# `hive-subagent-daemon` — long-running per-agent claude-subagent runner. +# Independent of `hive-bash-mcp` (own crate, own binary, own MCP server) — +# see lib.rs's module doc for why. Serves its MCP tools (`start`/ +# `continue`/`interrupt`) directly over streamable-http — no stdio bridge. +[[bin]] +name = "hive-subagent-daemon" +path = "src/main.rs" diff --git a/hive-subagent-mcp/README.md b/hive-subagent-mcp/README.md new file mode 100644 index 00000000..27ae605b --- /dev/null +++ b/hive-subagent-mcp/README.md @@ -0,0 +1,27 @@ +# hive-subagent-mcp + +Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless +`claude` sessions on request and serves the tool surface +(`start`/`continue`/`interrupt`) directly over streamable-http. No +stdio bridge, no per-turn respawn — claude reconnects to the same +stable URL every turn. + +Independent of `hive-bash-mcp` — a subagent spawns a full nested +`claude` session, a much heavier capability than a bash command, worth +its own deployable/restartable unit. + +## Shape + +One bin (`hive-subagent-daemon`, `src/main.rs`) built from the crate's +own lib (`src/lib.rs`): + +- **`session.rs`** — the actual claude-facing logic: `Claude::spawn` + + `RunningClaude::wait`/`cancel_handle` (not `InfiniteSession::run`, + which has no cancel handle to reach in — see the module doc for the + v1 scope this trades away), the in-memory `running` map that's the + _only_ state this daemon keeps (no task files — a restart stops + whatever's running; the actual claude session is the durable store, + found again by name via `hive_claude::SessionStore`). +- **`mcp.rs`** — the `rmcp` tool router (`start`/`continue`/`interrupt`) + - `serve_http`. +- **`paths.rs`** — the in-agent todo-socket path. diff --git a/hive-subagent-mcp/src/lib.rs b/hive-subagent-mcp/src/lib.rs new file mode 100644 index 00000000..180efee8 --- /dev/null +++ b/hive-subagent-mcp/src/lib.rs @@ -0,0 +1,14 @@ +//! Library for `hive-subagent-daemon`: spawns nested claude sessions on +//! request and serves the `start`/`continue`/`interrupt` MCP tool surface +//! directly over streamable-http — no stdio bridge, no round-trip socket. +//! Independent of `hive-bash-mcp` — a subagent is a much heavier capability +//! than a bash command (a full nested `claude` process), worth its own +//! deployable/restartable unit rather than sharing one. +//! +//! See [`session`]'s module doc for the actual design: no task files, no +//! restart recovery, no mid-turn compaction — the daemon's only state is an +//! in-memory `name -> Cancel` map, live only as long as the process is. + +pub mod mcp; +pub mod paths; +pub mod session; diff --git a/hive-subagent-mcp/src/main.rs b/hive-subagent-mcp/src/main.rs new file mode 100644 index 00000000..40f5fdc3 --- /dev/null +++ b/hive-subagent-mcp/src/main.rs @@ -0,0 +1,52 @@ +//! `hive-subagent-daemon` binary — spawns nested claude sessions on +//! request and serves the `start`/`continue`/`interrupt` MCP tool surface +//! directly over streamable-http on `--http ` — no stdio bridge, no +//! separate bin claude has to respawn every turn. + +use std::sync::Arc; + +use anyhow::Result; +use clap::Parser; + +#[derive(Parser)] +#[command( + name = "hive-subagent-daemon", + about = "claude-subagent runner + MCP daemon" +)] +struct Cli { + /// Serve the MCP tools over streamable-http on this address (e.g. + /// `127.0.0.1:8793`). Bind loopback only. + #[arg(long)] + http: std::net::SocketAddr, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_env("RUST_LOG") + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + // This is a systemd-managed daemon — stdout always goes to journald, + // never a human terminal, and journald doesn't strip ANSI escapes: + // they land in victorialogs as raw byte-array spam otherwise. + .with_ansi(false) + .init(); + + let cli = Cli::parse(); + let todo_socket = hive_subagent_mcp::paths::agent_socket(); + + tracing::info!( + http = %cli.http, + todo = %todo_socket.display(), + "hive-subagent-daemon starting" + ); + + let state = Arc::new(hive_subagent_mcp::session::State::new(todo_socket)); + + // Serve the MCP tools over streamable-http forever. No background poll + // loop to start — unlike the bash daemon's task-file queue, `start`/ + // `continue` spawn their subagent's background turn directly from the + // tool call itself, nothing to scan for. + hive_subagent_mcp::mcp::serve_http(cli.http, state).await +} diff --git a/hive-subagent-mcp/src/mcp.rs b/hive-subagent-mcp/src/mcp.rs new file mode 100644 index 00000000..e8d5491e --- /dev/null +++ b/hive-subagent-mcp/src/mcp.rs @@ -0,0 +1,182 @@ +//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`, +//! served directly over streamable-http — no stdio bridge, no round-trip +//! socket. + +use std::sync::Arc; + +use rmcp::{ + ServerHandler, + handler::server::wrapper::Parameters, + schemars::{self, JsonSchema}, + tool, tool_handler, tool_router, +}; +use serde::Deserialize; + +use crate::session::{self, State}; + +#[derive(Debug, Deserialize, JsonSchema)] +struct StartArgs { + /// Session name — this daemon's tracking key while it's alive, and the + /// identity to `continue`/`status`/`interrupt` it by afterward. Same + /// identifier rules as the `bash` server's task names: lowercase, + /// digits, hyphen, max 63 chars. Reusable once a prior *finished* + /// session under that name is done — rejected while one under the same + /// name is still running. + name: String, + /// Which model the subagent's own session runs. Omit for claude's own + /// default. The `base:claude-subagents` skill's "cheaper-than-you" + /// guidance still applies here. + #[serde(default)] + model: Option, + /// Path to a file holding the subagent's actual task instructions. A + /// file, not an inline string, so a large recipe can't blow past a + /// shell argument length limit. + prompt_file: String, + /// Written to the subagent's stdin as its first turn's prompt. Default: + /// a generic "carry out your instructions" nudge — the real task detail + /// belongs in `prompt_file`, not here. + #[serde(default = "default_trigger")] + trigger: String, +} + +fn default_trigger() -> String { + "Carry out the task described in your instructions.".to_owned() +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct ContinueArgs { + /// The existing session's name (from a prior `start`). + name: String, + /// The new turn's prompt, written to the subagent's stdin. + prompt: String, + /// Which model this turn runs. Omit to let claude fall back to its own + /// default — this does not have to match whatever model `start` used. + #[serde(default)] + model: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct StatusArgs { + /// The subagent name to check. + name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct InterruptArgs { + /// The running session's name to signal. + name: String, + /// `true` sends SIGKILL immediately; `false` (default) sends SIGINT, + /// letting claude shut down cleanly if it's already mid-response. + #[serde(default)] + force: bool, +} + +#[derive(Clone)] +struct SubagentMcp { + state: Arc, +} + +#[tool_router] +impl SubagentMcp { + #[tool( + description = "Start a fresh claude subagent session under `name`, running in the \ + background. Returns as soon as the process is confirmed running — not once it \ + finishes; this daemon pushes a todo when the turn ends, or use `continue` later to \ + give it another turn. A prior *finished* session under the same name is archived \ + first (real fresh start, not a silent resume); a *currently running* one is \ + refused. Runs unattended — every tool-call permission prompt is pre-approved rather \ + than interactively confirmed — with its MCP server set fixed to what this daemon \ + configures for it. See the `base:claude-subagents` skill for when to reach for this." + )] + fn start(&self, Parameters(args): Parameters) -> String { + match session::start( + &self.state, + &args.name, + args.model, + &args.prompt_file, + args.trigger, + ) { + Ok(msg) => msg, + Err(e) => format!("start error: {e:#}"), + } + } + + #[tool( + name = "continue", + description = "Give an existing named subagent session a new turn — whether that's \ + because its previous turn finished and you have a follow-up instruction, or you're \ + reattaching after this daemon restarted (the session itself survives independently \ + of the daemon that spawned it). Returns as soon as confirmed running, same as \ + `start`. Refuses a name with no session on disk at all, or one already running." + )] + fn r#continue(&self, Parameters(args): Parameters) -> String { + match session::continue_(&self.state, &args.name, args.prompt, args.model) { + Ok(msg) => msg, + Err(e) => format!("continue error: {e:#}"), + } + } + + #[tool( + description = "Signal a currently-running subagent session to stop. Only works once \ + it's actually running — a `start`/`continue` still in its brief window before the \ + process is confirmed spawned refuses interrupt too (nothing to signal yet; retry \ + shortly), same as a name with nothing tracked at all. `force: true` for SIGKILL, \ + otherwise SIGINT." + )] + fn interrupt(&self, Parameters(args): Parameters) -> String { + match session::interrupt(&self.state, &args.name, args.force) { + Ok(msg) => msg, + Err(e) => format!("interrupt error: {e:#}"), + } + } + + #[tool( + description = "Report whether a subagent is currently running — a zero-cost check that \ + never launches a process, unlike `continue`. Distinguishes running, starting (a \ + `start`/`continue` is in flight but not yet a confirmed spawn — this is normally \ + over in well under a second), idle (a session exists but nothing is in flight — \ + `continue` to give it another turn), and no such session at all." + )] + fn status(&self, Parameters(args): Parameters) -> String { + match session::status(&self.state, &args.name) { + Ok(msg) => msg, + Err(e) => format!("status error: {e:#}"), + } + } +} + +#[tool_handler] +impl ServerHandler for SubagentMcp {} + +/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`. +/// Loopback-only bind, one long-lived session — same shape as the bash and +/// matrix daemons' own `serve_http`. +/// +/// # Errors +/// +/// Returns an error if the listener cannot bind `addr` or the HTTP server +/// exits with a fatal error. +pub async fn serve_http(addr: std::net::SocketAddr, state: Arc) -> anyhow::Result<()> { + use rmcp::transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + }; + let mut session_manager = LocalSessionManager::default(); + // A subagent turn can run considerably longer than a bash command — + // same 24h keep-alive rationale as the bash/matrix daemons. + session_manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24)); + let session_manager = std::sync::Arc::new(session_manager); + let service = StreamableHttpService::new( + move || { + Ok(SubagentMcp { + state: Arc::clone(&state), + }) + }, + session_manager, + StreamableHttpServerConfig::default(), + ); + let app = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind(addr).await?; + tracing::info!(%addr, "serving hive-subagent MCP over streamable-http at /mcp"); + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/hive-subagent-mcp/src/paths.rs b/hive-subagent-mcp/src/paths.rs new file mode 100644 index 00000000..8a05e334 --- /dev/null +++ b/hive-subagent-mcp/src/paths.rs @@ -0,0 +1,15 @@ +//! The one per-agent path this daemon needs. + +use std::path::PathBuf; + +/// The harness-served in-agent todo socket (loose-ends v2). This daemon +/// pushes exactly one todo per subagent lifetime — the completion summary, +/// once its background turn finishes. Override via `HIVE_AGENT_SOCKET`; +/// same resolution as `hive-bash-mcp`'s own `paths::agent_socket`. +#[must_use] +pub fn agent_socket() -> PathBuf { + std::env::var_os("HIVE_AGENT_SOCKET").map_or_else( + || PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET), + PathBuf::from, + ) +} diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs new file mode 100644 index 00000000..8b2c5e90 --- /dev/null +++ b/hive-subagent-mcp/src/session.rs @@ -0,0 +1,489 @@ +//! The claude-facing half of this daemon: spawn a subagent turn, track it +//! only while it's alive, and push exactly one todo when it finishes. +//! +//! **No task files, no restart recovery.** The daemon's only state is an +//! in-memory `name -> Option` map (see `State`'s own doc for what +//! the `None`/`Some` split is for), live for exactly as long as the process +//! is — a daemon restart means whatever was running gets killed with it +//! (`tokio`'s own child-process drop semantics), not adopted. The durable +//! record of a subagent's existence is `hive_claude::SessionStore` — claude's +//! own on-disk session, found again by name. `continue` is how a caller +//! reattaches to it, whether that's "give it a new turn" or "the daemon +//! restarted and I want to pick this back up." +//! +//! **No mid-turn compaction.** Building on `hive_claude::Claude::spawn` + +//! `RunningClaude::wait` directly (not `InfiniteSession::run`) is what makes +//! `interrupt` possible at all — `InfiniteSession` has no cancel handle to +//! reach in from the outside, only `RunningClaude::cancel_handle` does. The +//! trade: this daemon doesn't get `InfiniteSession`'s reactive-compact-on- +//! overflow or proactive-checkpoint-compact for free: a turn that overflows +//! the context window surfaces as a plain `Error::PromptTooLong` to the +//! caller instead of self-healing. Subagents are meant to be bounded, +//! single-batch work (see the `base:claude-subagents` skill), not sessions +//! long-lived enough to need in-place compaction — a real follow-up if that +//! assumption stops holding, not shipped here. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, PoisonError}; + +use hive_claude::{Attach, Cancel, Claude, Config, NoopSink, SessionStore}; + +/// This daemon's whole state: which names currently have a live process or +/// a reservation in flight, and where to push the completion todo. +/// `Arc`-wrapped so the background task that drives a turn to completion +/// can outlive the tool call that started it. +/// +/// The map value is `Option`: `None` means `name` is reserved for +/// an in-flight `start`/`continue` that hasn't reached a confirmed +/// `Claude::spawn` yet; `Some(cancel)` means a real process is tracked and +/// interruptible. The `None` state exists to close a real TOCTOU window a +/// reviewer caught in the original check-then-insert version: checking "is +/// `name` free" and committing to it are two different lock acquisitions +/// unless the check *is* the reservation — see `reserve`. +pub struct State { + running: Mutex>>, + socket: PathBuf, +} + +impl State { + #[must_use] + pub fn new(socket: PathBuf) -> Self { + Self { + running: Mutex::new(HashMap::new()), + socket, + } + } + + /// `Some(true)` — a live process is tracked, interruptible. `Some(false)` + /// — reserved for an in-flight start/continue, not yet a confirmed + /// spawn. `None` — nothing tracked under `name` at all. + fn occupancy(&self, name: &str) -> Option { + self.running + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(name) + .map(Option::is_some) + } + + /// Atomically claim `name` for an in-flight start/continue: check + /// "is anything tracked under this name" and "commit to this call + /// owning it" in the *same* lock acquisition, so two calls racing the + /// same name can't both pass a check before either commits (the exact + /// same-name concurrent-run hazard this module's doc warns about). + /// Returns `false` (reserving nothing) if `name` is already reserved + /// or running. + fn reserve(&self, name: &str) -> bool { + let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); + if running.contains_key(name) { + return false; + } + running.insert(name.to_owned(), None); + true + } + + /// Release a reservation that never made it to a real spawn (an error + /// on the slow path between `reserve` and `Claude::spawn` succeeding). + /// A no-op if the entry was already upgraded to `Some` — this only ever + /// clears a still-`None` placeholder, never a live process. + fn release_reservation(&self, name: &str) { + let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner); + if matches!(running.get(name), Some(None)) { + running.remove(name); + } + } +} + +/// A caller-chosen name, validated the same way `hive-bash-mcp`'s task ids +/// are: a single safe [`hive_types::Ident`] segment, which doubles as +/// claude's own `--name`/`--resume` session title. +fn validate_name(name: &str) -> anyhow::Result<()> { + hive_types::Ident::parse(name) + .map(|_| ()) + .map_err(|e| anyhow::anyhow!("invalid subagent name {name:?}: {e}")) +} + +/// Extend the ambient `OTEL_RESOURCE_ATTRIBUTES` with a `subagent=` +/// attribute, so every token/cost/tool-call data point this subagent's own +/// claude process emits carries it alongside the parent's `agent=` +/// label. `Config.env` applies after the inherited environment, so this one +/// entry overriding the ambient value is the intended shape, not a +/// wholesale replacement. +fn subagent_otel_attrs(name: &str) -> String { + match std::env::var("OTEL_RESOURCE_ATTRIBUTES") { + Ok(existing) if !existing.is_empty() => format!("{existing},subagent={name}"), + _ => format!("subagent={name}"), + } +} + +/// Build the `Config` one subagent turn runs against. `prompt_file`, when +/// given, becomes `--append-system-prompt-file` — the subagent's task +/// instructions. Always `--dangerously-skip-permissions --strict-mcp-config` +/// (no `--mcp-config` override — a safety property, not a knob). +fn build_config(name: &str, model: Option, prompt_file: Option<&str>) -> Config { + let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()]; + if let Some(path) = prompt_file { + extra_args.push("--append-system-prompt-file".to_owned()); + extra_args.push(path.to_owned()); + } + Config { + model, + strict_mcp_config: true, + extra_args, + env: vec![( + "OTEL_RESOURCE_ATTRIBUTES".to_owned(), + subagent_otel_attrs(name), + )], + ..Default::default() + } +} + +/// The [`SessionStore`] a subagent's turn actually runs against — same +/// resolution `hive_claude::Claude` itself uses, so a lookup here can't +/// disagree with what the driver does a moment later. +fn build_store(config: &Config) -> std::io::Result { + Ok(SessionStore::new( + config.resolved_claude_home()?, + config.resolved_cwd()?, + )) +} + +/// Start a fresh subagent under `name`. A prior *finished* session under +/// the same name is archived first (so this is a real fresh start, not a +/// silent resume of old history) — a *currently running* one is refused +/// outright, since `hive_claude::InfiniteSession`'s own docs warn that two +/// concurrent runs against the same name corrupt both. +/// +/// # Errors +/// +/// A name already running, an invalid name, an archive failure, or the +/// underlying `Claude::spawn` failing (binary missing, etc.) — the last +/// case is the only one that can happen *after* commit-to-run, and it's +/// exactly why nothing is registered in `running` until spawn actually +/// succeeds. +pub fn start( + state: &Arc, + name: &str, + model: Option, + prompt_file: &str, + trigger: String, +) -> anyhow::Result { + validate_name(name)?; + if !state.reserve(name) { + anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`"); + } + let result = start_reserved(state, name, model, prompt_file, trigger); + if result.is_err() { + state.release_reservation(name); + } + result +} + +/// The slow, fallible part of `start`, run only after `reserve` has +/// already closed the TOCTOU window — split out so `start` can release the +/// reservation on any error path here without duplicating that logic per +/// failure site. +fn start_reserved( + state: &Arc, + name: &str, + model: Option, + prompt_file: &str, + trigger: String, +) -> anyhow::Result { + let config = build_config(name, model, Some(prompt_file)); + let store = build_store(&config)?; + if store.find_by_title(name).is_some() { + tracing::info!( + name, + "start: archiving a finished prior session for a fresh start" + ); + store + .archive_by_title(name) + .map_err(|e| anyhow::anyhow!("archiving the prior `{name}` session failed: {e}"))?; + } + spawn_and_track( + state, + name, + &config, + &Attach::Create(name.to_owned()), + trigger, + ) +} + +/// Give an existing named session a new turn — resuming it whether that +/// means "the previous turn finished, here's the next instruction" or "the +/// daemon restarted, reattaching." Refuses a name with no session on disk +/// at all (nothing to continue) or one already running (same +/// concurrent-run hazard as `start`). +/// +/// # Errors +/// +/// No session under `name`, an invalid name, one already running, or +/// `Claude::spawn` failing. +pub fn continue_( + state: &Arc, + name: &str, + prompt: String, + model: Option, +) -> anyhow::Result { + validate_name(name)?; + if !state.reserve(name) { + anyhow::bail!( + "subagent `{name}` is already running — use `interrupt` first if you meant to redirect it" + ); + } + let result = continue_reserved(state, name, prompt, model); + if result.is_err() { + state.release_reservation(name); + } + result +} + +/// The slow, fallible part of `continue_`, run only after `reserve` has +/// already closed the TOCTOU window — same split rationale as +/// `start_reserved`. +fn continue_reserved( + state: &Arc, + name: &str, + prompt: String, + model: Option, +) -> anyhow::Result { + let config = build_config(name, model, None); + let store = build_store(&config)?; + if store.find_by_title(name).is_none() { + anyhow::bail!( + "no session named `{name}` exists — `continue` resumes an existing subagent, `start` \ + creates one" + ); + } + spawn_and_track( + state, + name, + &config, + &Attach::Resume(name.to_owned()), + prompt, + ) +} + +/// Spawn the child (synchronous — returns with a real pid the instant the +/// process exists, which *is* "confirmed running": there is no stronger +/// signal to wait for without slowing every call down for no reason), track +/// it in `running`, and hand the actual turn off to a background task so +/// the caller returns immediately instead of blocking on the whole turn. +/// Not `async` itself — `tokio::spawn` needs an active runtime to spawn +/// *onto*, not an `async` caller to spawn *from*. +fn spawn_and_track( + state: &Arc, + name: &str, + config: &Config, + attach: &Attach, + prompt: String, +) -> anyhow::Result { + let running = Claude::spawn(config, attach) + .map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?; + // Upgrades the `None` reservation `reserve` already placed here to a + // real cancel handle — same key, so there's no window where `name` + // reads as unoccupied between the reservation and this insert. + state + .running + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(name.to_owned(), Some(running.cancel_handle())); + + let state = Arc::clone(state); + let task_name = name.to_owned(); + tokio::spawn(async move { + let outcome = running.wait(&prompt, &NoopSink).await; + state + .running + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&task_name); + let summary = match outcome { + Ok(()) => "turn complete".to_owned(), + Err(e) => { + tracing::warn!(name = %task_name, error = %e, "subagent: turn failed"); + format!("claude error: {e}") + } + }; + push_completion_todo(&state.socket, &task_name, &summary).await; + }); + + Ok(format!("subagent `{name}` started")) +} + +/// Report whether `name` is currently running — a zero-cost check that +/// never launches a process, unlike `continue`. Distinguishes four states: +/// running, starting (reserved, not yet a confirmed spawn — see `State`'s +/// doc), idle (a session exists but nothing is in flight), and no such +/// session at all. +/// +/// # Errors +/// +/// An invalid name, or no session — running or on disk — under `name`. +pub fn status(state: &State, name: &str) -> anyhow::Result { + validate_name(name)?; + match state.occupancy(name) { + Some(true) => return Ok(format!("subagent `{name}` is running")), + Some(false) => { + return Ok(format!( + "subagent `{name}` is starting — not yet confirmed running" + )); + } + None => {} + } + let config = build_config(name, None, None); + let store = build_store(&config)?; + if store.find_by_title(name).is_some() { + Ok(format!( + "subagent `{name}` is idle — its last turn finished; `continue` to give it another" + )) + } else { + anyhow::bail!("no subagent named `{name}` exists — `start` creates one") + } +} + +/// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see +/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: no +/// entry at all, or one still in the brief `reserve`d-but-not-yet-spawned +/// window (nothing to signal yet — the reservation is put back so a +/// concurrent `start`/`continue` for the same name still gets refused). +/// +/// # Errors +/// +/// An invalid name, nothing tracked under `name`, or `name` is still +/// starting (reserved, not yet a confirmed spawn). +pub fn interrupt(state: &State, name: &str, force: bool) -> anyhow::Result { + validate_name(name)?; + let mut running = state.running.lock().unwrap_or_else(PoisonError::into_inner); + match running.remove(name) { + None => anyhow::bail!("no subagent named `{name}` is currently running"), + Some(None) => { + running.insert(name.to_owned(), None); + anyhow::bail!( + "subagent `{name}` is still starting — not yet confirmed running, try again \ + shortly" + ); + } + Some(Some(cancel)) => { + drop(running); + cancel.cancel(force); + Ok(format!("interrupt sent to subagent `{name}`")) + } + } +} + +/// Push `name`'s one-shot completion todo. Best-effort: a connect/write +/// failure is logged and swallowed, matching every other in-agent-socket +/// producer in this codebase — there's no retry queue to fall back to, and +/// the caller has already moved on by the time this fires. +async fn push_completion_todo(socket: &std::path::Path, name: &str, summary: &str) { + let req = hive_agent_sock::Request::UpsertTodo { + subsystem: "subagent".to_owned(), + key: Some(name.to_owned()), + summary: format!("subagent `{name}` finished: {summary}"), + source: None, + reopen_if_acked: false, + }; + if let Err(e) = hive_sock_client::notify(socket, &req, hive_sock_client::Retry::None).await { + tracing::warn!(name, error = ?e, "subagent: completion todo push failed"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // `Cancel` can only be constructed from a real spawned process (no test + // fixture in `hive_claude` for it), so these exercise the reservation + // half of `State` directly — the actual TOCTOU-closure logic — rather + // than the full start/spawn path. + + #[test] + fn reserve_is_exclusive_for_the_same_name() { + let state = State::new(PathBuf::from("/dev/null")); + assert!(state.reserve("dup"), "first reservation should succeed"); + assert!( + !state.reserve("dup"), + "a second reservation for the same name must be refused — this is the exact race \ + argus found: two calls both passing a check before either commits" + ); + } + + #[test] + fn reserve_does_not_cross_block_different_names() { + let state = State::new(PathBuf::from("/dev/null")); + assert!(state.reserve("a")); + assert!( + state.reserve("b"), + "unrelated names must not block each other" + ); + } + + #[test] + fn release_reservation_frees_the_name_for_reuse() { + let state = State::new(PathBuf::from("/dev/null")); + assert!(state.reserve("n")); + state.release_reservation("n"); + assert!( + state.reserve("n"), + "releasing a still-`None` reservation must free the name again" + ); + } + + #[test] + fn occupancy_reflects_the_reserved_but_not_running_state() { + let state = State::new(PathBuf::from("/dev/null")); + assert_eq!(state.occupancy("never-reserved"), None); + state.reserve("n"); + assert_eq!( + state.occupancy("n"), + Some(false), + "reserved-but-not-yet-spawned must read as occupied-but-not-running" + ); + } + + // One test, not two: `subagent_otel_attrs` reads the real process-wide + // `OTEL_RESOURCE_ATTRIBUTES` env var, and cargo runs tests in parallel + // threads by default — two separate tests each mutating that global + // raced each other (and, in this container, lost to the agent's own + // real ambient value). Sequencing both assertions in one test removes + // the race instead of papering over it with a mutex. + #[test] + fn otel_attrs_append_ambient_value_or_stand_alone() { + // SAFETY: test-only env mutation; sequenced within this one test so + // no other test's concurrent read/write of the same var can race it. + unsafe { + std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); + } + assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1"); + + unsafe { + std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", "agent=damocles"); + } + assert_eq!( + subagent_otel_attrs("batch-1"), + "agent=damocles,subagent=batch-1" + ); + unsafe { + std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); + } + } + + #[test] + fn build_config_only_appends_system_prompt_when_given() { + let with = build_config("n", None, Some("/tmp/p.md")); + assert!( + with.extra_args + .contains(&"--append-system-prompt-file".to_owned()) + ); + assert!(with.extra_args.contains(&"/tmp/p.md".to_owned())); + + let without = build_config("n", None, None); + assert!( + !without + .extra_args + .contains(&"--append-system-prompt-file".to_owned()) + ); + } +} diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index 9cdbf52f..94f5e680 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -203,7 +203,8 @@ # unreachable from inside a container — wrapped with # `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins # the harness execs (hive-agent{,-mcp}, hive-bash-daemon, - # hive-matrix-daemon, hive-matrix-mcp) are wired via their own + # hive-subagent-daemon, hive-matrix-daemon, hive-matrix-mcp) are + # wired via their own # ExecStart/command lines in the sibling modules — they don't need # to be on PATH too. Only this one is actually looked up on PATH # by claude/shell code inside the container: diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 6ffc80c5..3d3f2c65 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -197,6 +197,23 @@ in ''; }; + options.hyperhive.mcp.subagentHttpPort = lib.mkOption { + type = lib.types.port; + default = 8793; + example = 8794; + description = '' + Loopback port `hive-subagent-daemon` serves its MCP tools + (`start`/`continue`/`interrupt`) on. Independent daemon (own crate, + `hive-subagent-mcp`) — a subagent spawns a full nested `claude` + process, a much heavier capability than a bash command, worth its own + deployable/restartable unit. Same shape/reasoning as + `hyperhive.mcp.bashHttpPort` otherwise: sole transport, self-healing + restart, loopback-only so no auth token is needed. Shipped default-on + for every agent today, same as `bash` — expected to become a real + opt-in capability gate later, not yet. + ''; + }; + config = { # Assert the transport-specific required field is actually set — # `command`/`url` are both `nullOr` so the submodule schema stays @@ -225,6 +242,17 @@ in allowedTools = [ "*" ]; }; + # Auto-inject the subagent MCP server — default-on for every agent for + # now (operator's call: "default on for now, should be a capability + # later" — not gated behind an enable option yet, unlike `matrix.nix`'s + # pattern). `lib.mkDefault` so an agent.nix can still override/disable + # the entry in the meantime. + hyperhive.extraMcpServers.subagent = lib.mkDefault { + type = "http"; + url = "http://127.0.0.1:${toString config.hyperhive.mcp.subagentHttpPort}/mcp"; + allowedTools = [ "*" ]; + }; + environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers; environment.etc."hyperhive/send-allow.json".text = @@ -278,6 +306,49 @@ in }; }; + # Subagent task runner daemon — independent of `hive-bash-daemon` (own + # crate, own process): spawns nested claude sessions on request, serves + # the `start`/`continue`/`interrupt` MCP tools directly over + # streamable-http on `hyperhive.mcp.subagentHttpPort`. No task files — + # this daemon's only state is an in-memory map of currently-running + # processes, live only as long as the process is (see + # `hive-subagent-mcp/src/session.rs`'s module doc); a restart stops + # whatever's running, the actual claude session survives independently. + systemd.services.hive-subagent-daemon = { + description = "subagent task runner + MCP daemon for hive-subagent"; + wantedBy = [ "multi-user.target" ]; + before = [ "hive-agent.service" ]; + # A subagent task runs its own nested `claude` invocation (via + # `hive-claude`), which needs to resolve `claude` itself off PATH — + # same reasoning as `hive-bash-daemon`'s `path` above, even though + # this daemon never shells out to `bash -c` directly. + path = [ + "/run/wrappers" + "/run/current-system/sw" + ]; + environment = { + # Same in-agent todo socket as `hive-bash-daemon` — both push task + # todos to the one harness socket. Must match the harness's + # HIVE_AGENT_SOCKET (agent-service.nix). + HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock"; + RUST_LOG = "info"; + # HYPERHIVE_HARNESS_DIR / HYPERHIVE_STATE_DIR: see + # `hive-bash-daemon`'s own comment above — same global injection, + # same reasoning. + }; + serviceConfig = { + ExecStart = "${config.hyperhive.packages.hive-subagent-daemon}/bin/hive-subagent-daemon --http 127.0.0.1:${toString config.hyperhive.mcp.subagentHttpPort}"; + SyslogIdentifier = "hive-subagent-daemon"; + # `always`, same reasoning as `hive-bash-daemon`: the MCP tools are + # served in-process, so a down window is total loss of + # `start`/`continue`/`status`/`interrupt` with no stdio fallback. + Restart = "always"; + RestartSec = 3; + User = userName; + Group = userName; + }; + }; + # Persistent streamable-http MCP daemon for the built-in hyperhive # surface — the *sole* transport for that surface; always # wired. Long-lived so claude reconnects to the stable URL each turn diff --git a/nix/agent-modules/packages.nix b/nix/agent-modules/packages.nix index 0e4fe36d..cc14d826 100644 --- a/nix/agent-modules/packages.nix +++ b/nix/agent-modules/packages.nix @@ -11,7 +11,7 @@ description = '' hyperhive package outputs consumed by the harness modules: the per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`, - `hive-bash-daemon`, + `hive-bash-daemon`, `hive-subagent-daemon`, `hive-forge`, `hive-forge-notify`, `hive-github-notify`, `hive-matrix-daemon`, `hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend`, diff --git a/nix/packages/default.nix b/nix/packages/default.nix index d3241997..38d41522 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -34,6 +34,7 @@ let hive-agent = "hyperhive in-container agent harness serve loop"; hive-agent-mcp = "hyperhive agent-surface MCP server"; hive-bash-daemon = "hyperhive per-agent bash-task runner daemon (serves its MCP tools directly over streamable-http)"; + hive-subagent-daemon = "hyperhive per-agent claude-subagent task runner daemon (serves its MCP tools directly over streamable-http)"; hive-matrix-daemon = "hyperhive per-agent matrix-sdk daemon (serves its MCP tools directly over streamable-http)"; hive-metric = "hyperhive agent-emitted custom metrics CLI"; hive-screen-mcp = "hyperhive screen MCP bridge (screenshot + input for GUI agents)";