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