Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26b754701 | ||
|
|
1ad7145cc5 | ||
|
|
561bd09618 | ||
|
|
c2fb3c6e3e | ||
|
|
e64639c4d0 | ||
|
|
c280664d74 | ||
|
|
7699db6500 |
21 changed files with 1022 additions and 65 deletions
|
|
@ -98,6 +98,15 @@ hand-maintained per-file tree drifts out of sync with the code.
|
|||
directly over streamable-http (no stdio bridge), writes task files
|
||||
under `/harness/bash-tasks/`, and records the favorite-tools
|
||||
`bash_commands` stat into turn-stats.sqlite.
|
||||
- **`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).
|
||||
- **`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
|
||||
|
|
|
|||
25
Cargo.lock
generated
25
Cargo.lock
generated
|
|
@ -1780,10 +1780,11 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "hive-claude"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540ef2338fc071b60e69cac345cb5ec04627c4ef11c49138d1c39d4f687f254"
|
||||
checksum = "7784a4aeb50369f2a49f052fd3bb28eee143ec6c8af8df806d3e375b045b0661"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
|
|
@ -1976,6 +1977,26 @@ dependencies = [
|
|||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hive-subagent-mcp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"hive-agent-sock",
|
||||
"hive-claude",
|
||||
"hive-sock-client",
|
||||
"hive-types",
|
||||
"rmcp",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hive-types"
|
||||
version = "0.1.0"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ members = [
|
|||
"hive-priv-sock",
|
||||
"hive-sh4re",
|
||||
"hive-sock-client",
|
||||
"hive-subagent-mcp",
|
||||
"hive-types",
|
||||
"hivectl",
|
||||
"swarm-authelia-bridge",
|
||||
|
|
@ -84,7 +85,7 @@ hive-jobq = { path = "hive-jobq" }
|
|||
hive-jobq-metrics = { path = "hive-jobq-metrics" }
|
||||
hive-jobq-wire = { path = "hive-jobq-wire" }
|
||||
hive-core-agent-sock = { path = "hive-core-agent-sock" }
|
||||
hive-claude = "0.1"
|
||||
hive-claude = "0.1.1"
|
||||
hive-host-sock = { path = "hive-host-sock" }
|
||||
hive-priv-sock = { path = "hive-priv-sock" }
|
||||
hive-sock-client = { path = "hive-sock-client" }
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ name: claude-subagents
|
|||
description: Spin up a short-lived headless `claude` sub-instance to grind through a well-scoped, mechanical batch (bulk relabeling, a repetitive find/replace, a mechanical migration) instead of burning your own context on it inline. Use this when a task has a clear, describable recipe and is either big enough to eat your context or long enough that you'd rather not babysit it. Not for judgement-heavy work, anything needing operator back-and-forth, or a task whose blast radius you can't bound up front.
|
||||
---
|
||||
|
||||
# Ephemeral Sub-Agents via the Claude CLI
|
||||
# Ephemeral Sub-Agents
|
||||
|
||||
`claude` is on `PATH` in your container, and a sub-instance you spawn
|
||||
inherits the same filesystem and credentials you have. This is a
|
||||
first-class tool for offloading a bounded, mechanical batch - not a hack.
|
||||
A sub-instance you spawn inherits the same filesystem and credentials you
|
||||
have. This is a first-class tool for offloading a bounded, mechanical
|
||||
batch.
|
||||
|
||||
## When to use it
|
||||
|
||||
|
|
@ -18,45 +18,28 @@ first-class tool for offloading a bounded, mechanical batch - not a hack.
|
|||
- **No:** judgement-heavy work, anything needing back-and-forth with a
|
||||
human, or a task whose blast radius you can't bound up front.
|
||||
|
||||
## The spawn command
|
||||
## The `subagent` MCP tools
|
||||
|
||||
Put the task recipe in a file and pass it as a path, with a short `-p`
|
||||
trigger:
|
||||
Your container's `subagent` MCP server (`hive-subagent-daemon`) runs this
|
||||
skill's recipe for you:
|
||||
|
||||
```bash
|
||||
claude --name <memorable> --model <cheaper-than-you> --dangerously-skip-permissions \
|
||||
--append-system-prompt-file task-prompt.md \
|
||||
-p "Carry out the task described in your instructions."
|
||||
```
|
||||
start(name, prompt_file, model?, trigger?)
|
||||
continue(name, prompt, model?)
|
||||
status(name)
|
||||
interrupt(name, force?)
|
||||
```
|
||||
|
||||
- **`--name <memorable>`** - names the session so you can `--resume` it
|
||||
by name for follow-ups.
|
||||
- **`--model <cheaper-than-you>`** - think about which model the task
|
||||
actually needs; don't spend a bigger model's tokens than your own on
|
||||
mechanical work a cheaper one handles fine. Never use a _bigger_ model
|
||||
than yourself for a sub-agent - if the task needs that much capability,
|
||||
it's not the "mechanical batch" case this skill is for.
|
||||
- **`--dangerously-skip-permissions`** - required in headless (`-p`)
|
||||
mode. Without it, tool calls that would normally prompt for approval
|
||||
are auto-**denied** non-interactively, so the sub-agent silently does
|
||||
nothing. Acceptable here because the sub-instance runs inside your
|
||||
already-sandboxed environment, with your already-scoped credentials,
|
||||
on a bounded task - don't reach for it when the task could touch
|
||||
things outside its intended blast radius.
|
||||
- **`--append-system-prompt-file <path>`** - read the recipe from a
|
||||
file rather than inlining it in `-p` (which is bounded by `ARG_MAX` -
|
||||
a long recipe passed as `-p "$(cat …)"` can blow past it and the exec
|
||||
fails). Keeps Claude Code's default scaffolding and adds your recipe
|
||||
on top.
|
||||
- **`-p "<trigger>"`** - headless print mode. Keep this short: just
|
||||
tell the sub-agent to act on its file-supplied instructions.
|
||||
`start` and `continue` return as soon as the process is confirmed
|
||||
running, not once it finishes — a completion lands as a todo
|
||||
(`get_loose_ends`), same as any other producer. Use `status` for a
|
||||
zero-cost "is it still going" check; reach for `continue` only once you
|
||||
actually have a new instruction for it, since that spends a turn.
|
||||
`interrupt` genuinely stops a running turn (`force: true` for SIGKILL).
|
||||
|
||||
## Run it in the background, don't babysit
|
||||
|
||||
Wrap the launch in a background bash task and end your turn - your
|
||||
bash-task runner already captures stdout/stderr and hands you a
|
||||
completion pointer, and ending the turn keeps you reachable for other
|
||||
work while the sub-agent grinds.
|
||||
Model choice, prompt hygiene, splitting big batches, verify-then-report —
|
||||
everything else in this skill — applies exactly the same whether you're
|
||||
calling the tool or thinking through the recipe by hand.
|
||||
|
||||
## Prompt hygiene - this is where batches succeed or fail
|
||||
|
||||
|
|
@ -92,10 +75,9 @@ running at once instead of handing the whole thing to one.
|
|||
different files never race on the same working tree or index. For a
|
||||
non-git batch (issue relabeling, API calls), independent items don't
|
||||
need filesystem isolation at all — just launch N in parallel.
|
||||
- **Launch all N in the background and don't babysit any single one** —
|
||||
same as the one-subagent case, just N background bash tasks instead of
|
||||
one. Check on them as a batch, not by polling each individually in a
|
||||
loop.
|
||||
- **Launch all N and don't babysit any single one** — same as the
|
||||
one-subagent case, just N `start` calls instead of one. Check on them
|
||||
as a batch, not by polling each individually in a loop.
|
||||
- **Mind the container's memory cap before picking N.** Your whole
|
||||
container shares one `MemoryMax` (a few GB by default) with every
|
||||
subagent you spawn _and_ your own process. A `claude` process plus its
|
||||
|
|
@ -118,10 +100,10 @@ threaded — the coordination overhead isn't worth it below that size.
|
|||
|
||||
## Resume for follow-ups
|
||||
|
||||
`claude --resume <name> -p "Now run the same procedure over the second batch."`
|
||||
reuses the same session, so the sub-agent keeps every constant and
|
||||
gotcha it already discovered instead of re-learning the surface from a
|
||||
cold prompt. Spawn a new `--name` only for a genuinely unrelated task.
|
||||
`continue` reuses the same session, so the sub-agent keeps every constant
|
||||
and gotcha it already discovered instead of re-learning the surface from
|
||||
a cold prompt. Reach for `start` under a new name only for a genuinely
|
||||
unrelated task.
|
||||
|
||||
## Verify, then report
|
||||
|
||||
|
|
@ -131,14 +113,12 @@ anything the sub-agent left ambiguous or exempted for a human call.
|
|||
|
||||
## Pitfalls (all observed in practice)
|
||||
|
||||
- No `--dangerously-skip-permissions` → headless tool calls denied, the
|
||||
sub-agent burns a run doing nothing.
|
||||
- Using a model bigger than yourself → paying premium cost for
|
||||
mechanical work that didn't need it.
|
||||
- Skipping the one-item tuning pass → a systematic mistake smeared
|
||||
across the whole batch.
|
||||
- Spawning fresh instead of `--resume` for a follow-up → throws away
|
||||
all the context the first pass earned.
|
||||
- `start`-ing fresh instead of `continue`-ing for a follow-up → throws
|
||||
away all the context the first pass earned.
|
||||
- Baking an unverified assumption into the recipe - if a quick check
|
||||
suggests something "isn't possible" or "doesn't exist," confirm it
|
||||
before writing that conclusion into the prompt; a wrong assumption
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ debug agent behavior.
|
|||
|
||||
- **[bash](bash.md)** — background shell execution (`mcp__bash__*`),
|
||||
available on every agent unconditionally.
|
||||
- **[subagent](subagent.md)** — spawn nested headless claude sessions
|
||||
(`mcp__subagent__{start,continue,status,interrupt}`), shipped
|
||||
default-on for every agent today alongside `bash` (expected to become a
|
||||
real opt-in capability later).
|
||||
- **[forge](forge.md)** — the `hive-forge` Forgejo CLI every agent has
|
||||
for issues, PRs, and comments. Not an MCP tool — a binary agents
|
||||
shell out to instead of ad-hoc curl.
|
||||
|
|
|
|||
|
|
@ -93,15 +93,14 @@ this structured path so tasks get task-id tracking and structured output.
|
|||
|
||||
`hive-bash-daemon` is a single long-running process (one per agent
|
||||
container, systemd service in `nix/agent-modules/mcp.nix`) — no stdio
|
||||
bridge, no separate bin. It owns subprocess management, output file
|
||||
writing, todo delivery on the harness's in-agent socket, **and** serves
|
||||
the `run`/`status`/`kill` MCP tools directly over streamable-http on
|
||||
`hyperhive.mcp.bashHttpPort` (declared in `hyperhive.extraMcpServers.bash`
|
||||
as `{ type = "http"; url = ...; }`). Same shape as the built-in
|
||||
`hyperhive` surface (`hive-mcp-http`) — claude reconnects to the stable
|
||||
URL every turn instead of respawning a stdio child, so there's no
|
||||
per-turn MCP re-registration race and no round-trip socket hop for tool
|
||||
calls.
|
||||
bridge. It owns subprocess management, output file writing, todo delivery
|
||||
on the harness's in-agent socket, **and** serves the `run`/`status`/`kill`
|
||||
MCP tools directly over streamable-http on `hyperhive.mcp.bashHttpPort`
|
||||
(declared in `hyperhive.extraMcpServers.bash` as `{ type = "http"; url =
|
||||
...; }`). Same shape as the built-in `hyperhive` surface (`hive-mcp-http`)
|
||||
— claude reconnects to the stable URL every turn instead of respawning a
|
||||
stdio child, so there's no per-turn MCP re-registration race and no
|
||||
round-trip socket hop for tool calls.
|
||||
|
||||
### Completion as a todo (loose-ends v2)
|
||||
|
||||
|
|
|
|||
54
docs/tools/subagent.md
Normal file
54
docs/tools/subagent.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# 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` unconditionally (`allowedTools
|
||||
= ["*"]`), same as `bash`. 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: a map of currently-running processes, live only as long
|
||||
as the daemon process is. 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.
|
||||
|
||||
## 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`.
|
||||
|
|
@ -128,6 +128,9 @@ hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
|
|||
|
||||
- **Bash execution** (`execution`) — background shell tasks. See
|
||||
[`docs/tools/bash.md`](../tools/bash.md).
|
||||
- **Subagent spawning** — headless claude sub-instances as background
|
||||
tasks, shipped default-on like bash execution (no tool group gates it
|
||||
yet). See [`docs/tools/subagent.md`](../tools/subagent.md).
|
||||
- **Lifecycle + config** (`lifecycle`, `approvals`) — manage child
|
||||
agents, spawn new ones, apply config commits. See
|
||||
[`docs/tools/lifecycle.md`](../tools/lifecycle.md).
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@
|
|||
hive-matrix-daemon
|
||||
hive-metric
|
||||
hive-screen-mcp
|
||||
hive-subagent-daemon
|
||||
assets
|
||||
frontend
|
||||
reference-docs
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ pub(super) async fn get_journal(
|
|||
"hive-agent.service",
|
||||
"hive-mcp-http.service",
|
||||
"hive-bash-daemon.service",
|
||||
"hive-subagent-daemon.service",
|
||||
"hive-matrix-daemon.service",
|
||||
];
|
||||
let unit = if u.ends_with(".service") {
|
||||
|
|
|
|||
32
hive-subagent-mcp/Cargo.toml
Normal file
32
hive-subagent-mcp/Cargo.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "hive-subagent-mcp"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
hive-agent-sock.workspace = true
|
||||
hive-claude.workspace = true
|
||||
hive-sock-client.workspace = true
|
||||
hive-types.workspace = true
|
||||
rmcp.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
# `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.
|
||||
[[bin]]
|
||||
name = "hive-subagent-daemon"
|
||||
path = "src/main.rs"
|
||||
27
hive-subagent-mcp/README.md
Normal file
27
hive-subagent-mcp/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# hive-subagent-mcp
|
||||
|
||||
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.
|
||||
|
||||
Independent of `hive-bash-mcp` — a subagent spawns a full nested
|
||||
`claude` session, a much heavier capability than a bash command, worth
|
||||
its own deployable/restartable unit.
|
||||
|
||||
## Shape
|
||||
|
||||
One bin (`hive-subagent-daemon`, `src/main.rs`) built from the crate's
|
||||
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`.
|
||||
- **`paths.rs`** — the in-agent todo-socket path.
|
||||
14
hive-subagent-mcp/src/lib.rs
Normal file
14
hive-subagent-mcp/src/lib.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! 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.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
pub mod mcp;
|
||||
pub mod paths;
|
||||
pub mod session;
|
||||
52
hive-subagent-mcp/src/main.rs
Normal file
52
hive-subagent-mcp/src/main.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! `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.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "hive-subagent-daemon",
|
||||
about = "claude-subagent runner + MCP daemon"
|
||||
)]
|
||||
struct Cli {
|
||||
/// Serve the MCP tools over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8793`). Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_env("RUST_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
// This is a systemd-managed daemon — stdout always goes to journald,
|
||||
// never a human terminal, and journald doesn't strip ANSI escapes:
|
||||
// they land in victorialogs as raw byte-array spam otherwise.
|
||||
.with_ansi(false)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let todo_socket = hive_subagent_mcp::paths::agent_socket();
|
||||
|
||||
tracing::info!(
|
||||
http = %cli.http,
|
||||
todo = %todo_socket.display(),
|
||||
"hive-subagent-daemon starting"
|
||||
);
|
||||
|
||||
let state = Arc::new(hive_subagent_mcp::session::State::new(todo_socket));
|
||||
|
||||
// Serve the MCP tools over streamable-http forever. No background poll
|
||||
// loop to start — unlike the bash daemon's task-file queue, `start`/
|
||||
// `continue` spawn their subagent's background turn directly from the
|
||||
// tool call itself, nothing to scan for.
|
||||
hive_subagent_mcp::mcp::serve_http(cli.http, state).await
|
||||
}
|
||||
182
hive-subagent-mcp/src/mcp.rs
Normal file
182
hive-subagent-mcp/src/mcp.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`,
|
||||
//! served directly over streamable-http — no stdio bridge, no round-trip
|
||||
//! socket.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rmcp::{
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
schemars::{self, JsonSchema},
|
||||
tool, tool_handler, tool_router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::session::{self, State};
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct StartArgs {
|
||||
/// Session name — this daemon's tracking key while it's alive, and the
|
||||
/// identity to `continue`/`status`/`interrupt` it by afterward. Same
|
||||
/// identifier rules as the `bash` server's task names: lowercase,
|
||||
/// digits, hyphen, max 63 chars. Reusable once a prior *finished*
|
||||
/// session under that name is done — rejected while one under the same
|
||||
/// name is still running.
|
||||
name: String,
|
||||
/// Which model the subagent's own session runs. Omit for claude's own
|
||||
/// default. The `base:claude-subagents` skill's "cheaper-than-you"
|
||||
/// guidance still applies here.
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
/// Path to a file holding the subagent's actual task instructions. A
|
||||
/// file, not an inline string, so a large recipe can't blow past a
|
||||
/// shell argument length limit.
|
||||
prompt_file: String,
|
||||
/// Written to the subagent's stdin as its first turn's prompt. Default:
|
||||
/// a generic "carry out your instructions" nudge — the real task detail
|
||||
/// belongs in `prompt_file`, not here.
|
||||
#[serde(default = "default_trigger")]
|
||||
trigger: String,
|
||||
}
|
||||
|
||||
fn default_trigger() -> String {
|
||||
"Carry out the task described in your instructions.".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct ContinueArgs {
|
||||
/// The existing session's name (from a prior `start`).
|
||||
name: String,
|
||||
/// The new turn's prompt, written to the subagent's stdin.
|
||||
prompt: String,
|
||||
/// Which model this turn runs. Omit to let claude fall back to its own
|
||||
/// default — this does not have to match whatever model `start` used.
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct StatusArgs {
|
||||
/// The subagent name to check.
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
struct InterruptArgs {
|
||||
/// The running session's name to signal.
|
||||
name: String,
|
||||
/// `true` sends SIGKILL immediately; `false` (default) sends SIGINT,
|
||||
/// letting claude shut down cleanly if it's already mid-response.
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SubagentMcp {
|
||||
state: Arc<State>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl SubagentMcp {
|
||||
#[tool(
|
||||
description = "Start a fresh claude subagent session under `name`, running in the \
|
||||
background. Returns as soon as the process is confirmed running — not once it \
|
||||
finishes; this daemon pushes a todo when the turn ends, or use `continue` later to \
|
||||
give it another turn. A prior *finished* session under the same name is archived \
|
||||
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."
|
||||
)]
|
||||
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
|
||||
match session::start(
|
||||
&self.state,
|
||||
&args.name,
|
||||
args.model,
|
||||
&args.prompt_file,
|
||||
args.trigger,
|
||||
) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("start error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "continue",
|
||||
description = "Give an existing named subagent session a new turn — whether that's \
|
||||
because its previous turn finished and you have a follow-up instruction, or you're \
|
||||
reattaching after this daemon restarted (the session itself survives independently \
|
||||
of the daemon that spawned it). Returns as soon as confirmed running, same as \
|
||||
`start`. Refuses a name with no session on disk at all, or one already running."
|
||||
)]
|
||||
fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
|
||||
match session::continue_(&self.state, &args.name, args.prompt, args.model) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("continue error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Signal a currently-running subagent session to stop. Only works once \
|
||||
it's actually running — a `start`/`continue` still in its brief window before the \
|
||||
process is confirmed spawned refuses interrupt too (nothing to signal yet; retry \
|
||||
shortly), same as a name with nothing tracked at all. `force: true` for SIGKILL, \
|
||||
otherwise SIGINT."
|
||||
)]
|
||||
fn interrupt(&self, Parameters(args): Parameters<InterruptArgs>) -> String {
|
||||
match session::interrupt(&self.state, &args.name, args.force) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("interrupt error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Report whether a subagent is currently running — a zero-cost check that \
|
||||
never launches a process, unlike `continue`. Distinguishes running, starting (a \
|
||||
`start`/`continue` is in flight but not yet a confirmed spawn — this is normally \
|
||||
over in well under a second), idle (a session exists but nothing is in flight — \
|
||||
`continue` to give it another turn), and no such session at all."
|
||||
)]
|
||||
fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
|
||||
match session::status(&self.state, &args.name) {
|
||||
Ok(msg) => msg,
|
||||
Err(e) => format!("status error: {e:#}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for SubagentMcp {}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// # 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 service = StreamableHttpService::new(
|
||||
move || {
|
||||
Ok(SubagentMcp {
|
||||
state: Arc::clone(&state),
|
||||
})
|
||||
},
|
||||
session_manager,
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let app = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "serving hive-subagent MCP over streamable-http at /mcp");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
15
hive-subagent-mcp/src/paths.rs
Normal file
15
hive-subagent-mcp/src/paths.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! The one per-agent path this daemon needs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The harness-served in-agent todo socket (loose-ends v2). This daemon
|
||||
/// pushes exactly one todo per subagent lifetime — the completion summary,
|
||||
/// once its background turn finishes. Override via `HIVE_AGENT_SOCKET`;
|
||||
/// same resolution as `hive-bash-mcp`'s own `paths::agent_socket`.
|
||||
#[must_use]
|
||||
pub fn agent_socket() -> PathBuf {
|
||||
std::env::var_os("HIVE_AGENT_SOCKET").map_or_else(
|
||||
|| PathBuf::from(hive_agent_sock::DEFAULT_AGENT_SOCKET),
|
||||
PathBuf::from,
|
||||
)
|
||||
}
|
||||
489
hive-subagent-mcp/src/session.rs
Normal file
489
hive-subagent-mcp/src/session.rs
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
//! The claude-facing half of this daemon: spawn a subagent turn, track it
|
||||
//! only while it's alive, and push exactly one todo when it finishes.
|
||||
//!
|
||||
//! **No task files, no restart recovery.** The daemon's only state is an
|
||||
//! in-memory `name -> Option<Cancel>` map (see `State`'s own doc for what
|
||||
//! the `None`/`Some` split is for), live for exactly as long as the process
|
||||
//! is — a daemon restart means whatever was running gets killed with it
|
||||
//! (`tokio`'s own child-process drop semantics), not adopted. The durable
|
||||
//! record of a subagent's existence is `hive_claude::SessionStore` — claude's
|
||||
//! own on-disk session, found again by name. `continue` is how a caller
|
||||
//! reattaches to it, whether that's "give it a new turn" or "the daemon
|
||||
//! restarted and I want to pick this back up."
|
||||
//!
|
||||
//! **No mid-turn compaction.** Building on `hive_claude::Claude::spawn` +
|
||||
//! `RunningClaude::wait` directly (not `InfiniteSession::run`) is what makes
|
||||
//! `interrupt` possible at all — `InfiniteSession` has no cancel handle to
|
||||
//! reach in from the outside, only `RunningClaude::cancel_handle` does. The
|
||||
//! trade: this daemon doesn't get `InfiniteSession`'s reactive-compact-on-
|
||||
//! overflow or proactive-checkpoint-compact for free: a turn that overflows
|
||||
//! the context window surfaces as a plain `Error::PromptTooLong` to the
|
||||
//! caller instead of self-healing. Subagents are meant to be bounded,
|
||||
//! single-batch work (see the `base:claude-subagents` skill), not sessions
|
||||
//! long-lived enough to need in-place compaction — a real follow-up if that
|
||||
//! assumption stops holding, not shipped here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use hive_claude::{Attach, Cancel, Claude, Config, NoopSink, SessionStore};
|
||||
|
||||
/// This daemon's whole state: which names currently have a live process or
|
||||
/// a reservation in flight, and where to push the completion todo.
|
||||
/// `Arc`-wrapped so the background task that drives a turn to completion
|
||||
/// can outlive the tool call that started it.
|
||||
///
|
||||
/// The map value is `Option<Cancel>`: `None` means `name` is reserved for
|
||||
/// an in-flight `start`/`continue` that hasn't reached a confirmed
|
||||
/// `Claude::spawn` yet; `Some(cancel)` means a real process is tracked and
|
||||
/// interruptible. The `None` state exists to close a real TOCTOU window a
|
||||
/// reviewer caught in the original check-then-insert version: checking "is
|
||||
/// `name` free" and committing to it are two different lock acquisitions
|
||||
/// unless the check *is* the reservation — see `reserve`.
|
||||
pub struct State {
|
||||
running: Mutex<HashMap<String, Option<Cancel>>>,
|
||||
socket: PathBuf,
|
||||
}
|
||||
|
||||
impl State {
|
||||
#[must_use]
|
||||
pub fn new(socket: PathBuf) -> Self {
|
||||
Self {
|
||||
running: Mutex::new(HashMap::new()),
|
||||
socket,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(true)` — a live process is tracked, interruptible. `Some(false)`
|
||||
/// — reserved for an in-flight start/continue, not yet a confirmed
|
||||
/// spawn. `None` — nothing tracked under `name` at all.
|
||||
fn occupancy(&self, name: &str) -> Option<bool> {
|
||||
self.running
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get(name)
|
||||
.map(Option::is_some)
|
||||
}
|
||||
|
||||
/// Atomically claim `name` for an in-flight start/continue: check
|
||||
/// "is anything tracked under this name" and "commit to this call
|
||||
/// owning it" in the *same* lock acquisition, so two calls racing the
|
||||
/// same name can't both pass a check before either commits (the exact
|
||||
/// same-name concurrent-run hazard this module's doc warns about).
|
||||
/// Returns `false` (reserving nothing) if `name` is already reserved
|
||||
/// or running.
|
||||
fn reserve(&self, name: &str) -> bool {
|
||||
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if running.contains_key(name) {
|
||||
return false;
|
||||
}
|
||||
running.insert(name.to_owned(), None);
|
||||
true
|
||||
}
|
||||
|
||||
/// Release a reservation that never made it to a real spawn (an error
|
||||
/// on the slow path between `reserve` and `Claude::spawn` succeeding).
|
||||
/// A no-op if the entry was already upgraded to `Some` — this only ever
|
||||
/// clears a still-`None` placeholder, never a live process.
|
||||
fn release_reservation(&self, name: &str) {
|
||||
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if matches!(running.get(name), Some(None)) {
|
||||
running.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A caller-chosen name, validated the same way `hive-bash-mcp`'s task ids
|
||||
/// are: a single safe [`hive_types::Ident`] segment, which doubles as
|
||||
/// claude's own `--name`/`--resume` session title.
|
||||
fn validate_name(name: &str) -> anyhow::Result<()> {
|
||||
hive_types::Ident::parse(name)
|
||||
.map(|_| ())
|
||||
.map_err(|e| anyhow::anyhow!("invalid subagent name {name:?}: {e}"))
|
||||
}
|
||||
|
||||
/// Extend the ambient `OTEL_RESOURCE_ATTRIBUTES` with a `subagent=<name>`
|
||||
/// attribute, so every token/cost/tool-call data point this subagent's own
|
||||
/// claude process emits carries it alongside the parent's `agent=<name>`
|
||||
/// label. `Config.env` applies after the inherited environment, so this one
|
||||
/// entry overriding the ambient value is the intended shape, not a
|
||||
/// wholesale replacement.
|
||||
fn subagent_otel_attrs(name: &str) -> String {
|
||||
match std::env::var("OTEL_RESOURCE_ATTRIBUTES") {
|
||||
Ok(existing) if !existing.is_empty() => format!("{existing},subagent={name}"),
|
||||
_ => format!("subagent={name}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `Config` one subagent turn runs against. `prompt_file`, when
|
||||
/// given, becomes `--append-system-prompt-file` — the subagent's task
|
||||
/// instructions. Always `--dangerously-skip-permissions --strict-mcp-config`
|
||||
/// (no `--mcp-config` override — a safety property, not a knob).
|
||||
fn build_config(name: &str, model: Option<String>, prompt_file: Option<&str>) -> Config {
|
||||
let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()];
|
||||
if let Some(path) = prompt_file {
|
||||
extra_args.push("--append-system-prompt-file".to_owned());
|
||||
extra_args.push(path.to_owned());
|
||||
}
|
||||
Config {
|
||||
model,
|
||||
strict_mcp_config: true,
|
||||
extra_args,
|
||||
env: vec![(
|
||||
"OTEL_RESOURCE_ATTRIBUTES".to_owned(),
|
||||
subagent_otel_attrs(name),
|
||||
)],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`SessionStore`] a subagent's turn actually runs against — same
|
||||
/// resolution `hive_claude::Claude` itself uses, so a lookup here can't
|
||||
/// disagree with what the driver does a moment later.
|
||||
fn build_store(config: &Config) -> std::io::Result<SessionStore> {
|
||||
Ok(SessionStore::new(
|
||||
config.resolved_claude_home()?,
|
||||
config.resolved_cwd()?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Start a fresh subagent under `name`. A prior *finished* session under
|
||||
/// the same name is archived first (so this is a real fresh start, not a
|
||||
/// silent resume of old history) — a *currently running* one is refused
|
||||
/// outright, since `hive_claude::InfiniteSession`'s own docs warn that two
|
||||
/// concurrent runs against the same name corrupt both.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// A name already running, an invalid name, an archive failure, or the
|
||||
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
|
||||
/// case is the only one that can happen *after* commit-to-run, and it's
|
||||
/// exactly why nothing is registered in `running` until spawn actually
|
||||
/// succeeds.
|
||||
pub fn start(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
model: Option<String>,
|
||||
prompt_file: &str,
|
||||
trigger: String,
|
||||
) -> anyhow::Result<String> {
|
||||
validate_name(name)?;
|
||||
if !state.reserve(name) {
|
||||
anyhow::bail!("subagent `{name}` is already running — use `continue` or `interrupt`");
|
||||
}
|
||||
let result = start_reserved(state, name, model, prompt_file, trigger);
|
||||
if result.is_err() {
|
||||
state.release_reservation(name);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The slow, fallible part of `start`, run only after `reserve` has
|
||||
/// already closed the TOCTOU window — split out so `start` can release the
|
||||
/// reservation on any error path here without duplicating that logic per
|
||||
/// failure site.
|
||||
fn start_reserved(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
model: Option<String>,
|
||||
prompt_file: &str,
|
||||
trigger: String,
|
||||
) -> anyhow::Result<String> {
|
||||
let config = build_config(name, model, Some(prompt_file));
|
||||
let store = build_store(&config)?;
|
||||
if store.find_by_title(name).is_some() {
|
||||
tracing::info!(
|
||||
name,
|
||||
"start: archiving a finished prior session for a fresh start"
|
||||
);
|
||||
store
|
||||
.archive_by_title(name)
|
||||
.map_err(|e| anyhow::anyhow!("archiving the prior `{name}` session failed: {e}"))?;
|
||||
}
|
||||
spawn_and_track(
|
||||
state,
|
||||
name,
|
||||
&config,
|
||||
&Attach::Create(name.to_owned()),
|
||||
trigger,
|
||||
)
|
||||
}
|
||||
|
||||
/// Give an existing named session a new turn — resuming it whether that
|
||||
/// means "the previous turn finished, here's the next instruction" or "the
|
||||
/// daemon restarted, reattaching." Refuses a name with no session on disk
|
||||
/// at all (nothing to continue) or one already running (same
|
||||
/// concurrent-run hazard as `start`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// No session under `name`, an invalid name, one already running, or
|
||||
/// `Claude::spawn` failing.
|
||||
pub fn continue_(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
validate_name(name)?;
|
||||
if !state.reserve(name) {
|
||||
anyhow::bail!(
|
||||
"subagent `{name}` is already running — use `interrupt` first if you meant to redirect it"
|
||||
);
|
||||
}
|
||||
let result = continue_reserved(state, name, prompt, model);
|
||||
if result.is_err() {
|
||||
state.release_reservation(name);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The slow, fallible part of `continue_`, run only after `reserve` has
|
||||
/// already closed the TOCTOU window — same split rationale as
|
||||
/// `start_reserved`.
|
||||
fn continue_reserved(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
prompt: String,
|
||||
model: Option<String>,
|
||||
) -> anyhow::Result<String> {
|
||||
let config = build_config(name, model, None);
|
||||
let store = build_store(&config)?;
|
||||
if store.find_by_title(name).is_none() {
|
||||
anyhow::bail!(
|
||||
"no session named `{name}` exists — `continue` resumes an existing subagent, `start` \
|
||||
creates one"
|
||||
);
|
||||
}
|
||||
spawn_and_track(
|
||||
state,
|
||||
name,
|
||||
&config,
|
||||
&Attach::Resume(name.to_owned()),
|
||||
prompt,
|
||||
)
|
||||
}
|
||||
|
||||
/// Spawn the child (synchronous — returns with a real pid the instant the
|
||||
/// process exists, which *is* "confirmed running": there is no stronger
|
||||
/// signal to wait for without slowing every call down for no reason), track
|
||||
/// it in `running`, and hand the actual turn off to a background task so
|
||||
/// the caller returns immediately instead of blocking on the whole turn.
|
||||
/// Not `async` itself — `tokio::spawn` needs an active runtime to spawn
|
||||
/// *onto*, not an `async` caller to spawn *from*.
|
||||
fn spawn_and_track(
|
||||
state: &Arc<State>,
|
||||
name: &str,
|
||||
config: &Config,
|
||||
attach: &Attach,
|
||||
prompt: String,
|
||||
) -> anyhow::Result<String> {
|
||||
let running = Claude::spawn(config, attach)
|
||||
.map_err(|e| anyhow::anyhow!("starting the subagent process failed: {e}"))?;
|
||||
// Upgrades the `None` reservation `reserve` already placed here to a
|
||||
// real cancel handle — same key, so there's no window where `name`
|
||||
// reads as unoccupied between the reservation and this insert.
|
||||
state
|
||||
.running
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(name.to_owned(), Some(running.cancel_handle()));
|
||||
|
||||
let state = Arc::clone(state);
|
||||
let task_name = name.to_owned();
|
||||
tokio::spawn(async move {
|
||||
let outcome = running.wait(&prompt, &NoopSink).await;
|
||||
state
|
||||
.running
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.remove(&task_name);
|
||||
let summary = match outcome {
|
||||
Ok(()) => "turn complete".to_owned(),
|
||||
Err(e) => {
|
||||
tracing::warn!(name = %task_name, error = %e, "subagent: turn failed");
|
||||
format!("claude error: {e}")
|
||||
}
|
||||
};
|
||||
push_completion_todo(&state.socket, &task_name, &summary).await;
|
||||
});
|
||||
|
||||
Ok(format!("subagent `{name}` started"))
|
||||
}
|
||||
|
||||
/// Report whether `name` is currently running — a zero-cost check that
|
||||
/// never launches a process, unlike `continue`. Distinguishes four states:
|
||||
/// running, starting (reserved, not yet a confirmed spawn — see `State`'s
|
||||
/// doc), idle (a session exists but nothing is in flight), and no such
|
||||
/// session at all.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// An invalid name, or no session — running or on disk — under `name`.
|
||||
pub fn status(state: &State, name: &str) -> anyhow::Result<String> {
|
||||
validate_name(name)?;
|
||||
match state.occupancy(name) {
|
||||
Some(true) => return Ok(format!("subagent `{name}` is running")),
|
||||
Some(false) => {
|
||||
return Ok(format!(
|
||||
"subagent `{name}` is starting — not yet confirmed running"
|
||||
));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let config = build_config(name, None, None);
|
||||
let store = build_store(&config)?;
|
||||
if store.find_by_title(name).is_some() {
|
||||
Ok(format!(
|
||||
"subagent `{name}` is idle — its last turn finished; `continue` to give it another"
|
||||
))
|
||||
} else {
|
||||
anyhow::bail!("no subagent named `{name}` exists — `start` creates one")
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal `name`'s running process — `force` picks SIGKILL over SIGINT (see
|
||||
/// `hive_claude::Cancel::cancel`). Refuses a name with nothing running: no
|
||||
/// entry at all, or one still in the brief `reserve`d-but-not-yet-spawned
|
||||
/// window (nothing to signal yet — the reservation is put back so a
|
||||
/// concurrent `start`/`continue` for the same name still gets refused).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// An invalid name, nothing tracked under `name`, or `name` is still
|
||||
/// starting (reserved, not yet a confirmed spawn).
|
||||
pub fn interrupt(state: &State, name: &str, force: bool) -> anyhow::Result<String> {
|
||||
validate_name(name)?;
|
||||
let mut running = state.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match running.remove(name) {
|
||||
None => anyhow::bail!("no subagent named `{name}` is currently running"),
|
||||
Some(None) => {
|
||||
running.insert(name.to_owned(), None);
|
||||
anyhow::bail!(
|
||||
"subagent `{name}` is still starting — not yet confirmed running, try again \
|
||||
shortly"
|
||||
);
|
||||
}
|
||||
Some(Some(cancel)) => {
|
||||
drop(running);
|
||||
cancel.cancel(force);
|
||||
Ok(format!("interrupt sent to subagent `{name}`"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push `name`'s one-shot completion todo. Best-effort: a connect/write
|
||||
/// failure is logged and swallowed, matching every other in-agent-socket
|
||||
/// producer in this codebase — there's no retry queue to fall back to, and
|
||||
/// the caller has already moved on by the time this fires.
|
||||
async fn push_completion_todo(socket: &std::path::Path, name: &str, summary: &str) {
|
||||
let req = hive_agent_sock::Request::UpsertTodo {
|
||||
subsystem: "subagent".to_owned(),
|
||||
key: Some(name.to_owned()),
|
||||
summary: format!("subagent `{name}` finished: {summary}"),
|
||||
source: None,
|
||||
reopen_if_acked: false,
|
||||
};
|
||||
if let Err(e) = hive_sock_client::notify(socket, &req, hive_sock_client::Retry::None).await {
|
||||
tracing::warn!(name, error = ?e, "subagent: completion todo push failed");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// `Cancel` can only be constructed from a real spawned process (no test
|
||||
// fixture in `hive_claude` for it), so these exercise the reservation
|
||||
// half of `State` directly — the actual TOCTOU-closure logic — rather
|
||||
// than the full start/spawn path.
|
||||
|
||||
#[test]
|
||||
fn reserve_is_exclusive_for_the_same_name() {
|
||||
let state = State::new(PathBuf::from("/dev/null"));
|
||||
assert!(state.reserve("dup"), "first reservation should succeed");
|
||||
assert!(
|
||||
!state.reserve("dup"),
|
||||
"a second reservation for the same name must be refused — this is the exact race \
|
||||
argus found: two calls both passing a check before either commits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserve_does_not_cross_block_different_names() {
|
||||
let state = State::new(PathBuf::from("/dev/null"));
|
||||
assert!(state.reserve("a"));
|
||||
assert!(
|
||||
state.reserve("b"),
|
||||
"unrelated names must not block each other"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_reservation_frees_the_name_for_reuse() {
|
||||
let state = State::new(PathBuf::from("/dev/null"));
|
||||
assert!(state.reserve("n"));
|
||||
state.release_reservation("n");
|
||||
assert!(
|
||||
state.reserve("n"),
|
||||
"releasing a still-`None` reservation must free the name again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupancy_reflects_the_reserved_but_not_running_state() {
|
||||
let state = State::new(PathBuf::from("/dev/null"));
|
||||
assert_eq!(state.occupancy("never-reserved"), None);
|
||||
state.reserve("n");
|
||||
assert_eq!(
|
||||
state.occupancy("n"),
|
||||
Some(false),
|
||||
"reserved-but-not-yet-spawned must read as occupied-but-not-running"
|
||||
);
|
||||
}
|
||||
|
||||
// One test, not two: `subagent_otel_attrs` reads the real process-wide
|
||||
// `OTEL_RESOURCE_ATTRIBUTES` env var, and cargo runs tests in parallel
|
||||
// threads by default — two separate tests each mutating that global
|
||||
// raced each other (and, in this container, lost to the agent's own
|
||||
// real ambient value). Sequencing both assertions in one test removes
|
||||
// the race instead of papering over it with a mutex.
|
||||
#[test]
|
||||
fn otel_attrs_append_ambient_value_or_stand_alone() {
|
||||
// SAFETY: test-only env mutation; sequenced within this one test so
|
||||
// no other test's concurrent read/write of the same var can race it.
|
||||
unsafe {
|
||||
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
|
||||
}
|
||||
assert_eq!(subagent_otel_attrs("batch-1"), "subagent=batch-1");
|
||||
|
||||
unsafe {
|
||||
std::env::set_var("OTEL_RESOURCE_ATTRIBUTES", "agent=damocles");
|
||||
}
|
||||
assert_eq!(
|
||||
subagent_otel_attrs("batch-1"),
|
||||
"agent=damocles,subagent=batch-1"
|
||||
);
|
||||
unsafe {
|
||||
std::env::remove_var("OTEL_RESOURCE_ATTRIBUTES");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_config_only_appends_system_prompt_when_given() {
|
||||
let with = build_config("n", None, Some("/tmp/p.md"));
|
||||
assert!(
|
||||
with.extra_args
|
||||
.contains(&"--append-system-prompt-file".to_owned())
|
||||
);
|
||||
assert!(with.extra_args.contains(&"/tmp/p.md".to_owned()));
|
||||
|
||||
let without = build_config("n", None, None);
|
||||
assert!(
|
||||
!without
|
||||
.extra_args
|
||||
.contains(&"--append-system-prompt-file".to_owned())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +203,8 @@
|
|||
# unreachable from inside a container — wrapped with
|
||||
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
|
||||
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
|
||||
# hive-matrix-daemon, hive-matrix-mcp) are wired via their own
|
||||
# hive-subagent-daemon, hive-matrix-daemon, hive-matrix-mcp) are
|
||||
# wired via their own
|
||||
# ExecStart/command lines in the sibling modules — they don't need
|
||||
# to be on PATH too. Only this one is actually looked up on PATH
|
||||
# by claude/shell code inside the container:
|
||||
|
|
|
|||
|
|
@ -197,6 +197,23 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.mcp.subagentHttpPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8793;
|
||||
example = 8794;
|
||||
description = ''
|
||||
Loopback port `hive-subagent-daemon` serves its MCP tools
|
||||
(`start`/`continue`/`interrupt`) on. Independent daemon (own crate,
|
||||
`hive-subagent-mcp`) — a subagent spawns a full nested `claude`
|
||||
process, a much heavier capability than a bash command, worth its own
|
||||
deployable/restartable unit. Same shape/reasoning as
|
||||
`hyperhive.mcp.bashHttpPort` otherwise: sole transport, self-healing
|
||||
restart, loopback-only so no auth token is needed. Shipped default-on
|
||||
for every agent today, same as `bash` — expected to become a real
|
||||
opt-in capability gate later, not yet.
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
# Assert the transport-specific required field is actually set —
|
||||
# `command`/`url` are both `nullOr` so the submodule schema stays
|
||||
|
|
@ -225,6 +242,17 @@ in
|
|||
allowedTools = [ "*" ];
|
||||
};
|
||||
|
||||
# Auto-inject the subagent MCP server — default-on for every agent for
|
||||
# now (operator's call: "default on for now, should be a capability
|
||||
# later" — not gated behind an enable option yet, unlike `matrix.nix`'s
|
||||
# pattern). `lib.mkDefault` so an agent.nix can still override/disable
|
||||
# the entry in the meantime.
|
||||
hyperhive.extraMcpServers.subagent = lib.mkDefault {
|
||||
type = "http";
|
||||
url = "http://127.0.0.1:${toString config.hyperhive.mcp.subagentHttpPort}/mcp";
|
||||
allowedTools = [ "*" ];
|
||||
};
|
||||
|
||||
environment.etc."hyperhive/extra-mcp.json".text = builtins.toJSON config.hyperhive.extraMcpServers;
|
||||
|
||||
environment.etc."hyperhive/send-allow.json".text =
|
||||
|
|
@ -278,6 +306,49 @@ in
|
|||
};
|
||||
};
|
||||
|
||||
# Subagent task runner daemon — independent of `hive-bash-daemon` (own
|
||||
# crate, own process): spawns nested claude sessions on request, serves
|
||||
# the `start`/`continue`/`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.
|
||||
systemd.services.hive-subagent-daemon = {
|
||||
description = "subagent task runner + MCP daemon for hive-subagent";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-agent.service" ];
|
||||
# A subagent task runs its own nested `claude` invocation (via
|
||||
# `hive-claude`), which needs to resolve `claude` itself off PATH —
|
||||
# same reasoning as `hive-bash-daemon`'s `path` above, even though
|
||||
# this daemon never shells out to `bash -c` directly.
|
||||
path = [
|
||||
"/run/wrappers"
|
||||
"/run/current-system/sw"
|
||||
];
|
||||
environment = {
|
||||
# Same in-agent todo socket as `hive-bash-daemon` — both push task
|
||||
# todos to the one harness socket. Must match the harness's
|
||||
# HIVE_AGENT_SOCKET (agent-service.nix).
|
||||
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
||||
RUST_LOG = "info";
|
||||
# HYPERHIVE_HARNESS_DIR / HYPERHIVE_STATE_DIR: see
|
||||
# `hive-bash-daemon`'s own comment above — same global injection,
|
||||
# same reasoning.
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${config.hyperhive.packages.hive-subagent-daemon}/bin/hive-subagent-daemon --http 127.0.0.1:${toString config.hyperhive.mcp.subagentHttpPort}";
|
||||
SyslogIdentifier = "hive-subagent-daemon";
|
||||
# `always`, same reasoning as `hive-bash-daemon`: the MCP tools are
|
||||
# served in-process, so a down window is total loss of
|
||||
# `start`/`continue`/`status`/`interrupt` with no stdio fallback.
|
||||
Restart = "always";
|
||||
RestartSec = 3;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
};
|
||||
};
|
||||
|
||||
# Persistent streamable-http MCP daemon for the built-in hyperhive
|
||||
# surface — the *sole* transport for that surface; always
|
||||
# wired. Long-lived so claude reconnects to the stable URL each turn
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
description = ''
|
||||
hyperhive package outputs consumed by the harness modules: the
|
||||
per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`,
|
||||
`hive-bash-daemon`,
|
||||
`hive-bash-daemon`, `hive-subagent-daemon`,
|
||||
`hive-forge`, `hive-forge-notify`, `hive-github-notify`,
|
||||
`hive-matrix-daemon`,
|
||||
`hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend`,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue