From 7699db6500a9369158306e7a2facd893708bcbf7 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 18:15:28 +0200 Subject: [PATCH 1/7] hive-subagent-mcp: new crate for the subagent daemon, independent of hive-bash-mcp --- Cargo.lock | 36 +++- Cargo.toml | 3 +- hive-subagent-mcp/Cargo.toml | 32 ++++ hive-subagent-mcp/README.md | 27 +++ hive-subagent-mcp/src/lib.rs | 14 ++ hive-subagent-mcp/src/main.rs | 52 +++++ hive-subagent-mcp/src/mcp.rs | 159 ++++++++++++++++ hive-subagent-mcp/src/paths.rs | 15 ++ hive-subagent-mcp/src/session.rs | 317 +++++++++++++++++++++++++++++++ 9 files changed, 646 insertions(+), 9 deletions(-) create mode 100644 hive-subagent-mcp/Cargo.toml create mode 100644 hive-subagent-mcp/README.md create mode 100644 hive-subagent-mcp/src/lib.rs create mode 100644 hive-subagent-mcp/src/main.rs create mode 100644 hive-subagent-mcp/src/mcp.rs create mode 100644 hive-subagent-mcp/src/paths.rs create mode 100644 hive-subagent-mcp/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index bd738290..8563a611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,7 +1256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1781,9 +1781,9 @@ dependencies = [ [[package]] name = "hive-claude" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540ef2338fc071b60e69cac345cb5ec04627c4ef11c49138d1c39d4f687f254" +source = "git+https://forge.darkest.space/hyperhive/hive-claude?branch=main#bacccc6580504d548387ffff7f0567be603e73a4" dependencies = [ + "libc", "serde", "serde_json", "thiserror 2.0.18", @@ -1976,6 +1976,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" @@ -3594,7 +3614,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4123,7 +4143,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4182,7 +4202,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4863,7 +4883,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5731,7 +5751,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 8ca89bd1..ba738ac0 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 = { git = "https://forge.darkest.space/hyperhive/hive-claude", branch = "main" } hive-host-sock = { path = "hive-host-sock" } hive-priv-sock = { path = "hive-priv-sock" } hive-sock-client = { path = "hive-sock-client" } 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..493948b3 --- /dev/null +++ b/hive-subagent-mcp/src/mcp.rs @@ -0,0 +1,159 @@ +//! The MCP tool surface: `start` / `continue` / `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 — becomes both this daemon's tracking key and claude's + /// own `--name`/`--resume` session title. 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, + /// `--model` for the subagent's own claude invocation. 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 passed as `--append-system-prompt-file` — the + /// subagent's actual task instructions. A file, not an inline string, + /// to avoid `ARG_MAX` on a large recipe. + 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, + /// `--model` for this turn. 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 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; poll for completion via the todo this daemon pushes 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. Always runs with \ + `--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config` override — \ + that's a safety property, not a knob). 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 while \ + it's actually running — there's no queued/pending state to cancel pre-emptively, \ + only running or not 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_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..d2a503d6 --- /dev/null +++ b/hive-subagent-mcp/src/session.rs @@ -0,0 +1,317 @@ +//! 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 -> Cancel` map, 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, +/// 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. +pub struct State { + running: Mutex>, + socket: PathBuf, +} + +impl State { + #[must_use] + pub fn new(socket: PathBuf) -> Self { + Self { + running: Mutex::new(HashMap::new()), + socket, + } + } + + fn is_running(&self, name: &str) -> bool { + self.running + .lock() + .unwrap_or_else(PoisonError::into_inner) + .contains_key(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.is_running(name) { + anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`"); + } + 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.is_running(name) { + anyhow::bail!( + "subagent `{name}` is already running — use `interrupt` first if you meant to redirect it" + ); + } + 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}"))?; + state + .running + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(name.to_owned(), 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")) +} + +/// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see +/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: +/// there's no queued/pending state to cancel pre-emptively any more (see +/// the module doc), only "running" or "not tracked." +/// +/// # Errors +/// +/// An invalid name, or nothing currently running under `name`. +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); + let Some(cancel) = running.remove(name) else { + anyhow::bail!("no subagent named `{name}` is currently 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::*; + + #[test] + fn otel_attrs_appends_when_ambient_var_is_set() { + // SAFETY: test-only env mutation, single-threaded within this fn's + // scope (no other test in this crate touches this var — checked). + 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 otel_attrs_stands_alone_when_ambient_var_is_unset() { + unsafe { + std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); + } + assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1"); + } + + #[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()) + ); + } +} From c280664d7424188a7b0c4757eadb19e852e9dc54 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 18:15:31 +0200 Subject: [PATCH 2/7] nix: wire the independent hive-subagent-daemon systemd unit and MCP server --- flake.nix | 1 + hive-c0re/src/dashboard/journal.rs | 1 + nix/agent-modules/default.nix | 3 +- nix/agent-modules/mcp.nix | 71 ++++++++++++++++++++++++++++++ nix/agent-modules/packages.nix | 2 +- nix/packages/default.nix | 1 + 6 files changed, 77 insertions(+), 2 deletions(-) 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/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..920d6f34 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 tool is + # served in-process, so a down window is total loss of + # `spawn_subagent` 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)"; From e64639c4d0d102ced3335bedd8d79d9b8faad61b Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 18:15:35 +0200 Subject: [PATCH 3/7] docs: document the independent subagent daemon (start/continue/interrupt) --- CLAUDE.md | 8 ++ .../base/skills/claude-subagents/SKILL.md | 39 ++++++- docs/tools/README.md | 4 + docs/tools/bash.md | 23 ++-- docs/tools/subagent.md | 102 ++++++++++++++++++ docs/turn-loop/mcp.md | 3 + 6 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 docs/tools/subagent.md diff --git a/CLAUDE.md b/CLAUDE.md index fb26720c..1fac48ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,14 @@ 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`/`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/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md index 4305eae2..d5a11c5a 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 - not a hack. ## When to use it @@ -18,7 +18,36 @@ 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 + +Your container's `subagent` MCP server (`hive-subagent-daemon`) is this +skill's recipe as MCP calls — no manual shell-wrapping, no hand-rolled +backgrounding: + +``` +start(name, prompt_file, model?, trigger?) +continue(name, prompt, model?) +interrupt(name, force?) +``` + +`name` ↔ `--name`, `model` ↔ `--model`, `prompt_file` ↔ +`--append-system-prompt-file`, `trigger`/`prompt` ↔ `-p`. `start` returns +once the process is confirmed running, not once it finishes — a +completion lands as a todo (`get_loose_ends`), same as any other producer. +There's no `status` poll: check on a subagent by `continue`-ing it (refused +while it's still running, which is itself the "still going" signal) or by +reading its completion todo. `interrupt` genuinely stops a running turn +(`force: true` for SIGKILL) — unlike bash tasks' `kill`, this isn't +limited to a still-pending state. + +Everything else in this skill (model choice, prompt hygiene, splitting +big batches, verify-then-report) applies unchanged; only the launch +mechanics differ from a raw shell invocation. + +No `subagent` MCP server on this container? Fall back to the manual +command below — same recipe, you drive the backgrounding yourself. + +## The spawn command (manual fallback) Put the task recipe in a file and pass it as a path, with a short `-p` trigger: diff --git a/docs/tools/README.md b/docs/tools/README.md index fba7cc15..04fd50a1 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,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..9b373aaf 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -93,15 +93,20 @@ 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. + +Independent daemon (own crate, own process/systemd unit/MCP server +`subagent`): `hive-subagent-daemon` — see [`subagent.md`](subagent.md). +Spawns nested claude sessions rather than shell commands, so it keeps no +task files here or anywhere else; a subagent task never shows up in this +daemon's `status`/`kill` or the running-tasks panel. ### 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..ff80c833 --- /dev/null +++ b/docs/tools/subagent.md @@ -0,0 +1,102 @@ +# Subagent tools + +Spawns nested headless `claude` sessions via `hive-subagent-daemon`. Tools +land as `mcp__subagent__` (the MCP server name is `subagent`, not +`hyperhive`). Shipped default-on for every agent today — +`nix/agent-modules/mcp.nix` always injects `subagent` into +`hyperhive.extraMcpServers` (with `allowedTools = ["*"]`), same as `bash`. +The operator's own framing: shipped default-on for now, expected to become +a real opt-in capability later (not built yet). + +See the `base:claude-subagents` skill for _when_ to reach for this +(mechanical, bounded batches) versus doing the work inline. + +## Tools + +### `start(name, prompt_file, model?, trigger?)` + +Start a fresh subagent session under `name`, running in the background. +Returns as soon as the process is confirmed running — not once it +finishes. + +- `name` — session display name: claude's own `--name`/`--resume` session + title, and this daemon's tracking key while the process is alive. + `[a-z0-9-]`, max 63 chars. Reusable once a prior session under that name + has _finished_; refused while one is still running. +- `prompt_file` — path to a file passed as `--append-system-prompt-file`, + the subagent's actual task instructions. A file, not an inline string, + to avoid `ARG_MAX` on a large recipe. +- `model` — `--model` for the subagent's own claude invocation. Omit for + claude's own default. +- `trigger` — written to the subagent's stdin as its first turn's prompt. + Defaults to a generic "carry out your instructions" nudge. + +A prior _finished_ session under the same name is archived first (renamed +`.jsonl.archived`, not deleted) — a real fresh start, not a silent resume +of old history. + +Exposed as `mcp__subagent__start`. + +### `continue(name, prompt, model?)` + +Give an existing named session a new turn — whether that means "the +previous turn finished, here's a follow-up instruction" or "the daemon +restarted, reattaching to a session that survived it independently." +Returns as soon as confirmed running, same as `start`. Refused for a name +with no session on disk at all, or one currently running. + +- `name` — the existing session's name (from a prior `start`). +- `prompt` — the new turn's prompt. +- `model` — `--model` for this turn; doesn't have to match whatever + `start` used. + +Exposed as `mcp__subagent__continue`. + +### `interrupt(name, force?)` + +Signal `name`'s currently-running process. Only works while it's actually +running — there's no queued/pending state to cancel pre-emptively. + +- `force: false` (default) — SIGINT, letting claude shut down cleanly if + it's mid-response. +- `force: true` — SIGKILL. + +Exposed as `mcp__subagent__interrupt`. + +Always runs with `--dangerously-skip-permissions --strict-mcp-config` (no +`--mcp-config` override — a safety property, not a knob). + +## Checking on a subagent + +There's no `status` tool. A subagent's own claude session — not a +parallel log this daemon writes — is the record of what it did; read that +the same way you'd catch up on any other agent, by asking it directly +(`continue` with a prompt). The daemon pushes exactly one todo per +session lifetime: when a turn finishes, its completion summary lands via +`get_loose_ends` like any other producer's todo. `continue` on a name +that's still running is refused (with that as the tell it's still going) +rather than queuing behind it. + +## Architecture + +`hive-subagent-daemon` (own crate, `hive-subagent-mcp`) is independent of +`hive-bash-daemon` — a subagent spawns a full nested `claude` process, a +materially heavier capability than a bash command, worth its own +deployable/restartable unit. Own systemd unit, own streamable-http +listener on `hyperhive.mcp.subagentHttpPort`. + +**No task files.** The daemon's only state is an in-memory map of +currently-running processes (`name -> Cancel`), live only as long as the +process is — a restart stops whatever's running rather than adopting it. +The durable record of a subagent's existence is claude's own on-disk +session, found again by name via `hive_claude::SessionStore`; that's what +`continue` reattaches to, restart or not. + +**No mid-turn compaction.** Built on `hive_claude::Claude::spawn` + +`RunningClaude::wait` directly, not `InfiniteSession::run` — the latter +has no cancel handle to reach in from the outside, which is what would +make `interrupt` a no-op. The trade: a turn that overflows the context +window surfaces as a plain error instead of self-healing via reactive +compaction. 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. 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). From c2fb3c6e3e027e67a2f2fdb8ca49ab4d88b7eb18 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 19:02:27 +0200 Subject: [PATCH 4/7] subagent: add status tool, cut docs down to operator-facing + no cli flags --- CLAUDE.md | 13 +- .../base/skills/claude-subagents/SKILL.md | 93 +++--------- docs/tools/README.md | 6 +- docs/tools/bash.md | 6 - docs/tools/subagent.md | 132 ++++++------------ hive-subagent-mcp/src/mcp.rs | 57 +++++--- hive-subagent-mcp/src/session.rs | 24 ++++ 7 files changed, 136 insertions(+), 195 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1fac48ea..5b0d3719 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,12 +100,13 @@ hand-maintained per-file tree drifts out of sync with the code. `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`/`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). + 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/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md index d5a11c5a..756b8604 100644 --- a/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md +++ b/claude-plugins/plugins/base/skills/claude-subagents/SKILL.md @@ -7,7 +7,7 @@ description: Spin up a short-lived headless `claude` sub-instance to grind throu 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. +batch. ## When to use it @@ -20,72 +20,26 @@ batch - not a hack. ## The `subagent` MCP tools -Your container's `subagent` MCP server (`hive-subagent-daemon`) is this -skill's recipe as MCP calls — no manual shell-wrapping, no hand-rolled -backgrounding: +Your container's `subagent` MCP server (`hive-subagent-daemon`) runs this +skill's recipe for you: ``` start(name, prompt_file, model?, trigger?) continue(name, prompt, model?) +status(name) interrupt(name, force?) ``` -`name` ↔ `--name`, `model` ↔ `--model`, `prompt_file` ↔ -`--append-system-prompt-file`, `trigger`/`prompt` ↔ `-p`. `start` returns -once the process is confirmed running, not once it finishes — a -completion lands as a todo (`get_loose_ends`), same as any other producer. -There's no `status` poll: check on a subagent by `continue`-ing it (refused -while it's still running, which is itself the "still going" signal) or by -reading its completion todo. `interrupt` genuinely stops a running turn -(`force: true` for SIGKILL) — unlike bash tasks' `kill`, this isn't -limited to a still-pending state. +`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). -Everything else in this skill (model choice, prompt hygiene, splitting -big batches, verify-then-report) applies unchanged; only the launch -mechanics differ from a raw shell invocation. - -No `subagent` MCP server on this container? Fall back to the manual -command below — same recipe, you drive the backgrounding yourself. - -## The spawn command (manual fallback) - -Put the task recipe in a file and pass it as a path, with a short `-p` -trigger: - -```bash -claude --name --model --dangerously-skip-permissions \ - --append-system-prompt-file task-prompt.md \ - -p "Carry out the task described in your instructions." -``` - -- **`--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. - -## 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 @@ -121,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 @@ -147,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 @@ -160,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 04fd50a1..b3946dc0 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -29,9 +29,9 @@ 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,interrupt}`), shipped default-on for - every agent today alongside `bash` (expected to become a real opt-in - capability later). + (`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 9b373aaf..2fa6f7c3 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -102,12 +102,6 @@ MCP tools directly over streamable-http on `hyperhive.mcp.bashHttpPort` stdio child, so there's no per-turn MCP re-registration race and no round-trip socket hop for tool calls. -Independent daemon (own crate, own process/systemd unit/MCP server -`subagent`): `hive-subagent-daemon` — see [`subagent.md`](subagent.md). -Spawns nested claude sessions rather than shell commands, so it keeps no -task files here or anywhere else; a subagent task never shows up in this -daemon's `status`/`kill` or the running-tasks panel. - ### Completion as a todo (loose-ends v2) When a bash task changes state, `hive-bash-daemon` upserts a single keyed diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index ff80c833..0de5845a 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -1,102 +1,54 @@ -# Subagent tools +# Subagent daemon -Spawns nested headless `claude` sessions via `hive-subagent-daemon`. Tools -land as `mcp__subagent__` (the MCP server name is `subagent`, not -`hyperhive`). Shipped default-on for every agent today — -`nix/agent-modules/mcp.nix` always injects `subagent` into -`hyperhive.extraMcpServers` (with `allowedTools = ["*"]`), same as `bash`. -The operator's own framing: shipped default-on for now, expected to become -a real opt-in capability later (not built yet). +`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. -See the `base:claude-subagents` skill for _when_ to reach for this -(mechanical, bounded batches) versus doing the work inline. +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 -### `start(name, prompt_file, model?, trigger?)` +Served under the `subagent` MCP server (`mcp__subagent__`): `start`, +`continue`, `status`, `interrupt`. -Start a fresh subagent session under `name`, running in the background. -Returns as soon as the process is confirmed running — not once it -finishes. +## State -- `name` — session display name: claude's own `--name`/`--resume` session - title, and this daemon's tracking key while the process is alive. - `[a-z0-9-]`, max 63 chars. Reusable once a prior session under that name - has _finished_; refused while one is still running. -- `prompt_file` — path to a file passed as `--append-system-prompt-file`, - the subagent's actual task instructions. A file, not an inline string, - to avoid `ARG_MAX` on a large recipe. -- `model` — `--model` for the subagent's own claude invocation. Omit for - claude's own default. -- `trigger` — written to the subagent's stdin as its first turn's prompt. - Defaults to a generic "carry out your instructions" nudge. +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. -A prior _finished_ session under the same name is archived first (renamed -`.jsonl.archived`, not deleted) — a real fresh start, not a silent resume -of old history. +## Compaction trade-off -Exposed as `mcp__subagent__start`. +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. -### `continue(name, prompt, model?)` +## Configuration -Give an existing named session a new turn — whether that means "the -previous turn finished, here's a follow-up instruction" or "the daemon -restarted, reattaching to a session that survived it independently." -Returns as soon as confirmed running, same as `start`. Refused for a name -with no session on disk at all, or one currently running. +`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. -- `name` — the existing session's name (from a prior `start`). -- `prompt` — the new turn's prompt. -- `model` — `--model` for this turn; doesn't have to match whatever - `start` used. - -Exposed as `mcp__subagent__continue`. - -### `interrupt(name, force?)` - -Signal `name`'s currently-running process. Only works while it's actually -running — there's no queued/pending state to cancel pre-emptively. - -- `force: false` (default) — SIGINT, letting claude shut down cleanly if - it's mid-response. -- `force: true` — SIGKILL. - -Exposed as `mcp__subagent__interrupt`. - -Always runs with `--dangerously-skip-permissions --strict-mcp-config` (no -`--mcp-config` override — a safety property, not a knob). - -## Checking on a subagent - -There's no `status` tool. A subagent's own claude session — not a -parallel log this daemon writes — is the record of what it did; read that -the same way you'd catch up on any other agent, by asking it directly -(`continue` with a prompt). The daemon pushes exactly one todo per -session lifetime: when a turn finishes, its completion summary lands via -`get_loose_ends` like any other producer's todo. `continue` on a name -that's still running is refused (with that as the tell it's still going) -rather than queuing behind it. - -## Architecture - -`hive-subagent-daemon` (own crate, `hive-subagent-mcp`) is independent of -`hive-bash-daemon` — a subagent spawns a full nested `claude` process, a -materially heavier capability than a bash command, worth its own -deployable/restartable unit. Own systemd unit, own streamable-http -listener on `hyperhive.mcp.subagentHttpPort`. - -**No task files.** The daemon's only state is an in-memory map of -currently-running processes (`name -> Cancel`), live only as long as the -process is — a restart stops whatever's running rather than adopting it. -The durable record of a subagent's existence is claude's own on-disk -session, found again by name via `hive_claude::SessionStore`; that's what -`continue` reattaches to, restart or not. - -**No mid-turn compaction.** Built on `hive_claude::Claude::spawn` + -`RunningClaude::wait` directly, not `InfiniteSession::run` — the latter -has no cancel handle to reach in from the outside, which is what would -make `interrupt` a no-op. The trade: a turn that overflows the context -window surfaces as a plain error instead of self-healing via reactive -compaction. 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. +Own systemd unit, defined alongside the other per-agent MCP daemons in +`nix/agent-modules/mcp.nix`. diff --git a/hive-subagent-mcp/src/mcp.rs b/hive-subagent-mcp/src/mcp.rs index 493948b3..b083aab6 100644 --- a/hive-subagent-mcp/src/mcp.rs +++ b/hive-subagent-mcp/src/mcp.rs @@ -15,20 +15,21 @@ use crate::session::{self, State}; #[derive(Debug, Deserialize, JsonSchema)] struct StartArgs { - /// Session name — becomes both this daemon's tracking key and claude's - /// own `--name`/`--resume` session title. 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. + /// 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, - /// `--model` for the subagent's own claude invocation. Omit for - /// claude's own default. The `base:claude-subagents` skill's - /// "cheaper-than-you" guidance still applies here. + /// 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 passed as `--append-system-prompt-file` — the - /// subagent's actual task instructions. A file, not an inline string, - /// to avoid `ARG_MAX` on a large recipe. + /// 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 @@ -47,12 +48,18 @@ struct ContinueArgs { name: String, /// The new turn's prompt, written to the subagent's stdin. prompt: String, - /// `--model` for this turn. Omit to let claude fall back to its own + /// 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. @@ -73,13 +80,12 @@ 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; poll for completion via the todo this daemon pushes 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. Always runs with \ - `--dangerously-skip-permissions --strict-mcp-config` (no `--mcp-config` override — \ - that's a safety property, not a knob). See the `base:claude-subagents` skill for \ - when to reach for this." + 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( @@ -120,6 +126,19 @@ impl SubagentMcp { 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, 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] diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index d2a503d6..774a6dd3 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -235,6 +235,30 @@ fn spawn_and_track( Ok(format!("subagent `{name}` started")) } +/// Report whether `name` is currently running — a zero-cost check that +/// never launches a process, unlike `continue`. Distinguishes three +/// states: running, 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)?; + if state.is_running(name) { + return Ok(format!("subagent `{name}` is running")); + } + 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: /// there's no queued/pending state to cancel pre-emptively any more (see From 561bd09618c9d9b64f0a3890298dbb9a90d77f60 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 19:49:19 +0200 Subject: [PATCH 5/7] subagent: close the start/continue TOCTOU race with an atomic reservation --- hive-subagent-mcp/src/mcp.rs | 20 ++-- hive-subagent-mcp/src/session.rs | 192 +++++++++++++++++++++++++++---- nix/agent-modules/mcp.nix | 4 +- 3 files changed, 181 insertions(+), 35 deletions(-) diff --git a/hive-subagent-mcp/src/mcp.rs b/hive-subagent-mcp/src/mcp.rs index b083aab6..e8d5491e 100644 --- a/hive-subagent-mcp/src/mcp.rs +++ b/hive-subagent-mcp/src/mcp.rs @@ -1,5 +1,6 @@ -//! The MCP tool surface: `start` / `continue` / `interrupt`, served -//! directly over streamable-http — no stdio bridge, no round-trip socket. +//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`, +//! served directly over streamable-http — no stdio bridge, no round-trip +//! socket. use std::sync::Arc; @@ -116,9 +117,11 @@ impl SubagentMcp { } #[tool( - description = "Signal a currently-running subagent session to stop. Only works while \ - it's actually running — there's no queued/pending state to cancel pre-emptively, \ - only running or not tracked at all. `force: true` for SIGKILL, otherwise SIGINT." + 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) { @@ -129,9 +132,10 @@ impl SubagentMcp { #[tool( description = "Report whether a subagent is currently running — a zero-cost check that \ - never launches a process, unlike `continue`. Distinguishes running, idle (a session \ - exists but nothing is in flight — `continue` to give it another turn), and no such \ - session at all." + 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) { diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 774a6dd3..920d5ea7 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -2,7 +2,8 @@ //! 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 -> Cancel` map, live for exactly as long as the process +//! 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 @@ -28,12 +29,20 @@ 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, -/// 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. +/// 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>, + running: Mutex>>, socket: PathBuf, } @@ -46,11 +55,42 @@ impl State { } } - fn is_running(&self, name: &str) -> bool { + /// `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) - .contains_key(name) + .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); + } } } @@ -129,9 +169,27 @@ pub fn start( trigger: String, ) -> anyhow::Result { validate_name(name)?; - if state.is_running(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() { @@ -169,11 +227,27 @@ pub fn continue_( model: Option, ) -> anyhow::Result { validate_name(name)?; - if state.is_running(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() { @@ -207,11 +281,14 @@ fn spawn_and_track( ) -> 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(), running.cancel_handle()); + .insert(name.to_owned(), Some(running.cancel_handle())); let state = Arc::clone(state); let task_name = name.to_owned(); @@ -236,17 +313,24 @@ fn spawn_and_track( } /// Report whether `name` is currently running — a zero-cost check that -/// never launches a process, unlike `continue`. Distinguishes three -/// states: running, idle (a session exists but nothing is in flight), and -/// no such session at all. +/// 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)?; - if state.is_running(name) { - return Ok(format!("subagent `{name}` is running")); + 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)?; @@ -260,21 +344,33 @@ pub fn status(state: &State, name: &str) -> anyhow::Result { } /// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see -/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: -/// there's no queued/pending state to cancel pre-emptively any more (see -/// the module doc), only "running" or "not tracked." +/// `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, or nothing currently running under `name`. +/// 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); - let Some(cancel) = running.remove(name) else { - anyhow::bail!("no subagent named `{name}` is currently running"); - }; - cancel.cancel(force); - Ok(format!("interrupt sent to subagent `{name}`")) + 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 @@ -298,6 +394,52 @@ async fn push_completion_todo(socket: &std::path::Path, name: &str, summary: &st 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" + ); + } + #[test] fn otel_attrs_appends_when_ambient_var_is_set() { // SAFETY: test-only env mutation, single-threaded within this fn's diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 920d6f34..3d3f2c65 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -339,9 +339,9 @@ in 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 tool is + # `always`, same reasoning as `hive-bash-daemon`: the MCP tools are # served in-process, so a down window is total loss of - # `spawn_subagent` with no stdio fallback. + # `start`/`continue`/`status`/`interrupt` with no stdio fallback. Restart = "always"; RestartSec = 3; User = userName; From 1ad7145cc54e403896261dd825c75b71780f5031 Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 23:44:53 +0200 Subject: [PATCH 6/7] swap hive-claude back to the published 0.1.1 release now that it's out --- Cargo.lock | 17 +++++++++-------- Cargo.toml | 2 +- docs/tools/subagent.md | 2 +- hive-subagent-mcp/src/session.rs | 5 ++++- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8563a611..28d61b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1256,7 +1256,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1780,8 +1780,9 @@ dependencies = [ [[package]] name = "hive-claude" -version = "0.1.0" -source = "git+https://forge.darkest.space/hyperhive/hive-claude?branch=main#bacccc6580504d548387ffff7f0567be603e73a4" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7784a4aeb50369f2a49f052fd3bb28eee143ec6c8af8df806d3e375b045b0661" dependencies = [ "libc", "serde", @@ -3614,7 +3615,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4143,7 +4144,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4202,7 +4203,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4883,7 +4884,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5751,7 +5752,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ba738ac0..c7ad7dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,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 = { git = "https://forge.darkest.space/hyperhive/hive-claude", branch = "main" } +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/docs/tools/subagent.md b/docs/tools/subagent.md index 0de5845a..314bf224 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -29,7 +29,7 @@ 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. +restart loses the _in-flight turn_, not the subagent's history. ## Compaction trade-off diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 920d5ea7..c9bad27a 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -414,7 +414,10 @@ mod tests { 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"); + assert!( + state.reserve("b"), + "unrelated names must not block each other" + ); } #[test] From d26b7547015e5177eabfb115ff08bc82df1085cd Mon Sep 17 00:00:00 2001 From: damocles Date: Wed, 9 Sep 2026 23:47:21 +0200 Subject: [PATCH 7/7] subagent: fix a real test race on the process-wide OTEL_RESOURCE_ATTRIBUTES env var --- hive-subagent-mcp/src/session.rs | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index c9bad27a..8b2c5e90 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -443,10 +443,21 @@ mod tests { ); } + // 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_appends_when_ambient_var_is_set() { - // SAFETY: test-only env mutation, single-threaded within this fn's - // scope (no other test in this crate touches this var — checked). + 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"); } @@ -459,14 +470,6 @@ mod tests { } } - #[test] - fn otel_attrs_stands_alone_when_ambient_var_is_unset() { - unsafe { - std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES"); - } - assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1"); - } - #[test] fn build_config_only_appends_system_prompt_when_given() { let with = build_config("n", None, Some("/tmp/p.md"));