diff --git a/CLAUDE.md b/CLAUDE.md index 5b0d3719..fb26720c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,15 +98,6 @@ 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 28d61b02..bd738290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1780,11 +1780,10 @@ dependencies = [ [[package]] name = "hive-claude" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7784a4aeb50369f2a49f052fd3bb28eee143ec6c8af8df806d3e375b045b0661" +checksum = "d540ef2338fc071b60e69cac345cb5ec04627c4ef11c49138d1c39d4f687f254" dependencies = [ - "libc", "serde", "serde_json", "thiserror 2.0.18", @@ -1977,26 +1976,6 @@ 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 c7ad7dfb..8ca89bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ members = [ "hive-priv-sock", "hive-sh4re", "hive-sock-client", - "hive-subagent-mcp", "hive-types", "hivectl", "swarm-authelia-bridge", @@ -85,7 +84,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.1" +hive-claude = "0.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 756b8604..4305eae2 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 +# Ephemeral Sub-Agents via the Claude CLI -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. +`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. ## When to use it @@ -18,28 +18,45 @@ batch. - **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 `subagent` MCP tools +## The spawn command -Your container's `subagent` MCP server (`hive-subagent-daemon`) runs this -skill's recipe for you: +Put the task recipe in a file and pass it as a path, with a short `-p` +trigger: -``` -start(name, prompt_file, model?, trigger?) -continue(name, prompt, model?) -status(name) -interrupt(name, force?) +```bash +claude --name --model --dangerously-skip-permissions \ + --append-system-prompt-file task-prompt.md \ + -p "Carry out the task described in your 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). +- **`--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. -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. +## 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. ## Prompt hygiene - this is where batches succeed or fail @@ -75,9 +92,10 @@ 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 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. +- **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. - **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 @@ -100,10 +118,10 @@ threaded — the coordination overhead isn't worth it below that size. ## Resume for follow-ups -`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. +`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. ## Verify, then report @@ -113,12 +131,14 @@ 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. -- `start`-ing fresh instead of `continue`-ing for a follow-up → throws - away all the context the first pass earned. +- Spawning fresh instead of `--resume` 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 b3946dc0..fba7cc15 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -28,10 +28,6 @@ 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 2fa6f7c3..c9ef0b52 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -93,14 +93,15 @@ 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. 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, 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. ### Completion as a todo (loose-ends v2) diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md deleted file mode 100644 index 314bf224..00000000 --- a/docs/tools/subagent.md +++ /dev/null @@ -1,54 +0,0 @@ -# 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 d3c59fe7..d1d63549 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -128,9 +128,6 @@ 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 a1f0b566..9e6d2ad4 100644 --- a/flake.nix +++ b/flake.nix @@ -93,7 +93,6 @@ 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 f09908c5..3898b5c9 100644 --- a/hive-c0re/src/dashboard/journal.rs +++ b/hive-c0re/src/dashboard/journal.rs @@ -110,7 +110,6 @@ 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 deleted file mode 100644 index b3315719..00000000 --- a/hive-subagent-mcp/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[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 deleted file mode 100644 index 27ae605b..00000000 --- a/hive-subagent-mcp/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# 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 deleted file mode 100644 index 180efee8..00000000 --- a/hive-subagent-mcp/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! 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 deleted file mode 100644 index 40f5fdc3..00000000 --- a/hive-subagent-mcp/src/main.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! `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 deleted file mode 100644 index e8d5491e..00000000 --- a/hive-subagent-mcp/src/mcp.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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 deleted file mode 100644 index 8a05e334..00000000 --- a/hive-subagent-mcp/src/paths.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! 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 deleted file mode 100644 index 8b2c5e90..00000000 --- a/hive-subagent-mcp/src/session.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! 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 94f5e680..9cdbf52f 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -203,8 +203,7 @@ # 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-subagent-daemon, hive-matrix-daemon, hive-matrix-mcp) are - # wired via their own + # 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 3d3f2c65..6ffc80c5 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -197,23 +197,6 @@ 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 @@ -242,17 +225,6 @@ 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 = @@ -306,49 +278,6 @@ 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 cc14d826..0e4fe36d 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-subagent-daemon`, + `hive-bash-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 38d41522..d3241997 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -34,7 +34,6 @@ 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)";