feat(#1106): split bash mcp into hive-bash-daemon + hive-bash-mcp bridge

- new hive-bash-mcp crate: daemon (subprocess runner, wake signals) +
  stdio bridge (mcp tools). mirrors hive-matrix-mcp architecture
- hive-ag3nt: remove bash_runner.rs and bash_run/bash_status mcp tools;
  get_loose_ends uses hive_bash_mcp:🏃:active_tasks() via crate dep
- harness-base.nix: add hive-bash-daemon systemd service + auto-inject
  bash extraMcpServer into every agent (socket: /run/hive-bash/socket)
This commit is contained in:
damocles 2026-06-03 17:03:16 +02:00 committed by mara
commit e86160820a
15 changed files with 811 additions and 373 deletions

View file

@ -494,96 +494,6 @@ pub struct RecvArgs {
pub max: Option<u32>,
}
/// MCP tool args for `bash_run`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BashRunArgs {
/// Shell command to run (passed to `sh -c`).
pub cmd: String,
/// Timeout in seconds. Defaults to 180. Task is killed and marked
/// `timed_out` when the limit is exceeded.
#[serde(default)]
pub timeout_secs: Option<u64>,
/// Optional inline wait: `bash_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 and no wake is fired;
/// 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 and always get the immediate response.
#[serde(default = "default_bash_run_wait")]
pub wait_seconds: Option<u64>,
}
fn default_bash_run_wait() -> Option<u64> {
Some(3)
}
/// MCP tool args for `bash_status`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BashStatusArgs {
/// Task ID returned by `bash_run`.
pub id: String,
/// Optional inline wait: if set, `bash_status` polls for up to
/// `wait_seconds` (capped at 30) before returning. When the task
/// finishes within the window the full status is returned immediately.
/// Useful to avoid a separate round-trip when a task is expected to
/// finish soon.
pub wait_seconds: Option<u64>,
}
/// Format the result of `bash_status` from a task ID.
///
/// Includes inline output tails (up to [`crate::bash_runner::SUMMARY_BYTES`])
/// and, when the full output file is larger than the inline tail, appends
/// the file path so the caller can read the rest with the `Read` tool.
#[must_use]
fn format_bash_status(id: &str) -> String {
use std::fmt::Write as _;
let Some(task) = crate::bash_runner::read_task(id) else {
return format!("bash_status: unknown task id `{id}`");
};
let mut out = format!("task `{id}`: status={status:?}", status = task.status);
if let Some(code) = task.exit_code {
let _ = write!(out, ", exit={code}");
}
if let Some(t) = task.started_at
&& task.completed_at.is_none()
{
let age = crate::serve_common::now_unix() - t;
let _ = write!(out, ", running for {age}s");
}
if let Some(t) = task.completed_at {
if let Some(s) = task.started_at {
let _ = write!(out, ", took {}s", t - s);
}
}
// Inline tail for stdout.
let out_path = crate::bash_runner::task_out_path(id);
let out_file_len = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0);
if let Some(ref stdout) = task.stdout_tail {
if !stdout.trim().is_empty() {
let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim());
}
}
if out_file_len > crate::bash_runner::SUMMARY_BYTES as u64 {
let _ = write!(out, "\n\nFull stdout lives in `{}`", out_path.display());
}
// Inline tail for stderr.
let err_path = crate::bash_runner::task_err_path(id);
let err_file_len = std::fs::metadata(&err_path).map(|m| m.len()).unwrap_or(0);
if let Some(ref stderr) = task.stderr_tail {
if !stderr.trim().is_empty() {
let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim());
}
}
if err_file_len > crate::bash_runner::SUMMARY_BYTES as u64 {
let _ = write!(out, "\n\nFull stderr lives in `{}`", err_path.display());
}
out
}
/// MCP tool args for `remind`. Exactly one of `delay_seconds` or
/// `at_unix_timestamp` must be set; both / neither is a tool-side error.
/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the
@ -829,7 +739,7 @@ impl AgentServer {
let mut out = annotate_retries(render_loose_ends(&loose_ends), retries);
// Append any local bash tasks still in pending/running state so
// the agent sees all outstanding work in one call.
let active = crate::bash_runner::active_tasks();
let active = hive_bash_mcp::runner::active_tasks();
if !active.is_empty() {
use std::fmt::Write as _;
let _ = write!(out, "\n\n{} active bash task(s):", active.len());
@ -964,94 +874,6 @@ impl AgentServer {
.await
}
#[tool(
description = "Run a shell command in the background. Returns a task ID immediately — \
do NOT wait inline. When the command finishes, the harness fires a wake with \
`from: \"bash-task-<id>\"` and the exit code + last stdout lines in the body; \
handle it on a future turn. Use `bash_status` to poll the task status within \
the same turn if needed. `timeout_secs` defaults to 180. Pass `wait_seconds` \
(capped at 30) to wait inline for fast commands: when the task finishes within \
the window the full status is returned immediately and no wake is fired; when \
the timeout expires the task keeps running and the normal `task started: id=<id>` \
response is returned. `wait_seconds` defaults to 3; pass `wait_seconds: 0` to \
disable inline waiting and always get the immediate response."
)]
async fn bash_run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("bash_run", log, async move {
let id = match crate::bash_runner::submit_task(args.cmd, args.timeout_secs) {
Ok(id) => id,
Err(e) => return format!("bash_run failed: {e:#}"),
};
// Inline wait: poll until done or deadline, whichever comes first.
if let Some(wait) = args.wait_seconds {
const MAX_WAIT_SECS: u64 = 30;
const POLL_MS: u64 = 100;
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(wait.min(MAX_WAIT_SECS));
loop {
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
if let Some(task) = crate::bash_runner::read_task(&id) {
use crate::bash_runner::TaskStatus;
if matches!(
task.status,
TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted
) {
return format_bash_status(&id);
}
}
if tokio::time::Instant::now() >= deadline {
break;
}
}
}
format!("task started: id={id}")
})
.await
}
#[tool(
description = "Check the status of a background bash task by its ID (from `bash_run`). \
Returns the current status (pending/running/done/timed_out/interrupted), 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 bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("bash_status", log, async move {
// Inline wait: if the task isn't terminal yet, poll until done or deadline.
if let Some(wait) = args.wait_seconds {
const MAX_WAIT_SECS: u64 = 30;
const POLL_MS: u64 = 100;
let deadline = tokio::time::Instant::now()
+ std::time::Duration::from_secs(wait.min(MAX_WAIT_SECS));
loop {
match crate::bash_runner::read_task(&args.id) {
None => break, // unknown ID — no point waiting
Some(task) => {
use crate::bash_runner::TaskStatus;
if matches!(
task.status,
TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted
) {
break;
}
}
}
if tokio::time::Instant::now() >= deadline {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await;
}
}
format_bash_status(&args.id)
})
.await
}
#[tool(
description = "Ask the harness to start another turn immediately after this one \
completes, even if the inbox is empty. Use this when you have ongoing work that \
@ -2110,8 +1932,8 @@ pub const SERVER_NAME: &str = "hyperhive";
/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are
/// tool-group-gated (`web_tools`) — off by default. Nested agents
/// (`Task`) are intentionally omitted. `Bash` is disallowed — shell
/// execution goes through `mcp__hyperhive__bash_run` (background tasks
/// with structured output) instead of a raw interactive shell. `TodoWrite`
/// execution goes through `mcp__hive_bash__bash_run` (background tasks
/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite`
/// is omitted because the todo list lives in claude's in-process session
/// state and silently evaporates on /compact or session reset — agents
/// should plan in /state notes instead.