`continue` returned "started" the instant `Claude::spawn` handed back a pid, and a resume that matched nothing only surfaced later, as an end-of-turn todo. By then the caller had moved on believing it had a running subagent. A pid is proof enough for `start`, which creates its session: the spawn succeeding is the whole story. It is not proof for a resume — claude exits non-zero a fraction of a second *after* the process exists. So `continue` now waits for the first real answer and reports a miss as its own `Err`, carrying claude's message and the directory searched. The wait ends on whichever comes first, so a successful `continue` pays no fixed delay: the turn's first non-terminal stream event settles it at about the same moment a miss's exit would have. Measured on this box: 14 runs of the driver's own invocation against a missing session took 550-1087 ms spawn to exit, and a healthy turn's first event lands at roughly 500 ms. The five-second cap is ~4.6x the slowest miss and is only ever reached by a child that neither speaks nor exits. The underway signal reads the event's kind, not its content: a missed resume is not silent — it emits a terminal `result` event and stderr before exiting — so "any sink callback" would have reported every miss as a successful start. Liveness still counts all three callbacks. The end-of-turn todo is unchanged for every failure later in the turn; the only one it no longer repeats is the miss the caller was just handed. Refs #4405
7.6 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.
State
In-memory only: what's running now, where each name's session lives, how
each name's last turn ended, and when each running turn last produced
output. All of it lives only as long as the daemon process does. 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.
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 with no end-of-turn todo means it's
stuck. That one number replaces inferring the same thing from ps output
and CPU-time deltas.
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.
MCP servers available to a subagent
A subagent runs with --strict-mcp-config and no --mcp-config by
default — zero MCP servers, full stop; it falls back to claude's own
native tools (Bash, WebFetch, etc.), not the parent's mcp__bash__* /
mcp__hyperhive__* surface. 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 its own --mcp-config file per turn; an entry left
at the default false never appears there.