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:
parent
9aa624d310
commit
e86160820a
15 changed files with 811 additions and 373 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue