Watch
0
0
Fork
You've already forked hyperhive
0

feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge

This commit is contained in:
damocles 2026-07-23 17:34:02 +02:00 • committed by mara
commit c4fcf7fbf1
26 changed files with 376 additions and 574 deletions

View file

@ -66,9 +66,11 @@ hand-maintained per-file tree drifts out of sync with the code.
verb under `src/verbs/`.
- **`hive-matrix-mcp/`** — per-agent matrix-sdk daemon plus the thin
stdio MCP bridge claude spawns per turn.
- **`hive-bash-mcp/`** — per-agent bash-task runner daemon plus its
stdio MCP bridge; writes task files under `/harness/bash-tasks/` and
the favorite-tools `bash_commands` stat into turn-stats.sqlite.
- **`hive-bash-mcp/`** — per-agent bash-task runner daemon
(`hive-bash-daemon`); serves its MCP tools (`run`/`status`/`kill`)
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-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

2
Cargo.lock generated
View file

@ -1592,6 +1592,8 @@ name = "hive-bash-mcp"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"clap",
"hive-agent-sock",
"hive-sh4re",
"hive-types",

View file

@ -121,7 +121,7 @@ Bin-loop helpers `build_row` + `record` land each row at
the turn loop continue.
A sibling `bash_commands(ts INTEGER, head TEXT)` table in the same
file is written by the `hive-bash-mcp` daemon (not the harness): one
file is written by the `hive-bash-daemon` (not the harness): one
row per executed bash task recording the normalised command head -
the basename of the first real command, looking past `cd repo &&`
prefixes, env-assignments, and prefix-runners like `sudo`/`env`. It
@ -234,7 +234,7 @@ Under `/var/lib/hyperhive/agents/<name>/`:
hourly and deletes terminal task trios older than 48 hours;
non-terminal (still-running) tasks are never deleted by vacuum.
- `hyperhive-todos.sqlite` — loose-ends-v2 todo store. In-container
MCP daemons (`hive-bash-mcp`, `hive-matrix-mcp`) and `forge_notify`
MCP daemons (`hive-bash-daemon`, `hive-matrix-mcp`) and `forge_notify`
upsert keyed todos here over the harness's in-agent socket
(`HIVE_AGENT_SOCKET`); the harness merges them into `get_loose_ends`
output and clears a row on `mark_todo_done`. Replaced the old

View file

@ -1,6 +1,6 @@
# Bash execution tools
Background shell execution via `hive-bash-mcp`. Tools land as
Background shell execution via `hive-bash-daemon`. Tools land as
`mcp__bash__<tool>` (the MCP server name is `bash`, not `hyperhive`).
Available on every agent unconditionally — `nix/agent-modules/mcp.nix` always
injects bash into `hyperhive.extraMcpServers` (with `allowedTools =
@ -91,21 +91,17 @@ this structured path so tasks get task-id tracking and structured output.
## Architecture
The bash tooling follows the same daemon + stdio-bridge pattern as the
matrix MCP:
- **`hive-bash-daemon`** — long-running process (one per agent container,
systemd service in `nix/agent-modules/mcp.nix`). Owns subprocess management,
output file writing, and todo delivery on the harness's in-agent socket.
Listens on `/run/hive-bash/socket` inside the container.
- **`hive-bash-mcp`** — stdio bridge spawned by claude per turn (declared
in `hyperhive.extraMcpServers.bash`). Connects to the daemon socket and
forwards `run` / `status` tool calls. Has no subprocess management logic
of its own.
This split keeps claude's turn-local MCP bridge lightweight while the
daemon tracks long-running tasks that outlive a single turn.
`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.
### Completion as a todo (loose-ends v2)

View file

@ -7,9 +7,12 @@ per-container private netns). Claude connects to its stable URL via
`--mcp-config` rather than respawning a stdio child each turn, so the
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
restart) — there is no per-turn MCP re-registration race for the
built-in surface. HTTP is the sole transport for it (no stdio fallback);
extra servers (`hyperhive.extraMcpServers`, e.g. matrix/bash) stay stdio
bridges. The server name is `hyperhive`, so the tools land in claude as
built-in surface. HTTP is the sole transport for it (no stdio fallback).
Extra servers (`hyperhive.extraMcpServers`) pick their own transport per
entry (`type = "stdio" | "http"`, default `"stdio"`): `matrix` stays a
stdio bridge, `bash` runs its own persistent streamable-http listener
(`hive-bash-daemon`) — same reasoning as the built-in surface. The
server name is `hyperhive`, so the tools land in claude as
`mcp__hyperhive__<tool>`.
Tool access is gated by tool groups (`HIVE_TOOL_GROUPS`). The default
@ -81,7 +84,7 @@ at_unix_timestamp?)`, `request_next_turn()`.
- `get_loose_ends(agent?)` — list pending questions (asked/owed),
scheduled reminders, and active local tasks published by external MCP
daemons (e.g. running bash tasks from `hive-bash-mcp`). Each row
daemons (e.g. running bash tasks from `hive-bash-daemon`). Each row
carries an id + kind for `cancel_loose_end`. Omit `agent` to list
your own threads. Pass `agent: "<name>"` to inspect a direct child
agent (always accessible per topology enforcement); non-children

View file

@ -454,7 +454,7 @@ error / rate-limit / compaction outcomes are visible over time
(the doughnut shows only the window total).
A **favorite tools** doughnut shows the most-run shell commands —
normalised `bash_commands` heads written per bash task by the
hive-bash-mcp capture: the basename of the *first real command*,
hive-bash-daemon capture: the basename of the *first real command*,
looking past `cd repo &&` prefixes, env-assignments, and
prefix-runners like `sudo` / `env` (so `cd /repo && cargo build`
records `cargo`, not `cd`). Read via `bash_breakdown`; the card

View file

@ -563,7 +563,7 @@ charts): ST4TS is the swarm-level rollup.
- **Model mix** — turns per model across the swarm, as CSS bars.
- **Favorite tools** — most-run normalised bash-command heads across the
swarm (top 10, as CSS bars), aggregated from each agent's
`bash_commands` table (written by the hive-bash-mcp capture). The
`bash_commands` table (written by the hive-bash-daemon capture). The
header + list stay hidden until at least one agent has recorded a
command, so the section never shows an empty block on a fresh hive.

View file

@ -88,7 +88,6 @@
hive-agent-mcp
hive-agent-wake
hive-bash-daemon
hive-bash-mcp
hive-forge
hive-matrix-daemon
hive-matrix-mcp

View file

@ -36,7 +36,7 @@
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
<!-- "favorite tools": most-run shell commands. Hidden until the
bash_commands capture (hive-bash-mcp) has recorded data, so the
bash_commands capture (hive-bash-daemon) has recorded data, so the
card never shows a permanently-empty doughnut. -->
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>

View file

@ -355,7 +355,7 @@ window.Chart = Chart;
}
// "favorite tools" doughnut: most-run shell commands. The capture
// (hive-bash-mcp -> bash_commands table) lands separately, so until
// (hive-bash-daemon -> bash_commands table) lands separately, so until
// there's data we hide the whole card rather than show an empty
// doughnut. Runs independently of turn_count (a bash task is tied to
// a turn, but we don't want to couple the two reads).

View file

@ -43,7 +43,7 @@
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-mcp capture has
Header + list hidden until the hive-bash-daemon capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
<div id="hive-stats-bash" hidden></div>

View file

@ -191,7 +191,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
continue;
}
for pat in spec.allowed_tools {
for pat in spec.allowed_tools() {
out.push(format!("mcp__{server}__{pat}"));
}
}
@ -250,16 +250,41 @@ pub fn builtin_tools_arg() -> String {
/// `mcp__<key>__<tool>` pattern in `--allowedTools`.
const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json";
/// An extra MCP server declared via `hyperhive.extraMcpServers`. Two
/// transports: `Stdio` (claude spawns `command` fresh each turn, talks
/// JSON-RPC over its stdin/stdout) and `Http` (claude points at a
/// long-lived streamable-http `url` instead — no per-turn spawn, no
/// re-registration race, same shape as the built-in hyperhive surface).
/// Internally tagged on the nix-rendered `type` field; unrecognised
/// fields for the inactive variant (e.g. `command` on an `Http` entry)
/// are ignored by serde's default struct deserialization.
#[derive(Debug, serde::Deserialize)]
struct ExtraMcpServer {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
#[serde(tag = "type", rename_all = "snake_case")]
enum ExtraMcpServer {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::BTreeMap<String, String>,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
},
Http {
url: String,
#[serde(default = "default_allowed_tools")]
#[serde(rename = "allowedTools")]
allowed_tools: Vec<String>,
},
}
impl ExtraMcpServer {
fn allowed_tools(&self) -> &[String] {
match self {
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
}
}
}
fn default_allowed_tools() -> Vec<String> {
@ -287,7 +312,9 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
/// is no per-turn stdio child for it. Merges in any extra MCP servers
/// declared via `hyperhive.extraMcpServers` (those stay stdio bridges).
/// declared via `hyperhive.extraMcpServers` — each one is either a
/// per-turn stdio bridge or another persistent HTTP entry, per its own
/// `type`.
#[must_use]
pub fn render_claude_config() -> String {
let config = serde_json::json!({ "mcpServers": build_mcp_servers() });
@ -296,7 +323,7 @@ pub fn render_claude_config() -> String {
/// The set of MCP server names claude is configured with this turn — the
/// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface +
/// any tool-group-permitted extra stdio servers). The harness compares
/// any tool-group-permitted extra servers). The harness compares
/// this against the per-turn `system`/`init` event's `mcp_servers` to
/// detect a configured server that failed to connect or was dropped
/// (the MCP-health instrumentation).
@ -307,8 +334,9 @@ pub fn configured_server_names() -> Vec<String> {
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
/// built-in hyperhive HTTP surface plus any tool-group-permitted extra
/// stdio servers. Shared by [`render_claude_config`] (serialises it) and
/// [`configured_server_names`] (lists its keys) so the two never drift.
/// servers (stdio or http, per each entry's `type`). Shared by
/// [`render_claude_config`] (serialises it) and [`configured_server_names`]
/// (lists its keys) so the two never drift.
fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
let mut servers = serde_json::Map::new();
// The built-in hyperhive surface is served exclusively over streamable
@ -316,10 +344,9 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
// agent's private network namespace). Point claude at the stable URL
// rather than respawning a fresh stdio child each turn: the URL survives
// the per-turn claude re-spawn, so there is no per-turn re-registration
// race for the hyperhive surface. Extra servers (matrix/bash) stay stdio
// bridges. The port comes from `HYPERHIVE_MCP_HTTP_PORT` (always set by
// the harness); `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix
// default.
// race for the hyperhive surface. The port comes from
// `HYPERHIVE_MCP_HTTP_PORT` (always set by the harness);
// `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix default.
let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT")
.ok()
.and_then(|p| p.trim().parse::<u16>().ok())
@ -329,16 +356,18 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
"url": format!("http://127.0.0.1:{port}/mcp"),
});
servers.insert(SERVER_NAME.to_owned(), hyperhive_entry);
// Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the
// agent's durable state dir without the agent author hard-coding it.
// Auto-inject HYPERHIVE_STATE_DIR so extra stdio MCP servers can resolve
// the agent's durable state dir without the agent author hard-coding it.
// User-supplied env takes precedence — we only fill in the missing key.
// (Http entries have no child-process env to inject into — the daemon
// behind the URL resolves its own state dir independently.)
let state_dir = crate::paths::state_dir();
// Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`).
// This is the security boundary for them: an out-of-process server the
// agent isn't entitled to must not even appear in the MCP config, or the
// agent could call it directly (there is no later enforcement point).
let groups = effective_tool_groups();
for (name, mut spec) in load_extra_mcp() {
for (name, spec) in load_extra_mcp() {
if name == SERVER_NAME {
tracing::warn!(
"extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring",
@ -352,17 +381,20 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
);
continue;
}
spec.env
.entry("HYPERHIVE_STATE_DIR".to_owned())
.or_insert_with(|| state_dir.display().to_string());
servers.insert(
name,
serde_json::json!({
"command": spec.command,
"args": spec.args,
"env": spec.env,
}),
);
let entry = match spec {
ExtraMcpServer::Stdio {
command,
args,
mut env,
..
} => {
env.entry("HYPERHIVE_STATE_DIR".to_owned())
.or_insert_with(|| state_dir.display().to_string());
serde_json::json!({ "command": command, "args": args, "env": env })
}
ExtraMcpServer::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }),
};
servers.insert(name, entry);
}
servers
}

