A session cannot be moved between directories, so a hint reading "pass dir" could be misread as pointing an existing session at any directory. Say instead that dir names the directory the session was started in. Refs #4405
139 lines
6.8 KiB
Markdown
139 lines
6.8 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`.
|
|
|
|
## 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 nothing
|
|
about the content is inspected. 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` returns as soon as the process is confirmed running, same as
|
|
always, so the failure lands where every other failed turn lands: that
|
|
turn's end-of-turn todo, carrying claude's own message plus the location
|
|
this daemon searched.
|
|
|
|
```
|
|
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.
|
|
|
|
## 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`.
|
|
|
|
## 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.
|