# 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` via `lib.mkDefault` (`allowedTools = ["*"]`), same as `bash`. Default-on rather than unconditional: an `agent.nix` can override or drop the entry, which is what `mkDefault` is there for. 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`. A second route on the same port serves the two tools a **subagent** calls about its own run — `goal_reached` and `need_help`. It isn't part of the `subagent` server an agent's own config points at; the daemon writes it into each subagent's `--mcp-config` itself, under `subagent_control`. Two routes rather than six tools on one, so that being able to report on a run never carries the ability to start one: there's no route a subagent holds that `start` is reachable from. ## A subagent can't name a session, not even its own Neither signal tool takes a session name. **The URL is the identity.** At each spawn the daemon mints that run an unguessable token, serves it at `/signal/mcp/`, and writes that one URL into that one subagent's own `--mcp-config` — a file per session, not a shared one. A request is resolved to a session before it's dispatched, and the tools read the session off the resolution. A subagent therefore has no field in which to name a sibling, and knowing a sibling's name buys nothing: a name isn't a token. Two subagents running concurrently can't signal each other — which the earlier shape, a shared route plus a `name` argument guarded by a liveness check, allowed. A token that resolves to nothing — never minted, or revoked when its run ended — gets a bare **404**, the same answer for every token, so nothing about the refusal says whether some other session exists. ## State In-memory only: what's running now, where each name's session lives, how each name's last turn ended, when each running turn last produced output, what each session is working toward, how far through its turn budget it is, why its run stopped, where it writes its report, and which signal token belongs to it. All of it lives only as long as the daemon process does — so a daemon restart invalidates every signal URL it had issued, which is the same thing as it having stopped the runs those URLs belonged to. 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. Because the remembered directory goes with the rest of it, a `continue` after a restart has to re-supply `dir` when the session lives anywhere other than the daemon's own working directory — and a restart is the situation you reach for `continue` in most often. ## Goals, and turns toward them `start` takes an optional `goal`. Without one a session is a single turn, exactly as it always was. With one, the daemon keeps the session going: when a turn ends and nothing has said to stop, it starts another turn re-prompting the subagent toward that goal, quoting it verbatim and saying which turn of the budget this is. `max_turns` caps that, defaulting to **5**. `status` reports `Turn N of M` for such a session in every state it reaches. Read alongside the last-event age below, it's what separates a subagent that's working from one that's wedged from one that's out of turns — without `ps` and without opening a file. Four things stop a run, and each is recorded distinctly, reported by `status`, and appended to the one todo the daemon pushes when the run ends: - **the turn ended and there was no goal** — the single-turn case; - **`goal_reached`**, which the subagent calls itself; - **`need_help`**, likewise; - **the turn cap**, which says so rather than stopping quietly: the todo states that the harness limit was reached and the goal was never reported reached, so the work stopped where it had got to. A killed or failed turn ends the run too, and keeps the records it already had — see [A killed turn](#a-killed-turn). `interrupt` therefore stops a whole goal run, not just the turn in flight. When the session was told where its report goes — `start`'s `report_file`, or the path the subagent names when it signals — the stop reason is appended to that file as well, so the artifact you were going to read anyway also says how the run ended. Nothing is inferred: with no path given, no file is touched. ## `goal_reached` is a label, not a gate Both signals stop the continuation and **extend** the done message. Extend, not replace: the turn's observed end and the reason the run stopped are different facts, and the second never stands in for the first. `goal_reached` is **self-reported**, by a subagent that has just been re-prompted with "you haven't reached the goal" — which is precisely the incentive to claim it. It's the same failure class as a build report asserting "done, tests pass": a claim about an artifact, not the artifact. Nothing in this daemon treats it as verification, and every surface that renders it says so. Read the diff and the gate output regardless. `need_help` is the blocking signal. It stops the run and shows up in `status` as its own state — blocked, with the subagent's reason — so a parent that polls `status` sees the block without reading anything else. `continue` is how you answer it. ## Is it working, or is it wedged? `status` reporting **running** says a process is tracked, which a wedged subagent satisfies as fully as a busy one. A running answer therefore carries the age of that turn's last event too: seconds means it's working, an age climbing into the minutes means it's stuck. That one number replaces inferring the same thing from `ps` output and CPU-time deltas. It resets at each turn's spawn, so on a goal run it describes the turn in flight rather than the run — which is what you want, since a run that's making progress spends several perfectly healthy minutes. Every line the subagent's `claude` process writes bumps the timestamp — stream-json events, plain stdout chatter and stderr alike — and what the subagent actually said is never read. The record says the child is alive, not what it's doing. The clock starts at the spawn, so a subagent that wedged before it ever emitted anything still reports a climbing age rather than no age at all. It's dropped when the turn ends, since a finished turn has no progress left to describe. ## A `continue` that finds no session `continue` doesn't check for the session before spawning. claude's own `--resume` is the authority, and it exits non-zero rather than quietly starting a fresh session, so the check could only duplicate the lookup the driver was about to do — while answering as though the session were gone. The usual truth is that the session exists somewhere else. `continue` waits for that answer instead. Where `start` returns the instant the process exists — it creates its session, so the spawn succeeding is the whole story — a resumed turn can fail a moment _after_ a pid exists, and a pid is no proof that turn began. `continue` holds the tool call open until the turn is underway or the resume has come back missed, and reports a miss as the call's own error, carrying claude's message plus the location this daemon searched: ``` continue error: claude error: no session matched the requested id or title (searched /home/agent/.claude for cwd /home/agent/work; if it was started elsewhere, pass the `dir` it was started in) ``` The directory is the part claude's own message never names, and the part that resolves the confusion: pass `dir` to point `continue` at the directory the session was started in. Nothing here is a fixed delay on the way to a successful turn. The wait ends on whichever comes first — the turn's first stream event or its early exit — and on this box both land inside a second, so a `continue` that works answers about as fast as it did before. A five-second cap bounds the one case neither covers: a child that neither speaks nor exits, reported as started, with the end-of-turn todo left to say how it goes. That todo still carries every failure that happens later in the turn, exactly as before; the only one it no longer repeats is the miss the caller has just been handed to its face. ## A killed turn A subagent whose `claude` process dies on a signal — the kernel's OOM killer under memory pressure, a stopped unit, an `interrupt` from the owning agent — didn't finish its turn, and the daemon says so rather than letting it settle back into `idle`: - `status` reports the session **killed**, naming the signal, instead of the `idle` it reports for a turn that ended on its own; - the end-of-turn todo the daemon pushes without being asked says the subagent was killed mid-turn, not that it finished; - `continue` still resumes such a session — often what you want — but its reply says the previous turn was killed, so nobody carries on from cut-off work believing it was complete. The record is per-name, in memory with the rest of this daemon's state, and the next confirmed spawn under that name clears it. A daemon restart loses it along with everything else — the daemon has to outlive the kill to report it, which is what `OOMPolicy=continue` on the unit is for. ## 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`. ## The tool surface a subagent gets Two flags, each covering one half, and neither covering the other: `--tools` governs claude's built-in tools, `--strict-mcp-config` governs the MCP ones. Dropping either brings that half back in full; in particular `--tools` does **not** filter `mcp__*` tools. ### Built-in tools (`--tools`) **A subagent gets exactly the built-ins its parent agent has, plus one deliberate exception** — the base resolution (`hive_sh4re::permissions::subagent_builtin_tools_arg`, wrapping the harness's own `builtin_tools_for`) from the same `HIVE_TOOL_GROUPS`: `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`, plus `WebFetch`/ `WebSearch` for an agent granted the `web_tools` tool group and not otherwise. See [the harness's own allowlist](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools) for what that list contains and why. The exception: **a subagent gets Claude's built-in `Bash` iff its parent holds the `execution` tool group** — the group that already grants the parent shell execution, via the out-of-process `bash` MCP server (`mcp__bash__run`/`status`/`kill`). A subagent has no MCP server of its own by default (see [MCP servers](#mcp-servers---strict-mcp-config) below), so there is no `mcp__bash__*` for it to inherit that capability through; it gets Claude's own tool instead. The rule is the capability transfers, not the mechanism — the parent itself never gains built-in `Bash` no matter which groups it holds, only a subagent spawned by an `execution`-holding parent does. Inheriting rather than listing is the point: a hardcoded subagent list would hand web egress (or `Bash`) to the subagent of an agent that isn't allowed it, and would diverge from the parent's on the first tool anyone adds to either. Everything else in claude's built-in set is absent, in particular the tools that let a session act outside the run it was started for: peer and operator messaging, nested agents (including the stop verb, which takes an _agent_ id rather than a session), schedule and webhook creation, and worktree switching. Before this flag was passed, a subagent reached all of them — `--dangerously-skip-permissions` had removed the only thing that would have asked, and `--allowedTools` would not have helped: it approves prompts in advance rather than restricting anything. One rule worth knowing before editing any of this: **the daemon never emits an empty `--tools` value**, and asserts rather than doing so. Not because of what an empty value does — that's exactly the point. Our own measurement and the installed `claude --help` disagree about whether `--tools ""` means "no tools" or reads as the flag being absent, and the answer belongs to whichever claude release is installed rather than to this repo. Refusing the case is correct under either reading, so the code never has to know which holds. Nothing here wants a subagent with no built-in tools anyway. ### MCP servers (`--strict-mcp-config`) A subagent runs with `--strict-mcp-config` and, by default, exactly one MCP server: the two-tool `subagent_control` route above — not the parent's `mcp__bash__*` / `mcp__hyperhive__*` surface. Those two signal tools are deliberately unnamed in `--tools`, which doesn't govern them; they survive on this flag alone. Nothing implicit reaches it: the built-in hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at all, and the automatically injected `bash`/`subagent` entries default to excluded too (a subagent can't spawn hive-bash tasks or its own nested subagents unless an operator opts them in explicitly, same as anything else). Set `hyperhive.extraMcpServers..availableToSubagents = true` on a specific entry to hand that one server to subagents as well — useful for, say, a read-only lookup or scraper MCP a subagent's bounded, single-batch task might need. `hive-subagent-mcp`'s `mcp_config` module renders the opted-in subset into a `--mcp-config` file per session, rewritten each turn; an entry left at the default `false` never appears there. Per session rather than one shared file, because the `subagent_control` entry in it carries that session's own signal URL — one file for everyone would be a race over whose identity each subagent reads at startup.