Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
11 changed files with 266 additions and 2189 deletions

View file

@ -101,15 +101,12 @@ hand-maintained per-file tree drifts out of sync with the code.
- **`hive-subagent-mcp/`** — per-agent claude-subagent runner daemon - **`hive-subagent-mcp/`** — per-agent claude-subagent runner daemon
(`hive-subagent-daemon`); spawns nested claude sessions on request and (`hive-subagent-daemon`); spawns nested claude sessions on request and
serves `start`/`continue`/`status`/`interrupt` directly over serves `start`/`continue`/`status`/`interrupt` directly over
streamable-http (no stdio bridge), plus a second subagent-facing route streamable-http (no stdio bridge). Independent of `hive-bash-mcp` (a
carrying `goal_reached`/`need_help` — served per session under a minted subagent is a much heavier capability than a bash command). No task
token, since neither tool takes a session name. Independent of `hive-bash-mcp` (a files — the daemon's only state is an in-memory map of currently-running
subagent is a much heavier capability than a bash command). A `start` processes, live only as long as the process is; the actual claude
with a `goal` is a multi-turn run: the daemon re-prompts the subagent session survives a daemon restart independently (see `session.rs`'s
toward the goal each time a turn ends, up to a per-session turn cap. No module doc).
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 + - **`hive-sh4re/`** — shared wire types (Agent / Manager request +
response, `Message`, `Approval`, `HelperEvent`) used across the unix response, `Message`, `Approval`, `HelperEvent`) used across the unix
sockets. Host-admin-socket and hive-priv-socket wire types have been sockets. Host-admin-socket and hive-priv-socket wire types have been

1
Cargo.lock generated
View file

@ -2004,7 +2004,6 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid",
] ]
[[package]] [[package]]

View file

@ -24,42 +24,11 @@ infrastructure, not the agent-facing API.
Served under the `subagent` MCP server (`mcp__subagent__<tool>`): `start`, Served under the `subagent` MCP server (`mcp__subagent__<tool>`): `start`,
`continue`, `status`, `interrupt`. `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 ## State
In-memory only: what's running now, where each name's session lives, how 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, each name's last turn ended, and when each running turn last produced
what each session is working toward, how far through its turn budget it output. All of it lives only as long as the daemon process does. A daemon
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 restart stops whatever was running rather than adopting it. The durable
record of a subagent's existence is claude's own on-disk session record of a subagent's existence is claude's own on-disk session
(`hive_claude::SessionStore`), which `continue` reattaches to independent (`hive_claude::SessionStore`), which `continue` reattaches to independent
@ -71,68 +40,14 @@ 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 other than the daemon's own working directory — and a restart is the
situation you reach for `continue` in most often. 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? ## Is it working, or is it wedged?
`status` reporting **running** says a process is tracked, which a wedged `status` reporting **running** says a process is tracked, which a wedged
subagent satisfies as fully as a busy one. A running answer therefore 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, 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 an age climbing into the minutes with no end-of-turn todo means it's
replaces inferring the same thing from `ps` output and CPU-time deltas. stuck. That one number replaces inferring the same thing from `ps` output
It resets at each turn's spawn, so on a goal run it describes the turn in and CPU-time deltas.
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 — Every line the subagent's `claude` process writes bumps the timestamp —
stream-json events, plain stdout chatter and stderr alike — and what the stream-json events, plain stdout chatter and stderr alike — and what the
@ -220,11 +135,10 @@ Own systemd unit, defined alongside the other per-agent MCP daemons in
## MCP servers available to a subagent ## MCP servers available to a subagent
A subagent runs with `--strict-mcp-config` and, by default, exactly one A subagent runs with `--strict-mcp-config` and no `--mcp-config` by
MCP server: the two-tool `subagent_control` route above. It otherwise default — zero MCP servers, full stop; it falls back to claude's own
falls back to claude's own native tools (`Bash`, `WebFetch`, etc.), not native tools (`Bash`, `WebFetch`, etc.), not the parent's `mcp__bash__*` /
the parent's `mcp__bash__*` / `mcp__hyperhive__*` surface. Nothing `mcp__hyperhive__*` surface. Nothing implicit reaches it: the built-in
implicit reaches it: the built-in
hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at
all, and the automatically injected `bash`/`subagent` entries default to excluded 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 too (a subagent can't spawn hive-bash tasks or its own nested subagents
@ -234,8 +148,5 @@ Set `hyperhive.extraMcpServers.<name>.availableToSubagents = true` on a
specific entry to hand that one server to subagents as well — useful for, 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 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 task might need. `hive-subagent-mcp`'s `mcp_config` module renders the
opted-in subset into a `--mcp-config` file per session, rewritten each opted-in subset into its own `--mcp-config` file per turn; an entry left
turn; an entry left at the default `false` never appears there. Per at the default `false` never appears there.
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.

View file

@ -23,10 +23,6 @@ serde_json.workspace = true
tokio.workspace = true tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.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` # `test-util` for `#[tokio::test(start_paused = true)]`: the `continue`
# resume-grace tests assert what happens when the bound is actually reached, # resume-grace tests assert what happens when the bound is actually reached,
@ -37,9 +33,7 @@ tokio = { workspace = true, features = ["test-util"] }
# `hive-subagent-daemon` — long-running per-agent claude-subagent runner. # `hive-subagent-daemon` — long-running per-agent claude-subagent runner.
# Independent of `hive-bash-mcp` (own crate, own binary, own MCP server) — # 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`/ # see lib.rs's module doc for why. Serves its MCP tools (`start`/
# `continue`/`status`/`interrupt`, plus the subagent-facing # `continue`/`interrupt`) directly over streamable-http — no stdio bridge.
# `goal_reached`/`need_help` route) directly over streamable-http — no
# stdio bridge.
[[bin]] [[bin]]
name = "hive-subagent-daemon" name = "hive-subagent-daemon"
path = "src/main.rs" path = "src/main.rs"

