The comments and docs around the empty-`--tools` assert stated a mechanism: that an empty value parses as *unset* and therefore grants MORE built-ins than omitting the flag. That claim came from a measurement, and the installed `claude --help` (2.1.268) says the opposite in as many words — `Use "" to disable all tools`. One of the two is wrong and this repo is not the place that settles it. The rule the code enforces is right under both readings, which is precisely why it should not be argued from either: we never emit an empty `--tools`, because what an empty one means is release-dependent and nothing here wants a subagent with no built-in tools regardless. So the prose now states the rule and the ambiguity, and asserts nothing about `""` in either direction — replacing the claim with its opposite would be the same mistake sourced differently. No behaviour change: the assert, the resolution and every test are untouched. Two test comments also gained the limit they were quietly missing. `no_spawned_tool_escapes_the_session` hardcodes ten names, so it is a canary for tools someone thought to list, not a guarantee — the guarantee is the subset property, which covers tools nobody has invented yet. And `a_subagent_gets_no_builtin_its_parent_lacks` resolves both sides in one process off one env var, so it catches a code divergence but not the two real systemd units disagreeing about `HIVE_TOOL_GROUPS` — which is what they did until the previous commit, and is not a thing a unit test can reach. Refs #4416
14 KiB
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__<tool>): 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/<token>, 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. 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:
statusreports the session killed, naming the signal, instead of theidleit 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;
continuestill 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 — the same
list, resolved by the same function
(hive_sh4re::permissions::builtin_tools_arg) 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
for what that list contains and why.
Inheriting rather than listing is the point: a hardcoded subagent list would hand web egress to the subagent of an agent that isn't allowed web egress, 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.<name>.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.