View file

@ -121,7 +121,7 @@ pub struct Snapshot {
pub tool_breakdown: Vec<KeyCount>,
/// Top shell commands ("favorite tools") by invocation count across
/// the window, capped to 10. Normalised command heads recorded per
/// bash task into the `bash_commands` table by hive-bash-mcp. Empty
/// bash task into the `bash_commands` table by hive-bash-daemon. Empty
/// until that capture lands (or on any agent that hasn't run a bash
/// task) — the table is created lazily by the writer, so a read
/// before the first insert returns an empty list, not an error.
@ -420,7 +420,7 @@ fn read_session_count(conn: &Connection, from: i64) -> rusqlite::Result<u64> {
/// Aggregate the top shell-command heads ("favorite tools") over
/// `[from, now]` from the `bash_commands` table — one row per bash task
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-daemon.
///
/// Returns `Err` (which the caller maps to an empty list) when the
/// table doesn't exist yet — the writer creates it lazily on first

View file

@ -9,6 +9,8 @@ workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
hive-agent-sock.workspace = true
hive-sh4re.workspace = true
hive-types.workspace = true
@ -22,19 +24,11 @@ tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# `hive-bash-daemon` — long-running per-agent bash task runner.
# Spawns `sh -c` subprocesses, monitors completion, writes task state
# files under harness/bash-tasks/, and fires hyperhive wake signals on
# completion. Listens on a unix socket for tool-call requests from the
# stdio MCP bridge.
# `hive-bash-daemon` — long-running per-agent bash task runner. Spawns
# `sh -c` subprocesses, monitors completion, writes task state files
# under harness/bash-tasks/, and serves the MCP tools (`run`/`status`/
# `kill`) directly over streamable-http — no stdio bridge, no separate
# bin.
[[bin]]
name = "hive-bash-daemon"
path = "src/main.rs"
# `hive-bash-mcp` — thin stdio MCP bridge spawned by claude per turn.
# Forwards every tool call (bash_run, bash_status) to the daemon over
# the unix socket, returns results to claude. No subprocess management
# at this entrypoint — the daemon owns that.
[[bin]]
name = "hive-bash-mcp"
path = "src/bin/mcp.rs"

View file

@ -1,11 +1,11 @@
//! Shared library for `hive-bash-daemon` and `hive-bash-mcp`.
//! Shared library for the `hive-bash-daemon` binary.
//!
//! The daemon owns the subprocess runner loop and unix socket server.
//! The stdio MCP bridge is a thin client that forwards each tool call
//! to the daemon over the unix socket.
//! The daemon owns the subprocess runner loop and serves its MCP tools
//! (`run`/`status`/`kill`) directly over streamable-http — no stdio
//! bridge, no round-trip socket.
pub mod mcp;
pub mod paths;
pub mod protocol;
pub mod runner;
pub mod socket;
pub mod stats;

View file

@ -1,10 +1,21 @@
//! `hive-bash-daemon` binary — long-running per-agent bash task runner.
//! Spawns `sh -c` subprocesses, monitors completion, writes task state
//! files, and surfaces task state to the agent as todos on the harness's
//! in-agent socket. Listens on a unix socket for tool-call requests from
//! the `hive-bash-mcp` stdio bridge.
//! in-agent socket. Serves its MCP tools (`run`/`status`/`kill`) directly
//! over streamable-http on `--http <addr>` — no stdio bridge, no separate
//! bin claude has to respawn every turn.
use anyhow::Result;
use clap::Parser;
#[derive(Parser)]
#[command(name = "hive-bash-daemon", about = "bash-task runner + MCP daemon")]
struct Cli {
/// Serve the MCP tools over streamable-http on this address (e.g.
/// `127.0.0.1:8791`). Bind loopback only.
#[arg(long)]
http: std::net::SocketAddr,
}
#[tokio::main]
async fn main() -> Result<()> {
@ -15,11 +26,11 @@ async fn main() -> Result<()> {
)
.init();
let socket_path = hive_bash_mcp::paths::daemon_socket();
let cli = Cli::parse();
let todo_socket = hive_bash_mcp::paths::agent_socket();
tracing::info!(
socket = %socket_path.display(),
http = %cli.http,
todo = %todo_socket.display(),
"hive-bash-daemon starting"
);
@ -28,6 +39,6 @@ async fn main() -> Result<()> {
// spawns them, pushing todos to the harness on task transitions.
hive_bash_mcp::runner::spawn_loop(todo_socket);
// Serve the unix socket forever.
hive_bash_mcp::socket::serve(&socket_path).await
// Serve the MCP tools over streamable-http forever.
hive_bash_mcp::mcp::serve_http(cli.http).await
}

321
hive-bash-mcp/src/mcp.rs Normal file
View file

@ -0,0 +1,321 @@
//! MCP tool surface for `hive-bash-daemon`, served directly over
//! streamable-http — no stdio bridge, no round-trip socket. The daemon
//! already owns the subprocess runner in-process, so the tool handlers
//! below call straight into [`crate::runner`]. Mirrors
//! `hive-agent-mcp::mcp::serve_http`'s shape (persistent daemon, stable
//! URL claude reconnects to every turn instead of respawning a stdio
//! child).
use std::fmt::Write as _;
use rmcp::{
ServerHandler,
handler::server::wrapper::Parameters,
schemars::{self, JsonSchema},
tool, tool_handler, tool_router,
};
use serde::Deserialize;
use crate::protocol::{TaskFile, TaskStatus};
use crate::runner::{self, SUMMARY_BYTES};
fn status_str(status: &TaskStatus) -> &'static str {
match status {
TaskStatus::Pending => "pending",
TaskStatus::Running => "running",
TaskStatus::Done => "done",
TaskStatus::TimedOut => "timed_out",
TaskStatus::Interrupted => "interrupted",
TaskStatus::Killed => "killed",
}
}
/// Format a `TaskFile` as the human-readable status string claude sees.
fn format_task(task: &TaskFile) -> String {
let mut out = format!("task `{}`: status={}", task.id, status_str(&task.status));
if let Some(code) = task.exit_code {
let _ = write!(out, ", exit={code}");
}
if let (Some(started), None) = (task.started_at, task.completed_at) {
let now = hive_sh4re::wire_time::now_unix();
let _ = write!(out, ", running for {}s", now - started);
}
if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) {
let _ = write!(out, ", took {}s", completed - started);
}
let out_file = crate::paths::task_out(&task.id);
let err_file = crate::paths::task_err(&task.id);
let out_len = std::fs::metadata(&out_file).map_or(0, |m| m.len());
let err_len = std::fs::metadata(&err_file).map_or(0, |m| m.len());
if let Some(stdout) = &task.stdout_tail {
let s = stdout.trim();
if !s.is_empty() {
let _ = write!(out, "\n\nstdout:\n```\n{s}\n```");
}
}
if out_len > SUMMARY_BYTES as u64 {
let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display());
}
if let Some(stderr) = &task.stderr_tail {
let s = stderr.trim();
if !s.is_empty() {
let _ = write!(out, "\n\nstderr:\n```\n{s}\n```");
}
}
if err_len > SUMMARY_BYTES as u64 {
let _ = write!(out, "\n\nFull stderr lives in `{}`", err_file.display());
}
out
}
/// Appended to a `status` result when the caller parked on a
/// `wait_seconds` poll that expired while the task was still running —
/// nudges the model to spend the idle time on other work instead of
/// immediately re-waiting on the same task.
const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait timed out before it \
finished. If you have other useful work, do that and check back later (the task keeps running, and \
it surfaces in your loose-ends when it completes) rather than immediately re-waiting.";
// ---------------------------------------------------------------------------
// MCP server
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize, JsonSchema)]
struct BashRunArgs {
/// Shell command to run (passed to `bash -c`).
cmd: String,
/// Timeout in seconds. Defaults to `None` (no timeout) — task runs until
/// natural exit. Pass an explicit value to kill the task after N seconds
/// and mark it `timed_out`.
#[serde(default)]
timeout_secs: Option<u64>,
/// Optional inline wait: `run` polls for up to `wait_seconds`
/// (capped at 30) before returning. When the task finishes within the
/// window the full status is returned immediately (nothing to handle
/// later); when the timeout expires the task keeps running and the
/// normal `task started: id=<id>` response is returned. Defaults to 3s.
/// Pass `0` to disable inline waiting and always get the immediate response.
#[serde(default = "default_wait")]
wait_seconds: Option<u64>,
/// Optional task name. When set it becomes the task id, so it appears
/// in `status` lookups and your loose-ends list — handy for recognising
/// a task later instead of an opaque hex id. A name can be reused once
/// its previous task has finished; reusing a name whose task is still
/// running is rejected. Allowed chars: `[a-z0-9-]` (a valid
/// identifier — lowercase, digits, hyphen; max 63). Omit to get the
/// auto-generated id.
#[serde(default)]
name: Option<String>,
}
#[allow(
clippy::unnecessary_wraps,
reason = "serde `#[serde(default = ...)]` requires the default fn's return type to match the field's `Option<u64>`, so the `Some` wrap is mandatory even though the value is constant"
)]
fn default_wait() -> Option<u64> {
Some(3)
}
#[derive(Debug, Deserialize, JsonSchema)]
struct BashStatusArgs {
/// Task ID returned by `run`.
id: String,
/// Optional inline wait: `status` polls for up to `wait_seconds`
/// (capped at 30) before returning. Useful to avoid a separate
/// round-trip when the task is expected to finish soon.
#[serde(default)]
wait_seconds: Option<u64>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct BashKillArgs {
/// Task ID (from `run`) to kill.
id: String,
/// `false` (default): SIGINT to the task's process group — graceful, the
/// process can clean up. `true`: SIGKILL. Either way the whole process
/// group is signalled, so children of the shell (e.g. a `cargo`/`nix`
/// invocation) are stopped too, not just the shell. Fire-and-forget: the
/// call sends the signal and returns without waiting. If a SIGINT'd task
/// doesn't exit, call `kill` again with `force: true` to send SIGKILL.
#[serde(default)]
force: bool,
}
#[derive(Clone)]
struct BashMcp;
#[tool_router]
impl BashMcp {
#[tool(
description = "Run a shell command in the background. Returns a task ID immediately — \
do NOT wait inline. When the command finishes it surfaces as a todo in your \
loose-ends (the exit status plus a `Read(<path>)` pointer to the captured \
`.out`/`.err` files — read only what you need); handle it on a future turn. Use \
`status` to poll within the same turn if needed. `timeout_secs` defaults to \
`None` (no timeout) — task runs until natural exit; pass a value to kill after \
N seconds. Pass `wait_seconds` (capped at 30) to wait inline for fast commands: \
if the task finishes within the window you get the full status immediately \
(nothing to handle later); otherwise it keeps running and you get \
`task started: id=<id>`. Defaults to 3; pass `0` to disable inline waiting. \
Pass `name` to label the task with a memorable id (shown in `status` lookups \
and your loose-ends) instead of an opaque hex id; reusable once the prior task \
has finished, rejected while one is still running."
)]
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let id = match runner::submit_task(args.cmd, args.timeout_secs, args.name) {
Ok(id) => id,
Err(e) => return format!("bash_run error: {e:#}"),
};
let wait = args.wait_seconds.unwrap_or(0);
if wait > 0
&& let Some(task) = runner::wait_for_task(&id, wait).await
&& matches!(
task.status,
TaskStatus::Done
| TaskStatus::TimedOut
| TaskStatus::Interrupted
| TaskStatus::Killed
)
{
return format_task(&task);
}
format!("task started: id={id}")
}
#[tool(
description = "Check the status of a background bash task by its ID (from `run`). \
Returns the current status (pending/running/done/timed_out/interrupted/killed), exit \
code if finished, and a tail of stdout/stderr. Full output lives in \
`harness/bash-tasks/<id>.out` / `.err`. \
Pass `wait_seconds` (capped at 30) to wait inline for the task to finish: when the \
task finishes within the window the full status is returned immediately. Useful to \
avoid a separate round-trip when the task is expected to finish soon."
)]
async fn status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
let waited = args.wait_seconds.is_some_and(|w| w > 0);
let wait = args.wait_seconds.unwrap_or(0);
let task = if wait > 0 {
runner::wait_for_task(&args.id, wait).await
} else {
runner::read_task(&args.id)
};
match task {
Some(t) => {
let mut out = format_task(&t);
if waited && matches!(t.status, TaskStatus::Pending | TaskStatus::Running) {
out.push_str(BASH_IDLE_WAIT_HINT);
}
out
}
None => format!("unknown task id `{}`", args.id),
}
}
#[tool(
description = "Kill a background bash task you started (by its ID from `run`). \
`force: false` (default) sends SIGINT — graceful, lets the process clean up; \
`force: true` sends SIGKILL. Either way the task's whole process group is \
signalled, so a runaway child (cargo/nix/etc.) is stopped too, not just the shell. \
Fire-and-forget: sends the signal and returns without waiting. If a SIGINT'd task \
doesn't exit, call kill again with `force: true` to SIGKILL. A still-pending task \
is cancelled before it starts. The task ends as `killed` and surfaces in your \
loose-ends like any completion."
)]
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
let (killed, was_running) = runner::kill_task(&args.id, args.force);
if !killed {
return format!(
"no running or pending task with id `{}` (already finished or unknown)",
args.id
);
}
if was_running {
let sig = if args.force { "SIGKILL" } else { "SIGINT" };
format!(
"task `{}`: {sig} sent to its process group; it transitions to `killed` \
once the process exits and then surfaces in your loose-ends.",
args.id
)
} else {
format!(
"task `{}` was pending — cancelled before it started.",
args.id
)
}
}
}
#[tool_handler]
impl ServerHandler for BashMcp {}
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
///
/// Sole transport — there is no stdio mode. Long-lived so claude
/// reconnects to the stable URL each turn instead of respawning a stdio
/// child; since the daemon already owns [`crate::runner`] in-process,
/// tool calls need no round-trip to anywhere.
///
/// Binds loopback only in practice; the default `allowed_hosts`
/// (`localhost`/`127.0.0.1`/`::1`) rejects Host headers from anywhere else.
///
/// # 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) -> anyhow::Result<()> {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
let session_manager = std::sync::Arc::new(LocalSessionManager::default());
let service = StreamableHttpService::new(
|| Ok(BashMcp),
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-bash MCP over streamable-http at /mcp");
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod status_hint_tests {
use super::{BASH_IDLE_WAIT_HINT, format_task};
use crate::protocol::{TaskFile, TaskStatus};
fn task(status: TaskStatus) -> TaskFile {
TaskFile {
id: "t1".to_owned(),
cmd: "echo hi".to_owned(),
timeout_secs: None,
status,
created_at: 1,
started_at: Some(1),
completed_at: None,
exit_code: None,
stdout_tail: None,
stderr_tail: None,
}
}
#[test]
fn running_task_after_wait_appends_idle_hint() {
let t = task(TaskStatus::Running);
let mut out = format_task(&t);
out.push_str(BASH_IDLE_WAIT_HINT);
assert!(out.contains(BASH_IDLE_WAIT_HINT));
}
#[test]
fn done_task_formats_without_hint() {
let out = format_task(&task(TaskStatus::Done));
assert!(!out.contains(BASH_IDLE_WAIT_HINT));
assert!(out.contains("status=done"));
}
}

View file

@ -1,25 +1,10 @@
//! Per-agent filesystem paths used by both `hive-bash-daemon` and the
//! stdio MCP bridge.
//! Per-agent filesystem paths used by `hive-bash-daemon`.
//!
//! All paths are overridable via env vars so the operator can redirect
//! them in agent.nix when needed.
use std::path::PathBuf;
/// Default unix socket path the daemon listens on inside the agent
/// container. Lives under systemd's `RuntimeDirectory=hive-bash`
/// (a tmpfs path that disappears on container restart — the daemon
/// recreates the socket on its own boot) so the agent unix user can
/// bind without root in `/run`.
pub const DEFAULT_DAEMON_SOCKET: &str = "/run/hive-bash/socket";
/// Resolve the daemon's unix socket path. Override via `HIVE_BASH_SOCKET`.
#[must_use]
pub fn daemon_socket() -> PathBuf {
std::env::var_os("HIVE_BASH_SOCKET")
.map_or_else(|| PathBuf::from(DEFAULT_DAEMON_SOCKET), PathBuf::from)
}
/// Base harness directory. Shared resolution lives in
/// `hive_sh4re::paths::harness_dir` so the harness + every MCP daemon
/// agree on the layout. Re-exported here as the base for the per-agent

View file

@ -1,102 +1,10 @@
//! Wire types for the unix socket protocol between `hive-bash-daemon`
//! and `hive-bash-mcp`. One JSON request line in, one JSON response line
//! out per connection. Connections are short-lived (per tool call).
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Task state (shared between runner and protocol)
// ---------------------------------------------------------------------------
// `TaskFile` + `TaskStatus` are the bash-task on-disk schema. They live in
// `hive-sh4re` (the shared wire-types crate) so the agent web UI in
// `hive-agent` can deserialize the same canonical type when reading the
// tasks dir for its running-tasks panel — no parallel copy to drift. Both
// are re-exported here so existing `crate::protocol::{TaskFile, TaskStatus}`
// imports across this crate keep compiling unchanged.
//! Bash-task on-disk schema, shared by the runner and the MCP tool
//! layer.
//!
//! `TaskFile` + `TaskStatus` live in `hive-sh4re` (the shared wire-types
//! crate) so the agent web UI in `hive-agent` can deserialize the same
//! canonical type when reading the tasks dir for its running-tasks
//! panel — no parallel copy to drift. Re-exported here so existing
//! `crate::protocol::{TaskFile, TaskStatus}` imports across this crate
//! keep compiling unchanged.
pub use hive_sh4re::{TaskFile, TaskStatus};
// ---------------------------------------------------------------------------
// Request / response
// ---------------------------------------------------------------------------
/// Requests the MCP bridge sends to the daemon.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DaemonRequest {
/// Liveness probe — fast round-trip that doesn't touch any subprocess.
Ping,
/// Submit a new bash task. Returns the task ID on success.
/// `wait_seconds`: optional inline poll (capped at 30s); when the
/// task finishes within the window the response carries the full
/// status payload. When it doesn't, the response carries just the
/// task ID so the caller can check back with `BashStatus`.
BashRun {
cmd: String,
#[serde(default)]
timeout_secs: Option<u64>,
/// Inline wait cap: 30s. Pass `None` or `0` to get the
/// task-started-id response immediately.
#[serde(default)]
wait_seconds: Option<u64>,
/// Optional caller-chosen task name, used as the task id (so it
/// flows into the wake `from`, status lookups, and the loose-ends
/// summary). Must be filesystem-safe. Reusable once any prior task
/// of the same name has finished; rejected while one is still
/// running. `None` falls back to the auto-generated id.
#[serde(default)]
name: Option<String>,
},
/// Query the current status of a task. Returns the full `TaskFile`
/// (formatted as text by the bridge). `wait_seconds`: optional
/// inline poll (capped at 30s) — daemon returns as soon as the
/// task reaches a terminal state or the window expires.
BashStatus {
id: String,
#[serde(default)]
wait_seconds: Option<u64>,
},
/// Return all tasks currently in `Pending` or `Running` state.
/// Used by the harness `get_loose_ends` to surface active
/// background work.
ActiveTasks,
/// Kill a running (or still-pending) task. `force = false` sends
/// `SIGINT` to the task's process group (graceful — the process can
/// clean up); `force = true` sends `SIGKILL` (immediate). Signalling
/// the whole process group reaps `sh -c` plus any children it spawned,
/// so a runaway grandchild (e.g. `cargo`/`nix`) is actually stopped.
BashKill {
id: String,
#[serde(default)]
force: bool,
},
}
/// Response shape from the daemon. `Ok` carries a JSON payload; `Error`
/// carries a human-readable message.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DaemonResponse {
Ok { payload: serde_json::Value },
Error { message: String },
}
impl DaemonResponse {
/// Build an Ok response from any serialisable value.
pub fn ok<T: Serialize>(payload: &T) -> Self {
let payload = serde_json::to_value(payload)
.unwrap_or_else(|e| serde_json::json!({ "serialise_error": e.to_string() }));
Self::Ok { payload }
}
/// Build an Error response from any `Display` value.
pub fn error(msg: impl std::fmt::Display) -> Self {
Self::Error {
message: msg.to_string(),
}
}
}

View file

@ -63,9 +63,9 @@ struct RunningHandle {
}
/// Global registry of running tasks (id → handle). Populated by `run_task`
/// for the duration of execution and read by [`kill_task`] from the socket
/// dispatch path — a separate async context from the runner loop, so a
/// shared global (rather than the loop-local `claimed` set) is needed.
/// for the duration of execution and read by [`kill_task`] from the MCP
/// tool-call handler — a separate async context from the runner loop, so
/// a shared global (rather than the loop-local `claimed` set) is needed.
fn running() -> &'static Mutex<HashMap<String, RunningHandle>> {
static RUNNING: OnceLock<Mutex<HashMap<String, RunningHandle>>> = OnceLock::new();
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
@ -308,29 +308,6 @@ pub fn submit_task(cmd: String, timeout_secs: Option<u64>, name: Option<String>)
Ok(id)
}
/// Return all tasks currently in `Pending` or `Running` state.
#[must_use]
pub fn active_tasks() -> Vec<TaskFile> {
let Ok(rd) = std::fs::read_dir(paths::tasks_dir()) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
continue;
};
let Some(task) = read_task(&id) else { continue };
if matches!(task.status, TaskStatus::Pending | TaskStatus::Running) {
out.push(task);
}
}
out
}
/// Inline wait: poll `read_task(id)` until terminal state or deadline.
/// Returns the final task on success, or `None` if it never completed.
///
@ -554,7 +531,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
let err_path = paths::task_err(&id);
// Register a kill handle for the duration of execution so `kill_task`
// (called from the socket dispatch path) can signal this task.
// (called from the MCP tool-call handler) can signal this task.
let cancel = Arc::new(Notify::new());
let force = Arc::new(AtomicBool::new(false));
running().lock().unwrap().insert(

Some files were not shown because too many files have changed in this diff Show more