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

@ -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
}

View file

@ -1,86 +1,56 @@
//! `hive-bash-mcp` binary — stdio MCP server claude spawns per turn.
//! Thin protocol bridge: every tool call → connect to the daemon's
//! unix socket → write a JSON request line → read the JSON response →
//! return the result to claude.
//!
//! No subprocess management at this entrypoint — the daemon owns that.
//! Cold-starts in milliseconds.
//! 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 anyhow::{Context, Result};
use rmcp::{
ServerHandler, ServiceExt,
ServerHandler,
handler::server::wrapper::Parameters,
schemars::{self, JsonSchema},
tool, tool_handler, tool_router,
transport::stdio,
};
use serde::Deserialize;
use std::fmt::Write as _;
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
use tokio::net::UnixStream;
use hive_bash_mcp::paths;
use hive_bash_mcp::protocol::{DaemonRequest, DaemonResponse};
use hive_bash_mcp::runner::SUMMARY_BYTES;
use crate::protocol::{TaskFile, TaskStatus};
use crate::runner::{self, SUMMARY_BYTES};
/// Send `req` to the daemon and read back the response. Each call is a
/// fresh unix-socket connection — short-lived (single round-trip) so
/// connection pooling is unnecessary.
async fn round_trip(req: DaemonRequest) -> Result<DaemonResponse> {
let socket = paths::daemon_socket();
let stream = UnixStream::connect(&socket)
.await
.with_context(|| format!("connect bash daemon socket {}", socket.display()))?;
let (reader, mut writer) = stream.into_split();
let mut line = serde_json::to_string(&req)?;
line.push('\n');
writer
.write_all(line.as_bytes())
.await
.context("write request to bash daemon socket")?;
writer.shutdown().await.ok();
let mut buf = String::new();
BufReader::new(reader)
.read_line(&mut buf)
.await
.context("read response from bash daemon socket")?;
serde_json::from_str(&buf).context("parse bash daemon response")
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` JSON value as a human-readable status string.
/// Mirrors `format_bash_status` in the old hive-agent, adapted to work
/// from the daemon's JSON payload.
fn format_task(id: &str, task: &serde_json::Value) -> String {
let status = task["status"].as_str().unwrap_or("unknown");
let mut out = format!("task `{id}`: status={status}");
/// 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"].as_i64() {
if let Some(code) = task.exit_code {
let _ = write!(out, ", exit={code}");
}
if let (Some(started), None) = (
task["started_at"].as_i64(),
task["completed_at"].as_i64().map(|_| ()),
) {
// Running — show age.
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.cast_signed();
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"].as_i64(), task["started_at"].as_i64())
{
if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) {
let _ = write!(out, ", took {}s", completed - started);
}
let out_file = paths::task_out(id);
let err_file = paths::task_err(id);
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"].as_str() {
if let Some(stdout) = &task.stdout_tail {
let s = stdout.trim();
if !s.is_empty() {
let _ = write!(out, "\n\nstdout:\n```\n{s}\n```");
@ -90,7 +60,7 @@ fn format_task(id: &str, task: &serde_json::Value) -> String {
let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display());
}
if let Some(stderr) = task["stderr_tail"].as_str() {
if let Some(stderr) = &task.stderr_tail {
let s = stderr.trim();
if !s.is_empty() {
let _ = write!(out, "\n\nstderr:\n```\n{s}\n```");
@ -103,22 +73,6 @@ fn format_task(id: &str, task: &serde_json::Value) -> String {
out
}
/// Turn a `DaemonResponse` from a `BashRun` call into the string
/// claude sees as the tool result.
fn render_bash_run(id: &str, resp: Result<DaemonResponse>) -> String {
match resp {
Ok(DaemonResponse::Ok { payload }) => {
let finished = payload["finished"].as_bool().unwrap_or(false);
if finished && let Some(task) = payload.get("task") {
return format_task(id, task);
}
format!("task started: id={id}")
}
Ok(DaemonResponse::Error { message }) => format!("bash_run error: {message}"),
Err(e) => format!("bash bridge error: {e:#}"),
}
}
/// 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
@ -127,44 +81,6 @@ const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait t
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.";
/// Turn a `DaemonResponse` from a `BashStatus` call into the string
/// claude sees as the tool result. When `waited` is set (the call
/// parked on a `wait_seconds` poll) and the task is still non-terminal,
/// [`BASH_IDLE_WAIT_HINT`] is appended.
fn render_bash_status(id: &str, resp: Result<DaemonResponse>, waited: bool) -> String {
match resp {
Ok(DaemonResponse::Ok { payload }) => {
let mut out = format_task(id, &payload);
if waited && matches!(payload["status"].as_str(), Some("pending" | "running")) {
out.push_str(BASH_IDLE_WAIT_HINT);
}
out
}
Ok(DaemonResponse::Error { message }) => message,
Err(e) => format!("bash bridge error: {e:#}"),
}
}
/// Turn a `DaemonResponse` from a `BashKill` call into the string claude sees.
fn render_bash_kill(resp: Result<DaemonResponse>) -> String {
match resp {
Ok(DaemonResponse::Ok { payload }) => {
let id = payload["id"].as_str().unwrap_or("unknown");
if payload["was_running"].as_bool().unwrap_or(false) {
let sig = payload["signal"].as_str().unwrap_or("SIGINT");
format!(
"task `{id}`: {sig} sent to its process group; it transitions to `killed` \
once the process exits and then surfaces in your loose-ends."
)
} else {
format!("task `{id}` was pending — cancelled before it started.")
}
}
Ok(DaemonResponse::Error { message }) => format!("kill error: {message}"),
Err(e) => format!("bash bridge error: {e:#}"),
}
}
// ---------------------------------------------------------------------------
// MCP server
// ---------------------------------------------------------------------------
@ -251,21 +167,24 @@ impl BashMcp {
has finished, rejected while one is still running."
)]
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
let req = DaemonRequest::BashRun {
cmd: args.cmd,
timeout_secs: args.timeout_secs,
wait_seconds: args.wait_seconds,
name: args.name,
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 resp = round_trip(req).await;
// Extract the id from the response to format the result.
match &resp {
Ok(DaemonResponse::Ok { payload }) => {
let id = payload["id"].as_str().unwrap_or("unknown").to_owned();
render_bash_run(&id, resp)
}
_ => render_bash_run("unknown", resp),
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(
@ -278,13 +197,23 @@ impl BashMcp {
avoid a separate round-trip when the task is expected to finish soon."
)]
async fn status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
let id = args.id.clone();
let waited = args.wait_seconds.is_some_and(|w| w > 0);
let req = DaemonRequest::BashStatus {
id: args.id,
wait_seconds: args.wait_seconds,
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)
};
render_bash_status(&id, round_trip(req).await, waited)
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(
@ -298,58 +227,95 @@ impl BashMcp {
loose-ends like any completion."
)]
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
let req = DaemonRequest::BashKill {
id: args.id,
force: args.force,
};
render_bash_kill(round_trip(req).await)
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 {}
#[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("warn")),
)
.with_writer(std::io::stderr)
.init();
let service = BashMcp.serve(stdio()).await?;
service.waiting().await?;
/// 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, render_bash_status};
use hive_bash_mcp::protocol::DaemonResponse;
use super::{BASH_IDLE_WAIT_HINT, format_task};
use crate::protocol::{TaskFile, TaskStatus};
fn status_resp(status: &str) -> DaemonResponse {
DaemonResponse::Ok {
payload: serde_json::json!({ "status": status, "started_at": 1 }),
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 out = render_bash_status("t1", Ok(status_resp("running")), true);
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 running_task_without_wait_has_no_hint() {
let out = render_bash_status("t1", Ok(status_resp("running")), false);
assert!(!out.contains(BASH_IDLE_WAIT_HINT));
}
#[test]
fn finished_task_after_wait_has_no_hint() {
let out = render_bash_status("t1", Ok(status_resp("done")), true);
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(

View file

@ -1,135 +0,0 @@
//! Unix socket server: the daemon listens here, the stdio MCP bridge
//! `connect()`s on every tool call. One JSON request line in, one
//! JSON response line out. Connections are short-lived (per tool call)
//! so the loop is just accept → dispatch → reply → close.
use std::path::Path;
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
use tokio::net::{UnixListener, UnixStream};
use crate::protocol::{DaemonRequest, DaemonResponse, TaskStatus};
use crate::runner;
/// Start listening on `socket_path` and serve forever. Removes any
/// stale socket file first so a daemon restart doesn't hit EADDRINUSE.
pub async fn serve(socket_path: &Path) -> Result<()> {
let _ = tokio::fs::remove_file(socket_path).await;
if let Some(parent) = socket_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("mkdir {}", parent.display()))?;
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("bind unix socket {}", socket_path.display()))?;
tracing::info!(path = %socket_path.display(), "bash daemon socket up");
loop {
let (stream, _) = listener
.accept()
.await
.context("accept on bash daemon socket")?;
tokio::spawn(async move {
if let Err(e) = handle_connection(stream).await {
tracing::warn!(error = %e, "bash socket connection error");
}
});
}
}
async fn handle_connection(stream: UnixStream) -> Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
while let Some(line) = lines.next_line().await? {
let response = match serde_json::from_str::<DaemonRequest>(&line) {
Ok(req) => dispatch(req).await,
Err(e) => DaemonResponse::error(format!("parse request: {e}")),
};
let mut json = serde_json::to_string(&response)?;
json.push('\n');
writer.write_all(json.as_bytes()).await?;
writer.flush().await?;
}
Ok(())
}
async fn dispatch(req: DaemonRequest) -> DaemonResponse {
match req {
DaemonRequest::Ping => DaemonResponse::ok(&serde_json::json!({"ok": true})),
DaemonRequest::BashRun {
cmd,
timeout_secs,
wait_seconds,
name,
} => {
let id = match runner::submit_task(cmd, timeout_secs, name) {
Ok(id) => id,
Err(e) => return DaemonResponse::error(format!("submit_task: {e:#}")),
};
// Inline wait: if requested and the task finishes quickly,
// return the full status instead of just the task ID.
let wait = 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 DaemonResponse::ok(&serde_json::json!({
"id": id,
"finished": true,
"task": task,
}));
}
DaemonResponse::ok(&serde_json::json!({ "id": id, "finished": false }))
}
DaemonRequest::BashStatus { id, wait_seconds } => {
let wait = wait_seconds.unwrap_or(0);
let task = if wait > 0 {
runner::wait_for_task(&id, wait).await
} else {
runner::read_task(&id)
};
match task {
Some(t) => DaemonResponse::ok(&t),
None => DaemonResponse::error(format!("unknown task id `{id}`")),
}
}
DaemonRequest::ActiveTasks => {
let tasks = runner::active_tasks();
DaemonResponse::ok(&tasks)
}
DaemonRequest::BashKill { id, force } => {
let (killed, was_running) = runner::kill_task(&id, force);
if !killed {
return DaemonResponse::error(format!(
"no running or pending task with id `{id}` (already finished or unknown)"
));
}
let payload = if was_running {
serde_json::json!({
"id": id,
"killed": true,
"was_running": true,
"signal": if force { "SIGKILL" } else { "SIGINT" },
})
} else {
serde_json::json!({
"id": id,
"killed": true,
"was_running": false,
"note": "task was pending — cancelled before it started",
})
};
DaemonResponse::ok(&payload)
}
}
}