From c4fcf7fbf1b3b60b78c31be63a54bbbb788b29fc Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 23 Jul 2026 17:34:02 +0200 Subject: [PATCH] feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge --- CLAUDE.md | 8 +- Cargo.lock | 2 + docs/persistence.md | 4 +- docs/tools/bash.md | 28 +- docs/turn-loop/mcp.md | 11 +- docs/web-ui/agent.md | 2 +- docs/web-ui/dashboard.md | 2 +- flake.nix | 1 - frontend/packages/agent/src/stats.html | 2 +- frontend/packages/agent/src/stats.js | 2 +- frontend/packages/dashboard/src/stats.html | 2 +- hive-agent/src/mcp_config.rs | 96 ++++--- hive-agent/src/stats.rs | 4 +- hive-bash-mcp/Cargo.toml | 20 +- hive-bash-mcp/src/lib.rs | 10 +- hive-bash-mcp/src/main.rs | 23 +- hive-bash-mcp/src/{bin => }/mcp.rs | 296 +++++++++------------ hive-bash-mcp/src/paths.rs | 17 +- hive-bash-mcp/src/protocol.rs | 110 +------- hive-bash-mcp/src/runner.rs | 31 +-- hive-bash-mcp/src/socket.rs | 135 ---------- hive-c0re/src/stats/hive_stats.rs | 6 +- nix/agent-modules/default.nix | 8 +- nix/agent-modules/mcp.nix | 125 ++++++--- nix/agent-modules/packages.nix | 2 +- nix/packages/default.nix | 3 +- 26 files changed, 376 insertions(+), 574 deletions(-) rename hive-bash-mcp/src/{bin => }/mcp.rs (52%) delete mode 100644 hive-bash-mcp/src/socket.rs diff --git a/CLAUDE.md b/CLAUDE.md index e2548902..cd464638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/Cargo.lock b/Cargo.lock index eb4d9698..701bd782 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1592,6 +1592,8 @@ name = "hive-bash-mcp" version = "0.1.0" dependencies = [ "anyhow", + "axum", + "clap", "hive-agent-sock", "hive-sh4re", "hive-types", diff --git a/docs/persistence.md b/docs/persistence.md index 10495ef4..05f587c1 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -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//`: 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 diff --git a/docs/tools/bash.md b/docs/tools/bash.md index 1c05740f..d148a17e 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -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__` (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) diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index de57c8cc..13d2447d 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -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 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: ""` to inspect a direct child agent (always accessible per topology enforcement); non-children diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 3a846dd7..5f46df80 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -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 diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index cdb0dd89..efb6c51b 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -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. diff --git a/flake.nix b/flake.nix index d9eb713c..fd8ed0c6 100644 --- a/flake.nix +++ b/flake.nix @@ -88,7 +88,6 @@ hive-agent-mcp hive-agent-wake hive-bash-daemon - hive-bash-mcp hive-forge hive-matrix-daemon hive-matrix-mcp diff --git a/frontend/packages/agent/src/stats.html b/frontend/packages/agent/src/stats.html index 0709130f..85f6bc23 100644 --- a/frontend/packages/agent/src/stats.html +++ b/frontend/packages/agent/src/stats.html @@ -36,7 +36,7 @@

turns by model per bucket — model drives token cost

top tools

wake source mix

diff --git a/frontend/packages/agent/src/stats.js b/frontend/packages/agent/src/stats.js index 4c3521ba..44051f5f 100644 --- a/frontend/packages/agent/src/stats.js +++ b/frontend/packages/agent/src/stats.js @@ -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). diff --git a/frontend/packages/dashboard/src/stats.html b/frontend/packages/dashboard/src/stats.html index 97ac3954..898e99b8 100644 --- a/frontend/packages/dashboard/src/stats.html +++ b/frontend/packages/dashboard/src/stats.html @@ -43,7 +43,7 @@

◇ model mix (turns across the swarm)

diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index dd8cc823..15476d87 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -191,7 +191,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { 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____` 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, - #[serde(default)] - env: std::collections::BTreeMap, - #[serde(default = "default_allowed_tools")] - #[serde(rename = "allowedTools")] - allowed_tools: Vec, +#[serde(tag = "type", rename_all = "snake_case")] +enum ExtraMcpServer { + Stdio { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec, + }, + Http { + url: String, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec, + }, +} + +impl ExtraMcpServer { + fn allowed_tools(&self) -> &[String] { + match self { + Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools, + } + } } fn default_allowed_tools() -> Vec { @@ -287,7 +312,9 @@ fn load_extra_mcp() -> std::collections::BTreeMap { /// 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 { /// 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 { 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 { // 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::().ok()) @@ -329,16 +356,18 @@ fn build_mcp_servers() -> serde_json::Map { "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 { ); 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 } diff --git a/hive-agent/src/stats.rs b/hive-agent/src/stats.rs index 6e7e873d..91563fdb 100644 --- a/hive-agent/src/stats.rs +++ b/hive-agent/src/stats.rs @@ -121,7 +121,7 @@ pub struct Snapshot { pub tool_breakdown: Vec, /// 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 { /// 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 diff --git a/hive-bash-mcp/Cargo.toml b/hive-bash-mcp/Cargo.toml index 77b995b0..984f700b 100644 --- a/hive-bash-mcp/Cargo.toml +++ b/hive-bash-mcp/Cargo.toml @@ -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" diff --git a/hive-bash-mcp/src/lib.rs b/hive-bash-mcp/src/lib.rs index ce6d4cf8..8f8a6ee3 100644 --- a/hive-bash-mcp/src/lib.rs +++ b/hive-bash-mcp/src/lib.rs @@ -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; diff --git a/hive-bash-mcp/src/main.rs b/hive-bash-mcp/src/main.rs index d7c1e166..a29de536 100644 --- a/hive-bash-mcp/src/main.rs +++ b/hive-bash-mcp/src/main.rs @@ -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 ` — 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 } diff --git a/hive-bash-mcp/src/bin/mcp.rs b/hive-bash-mcp/src/mcp.rs similarity index 52% rename from hive-bash-mcp/src/bin/mcp.rs rename to hive-bash-mcp/src/mcp.rs index 0bab814e..2653f39c 100644 --- a/hive-bash-mcp/src/bin/mcp.rs +++ b/hive-bash-mcp/src/mcp.rs @@ -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 { - 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) -> 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, 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) -> 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) -> 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) -> 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) -> 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")); } } diff --git a/hive-bash-mcp/src/paths.rs b/hive-bash-mcp/src/paths.rs index 478861fd..a68f6cb5 100644 --- a/hive-bash-mcp/src/paths.rs +++ b/hive-bash-mcp/src/paths.rs @@ -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 diff --git a/hive-bash-mcp/src/protocol.rs b/hive-bash-mcp/src/protocol.rs index b788a780..c008583a 100644 --- a/hive-bash-mcp/src/protocol.rs +++ b/hive-bash-mcp/src/protocol.rs @@ -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, - /// Inline wait cap: 30s. Pass `None` or `0` to get the - /// task-started-id response immediately. - #[serde(default)] - wait_seconds: Option, - /// 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, - }, - - /// 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, - }, - - /// 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(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(), - } - } -} diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index ef401ac6..edff25a5 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -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> { static RUNNING: OnceLock>> = OnceLock::new(); RUNNING.get_or_init(|| Mutex::new(HashMap::new())) @@ -308,29 +308,6 @@ pub fn submit_task(cmd: String, timeout_secs: Option, name: Option) Ok(id) } -/// Return all tasks currently in `Pending` or `Running` state. -#[must_use] -pub fn active_tasks() -> Vec { - 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( diff --git a/hive-bash-mcp/src/socket.rs b/hive-bash-mcp/src/socket.rs deleted file mode 100644 index cad8f672..00000000 --- a/hive-bash-mcp/src/socket.rs +++ /dev/null @@ -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::(&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) - } - } -} diff --git a/hive-c0re/src/stats/hive_stats.rs b/hive-c0re/src/stats/hive_stats.rs index bbb7eba7..df0bf7c3 100644 --- a/hive-c0re/src/stats/hive_stats.rs +++ b/hive-c0re/src/stats/hive_stats.rs @@ -206,7 +206,7 @@ pub struct HiveStats { pub model_mix: Vec, /// Most-run normalised bash-command heads ("favorite tools") across /// the whole swarm, busiest first, capped to 10. Empty until the - /// hive-bash-mcp capture has recorded `bash_commands` rows on at + /// hive-bash-daemon capture has recorded `bash_commands` rows on at /// least one active agent. pub bash_mix: Vec, } @@ -221,7 +221,7 @@ struct AgentAgg { cost: f64, models: HashMap, /// Normalised bash-command head → invocation count, from the agent's - /// `bash_commands` table (written by hive-bash-mcp). Empty when that + /// `bash_commands` table (written by hive-bash-daemon). Empty when that /// capture hasn't run for this agent (table absent). bash: HashMap, } @@ -286,7 +286,7 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result