View file

@ -2,10 +2,9 @@
Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless Per-agent daemon (`hive-subagent-daemon`) that spawns nested headless
`claude` sessions on request and serves the tool surface `claude` sessions on request and serves the tool surface
(`start`/`continue`/`status`/`interrupt`, plus a separate (`start`/`continue`/`interrupt`) directly over streamable-http. No
subagent-facing `goal_reached`/`need_help` route, one per-session URL) stdio bridge, no per-turn respawn — claude reconnects to the same
directly over streamable-http. No stdio bridge, no per-turn respawn — an stable URL every turn.
agent's claude reconnects to the same stable URL every turn.
Independent of `hive-bash-mcp` — a subagent spawns a full nested Independent of `hive-bash-mcp` — a subagent spawns a full nested
`claude` session, a much heavier capability than a bash command, worth `claude` session, a much heavier capability than a bash command, worth
@ -19,17 +18,10 @@ own lib (`src/lib.rs`):
- **`session.rs`** — the actual claude-facing logic: `Claude::spawn` + - **`session.rs`** — the actual claude-facing logic: `Claude::spawn` +
`RunningClaude::wait`/`cancel_handle` (not `InfiniteSession::run`, `RunningClaude::wait`/`cancel_handle` (not `InfiniteSession::run`,
which has no cancel handle to reach in — see the module doc for the which has no cancel handle to reach in — see the module doc for the
v1 scope this trades away), the turn-continuation loop a `goal` v1 scope this trades away), the in-memory `running` map that's the
switches on, and the in-memory maps that are the _only_ state this _only_ state this daemon keeps (no task files — a restart stops
daemon keeps (no task files — a restart stops whatever's running; whatever's running; the actual claude session is the durable store,
the actual claude session is the durable store, found again by name found again by name via `hive_claude::SessionStore`).
via `hive_claude::SessionStore`). - **`mcp.rs`** — the `rmcp` tool router (`start`/`continue`/`interrupt`)
- **`mcp.rs`** — the `rmcp` tool routers (the parent's - `serve_http`.
`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. - **`paths.rs`** — the in-agent todo-socket path.

View file

@ -1,20 +1,13 @@
//! Library for `hive-subagent-daemon`: spawns nested claude sessions on //! Library for `hive-subagent-daemon`: spawns nested claude sessions on
//! request and serves the `start`/`continue`/`status`/`interrupt` MCP tool //! request and serves the `start`/`continue`/`interrupt` MCP tool surface
//! surface directly over streamable-http — no stdio bridge, no round-trip //! directly over streamable-http — no stdio bridge, no round-trip socket.
//! socket. Independent of `hive-bash-mcp` — a subagent is a much heavier //! Independent of `hive-bash-mcp` — a subagent is a much heavier capability
//! capability than a bash command (a full nested `claude` process), worth //! than a bash command (a full nested `claude` process), worth its own
//! its own deployable/restartable unit rather than sharing one. //! 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 //! 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 a //! restart recovery, no mid-turn compaction — the daemon's only state is an
//! handful of in-memory maps, live only as long as the process is. //! in-memory `name -> Cancel` map, live only as long as the process is.
pub mod mcp; pub mod mcp;
pub mod mcp_config; pub mod mcp_config;

View file

@ -1,9 +1,7 @@
//! `hive-subagent-daemon` binary — spawns nested claude sessions on //! `hive-subagent-daemon` binary — spawns nested claude sessions on
//! request and serves the `start`/`continue`/`status`/`interrupt` MCP tool //! request and serves the `start`/`continue`/`interrupt` MCP tool surface
//! surface directly over streamable-http on `--http <addr>` — no stdio //! directly over streamable-http on `--http <addr>` — no stdio bridge, no
//! bridge, no separate bin claude has to respawn every turn. The same //! separate bin claude has to respawn every turn.
//! 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; use std::sync::Arc;
@ -44,17 +42,7 @@ async fn main() -> Result<()> {
"hive-subagent-daemon starting" "hive-subagent-daemon starting"
); );
// The one place the subagent-facing signal URLs can come from: the let state = Arc::new(hive_subagent_mcp::session::State::new(todo_socket));
// 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 // Serve the MCP tools over streamable-http forever. No background poll
// loop to start — unlike the bash daemon's task-file queue, `start`/ // loop to start — unlike the bash daemon's task-file queue, `start`/

View file

@ -1,30 +1,9 @@
//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`, //! The MCP tool surface: `start` / `continue` / `status` / `interrupt`,
//! served directly over streamable-http — no stdio bridge, no round-trip //! served directly over streamable-http — no stdio bridge, no round-trip
//! socket. //! 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::collections::HashMap; use std::sync::Arc;
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::{ use rmcp::{
ServerHandler, ServerHandler,
handler::server::wrapper::Parameters, handler::server::wrapper::Parameters,
@ -78,62 +57,12 @@ struct StartArgs {
/// this daemon tracks in memory. /// this daemon tracks in memory.
#[serde(default)] #[serde(default)]
dir: Option<String>, 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 { fn default_trigger() -> String {
"Carry out the task described in your instructions.".to_owned() "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)] #[derive(Debug, Deserialize, JsonSchema)]
struct ContinueArgs { struct ContinueArgs {
/// The existing session's name (from a prior `start`). /// The existing session's name (from a prior `start`).
@ -198,26 +127,17 @@ impl SubagentMcp {
first (real fresh start, not a silent resume); a *currently running* one is \ 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 \ 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 \ than interactively confirmed — with its MCP server set fixed to what this daemon \
configures for it. Pass `goal` to make this a multi-turn run: the daemon re-prompts \ configures for it. See the `base:claude-subagents` skill for when to reach for this."
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 { fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
match session::start( match session::start(
&self.state, &self.state,
session::StartRequest { &args.name,
name: args.name, args.model,
model: args.model, args.effort,
effort: args.effort, &args.prompt_file,
prompt_file: args.prompt_file, args.trigger,
trigger: args.trigger, args.dir.as_deref(),
dir: args.dir,
goal: args.goal,
max_turns: args.max_turns,
report_file: args.report_file,
},
) { ) {
Ok(msg) => msg, Ok(msg) => msg,
Err(e) => format!("start error: {e:#}"), Err(e) => format!("start error: {e:#}"),
@ -273,9 +193,7 @@ impl SubagentMcp {
never launches a process, unlike `continue`. The answer says what state it found \ 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 \ 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 \ last produced any output, which is how you tell a subagent that's working from one \
that has wedged without resorting to `ps`. For a session started with a `goal` it \ that has wedged without resorting to `ps`."
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 { fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
match session::status(&self.state, &args.name, args.dir.as_deref()) { match session::status(&self.state, &args.name, args.dir.as_deref()) {
@ -288,297 +206,35 @@ impl SubagentMcp {
#[tool_handler] #[tool_handler]
impl ServerHandler for SubagentMcp {} 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`. /// 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 /// Loopback-only bind, one long-lived session — same shape as the bash and
/// matrix daemons' own `serve_http`. /// 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 /// # Errors
/// ///
/// Returns an error if the listener cannot bind `addr` or the HTTP server /// Returns an error if the listener cannot bind `addr` or the HTTP server
/// exits with a fatal error. /// exits with a fatal error.
pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> { pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> {
let parent_state = Arc::clone(&state); 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 service = StreamableHttpService::new( let service = StreamableHttpService::new(
move || { move || {
Ok(SubagentMcp { Ok(SubagentMcp {
state: Arc::clone(&parent_state), state: Arc::clone(&state),
}) })
}, },
session_manager(), session_manager,
StreamableHttpServerConfig::default(), StreamableHttpServerConfig::default(),
); );
let routing = SignalRouting { let app = axum::Router::new().nest_service("/mcp", service);
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?; let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!( tracing::info!(%addr, "serving hive-subagent MCP over streamable-http at /mcp");
%addr,
signal = SIGNAL_PATH,
"serving hive-subagent MCP over streamable-http at /mcp"
);
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) 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"
);
}
}

View file

@ -1,74 +1,35 @@
//! Builds a subagent's own filtered `--mcp-config`: only //! Builds a subagent's own filtered `--mcp-config`: only
//! `hyperhive.extraMcpServers` entries with `availableToSubagents = true` //! `hyperhive.extraMcpServers` entries with `availableToSubagents = true`
//! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach //! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
//! a subagent's claude invocation, plus this daemon's own two-tool signal //! a subagent's claude invocation. Everything else — the built-in hyperhive
//! surface. Everything else — the built-in hyperhive
//! surface (todos/messaging), the automatically injected `bash` and `subagent` //! surface (todos/messaging), the automatically injected `bash` and `subagent`
//! entries — stays unreachable by construction: none of those default to //! entries — stays unreachable by construction: none of those default to
//! opted in, and the built-in surface isn't an `extraMcpServers` entry at //! 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 //! all, so there's no name for an operator to opt it in under even if they
//! wanted to. //! 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; use std::path::PathBuf;
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`], /// 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 const CONFIG_FILE: &str = "subagent-mcp-config.json";
/// 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")
}
/// Name the signal surface appears under in a subagent's own MCP config, /// Render the subagent-eligible extra-MCP servers to a `--mcp-config` file,
/// and therefore the prefix its tools are called by /// returning its path — or `None` when no entry opts in (or the render/write
/// (`mcp__subagent_control__goal_reached`). /// fails), so [`hive_claude::Config::mcp_config`] stays unset and the
const SIGNAL_SERVER: &str = "subagent_control"; /// 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
/// 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 /// file write) rather than cached once at daemon startup, so a config change
/// takes effect on this subagent's next `start`/`continue` without needing /// takes effect on this subagent's next `start`/`continue` without needing
/// the daemon itself restarted. /// the daemon itself restarted.
#[must_use] #[must_use]
pub fn build(name: &str, signal_url: Option<&str>) -> Option<PathBuf> { pub fn build() -> Option<PathBuf> {
let state_dir = crate::paths::state_dir(); let state_dir = crate::paths::state_dir();
let mut servers: serde_json::Map<String, serde_json::Value> = let servers: serde_json::Map<String, serde_json::Value> =
hive_agent_sock::extra_mcp::load_extra_mcp() hive_agent_sock::extra_mcp::load_extra_mcp()
.into_iter() .into_iter()
.filter(|(_, spec)| spec.available_to_subagents()) .filter(|(_, spec)| spec.available_to_subagents())
.map(|(name, spec)| (name, spec.to_json_entry(&state_dir))) .map(|(name, spec)| (name, spec.to_json_entry(&state_dir)))
.collect(); .collect();
if let Some(url) = signal_url {
servers.insert(
SIGNAL_SERVER.to_owned(),
serde_json::json!({ "type": "http", "url": url }),
);
}
if servers.is_empty() { if servers.is_empty() {
return None; return None;
} }
@ -83,7 +44,7 @@ pub fn build(name: &str, signal_url: Option<&str>) -> Option<PathBuf> {
); );
return None; return None;
} }
let path = dir.join(config_file(name)); let path = dir.join(CONFIG_FILE);
if let Err(e) = std::fs::write(&path, body) { if let Err(e) = std::fs::write(&path, body) {
tracing::warn!( tracing::warn!(
error = ?e, error = ?e,

File diff suppressed because it is too large Load diff

View file

@ -349,17 +349,11 @@ in
# Subagent task runner daemon — independent of `hive-bash-daemon` (own # Subagent task runner daemon — independent of `hive-bash-daemon` (own
# crate, own process): spawns nested claude sessions on request, serves # crate, own process): spawns nested claude sessions on request, serves
# the `start`/`continue`/`status`/`interrupt` MCP tools directly over # the `start`/`continue`/`status`/`interrupt` MCP tools directly over
# streamable-http on `hyperhive.mcp.subagentHttpPort`. The same port also # streamable-http on `hyperhive.mcp.subagentHttpPort`. No task files —
# serves a second, subagent-facing route (`/signal/mcp/<token>`: # this daemon's only state is an in-memory map of currently-running
# `goal_reached`/`need_help`) — not something an agent's own config points # processes, live only as long as the process is (see
# at: the daemon mints each subagent it spawns its own token and writes # `hive-subagent-mcp/src/session.rs`'s module doc); a restart stops
# that one URL into that subagent's own `--mcp-config`, which is how a # whatever's running, the actual claude session survives independently.
# 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 = { systemd.services.hive-subagent-daemon = {
description = "subagent task runner + MCP daemon for hive-subagent"; description = "subagent task runner + MCP daemon for hive-subagent";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];