Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34129d776c | ||
|
|
b5fc17aa59 | ||
|
|
b18348bc9a |
11 changed files with 2189 additions and 266 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -101,12 +101,15 @@ hand-maintained per-file tree drifts out of sync with the code.
|
|||
- **`hive-subagent-mcp/`** — per-agent claude-subagent runner daemon
|
||||
(`hive-subagent-daemon`); spawns nested claude sessions on request and
|
||||
serves `start`/`continue`/`status`/`interrupt` directly over
|
||||
streamable-http (no stdio bridge). Independent of `hive-bash-mcp` (a
|
||||
subagent is a much heavier capability than a bash command). No task
|
||||
files — the daemon's only state is an in-memory map of currently-running
|
||||
processes, live only as long as the process is; the actual claude
|
||||
session survives a daemon restart independently (see `session.rs`'s
|
||||
module doc).
|
||||
streamable-http (no stdio bridge), plus a second subagent-facing route
|
||||
carrying `goal_reached`/`need_help` — served per session under a minted
|
||||
token, since neither tool takes a session name. Independent of `hive-bash-mcp` (a
|
||||
subagent is a much heavier capability than a bash command). A `start`
|
||||
with a `goal` is a multi-turn run: the daemon re-prompts the subagent
|
||||
toward the goal each time a turn ends, up to a per-session turn cap. No
|
||||
task files — the daemon's state is in-memory only, live only as long as
|
||||
the process is; the actual claude session survives a daemon restart
|
||||
independently (see `session.rs`'s module doc).
|
||||
- **`hive-sh4re/`** — shared wire types (Agent / Manager request +
|
||||
response, `Message`, `Approval`, `HelperEvent`) used across the unix
|
||||
sockets. Host-admin-socket and hive-priv-socket wire types have been
|
||||
|
|
|
|||
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2004,6 +2004,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -24,11 +24,42 @@ infrastructure, not the agent-facing API.
|
|||
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, and when each running turn last produced
|
||||
output. All of it lives only as long as the daemon process does. A daemon
|
||||
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
|
||||
|
|
@ -40,14 +71,68 @@ 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 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.
|
||||
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
|
||||
|
|
@ -135,10 +220,11 @@ Own systemd unit, defined alongside the other per-agent MCP daemons in
|
|||
|
||||
## 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
|
||||
A subagent runs with `--strict-mcp-config` and, by default, exactly one
|
||||
MCP server: the two-tool `subagent_control` route above. It otherwise
|
||||
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
|
||||
|
|
@ -148,5 +234,8 @@ 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ serde_json.workspace = true
|
|||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
# Signal-route tokens. `Uuid::new_v4` draws from the OS CSPRNG (getrandom),
|
||||
# which is the property the per-session URL rests on — see
|
||||
# `session::State::mint_signal_url`.
|
||||
uuid.workspace = true
|
||||
|
||||
# `test-util` for `#[tokio::test(start_paused = true)]`: the `continue`
|
||||
# resume-grace tests assert what happens when the bound is actually reached,
|
||||
|
|
@ -33,7 +37,9 @@ tokio = { workspace = true, features = ["test-util"] }
|
|||
# `hive-subagent-daemon` — long-running per-agent claude-subagent runner.
|
||||
# Independent of `hive-bash-mcp` (own crate, own binary, own MCP server) —
|
||||
# see lib.rs's module doc for why. Serves its MCP tools (`start`/
|
||||
# `continue`/`interrupt`) directly over streamable-http — no stdio bridge.
|
||||
# `continue`/`status`/`interrupt`, plus the subagent-facing
|
||||
# `goal_reached`/`need_help` route) directly over streamable-http — no
|
||||
# stdio bridge.
|
||||
[[bin]]
|
||||
name = "hive-subagent-daemon"
|
||||
path = "src/main.rs"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless
|
||||
`claude` sessions on request and serves the tool surface
|
||||
(`start`/`continue`/`interrupt`) directly over streamable-http. No
|
||||
stdio bridge, no per-turn respawn — claude reconnects to the same
|
||||
stable URL every turn.
|
||||
(`start`/`continue`/`status`/`interrupt`, plus a separate
|
||||
subagent-facing `goal_reached`/`need_help` route, one per-session URL)
|
||||
directly over streamable-http. No stdio bridge, no per-turn respawn — an
|
||||
agent's claude reconnects to the same stable URL every turn.
|
||||
|
||||
Independent of `hive-bash-mcp` — a subagent spawns a full nested
|
||||
`claude` session, a much heavier capability than a bash command, worth
|
||||
|
|
@ -18,10 +19,17 @@ own lib (`src/lib.rs`):
|
|||
- **`session.rs`** — the actual claude-facing logic: `Claude::spawn` +
|
||||
`RunningClaude::wait`/`cancel_handle` (not `InfiniteSession::run`,
|
||||
which has no cancel handle to reach in — see the module doc for the
|
||||
v1 scope this trades away), the in-memory `running` map that's the
|
||||
_only_ state this daemon keeps (no task files — a restart stops
|
||||
whatever's running; the actual claude session is the durable store,
|
||||
found again by name via `hive_claude::SessionStore`).
|
||||
- **`mcp.rs`** — the `rmcp` tool router (`start`/`continue`/`interrupt`)
|
||||
- `serve_http`.
|
||||
v1 scope this trades away), the turn-continuation loop a `goal`
|
||||
switches on, and the in-memory maps that are the _only_ state this
|
||||
daemon keeps (no task files — a restart stops whatever's running;
|
||||
the actual claude session is the durable store, found again by name
|
||||
via `hive_claude::SessionStore`).
|
||||
- **`mcp.rs`** — the `rmcp` tool routers (the parent's
|
||||
`start`/`continue`/`status`/`interrupt` on `/mcp`, the subagent's
|
||||
`goal_reached`/`need_help` on `/signal/mcp/<token>`) + `serve_http`.
|
||||
Neither signal tool takes a session name: the token in the path is
|
||||
minted per run and resolved to a session before dispatch, so a subagent
|
||||
has no way to name — and therefore no way to signal — a sibling. One
|
||||
route with a path parameter, because the `Router` is built once at
|
||||
startup and sessions come and go for the daemon's whole life.
|
||||
- **`paths.rs`** — the in-agent todo-socket path.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
//! Library for `hive-subagent-daemon`: spawns nested claude sessions on
|
||||
//! request and serves the `start`/`continue`/`interrupt` MCP tool surface
|
||||
//! directly over streamable-http — no stdio bridge, no round-trip socket.
|
||||
//! Independent of `hive-bash-mcp` — a subagent is a much heavier capability
|
||||
//! than a bash command (a full nested `claude` process), worth its own
|
||||
//! deployable/restartable unit rather than sharing one.
|
||||
//! request and serves the `start`/`continue`/`status`/`interrupt` MCP tool
|
||||
//! surface directly over streamable-http — no stdio bridge, no round-trip
|
||||
//! socket. Independent of `hive-bash-mcp` — a subagent is a much heavier
|
||||
//! capability than a bash command (a full nested `claude` process), worth
|
||||
//! its own deployable/restartable unit rather than sharing one.
|
||||
//!
|
||||
//! A second route on the same listener serves `goal_reached`/`need_help` to
|
||||
//! the *subagents*, which is how a run says it's done or stuck; see
|
||||
//! [`mcp`]'s module doc for why that is a separate surface rather than two
|
||||
//! more tools on the parent's, and why it is served per session under a
|
||||
//! minted token rather than at one shared path — neither tool takes a
|
||||
//! session name, so a subagent has no way to say it is somebody else.
|
||||
//!
|
||||
//! See [`session`]'s module doc for the actual design: no task files, no
|
||||
//! restart recovery, no mid-turn compaction — the daemon's only state is an
|
||||
//! in-memory `name -> Cancel` map, live only as long as the process is.
|
||||
//! restart recovery, no mid-turn compaction — the daemon's only state is a
|
||||
//! handful of in-memory maps, live only as long as the process is.
|
||||
|
||||
pub mod mcp;
|
||||
pub mod mcp_config;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
//! `hive-subagent-daemon` binary — spawns nested claude sessions on
|
||||
//! request and serves the `start`/`continue`/`interrupt` MCP tool surface
|
||||
//! directly over streamable-http on `--http <addr>` — no stdio bridge, no
|
||||
//! separate bin claude has to respawn every turn.
|
||||
//! request and serves the `start`/`continue`/`status`/`interrupt` MCP tool
|
||||
//! surface directly over streamable-http on `--http <addr>` — no stdio
|
||||
//! bridge, no separate bin claude has to respawn every turn. The same
|
||||
//! address also carries the subagent-facing `goal_reached`/`need_help`
|
||||
//! route, which is the only place the daemon can learn its own URL from.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -42,7 +44,17 @@ async fn main() -> Result<()> {
|
|||
"hive-subagent-daemon starting"
|
||||
);
|
||||
|
||||
let state = Arc::new(hive_subagent_mcp::session::State::new(todo_socket));
|
||||
// The one place the subagent-facing signal URLs can come from: the
|
||||
// address this process was told to listen on. Anything else would be a
|
||||
// guess at the deployment's own port assignment. This is only their
|
||||
// common prefix — each session's actual URL is this plus a token minted
|
||||
// for that session alone, which is what makes a signal's identity a
|
||||
// property of the endpoint rather than of the payload.
|
||||
let signal_base = format!("http://{}{}", cli.http, hive_subagent_mcp::mcp::SIGNAL_PATH);
|
||||
let state = Arc::new(hive_subagent_mcp::session::State::new(
|
||||
todo_socket,
|
||||
signal_base,
|
||||
));
|
||||
|
||||
// Serve the MCP tools over streamable-http forever. No background poll
|
||||
// loop to start — unlike the bash daemon's task-file queue, `start`/
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`,
|
||||
//! served directly over streamable-http — no stdio bridge, no round-trip
|
||||
//! socket.
|
||||
//!
|
||||
//! Two surfaces, two routes. `/mcp` is the parent's: the four tools above.
|
||||
//! `/signal/mcp/<token>` is the *subagent's*, and carries `goal_reached` and
|
||||
//! `need_help` only — it is what a subagent is handed in its own
|
||||
//! `--mcp-config` (see [`crate::mcp_config`]), so the ability to report on
|
||||
//! its own run can't be the ability to spawn a nested one.
|
||||
//!
|
||||
//! **The token in that path is the subagent's identity, and neither signal
|
||||
//! tool takes a session name.** One route is registered, once, at startup
|
||||
//! (sessions come and go far faster than a `Router` can be rebuilt); every
|
||||
//! request resolves its token to a session and dispatches to a handler bound
|
||||
//! to *that* session, so the name a signal lands against is never a value the
|
||||
//! sender chose. A token that resolves to nothing — never minted, or revoked
|
||||
//! when its run ended — is a bare 404, which is also why the route says
|
||||
//! nothing that would let a subagent enumerate its siblings.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use axum::extract::{Path, Request, State as RouteState};
|
||||
use axum::response::{IntoResponse as _, Response};
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
|
|
@ -57,12 +78,62 @@ struct StartArgs {
|
|||
/// this daemon tracks in memory.
|
||||
#[serde(default)]
|
||||
dir: Option<String>,
|
||||
/// What this subagent is working *toward*, in its own words — set it and
|
||||
/// the daemon keeps giving it turns until it says it's done, says it's
|
||||
/// stuck, or runs out. Omit it and the session is a single turn, exactly
|
||||
/// as before. Written for the subagent to read: it's quoted back at it
|
||||
/// verbatim at the start of every continued turn, so "get `cargo clippy
|
||||
/// --workspace` to pass with no warnings" continues far better than "fix
|
||||
/// the lints".
|
||||
#[serde(default)]
|
||||
goal: Option<String>,
|
||||
/// How many turns the continuation may spend before the harness stops it
|
||||
/// itself. Default 5. Only meaningful alongside `goal` — without one
|
||||
/// there's nothing to re-prompt toward, so nothing to cap. The cap
|
||||
/// bounds *unattended* re-prompting: a `continue` you issue yourself
|
||||
/// starts the allowance over.
|
||||
#[serde(default)]
|
||||
max_turns: Option<u32>,
|
||||
/// Where the instructions in `prompt_file` told this subagent to write
|
||||
/// its report. The daemon appends the run's stop reason to that file when
|
||||
/// the run ends, so the artifact you were going to read anyway also says
|
||||
/// how it stopped. Nothing is inferred: if you don't pass it (and the
|
||||
/// subagent doesn't name it when it signals), no file is touched.
|
||||
#[serde(default)]
|
||||
report_file: Option<String>,
|
||||
}
|
||||
|
||||
fn default_trigger() -> String {
|
||||
"Carry out the task described in your instructions.".to_owned()
|
||||
}
|
||||
|
||||
/// No session name, deliberately: which session this signal is about is
|
||||
/// decided by the URL it arrives on (see this module's doc), so there is no
|
||||
/// field here in which one subagent could name another.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct GoalReachedArgs {
|
||||
/// Optionally, what you did. It's shown to whoever spawned you.
|
||||
#[serde(default)]
|
||||
msg: Option<String>,
|
||||
/// Optionally, the path you wrote your report to, so the stop reason
|
||||
/// gets appended to it.
|
||||
#[serde(default)]
|
||||
report_file: Option<String>,
|
||||
}
|
||||
|
||||
/// No session name here either — same reason as [`GoalReachedArgs`].
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct NeedHelpArgs {
|
||||
/// What is blocking you, specifically enough for someone else to act on
|
||||
/// it. This is the whole content of the signal, which is why it's
|
||||
/// required.
|
||||
msg: String,
|
||||
/// Optionally, the path you wrote your report to, so the stop reason
|
||||
/// gets appended to it.
|
||||
#[serde(default)]
|
||||
report_file: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ContinueArgs {
|
||||
/// The existing session's name (from a prior `start`).
|
||||
|
|
@ -127,17 +198,26 @@ impl SubagentMcp {
|
|||
first (real fresh start, not a silent resume); a *currently running* one is \
|
||||
refused. Runs unattended — every tool-call permission prompt is pre-approved rather \
|
||||
than interactively confirmed — with its MCP server set fixed to what this daemon \
|
||||
configures for it. See the `base:claude-subagents` skill for when to reach for this."
|
||||
configures for it. Pass `goal` to make this a multi-turn run: the daemon re-prompts \
|
||||
the subagent toward that goal each time a turn ends, up to `max_turns` (default 5), \
|
||||
stopping early when the subagent reports the goal reached or asks for help. Whichever \
|
||||
way it stops, one todo is pushed at the end and `status` says which. See the \
|
||||
`base:claude-subagents` skill for when to reach for this."
|
||||
)]
|
||||
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
||||
match session::start(
|
||||
&self.state,
|
||||
&args.name,
|
||||
args.model,
|
||||
args.effort,
|
||||
&args.prompt_file,
|
||||
args.trigger,
|
||||
args.dir.as_deref(),
|
||||
session::StartRequest {
|
||||
name: args.name,
|
||||
model: args.model,
|
||||
effort: args.effort,
|
||||
prompt_file: args.prompt_file,
|
||||
trigger: args.trigger,
|
||||
dir: args.dir,
|
||||
goal: args.goal,
|
||||
max_turns: args.max_turns,
|
||||
report_file: args.report_file,
|
||||
},
|
||||
) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("start error: {e:#}"),
|
||||
|
|
@ -193,7 +273,9 @@ impl SubagentMcp {
|
|||
never launches a process, unlike `continue`. The answer says what state it found \
|
||||
and what to do about it. For a running one it also reports how long since that turn \
|
||||
last produced any output, which is how you tell a subagent that's working from one \
|
||||
that has wedged without resorting to `ps`."
|
||||
that has wedged without resorting to `ps`. For a session started with a `goal` it \
|
||||
reports which turn of the budget it is on, and — once the run has stopped — why it \
|
||||
stopped: goal reported reached, blocked and needing help, or out of turns."
|
||||
)]
|
||||
fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
|
||||
match session::status(&self.state, &args.name, args.dir.as_deref()) {
|
||||
|
|
@ -206,35 +288,297 @@ impl SubagentMcp {
|
|||
#[tool_handler]
|
||||
impl ServerHandler for SubagentMcp {}
|
||||
|
||||
/// The surface a *subagent* gets, served on its own route: two tools, both
|
||||
/// of them ways for a running subagent to say how its own run should end.
|
||||
/// Separate handler rather than two more tools on [`SubagentMcp`] so the
|
||||
/// split is structural — there is no route a subagent holds that `start` is
|
||||
/// reachable from.
|
||||
///
|
||||
/// `session` is the whole point of the type: an instance is built only by
|
||||
/// `signal_route`, from a token that had already resolved, and both tools
|
||||
/// read the session they act on out of `self`. Nothing in an incoming
|
||||
/// payload can reach that field.
|
||||
#[derive(Clone)]
|
||||
struct SubagentSignalMcp {
|
||||
state: Arc<State>,
|
||||
session: String,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl SubagentSignalMcp {
|
||||
#[tool(
|
||||
description = "Report that you have reached the goal you were given. Stops the harness \
|
||||
from starting another turn to re-prompt you toward it, and extends the \"subagent \
|
||||
done\" message your parent gets with what you say here. It always applies to your \
|
||||
own session and takes no session name — the endpoint you are calling it on is yours \
|
||||
alone, so there is nothing to address it to. This records a claim, not a result: \
|
||||
whoever spawned you reads the diff and the gate output regardless, so calling it \
|
||||
does not make unfinished work finished. Call it when the goal is actually met — \
|
||||
otherwise keep working, or call `need_help` if you can't proceed."
|
||||
)]
|
||||
fn goal_reached(&self, Parameters(args): Parameters<GoalReachedArgs>) -> String {
|
||||
session::goal_reached(
|
||||
&self.state,
|
||||
&self.session,
|
||||
args.msg,
|
||||
args.report_file.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Report that you cannot proceed, and why. Stops the harness from starting \
|
||||
another turn to re-prompt you toward your goal, marks your session as blocked so \
|
||||
whoever spawned you sees it in `status` without reading any file, and extends the \
|
||||
\"subagent done\" message with your reason. Like `goal_reached` it takes no session \
|
||||
name and always applies to your own session. Use it for a genuine block — a missing \
|
||||
credential, a decision that isn't yours, an instruction that contradicts what you \
|
||||
found — not for work that is merely hard. Say enough that someone else can act on it."
|
||||
)]
|
||||
fn need_help(&self, Parameters(args): Parameters<NeedHelpArgs>) -> String {
|
||||
session::need_help(
|
||||
&self.state,
|
||||
&self.session,
|
||||
args.msg,
|
||||
args.report_file.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for SubagentSignalMcp {}
|
||||
|
||||
/// Prefix the subagent-facing signal surface is served under, and the part
|
||||
/// of a subagent's URL that is *not* secret: every real endpoint is this plus
|
||||
/// that session's own token. Kept here, next to the route that answers it, so
|
||||
/// the two can't drift. Nothing is served at the bare prefix.
|
||||
pub const SIGNAL_PATH: &str = "/signal/mcp";
|
||||
|
||||
/// Everything the one registered signal route needs to answer a request for
|
||||
/// any session: the daemon state the token resolves against, and a session
|
||||
/// manager per live token.
|
||||
///
|
||||
/// **Per token, not one shared manager.** rmcp keeps an MCP session (created
|
||||
/// by `initialize`, named by an `mcp-session-id` header) inside its manager,
|
||||
/// and a manager shared across tokens would make that header a second way to
|
||||
/// pick which bound handler serves a request — an id is unguessable, but this
|
||||
/// is the one property the route exists to make structural rather than
|
||||
/// probabilistic. With a manager per token, an id minted under one token
|
||||
/// simply does not exist under another.
|
||||
#[derive(Clone)]
|
||||
struct SignalRouting {
|
||||
state: Arc<State>,
|
||||
managers: Arc<Mutex<HashMap<String, Arc<LocalSessionManager>>>>,
|
||||
}
|
||||
|
||||
impl SignalRouting {
|
||||
/// The session manager for `token`, created on its first request.
|
||||
///
|
||||
/// Entries for tokens that no longer resolve are dropped on the way
|
||||
/// past — the map would otherwise grow for the daemon's whole life, and
|
||||
/// a revoked token's MCP sessions have nothing left to serve. It's a
|
||||
/// linear scan of a map with one entry per *live* subagent, on a request
|
||||
/// a subagent makes a handful of times per run.
|
||||
fn manager_for(&self, token: &str) -> Arc<LocalSessionManager> {
|
||||
let mut managers = self.managers.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
managers.retain(|t, _| self.state.session_for_signal_token(t).is_some());
|
||||
Arc::clone(
|
||||
managers
|
||||
.entry(token.to_owned())
|
||||
.or_insert_with(session_manager),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A session manager with this daemon's keep-alive: a subagent turn can run
|
||||
/// considerably longer than a bash command — same 24h rationale as the bash
|
||||
/// and matrix daemons.
|
||||
fn session_manager() -> Arc<LocalSessionManager> {
|
||||
let mut manager = LocalSessionManager::default();
|
||||
manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
|
||||
Arc::new(manager)
|
||||
}
|
||||
|
||||
/// Resolve the request's token to a session and serve it with a handler bound
|
||||
/// to that session — or 404, for a token this daemon never minted or has
|
||||
/// since revoked.
|
||||
///
|
||||
/// The `StreamableHttpService` is built per request, which is cheap (a few
|
||||
/// `Arc`s) and is what lets the bound session come from the URL: rmcp's
|
||||
/// service factory takes no request, so the binding has to happen outside it.
|
||||
/// Only a request that starts an MCP session ever calls the factory; the rest
|
||||
/// are answered from the token's own manager, against the handler built when
|
||||
/// that session was initialized.
|
||||
async fn signal_route(
|
||||
RouteState(routing): RouteState<SignalRouting>,
|
||||
Path(token): Path<String>,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let Some(session) = routing.state.session_for_signal_token(&token) else {
|
||||
tracing::debug!("signal route: unknown or expired token, refusing with 404");
|
||||
return axum::http::StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let manager = routing.manager_for(&token);
|
||||
let state = Arc::clone(&routing.state);
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
Ok(SubagentSignalMcp {
|
||||
state: Arc::clone(&state),
|
||||
session: session.clone(),
|
||||
})
|
||||
},
|
||||
manager,
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
service.handle(request).await.into_response()
|
||||
}
|
||||
|
||||
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
|
||||
/// Loopback-only bind, one long-lived session — same shape as the bash and
|
||||
/// matrix daemons' own `serve_http`.
|
||||
///
|
||||
/// Two routes off one listener: `/mcp` for the parent's four tools, and
|
||||
/// `<SIGNAL_PATH>/{token}` for the subagent's two. Separate session managers
|
||||
/// because they're separate MCP servers to separate clients — the parent's
|
||||
/// harness on one, each subagent's own claude on its own token's.
|
||||
///
|
||||
/// The signal route is registered **once**, with the token as a path
|
||||
/// parameter, because this `Router` is built at startup and subagents come
|
||||
/// and go for the daemon's whole life: there is no mounting a fresh route per
|
||||
/// session. Resolution therefore happens per request, in `signal_route`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the listener cannot bind `addr` or the HTTP server
|
||||
/// exits with a fatal error.
|
||||
pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
let mut session_manager = LocalSessionManager::default();
|
||||
// A subagent turn can run considerably longer than a bash command —
|
||||
// same 24h keep-alive rationale as the bash/matrix daemons.
|
||||
session_manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
|
||||
let session_manager = std::sync::Arc::new(session_manager);
|
||||
let parent_state = Arc::clone(&state);
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
Ok(SubagentMcp {
|
||||
state: Arc::clone(&state),
|
||||
state: Arc::clone(&parent_state),
|
||||
})
|
||||
},
|
||||
session_manager,
|
||||
session_manager(),
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let app = axum::Router::new().nest_service("/mcp", service);
|
||||
let routing = SignalRouting {
|
||||
state,
|
||||
managers: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
let app = axum::Router::new()
|
||||
.nest_service("/mcp", service)
|
||||
.route(
|
||||
&format!("{SIGNAL_PATH}/{{token}}"),
|
||||
axum::routing::any(signal_route),
|
||||
)
|
||||
.with_state(routing);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "serving hive-subagent MCP over streamable-http at /mcp");
|
||||
tracing::info!(
|
||||
%addr,
|
||||
signal = SIGNAL_PATH,
|
||||
"serving hive-subagent MCP over streamable-http at /mcp"
|
||||
);
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The state a route test resolves against, with `name` already minted a
|
||||
/// token — the same shape `start` leaves behind.
|
||||
fn state() -> Arc<State> {
|
||||
Arc::new(State::new(
|
||||
std::path::PathBuf::from("/dev/null"),
|
||||
format!("http://127.0.0.1:1{SIGNAL_PATH}"),
|
||||
))
|
||||
}
|
||||
|
||||
fn routing(state: Arc<State>) -> SignalRouting {
|
||||
SignalRouting {
|
||||
state,
|
||||
managers: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint `name` a signal URL the way a spawning run does, and hand back
|
||||
/// just the token — what the route would have taken out of the path.
|
||||
fn minted(state: &State, name: &str) -> String {
|
||||
let url = state.mint_signal_url(name);
|
||||
url.rsplit('/')
|
||||
.next()
|
||||
.expect("a minted URL always has a last segment")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
fn post(uri: &str) -> Request {
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(uri)
|
||||
.body(axum::body::Body::empty())
|
||||
.expect("a well-formed test request")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signal_tools_take_no_session_name() {
|
||||
// The fix itself, pinned: a subagent has no field in which to name a
|
||||
// sibling. Asserted against the generated JSON schema rather than the
|
||||
// struct, because the schema is what actually reaches the subagent —
|
||||
// a `name` re-added under any serde rename would show up here.
|
||||
for (tool, schema) in [
|
||||
("goal_reached", schemars::schema_for!(GoalReachedArgs)),
|
||||
("need_help", schemars::schema_for!(NeedHelpArgs)),
|
||||
] {
|
||||
let schema = serde_json::to_value(&schema).expect("a schema serializes");
|
||||
let properties = schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
!properties.contains_key("name"),
|
||||
"`{tool}` must not take a session name — identity comes from the route's token, \
|
||||
and an argument for it is an argument one subagent can fill in with another's \
|
||||
name. Properties offered: {:?}",
|
||||
properties.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unminted_signal_token_is_a_bare_404() {
|
||||
// No enumeration: a token this daemon never issued gets the same
|
||||
// answer whether or not a session by any name exists.
|
||||
let state = state();
|
||||
minted(&state, "live");
|
||||
let response = signal_route(
|
||||
RouteState(routing(state)),
|
||||
Path("not-a-token".to_owned()),
|
||||
post("/signal/mcp/not-a-token"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_minted_signal_token_reaches_the_surface_at_all() {
|
||||
// The other half of the 404 above: it is the *resolution* refusing,
|
||||
// not the route being unreachable. The body here is not a valid MCP
|
||||
// request, so rmcp rejects it — on its own terms, with some answer
|
||||
// that is not this route's "no such token".
|
||||
let state = state();
|
||||
let token = minted(&state, "live");
|
||||
let response = signal_route(
|
||||
RouteState(routing(state)),
|
||||
Path(token.clone()),
|
||||
post(&format!("/signal/mcp/{token}")),
|
||||
)
|
||||
.await;
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"a live session's own token must resolve"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,74 @@
|
|||
//! Builds a subagent's own filtered `--mcp-config`: only
|
||||
//! `hyperhive.extraMcpServers` entries with `availableToSubagents = true`
|
||||
//! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
|
||||
//! a subagent's claude invocation. Everything else — the built-in hyperhive
|
||||
//! a subagent's claude invocation, plus this daemon's own two-tool signal
|
||||
//! surface. Everything else — the built-in hyperhive
|
||||
//! surface (todos/messaging), the automatically injected `bash` and `subagent`
|
||||
//! entries — stays unreachable by construction: none of those default to
|
||||
//! opted in, and the built-in surface isn't an `extraMcpServers` entry at
|
||||
//! all, so there's no name for an operator to opt it in under even if they
|
||||
//! wanted to.
|
||||
//!
|
||||
//! The signal surface is the one server a subagent always gets. It is a
|
||||
//! *different route* on this daemon's listener from the one the parent
|
||||
//! uses, serving `goal_reached` and `need_help` and nothing else — so
|
||||
//! "a subagent can say it is done or stuck" never widens into "a subagent
|
||||
//! can spawn subagents", which is what handing it the parent's route would
|
||||
//! have meant.
|
||||
//!
|
||||
//! **One file per session, not one file.** The signal URL carries the
|
||||
//! session's own token (`State::mint_signal_url`), so the
|
||||
//! file it is written into has to be the session's own too: a single shared
|
||||
//! path is a race between two `start`s — whichever wrote last decides which
|
||||
//! token the other's `claude` reads at startup, which would hand a subagent
|
||||
//! a sibling's identity by accident.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`].
|
||||
const CONFIG_FILE: &str = "subagent-mcp-config.json";
|
||||
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`],
|
||||
/// per session. The name is a validated [`hive_types::Ident`] by the time it
|
||||
/// gets here (`crate::session::validate_name`), so it is a single safe path
|
||||
/// segment and never escapes the directory.
|
||||
fn config_file(name: &str) -> String {
|
||||
format!("subagent-mcp-config-{name}.json")
|
||||
}
|
||||
|
||||
/// Render the subagent-eligible extra-MCP servers to a `--mcp-config` file,
|
||||
/// returning its path — or `None` when no entry opts in (or the render/write
|
||||
/// fails), so [`hive_claude::Config::mcp_config`] stays unset and the
|
||||
/// subagent gets literally zero MCP servers, the same default as before this
|
||||
/// toggle existed. Re-rendered on every call (cheap: a filter plus a small
|
||||
/// Name the signal surface appears under in a subagent's own MCP config,
|
||||
/// and therefore the prefix its tools are called by
|
||||
/// (`mcp__subagent_control__goal_reached`).
|
||||
const SIGNAL_SERVER: &str = "subagent_control";
|
||||
|
||||
/// Render a subagent's `--mcp-config` file, returning its path — or `None`
|
||||
/// when there is nothing to put in it (or the render/write fails), so
|
||||
/// [`hive_claude::Config::mcp_config`] stays unset and the subagent gets
|
||||
/// literally zero MCP servers.
|
||||
///
|
||||
/// `signal_url` is `name`'s *own* `goal_reached`/`need_help` route — this
|
||||
/// daemon's signal path plus the token minted for this run, and the only
|
||||
/// place that token is ever written. It is always included when given, since
|
||||
/// a subagent that can't say it's done or stuck is exactly the one the turn
|
||||
/// cap has to stop on its behalf. `None` reproduces the pre-continuation
|
||||
/// shape — only the opted-in extras, and no file at all when none opt in.
|
||||
///
|
||||
/// Re-rendered on every call (cheap: a filter plus a small
|
||||
/// file write) rather than cached once at daemon startup, so a config change
|
||||
/// takes effect on this subagent's next `start`/`continue` without needing
|
||||
/// the daemon itself restarted.
|
||||
#[must_use]
|
||||
pub fn build() -> Option<PathBuf> {
|
||||
pub fn build(name: &str, signal_url: Option<&str>) -> Option<PathBuf> {
|
||||
let state_dir = crate::paths::state_dir();
|
||||
let servers: serde_json::Map<String, serde_json::Value> =
|
||||
let mut servers: serde_json::Map<String, serde_json::Value> =
|
||||
hive_agent_sock::extra_mcp::load_extra_mcp()
|
||||
.into_iter()
|
||||
.filter(|(_, spec)| spec.available_to_subagents())
|
||||
.map(|(name, spec)| (name, spec.to_json_entry(&state_dir)))
|
||||
.collect();
|
||||
if let Some(url) = signal_url {
|
||||
servers.insert(
|
||||
SIGNAL_SERVER.to_owned(),
|
||||
serde_json::json!({ "type": "http", "url": url }),
|
||||
);
|
||||
}
|
||||
if servers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -44,7 +83,7 @@ pub fn build() -> Option<PathBuf> {
|
|||
);
|
||||
return None;
|
||||
}
|
||||
let path = dir.join(CONFIG_FILE);
|
||||
let path = dir.join(config_file(name));
|
||||
if let Err(e) = std::fs::write(&path, body) {
|
||||
tracing::warn!(
|
||||
error = ?e,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -349,11 +349,17 @@ in
|
|||
# Subagent task runner daemon — independent of `hive-bash-daemon` (own
|
||||
# crate, own process): spawns nested claude sessions on request, serves
|
||||
# the `start`/`continue`/`status`/`interrupt` MCP tools directly over
|
||||
# streamable-http on `hyperhive.mcp.subagentHttpPort`. No task files —
|
||||
# this daemon's only state is an in-memory map of currently-running
|
||||
# processes, live only as long as the process is (see
|
||||
# `hive-subagent-mcp/src/session.rs`'s module doc); a restart stops
|
||||
# whatever's running, the actual claude session survives independently.
|
||||
# streamable-http on `hyperhive.mcp.subagentHttpPort`. The same port also
|
||||
# serves a second, subagent-facing route (`/signal/mcp/<token>`:
|
||||
# `goal_reached`/`need_help`) — not something an agent's own config points
|
||||
# at: the daemon mints each subagent it spawns its own token and writes
|
||||
# that one URL into that subagent's own `--mcp-config`, which is how a
|
||||
# signal's identity comes from the endpoint instead of from a `name` the
|
||||
# caller could have filled in with a sibling's. No task files —
|
||||
# this daemon's only state is in-memory, live only as long as the process
|
||||
# is (see `hive-subagent-mcp/src/session.rs`'s module doc); a restart
|
||||
# stops whatever's running, the actual claude session survives
|
||||
# independently.
|
||||
systemd.services.hive-subagent-daemon = {
|
||||
description = "subagent task runner + MCP daemon for hive-subagent";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
|
|
|||
Loading…
Reference in a new issue