diff --git a/CLAUDE.md b/CLAUDE.md index cd464638..e2548902 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,11 +66,9 @@ 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 - (`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-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-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 701bd782..eb4d9698 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1592,8 +1592,6 @@ 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 05f587c1..10495ef4 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-daemon` (not the harness): one +file is written by the `hive-bash-mcp` 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-daemon`, `hive-matrix-mcp`) and `forge_notify` + MCP daemons (`hive-bash-mcp`, `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 d148a17e..1c05740f 100644 --- a/docs/tools/bash.md +++ b/docs/tools/bash.md @@ -1,6 +1,6 @@ # Bash execution tools -Background shell execution via `hive-bash-daemon`. Tools land as +Background shell execution via `hive-bash-mcp`. 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,17 +91,21 @@ this structured path so tasks get task-id tracking and structured output. ## Architecture -`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. +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. ### Completion as a todo (loose-ends v2) diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index 13d2447d..de57c8cc 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -7,12 +7,9 @@ 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`) 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 +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 `mcp__hyperhive__`. Tool access is gated by tool groups (`HIVE_TOOL_GROUPS`). The default @@ -84,7 +81,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-daemon`). Each row + daemons (e.g. running bash tasks from `hive-bash-mcp`). 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 5f46df80..3a846dd7 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-daemon capture: the basename of the *first real command*, +hive-bash-mcp 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 efb6c51b..cdb0dd89 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-daemon capture). The + `bash_commands` table (written by the hive-bash-mcp 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 fd8ed0c6..d9eb713c 100644 --- a/flake.nix +++ b/flake.nix @@ -88,6 +88,7 @@ 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 85f6bc23..0709130f 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 44051f5f..4c3521ba 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-daemon -> bash_commands table) lands separately, so until + // (hive-bash-mcp -> 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 898e99b8..97ac3954 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 15476d87..dd8cc823 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,41 +250,16 @@ 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)] -#[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, - } - } +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, } fn default_allowed_tools() -> Vec { @@ -312,9 +287,7 @@ 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` — each one is either a -/// per-turn stdio bridge or another persistent HTTP entry, per its own -/// `type`. +/// declared via `hyperhive.extraMcpServers` (those stay stdio bridges). #[must_use] pub fn render_claude_config() -> String { let config = serde_json::json!({ "mcpServers": build_mcp_servers() }); @@ -323,7 +296,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 servers). The harness compares +/// any tool-group-permitted extra stdio 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). @@ -334,9 +307,8 @@ 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 -/// 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. +/// stdio servers. 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 @@ -344,9 +316,10 @@ 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. 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. 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. let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT") .ok() .and_then(|p| p.trim().parse::().ok()) @@ -356,18 +329,16 @@ 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 stdio MCP servers can resolve - // the agent's durable state dir without the agent author hard-coding it. + // Auto-inject HYPERHIVE_STATE_DIR so extra 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, spec) in load_extra_mcp() { + for (name, mut spec) in load_extra_mcp() { if name == SERVER_NAME { tracing::warn!( "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", @@ -381,20 +352,17 @@ fn build_mcp_servers() -> serde_json::Map { ); continue; } - 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); + 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, + }), + ); } servers } diff --git a/hive-agent/src/stats.rs b/hive-agent/src/stats.rs index 91563fdb..6e7e873d 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-daemon. Empty + /// bash task into the `bash_commands` table by hive-bash-mcp. 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-daemon. +/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp. /// /// 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 984f700b..77b995b0 100644 --- a/hive-bash-mcp/Cargo.toml +++ b/hive-bash-mcp/Cargo.toml @@ -9,8 +9,6 @@ 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 @@ -24,11 +22,19 @@ 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 serves the MCP tools (`run`/`status`/ -# `kill`) directly over streamable-http — no stdio bridge, no separate -# bin. +# `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. [[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/mcp.rs b/hive-bash-mcp/src/bin/mcp.rs similarity index 52% rename from hive-bash-mcp/src/mcp.rs rename to hive-bash-mcp/src/bin/mcp.rs index 2653f39c..0bab814e 100644 --- a/hive-bash-mcp/src/mcp.rs +++ b/hive-bash-mcp/src/bin/mcp.rs @@ -1,56 +1,86 @@ -//! 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 _; +//! `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. +use anyhow::{Context, Result}; use rmcp::{ - ServerHandler, + ServerHandler, ServiceExt, 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 crate::protocol::{TaskFile, TaskStatus}; -use crate::runner::{self, SUMMARY_BYTES}; +use hive_bash_mcp::paths; +use hive_bash_mcp::protocol::{DaemonRequest, DaemonResponse}; +use hive_bash_mcp::runner::SUMMARY_BYTES; -fn status_str(status: &TaskStatus) -> &'static str { - match status { - TaskStatus::Pending => "pending", - TaskStatus::Running => "running", - TaskStatus::Done => "done", - TaskStatus::TimedOut => "timed_out", - TaskStatus::Interrupted => "interrupted", - TaskStatus::Killed => "killed", - } +/// 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") } -/// 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)); +/// 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}"); - if let Some(code) = task.exit_code { + if let Some(code) = task["exit_code"].as_i64() { let _ = write!(out, ", exit={code}"); } - if let (Some(started), None) = (task.started_at, task.completed_at) { - let now = hive_sh4re::wire_time::now_unix(); + 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(); let _ = write!(out, ", running for {}s", now - started); } - if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) { + if let (Some(completed), Some(started)) = + (task["completed_at"].as_i64(), task["started_at"].as_i64()) + { let _ = write!(out, ", took {}s", completed - started); } - let out_file = crate::paths::task_out(&task.id); - let err_file = crate::paths::task_err(&task.id); + let out_file = paths::task_out(id); + let err_file = paths::task_err(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 { + if let Some(stdout) = task["stdout_tail"].as_str() { let s = stdout.trim(); if !s.is_empty() { let _ = write!(out, "\n\nstdout:\n```\n{s}\n```"); @@ -60,7 +90,7 @@ fn format_task(task: &TaskFile) -> String { let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display()); } - if let Some(stderr) = &task.stderr_tail { + if let Some(stderr) = task["stderr_tail"].as_str() { let s = stderr.trim(); if !s.is_empty() { let _ = write!(out, "\n\nstderr:\n```\n{s}\n```"); @@ -73,6 +103,22 @@ fn format_task(task: &TaskFile) -> 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 @@ -81,6 +127,44 @@ 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 // --------------------------------------------------------------------------- @@ -167,24 +251,21 @@ impl BashMcp { has finished, rejected while one is still running." )] async fn run(&self, Parameters(args): Parameters) -> String { - let id = match runner::submit_task(args.cmd, args.timeout_secs, args.name) { - Ok(id) => id, - Err(e) => return format!("bash_run error: {e:#}"), + let req = DaemonRequest::BashRun { + cmd: args.cmd, + timeout_secs: args.timeout_secs, + wait_seconds: args.wait_seconds, + name: args.name, }; - 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); + 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), } - format!("task started: id={id}") } #[tool( @@ -197,23 +278,13 @@ 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 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) + let req = DaemonRequest::BashStatus { + id: args.id, + wait_seconds: args.wait_seconds, }; - 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), - } + render_bash_status(&id, round_trip(req).await, waited) } #[tool( @@ -227,95 +298,58 @@ impl BashMcp { loose-ends like any completion." )] async fn kill(&self, Parameters(args): Parameters) -> String { - let (killed, was_running) = runner::kill_task(&args.id, args.force); - if !killed { - return format!( - "no running or pending task with id `{}` (already finished or unknown)", - args.id - ); - } - if was_running { - let sig = if args.force { "SIGKILL" } else { "SIGINT" }; - format!( - "task `{}`: {sig} sent to its process group; it transitions to `killed` \ - once the process exits and then surfaces in your loose-ends.", - args.id - ) - } else { - format!( - "task `{}` was pending — cancelled before it started.", - args.id - ) - } + let req = DaemonRequest::BashKill { + id: args.id, + force: args.force, + }; + render_bash_kill(round_trip(req).await) } } #[tool_handler] impl ServerHandler for BashMcp {} -/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`. -/// -/// Sole transport — there is no stdio mode. Long-lived so claude -/// reconnects to the stable URL each turn instead of respawning a stdio -/// child; since the daemon already owns [`crate::runner`] in-process, -/// tool calls need no round-trip to anywhere. -/// -/// Binds loopback only in practice; the default `allowed_hosts` -/// (`localhost`/`127.0.0.1`/`::1`) rejects Host headers from anywhere else. -/// -/// # Errors -/// -/// Returns an error if the listener cannot bind `addr` or the HTTP server -/// exits with a fatal error. -pub async fn serve_http(addr: std::net::SocketAddr) -> anyhow::Result<()> { - use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, - }; - let session_manager = std::sync::Arc::new(LocalSessionManager::default()); - let service = StreamableHttpService::new( - || Ok(BashMcp), - session_manager, - StreamableHttpServerConfig::default(), - ); - let app = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!(%addr, "serving hive-bash MCP over streamable-http at /mcp"); - axum::serve(listener, app).await?; +#[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?; Ok(()) } #[cfg(test)] mod status_hint_tests { - use super::{BASH_IDLE_WAIT_HINT, format_task}; - use crate::protocol::{TaskFile, TaskStatus}; + use super::{BASH_IDLE_WAIT_HINT, render_bash_status}; + use hive_bash_mcp::protocol::DaemonResponse; - 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, + fn status_resp(status: &str) -> DaemonResponse { + DaemonResponse::Ok { + payload: serde_json::json!({ "status": status, "started_at": 1 }), } } #[test] fn running_task_after_wait_appends_idle_hint() { - let t = task(TaskStatus::Running); - let mut out = format_task(&t); - out.push_str(BASH_IDLE_WAIT_HINT); + let out = render_bash_status("t1", Ok(status_resp("running")), true); assert!(out.contains(BASH_IDLE_WAIT_HINT)); } #[test] - fn done_task_formats_without_hint() { - let out = format_task(&task(TaskStatus::Done)); + 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); assert!(!out.contains(BASH_IDLE_WAIT_HINT)); - assert!(out.contains("status=done")); } } diff --git a/hive-bash-mcp/src/lib.rs b/hive-bash-mcp/src/lib.rs index 8f8a6ee3..ce6d4cf8 100644 --- a/hive-bash-mcp/src/lib.rs +++ b/hive-bash-mcp/src/lib.rs @@ -1,11 +1,11 @@ -//! Shared library for the `hive-bash-daemon` binary. +//! Shared library for `hive-bash-daemon` and `hive-bash-mcp`. //! -//! 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. +//! 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. -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 a29de536..d7c1e166 100644 --- a/hive-bash-mcp/src/main.rs +++ b/hive-bash-mcp/src/main.rs @@ -1,21 +1,10 @@ //! `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. 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. +//! in-agent socket. Listens on a unix socket for tool-call requests from +//! the `hive-bash-mcp` stdio bridge. 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<()> { @@ -26,11 +15,11 @@ async fn main() -> Result<()> { ) .init(); - let cli = Cli::parse(); + let socket_path = hive_bash_mcp::paths::daemon_socket(); let todo_socket = hive_bash_mcp::paths::agent_socket(); tracing::info!( - http = %cli.http, + socket = %socket_path.display(), todo = %todo_socket.display(), "hive-bash-daemon starting" ); @@ -39,6 +28,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 MCP tools over streamable-http forever. - hive_bash_mcp::mcp::serve_http(cli.http).await + // Serve the unix socket forever. + hive_bash_mcp::socket::serve(&socket_path).await } diff --git a/hive-bash-mcp/src/paths.rs b/hive-bash-mcp/src/paths.rs index a68f6cb5..478861fd 100644 --- a/hive-bash-mcp/src/paths.rs +++ b/hive-bash-mcp/src/paths.rs @@ -1,10 +1,25 @@ -//! Per-agent filesystem paths used by `hive-bash-daemon`. +//! Per-agent filesystem paths used by both `hive-bash-daemon` and the +//! stdio MCP bridge. //! //! 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 c008583a..b788a780 100644 --- a/hive-bash-mcp/src/protocol.rs +++ b/hive-bash-mcp/src/protocol.rs @@ -1,10 +1,102 @@ -//! 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. +//! 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. 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 edff25a5..ef401ac6 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 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. +/// 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. fn running() -> &'static Mutex> { static RUNNING: OnceLock>> = OnceLock::new(); RUNNING.get_or_init(|| Mutex::new(HashMap::new())) @@ -308,6 +308,29 @@ 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. /// @@ -531,7 +554,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 MCP tool-call handler) can signal this task. + // (called from the socket dispatch path) 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 new file mode 100644 index 00000000..cad8f672 --- /dev/null +++ b/hive-bash-mcp/src/socket.rs @@ -0,0 +1,135 @@ +//! 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 df0bf7c3..bbb7eba7 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-daemon capture has recorded `bash_commands` rows on at + /// hive-bash-mcp 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-daemon). Empty when that + /// `bash_commands` table (written by hive-bash-mcp). 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