`build_config` spawned a subagent with `--dangerously-skip-permissions` and no `--tools` at all, so it got claude's entire built-in set — `SendMessage` and `ListAgents` (message peers, or the operator, as its parent), `Task*` including `TaskStop`, which takes an *agent* id and so reaches clean outside the run, `Cron*`, `RemoteTrigger` and `EnterWorktree`/`ExitWorktree`. None of that is part of "do this bounded task in this directory", and none of it is something the parent agent itself can do: the harness has always passed `--tools`. Pass the same one. The value comes from `hive_sh4re::permissions::builtin_tools_arg()` — literally the function the harness resolves its own session with — so the subagent's set is the parent's set, `HIVE_TOOL_GROUPS` and all. That inheritance is the requirement, not an implementation detail: a hardcoded subagent list would hand `WebFetch`/`WebSearch` to the subagent of an agent without the `web_tools` group, which is a privilege escalation, and would drift from the parent's list the first time anyone added a tool to either. `--tools` is the real gate: it holds under `--dangerously-skip-permissions`, unlike `--allowedTools`, which only auto-approves prompts. It does not filter MCP tools, so the `goal_reached`/`need_help` signal surface is deliberately unnamed in it and survives on `--strict-mcp-config` alone. `build_config`'s doc comment claimed `strict_mcp_config` was *the* safety property and that a subagent got "nothing implicit and nothing more". That was false for built-ins, and is what hid this gap for as long as it did; it now says which flag covers which half and that neither substitutes for the other. An empty `--tools` value parses as *unset* and grants more than omitting the flag, so an empty resolution can only be a bug — `build_config` asserts against it and a test pins the non-emptiness alongside the subset-of-parent property. Refs #4416
278 lines
14 KiB
Markdown
278 lines
14 KiB
Markdown
# 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](#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** — 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](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools)
|
|
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 trap worth knowing before editing any of this: an empty `--tools`
|
|
value parses as _unset_ and grants **more** than omitting the flag, so
|
|
there is no way to spell "no built-in tools" — the daemon asserts rather
|
|
than emitting one.
|
|
|
|
### 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.
|