feat(#2659): serve hive-bash-mcp over persistent streamable-http, drop stdio bridge
This commit is contained in:
parent
ecc2ebe682
commit
c4fcf7fbf1
26 changed files with 376 additions and 574 deletions
|
|
@ -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
|
||||
|
|
|
|||
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1592,6 +1592,8 @@ name = "hive-bash-mcp"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"hive-agent-sock",
|
||||
"hive-sh4re",
|
||||
"hive-types",
|
||||
|
|
|
|||
|
|
@ -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/<name>/`:
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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__<tool>` (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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`.
|
||||
|
||||
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: "<name>"` to inspect a direct child
|
||||
agent (always accessible per topology enforcement); non-children
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@
|
|||
hive-agent-mcp
|
||||
hive-agent-wake
|
||||
hive-bash-daemon
|
||||
hive-bash-mcp
|
||||
hive-forge
|
||||
hive-matrix-daemon
|
||||
hive-matrix-mcp
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
|
||||
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
|
||||
<!-- "favorite tools": most-run shell commands. Hidden until the
|
||||
bash_commands capture (hive-bash-mcp) has recorded data, so the
|
||||
bash_commands capture (hive-bash-daemon) has recorded data, so the
|
||||
card never shows a permanently-empty doughnut. -->
|
||||
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
|
||||
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@
|
|||
<h3>◇ model mix (turns across the swarm)</h3>
|
||||
<div id="hive-stats-models"></div>
|
||||
<!-- "favorite tools": most-run bash commands across the swarm.
|
||||
Header + list hidden until the hive-bash-mcp capture has
|
||||
Header + list hidden until the hive-bash-daemon capture has
|
||||
recorded data, so the section never shows an empty block. -->
|
||||
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
|
||||
<div id="hive-stats-bash" hidden></div>
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> {
|
|||
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__<key>__<tool>` 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<String>,
|
||||
#[serde(default)]
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
#[serde(default = "default_allowed_tools")]
|
||||
#[serde(rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum ExtraMcpServer {
|
||||
Stdio {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
#[serde(default)]
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
#[serde(default = "default_allowed_tools")]
|
||||
#[serde(rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
},
|
||||
Http {
|
||||
url: String,
|
||||
#[serde(default = "default_allowed_tools")]
|
||||
#[serde(rename = "allowedTools")]
|
||||
allowed_tools: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExtraMcpServer {
|
||||
fn allowed_tools(&self) -> &[String] {
|
||||
match self {
|
||||
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_allowed_tools() -> Vec<String> {
|
||||
|
|
@ -287,7 +312,9 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
|||
/// 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<String> {
|
|||
|
||||
/// 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<String, serde_json::Value> {
|
||||
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<String, serde_json::Value> {
|
|||
// 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::<u16>().ok())
|
||||
|
|
@ -329,16 +356,18 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
|
|||
"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<String, serde_json::Value> {
|
|||
);
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ pub struct Snapshot {
|
|||
pub tool_breakdown: Vec<KeyCount>,
|
||||
/// 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<u64> {
|
|||
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
//! `hive-bash-daemon` binary — long-running per-agent bash task runner.
|
||||
//! Spawns `sh -c` subprocesses, monitors completion, writes task state
|
||||
//! files, and surfaces task state to the agent as todos on the harness's
|
||||
//! in-agent socket. Listens on a unix socket for tool-call requests from
|
||||
//! the `hive-bash-mcp` stdio bridge.
|
||||
//! in-agent socket. Serves its MCP tools (`run`/`status`/`kill`) directly
|
||||
//! over streamable-http on `--http <addr>` — no stdio bridge, no separate
|
||||
//! bin claude has to respawn every turn.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-bash-daemon", about = "bash-task runner + MCP daemon")]
|
||||
struct Cli {
|
||||
/// Serve the MCP tools over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8791`). Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: std::net::SocketAddr,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
|
|
@ -15,11 +26,11 @@ async fn main() -> Result<()> {
|
|||
)
|
||||
.init();
|
||||
|
||||
let socket_path = hive_bash_mcp::paths::daemon_socket();
|
||||
let cli = Cli::parse();
|
||||
let todo_socket = hive_bash_mcp::paths::agent_socket();
|
||||
|
||||
tracing::info!(
|
||||
socket = %socket_path.display(),
|
||||
http = %cli.http,
|
||||
todo = %todo_socket.display(),
|
||||
"hive-bash-daemon starting"
|
||||
);
|
||||
|
|
@ -28,6 +39,6 @@ async fn main() -> Result<()> {
|
|||
// spawns them, pushing todos to the harness on task transitions.
|
||||
hive_bash_mcp::runner::spawn_loop(todo_socket);
|
||||
|
||||
// Serve the unix socket forever.
|
||||
hive_bash_mcp::socket::serve(&socket_path).await
|
||||
// Serve the MCP tools over streamable-http forever.
|
||||
hive_bash_mcp::mcp::serve_http(cli.http).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,86 +1,56 @@
|
|||
//! `hive-bash-mcp` binary — stdio MCP server claude spawns per turn.
|
||||
//! Thin protocol bridge: every tool call → connect to the daemon's
|
||||
//! unix socket → write a JSON request line → read the JSON response →
|
||||
//! return the result to claude.
|
||||
//!
|
||||
//! No subprocess management at this entrypoint — the daemon owns that.
|
||||
//! Cold-starts in milliseconds.
|
||||
//! MCP tool surface for `hive-bash-daemon`, served directly over
|
||||
//! streamable-http — no stdio bridge, no round-trip socket. The daemon
|
||||
//! already owns the subprocess runner in-process, so the tool handlers
|
||||
//! below call straight into [`crate::runner`]. Mirrors
|
||||
//! `hive-agent-mcp::mcp::serve_http`'s shape (persistent daemon, stable
|
||||
//! URL claude reconnects to every turn instead of respawning a stdio
|
||||
//! child).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rmcp::{
|
||||
ServerHandler, ServiceExt,
|
||||
ServerHandler,
|
||||
handler::server::wrapper::Parameters,
|
||||
schemars::{self, JsonSchema},
|
||||
tool, tool_handler, tool_router,
|
||||
transport::stdio,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::fmt::Write as _;
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
|
||||
use hive_bash_mcp::paths;
|
||||
use hive_bash_mcp::protocol::{DaemonRequest, DaemonResponse};
|
||||
use hive_bash_mcp::runner::SUMMARY_BYTES;
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
use crate::runner::{self, SUMMARY_BYTES};
|
||||
|
||||
/// Send `req` to the daemon and read back the response. Each call is a
|
||||
/// fresh unix-socket connection — short-lived (single round-trip) so
|
||||
/// connection pooling is unnecessary.
|
||||
async fn round_trip(req: DaemonRequest) -> Result<DaemonResponse> {
|
||||
let socket = paths::daemon_socket();
|
||||
let stream = UnixStream::connect(&socket)
|
||||
.await
|
||||
.with_context(|| format!("connect bash daemon socket {}", socket.display()))?;
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut line = serde_json::to_string(&req)?;
|
||||
line.push('\n');
|
||||
writer
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("write request to bash daemon socket")?;
|
||||
writer.shutdown().await.ok();
|
||||
let mut buf = String::new();
|
||||
BufReader::new(reader)
|
||||
.read_line(&mut buf)
|
||||
.await
|
||||
.context("read response from bash daemon socket")?;
|
||||
serde_json::from_str(&buf).context("parse bash daemon response")
|
||||
fn status_str(status: &TaskStatus) -> &'static str {
|
||||
match status {
|
||||
TaskStatus::Pending => "pending",
|
||||
TaskStatus::Running => "running",
|
||||
TaskStatus::Done => "done",
|
||||
TaskStatus::TimedOut => "timed_out",
|
||||
TaskStatus::Interrupted => "interrupted",
|
||||
TaskStatus::Killed => "killed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a `TaskFile` JSON value as a human-readable status string.
|
||||
/// Mirrors `format_bash_status` in the old hive-agent, adapted to work
|
||||
/// from the daemon's JSON payload.
|
||||
fn format_task(id: &str, task: &serde_json::Value) -> String {
|
||||
let status = task["status"].as_str().unwrap_or("unknown");
|
||||
let mut out = format!("task `{id}`: status={status}");
|
||||
/// Format a `TaskFile` as the human-readable status string claude sees.
|
||||
fn format_task(task: &TaskFile) -> String {
|
||||
let mut out = format!("task `{}`: status={}", task.id, status_str(&task.status));
|
||||
|
||||
if let Some(code) = task["exit_code"].as_i64() {
|
||||
if let Some(code) = task.exit_code {
|
||||
let _ = write!(out, ", exit={code}");
|
||||
}
|
||||
if let (Some(started), None) = (
|
||||
task["started_at"].as_i64(),
|
||||
task["completed_at"].as_i64().map(|_| ()),
|
||||
) {
|
||||
// Running — show age.
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
.cast_signed();
|
||||
if let (Some(started), None) = (task.started_at, task.completed_at) {
|
||||
let now = hive_sh4re::wire_time::now_unix();
|
||||
let _ = write!(out, ", running for {}s", now - started);
|
||||
}
|
||||
if let (Some(completed), Some(started)) =
|
||||
(task["completed_at"].as_i64(), task["started_at"].as_i64())
|
||||
{
|
||||
if let (Some(completed), Some(started)) = (task.completed_at, task.started_at) {
|
||||
let _ = write!(out, ", took {}s", completed - started);
|
||||
}
|
||||
|
||||
let out_file = paths::task_out(id);
|
||||
let err_file = paths::task_err(id);
|
||||
let out_file = crate::paths::task_out(&task.id);
|
||||
let err_file = crate::paths::task_err(&task.id);
|
||||
let out_len = std::fs::metadata(&out_file).map_or(0, |m| m.len());
|
||||
let err_len = std::fs::metadata(&err_file).map_or(0, |m| m.len());
|
||||
|
||||
if let Some(stdout) = task["stdout_tail"].as_str() {
|
||||
if let Some(stdout) = &task.stdout_tail {
|
||||
let s = stdout.trim();
|
||||
if !s.is_empty() {
|
||||
let _ = write!(out, "\n\nstdout:\n```\n{s}\n```");
|
||||
|
|
@ -90,7 +60,7 @@ fn format_task(id: &str, task: &serde_json::Value) -> String {
|
|||
let _ = write!(out, "\n\nFull stdout lives in `{}`", out_file.display());
|
||||
}
|
||||
|
||||
if let Some(stderr) = task["stderr_tail"].as_str() {
|
||||
if let Some(stderr) = &task.stderr_tail {
|
||||
let s = stderr.trim();
|
||||
if !s.is_empty() {
|
||||
let _ = write!(out, "\n\nstderr:\n```\n{s}\n```");
|
||||
|
|
@ -103,22 +73,6 @@ fn format_task(id: &str, task: &serde_json::Value) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// Turn a `DaemonResponse` from a `BashRun` call into the string
|
||||
/// claude sees as the tool result.
|
||||
fn render_bash_run(id: &str, resp: Result<DaemonResponse>) -> String {
|
||||
match resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
let finished = payload["finished"].as_bool().unwrap_or(false);
|
||||
if finished && let Some(task) = payload.get("task") {
|
||||
return format_task(id, task);
|
||||
}
|
||||
format!("task started: id={id}")
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => format!("bash_run error: {message}"),
|
||||
Err(e) => format!("bash bridge error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Appended to a `status` result when the caller parked on a
|
||||
/// `wait_seconds` poll that expired while the task was still running —
|
||||
/// nudges the model to spend the idle time on other work instead of
|
||||
|
|
@ -127,44 +81,6 @@ const BASH_IDLE_WAIT_HINT: &str = "\n\nThe task is still running — your wait t
|
|||
finished. If you have other useful work, do that and check back later (the task keeps running, and \
|
||||
it surfaces in your loose-ends when it completes) rather than immediately re-waiting.";
|
||||
|
||||
/// Turn a `DaemonResponse` from a `BashStatus` call into the string
|
||||
/// claude sees as the tool result. When `waited` is set (the call
|
||||
/// parked on a `wait_seconds` poll) and the task is still non-terminal,
|
||||
/// [`BASH_IDLE_WAIT_HINT`] is appended.
|
||||
fn render_bash_status(id: &str, resp: Result<DaemonResponse>, waited: bool) -> String {
|
||||
match resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
let mut out = format_task(id, &payload);
|
||||
if waited && matches!(payload["status"].as_str(), Some("pending" | "running")) {
|
||||
out.push_str(BASH_IDLE_WAIT_HINT);
|
||||
}
|
||||
out
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => message,
|
||||
Err(e) => format!("bash bridge error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn a `DaemonResponse` from a `BashKill` call into the string claude sees.
|
||||
fn render_bash_kill(resp: Result<DaemonResponse>) -> String {
|
||||
match resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
let id = payload["id"].as_str().unwrap_or("unknown");
|
||||
if payload["was_running"].as_bool().unwrap_or(false) {
|
||||
let sig = payload["signal"].as_str().unwrap_or("SIGINT");
|
||||
format!(
|
||||
"task `{id}`: {sig} sent to its process group; it transitions to `killed` \
|
||||
once the process exits and then surfaces in your loose-ends."
|
||||
)
|
||||
} else {
|
||||
format!("task `{id}` was pending — cancelled before it started.")
|
||||
}
|
||||
}
|
||||
Ok(DaemonResponse::Error { message }) => format!("kill error: {message}"),
|
||||
Err(e) => format!("bash bridge error: {e:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MCP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -251,21 +167,24 @@ impl BashMcp {
|
|||
has finished, rejected while one is still running."
|
||||
)]
|
||||
async fn run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
|
||||
let req = DaemonRequest::BashRun {
|
||||
cmd: args.cmd,
|
||||
timeout_secs: args.timeout_secs,
|
||||
wait_seconds: args.wait_seconds,
|
||||
name: args.name,
|
||||
let id = match runner::submit_task(args.cmd, args.timeout_secs, args.name) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return format!("bash_run error: {e:#}"),
|
||||
};
|
||||
let resp = round_trip(req).await;
|
||||
// Extract the id from the response to format the result.
|
||||
match &resp {
|
||||
Ok(DaemonResponse::Ok { payload }) => {
|
||||
let id = payload["id"].as_str().unwrap_or("unknown").to_owned();
|
||||
render_bash_run(&id, resp)
|
||||
}
|
||||
_ => render_bash_run("unknown", resp),
|
||||
let wait = args.wait_seconds.unwrap_or(0);
|
||||
if wait > 0
|
||||
&& let Some(task) = runner::wait_for_task(&id, wait).await
|
||||
&& matches!(
|
||||
task.status,
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
)
|
||||
{
|
||||
return format_task(&task);
|
||||
}
|
||||
format!("task started: id={id}")
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -278,13 +197,23 @@ impl BashMcp {
|
|||
avoid a separate round-trip when the task is expected to finish soon."
|
||||
)]
|
||||
async fn status(&self, Parameters(args): Parameters<BashStatusArgs>) -> String {
|
||||
let id = args.id.clone();
|
||||
let waited = args.wait_seconds.is_some_and(|w| w > 0);
|
||||
let req = DaemonRequest::BashStatus {
|
||||
id: args.id,
|
||||
wait_seconds: args.wait_seconds,
|
||||
let wait = args.wait_seconds.unwrap_or(0);
|
||||
let task = if wait > 0 {
|
||||
runner::wait_for_task(&args.id, wait).await
|
||||
} else {
|
||||
runner::read_task(&args.id)
|
||||
};
|
||||
render_bash_status(&id, round_trip(req).await, waited)
|
||||
match task {
|
||||
Some(t) => {
|
||||
let mut out = format_task(&t);
|
||||
if waited && matches!(t.status, TaskStatus::Pending | TaskStatus::Running) {
|
||||
out.push_str(BASH_IDLE_WAIT_HINT);
|
||||
}
|
||||
out
|
||||
}
|
||||
None => format!("unknown task id `{}`", args.id),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
|
|
@ -298,58 +227,95 @@ impl BashMcp {
|
|||
loose-ends like any completion."
|
||||
)]
|
||||
async fn kill(&self, Parameters(args): Parameters<BashKillArgs>) -> String {
|
||||
let req = DaemonRequest::BashKill {
|
||||
id: args.id,
|
||||
force: args.force,
|
||||
};
|
||||
render_bash_kill(round_trip(req).await)
|
||||
let (killed, was_running) = runner::kill_task(&args.id, args.force);
|
||||
if !killed {
|
||||
return format!(
|
||||
"no running or pending task with id `{}` (already finished or unknown)",
|
||||
args.id
|
||||
);
|
||||
}
|
||||
if was_running {
|
||||
let sig = if args.force { "SIGKILL" } else { "SIGINT" };
|
||||
format!(
|
||||
"task `{}`: {sig} sent to its process group; it transitions to `killed` \
|
||||
once the process exits and then surfaces in your loose-ends.",
|
||||
args.id
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"task `{}` was pending — cancelled before it started.",
|
||||
args.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for BashMcp {}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_env("RUST_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let service = BashMcp.serve(stdio()).await?;
|
||||
service.waiting().await?;
|
||||
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
|
||||
///
|
||||
/// Sole transport — there is no stdio mode. Long-lived so claude
|
||||
/// reconnects to the stable URL each turn instead of respawning a stdio
|
||||
/// child; since the daemon already owns [`crate::runner`] in-process,
|
||||
/// tool calls need no round-trip to anywhere.
|
||||
///
|
||||
/// Binds loopback only in practice; the default `allowed_hosts`
|
||||
/// (`localhost`/`127.0.0.1`/`::1`) rejects Host headers from anywhere else.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the listener cannot bind `addr` or the HTTP server
|
||||
/// exits with a fatal error.
|
||||
pub async fn serve_http(addr: std::net::SocketAddr) -> anyhow::Result<()> {
|
||||
use rmcp::transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
|
||||
};
|
||||
let session_manager = std::sync::Arc::new(LocalSessionManager::default());
|
||||
let service = StreamableHttpService::new(
|
||||
|| Ok(BashMcp),
|
||||
session_manager,
|
||||
StreamableHttpServerConfig::default(),
|
||||
);
|
||||
let app = axum::Router::new().nest_service("/mcp", service);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
tracing::info!(%addr, "serving hive-bash MCP over streamable-http at /mcp");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod status_hint_tests {
|
||||
use super::{BASH_IDLE_WAIT_HINT, render_bash_status};
|
||||
use hive_bash_mcp::protocol::DaemonResponse;
|
||||
use super::{BASH_IDLE_WAIT_HINT, format_task};
|
||||
use crate::protocol::{TaskFile, TaskStatus};
|
||||
|
||||
fn status_resp(status: &str) -> DaemonResponse {
|
||||
DaemonResponse::Ok {
|
||||
payload: serde_json::json!({ "status": status, "started_at": 1 }),
|
||||
fn task(status: TaskStatus) -> TaskFile {
|
||||
TaskFile {
|
||||
id: "t1".to_owned(),
|
||||
cmd: "echo hi".to_owned(),
|
||||
timeout_secs: None,
|
||||
status,
|
||||
created_at: 1,
|
||||
started_at: Some(1),
|
||||
completed_at: None,
|
||||
exit_code: None,
|
||||
stdout_tail: None,
|
||||
stderr_tail: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_task_after_wait_appends_idle_hint() {
|
||||
let out = render_bash_status("t1", Ok(status_resp("running")), true);
|
||||
let t = task(TaskStatus::Running);
|
||||
let mut out = format_task(&t);
|
||||
out.push_str(BASH_IDLE_WAIT_HINT);
|
||||
assert!(out.contains(BASH_IDLE_WAIT_HINT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_task_without_wait_has_no_hint() {
|
||||
let out = render_bash_status("t1", Ok(status_resp("running")), false);
|
||||
assert!(!out.contains(BASH_IDLE_WAIT_HINT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finished_task_after_wait_has_no_hint() {
|
||||
let out = render_bash_status("t1", Ok(status_resp("done")), true);
|
||||
fn done_task_formats_without_hint() {
|
||||
let out = format_task(&task(TaskStatus::Done));
|
||||
assert!(!out.contains(BASH_IDLE_WAIT_HINT));
|
||||
assert!(out.contains("status=done"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,102 +1,10 @@
|
|||
//! Wire types for the unix socket protocol between `hive-bash-daemon`
|
||||
//! and `hive-bash-mcp`. One JSON request line in, one JSON response line
|
||||
//! out per connection. Connections are short-lived (per tool call).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task state (shared between runner and protocol)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `TaskFile` + `TaskStatus` are the bash-task on-disk schema. They live in
|
||||
// `hive-sh4re` (the shared wire-types crate) so the agent web UI in
|
||||
// `hive-agent` can deserialize the same canonical type when reading the
|
||||
// tasks dir for its running-tasks panel — no parallel copy to drift. Both
|
||||
// are re-exported here so existing `crate::protocol::{TaskFile, TaskStatus}`
|
||||
// imports across this crate keep compiling unchanged.
|
||||
//! Bash-task on-disk schema, shared by the runner and the MCP tool
|
||||
//! layer.
|
||||
//!
|
||||
//! `TaskFile` + `TaskStatus` live in `hive-sh4re` (the shared wire-types
|
||||
//! crate) so the agent web UI in `hive-agent` can deserialize the same
|
||||
//! canonical type when reading the tasks dir for its running-tasks
|
||||
//! panel — no parallel copy to drift. Re-exported here so existing
|
||||
//! `crate::protocol::{TaskFile, TaskStatus}` imports across this crate
|
||||
//! keep compiling unchanged.
|
||||
pub use hive_sh4re::{TaskFile, TaskStatus};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Requests the MCP bridge sends to the daemon.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DaemonRequest {
|
||||
/// Liveness probe — fast round-trip that doesn't touch any subprocess.
|
||||
Ping,
|
||||
|
||||
/// Submit a new bash task. Returns the task ID on success.
|
||||
/// `wait_seconds`: optional inline poll (capped at 30s); when the
|
||||
/// task finishes within the window the response carries the full
|
||||
/// status payload. When it doesn't, the response carries just the
|
||||
/// task ID so the caller can check back with `BashStatus`.
|
||||
BashRun {
|
||||
cmd: String,
|
||||
#[serde(default)]
|
||||
timeout_secs: Option<u64>,
|
||||
/// Inline wait cap: 30s. Pass `None` or `0` to get the
|
||||
/// task-started-id response immediately.
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
/// Optional caller-chosen task name, used as the task id (so it
|
||||
/// flows into the wake `from`, status lookups, and the loose-ends
|
||||
/// summary). Must be filesystem-safe. Reusable once any prior task
|
||||
/// of the same name has finished; rejected while one is still
|
||||
/// running. `None` falls back to the auto-generated id.
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
},
|
||||
|
||||
/// Query the current status of a task. Returns the full `TaskFile`
|
||||
/// (formatted as text by the bridge). `wait_seconds`: optional
|
||||
/// inline poll (capped at 30s) — daemon returns as soon as the
|
||||
/// task reaches a terminal state or the window expires.
|
||||
BashStatus {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
wait_seconds: Option<u64>,
|
||||
},
|
||||
|
||||
/// Return all tasks currently in `Pending` or `Running` state.
|
||||
/// Used by the harness `get_loose_ends` to surface active
|
||||
/// background work.
|
||||
ActiveTasks,
|
||||
|
||||
/// Kill a running (or still-pending) task. `force = false` sends
|
||||
/// `SIGINT` to the task's process group (graceful — the process can
|
||||
/// clean up); `force = true` sends `SIGKILL` (immediate). Signalling
|
||||
/// the whole process group reaps `sh -c` plus any children it spawned,
|
||||
/// so a runaway grandchild (e.g. `cargo`/`nix`) is actually stopped.
|
||||
BashKill {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Response shape from the daemon. `Ok` carries a JSON payload; `Error`
|
||||
/// carries a human-readable message.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum DaemonResponse {
|
||||
Ok { payload: serde_json::Value },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
impl DaemonResponse {
|
||||
/// Build an Ok response from any serialisable value.
|
||||
pub fn ok<T: Serialize>(payload: &T) -> Self {
|
||||
let payload = serde_json::to_value(payload)
|
||||
.unwrap_or_else(|e| serde_json::json!({ "serialise_error": e.to_string() }));
|
||||
Self::Ok { payload }
|
||||
}
|
||||
|
||||
/// Build an Error response from any `Display` value.
|
||||
pub fn error(msg: impl std::fmt::Display) -> Self {
|
||||
Self::Error {
|
||||
message: msg.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ struct RunningHandle {
|
|||
}
|
||||
|
||||
/// Global registry of running tasks (id → handle). Populated by `run_task`
|
||||
/// for the duration of execution and read by [`kill_task`] from the socket
|
||||
/// dispatch path — a separate async context from the runner loop, so a
|
||||
/// shared global (rather than the loop-local `claimed` set) is needed.
|
||||
/// for the duration of execution and read by [`kill_task`] from the MCP
|
||||
/// tool-call handler — a separate async context from the runner loop, so
|
||||
/// a shared global (rather than the loop-local `claimed` set) is needed.
|
||||
fn running() -> &'static Mutex<HashMap<String, RunningHandle>> {
|
||||
static RUNNING: OnceLock<Mutex<HashMap<String, RunningHandle>>> = OnceLock::new();
|
||||
RUNNING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
|
|
@ -308,29 +308,6 @@ pub fn submit_task(cmd: String, timeout_secs: Option<u64>, name: Option<String>)
|
|||
Ok(id)
|
||||
}
|
||||
|
||||
/// Return all tasks currently in `Pending` or `Running` state.
|
||||
#[must_use]
|
||||
pub fn active_tasks() -> Vec<TaskFile> {
|
||||
let Ok(rd) = std::fs::read_dir(paths::tasks_dir()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in rd.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let Some(task) = read_task(&id) else { continue };
|
||||
if matches!(task.status, TaskStatus::Pending | TaskStatus::Running) {
|
||||
out.push(task);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Inline wait: poll `read_task(id)` until terminal state or deadline.
|
||||
/// Returns the final task on success, or `None` if it never completed.
|
||||
///
|
||||
|
|
@ -554,7 +531,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
|
|||
let err_path = paths::task_err(&id);
|
||||
|
||||
// Register a kill handle for the duration of execution so `kill_task`
|
||||
// (called from the socket dispatch path) can signal this task.
|
||||
// (called from the MCP tool-call handler) can signal this task.
|
||||
let cancel = Arc::new(Notify::new());
|
||||
let force = Arc::new(AtomicBool::new(false));
|
||||
running().lock().unwrap().insert(
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
//! Unix socket server: the daemon listens here, the stdio MCP bridge
|
||||
//! `connect()`s on every tool call. One JSON request line in, one
|
||||
//! JSON response line out. Connections are short-lived (per tool call)
|
||||
//! so the loop is just accept → dispatch → reply → close.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
use crate::protocol::{DaemonRequest, DaemonResponse, TaskStatus};
|
||||
use crate::runner;
|
||||
|
||||
/// Start listening on `socket_path` and serve forever. Removes any
|
||||
/// stale socket file first so a daemon restart doesn't hit EADDRINUSE.
|
||||
pub async fn serve(socket_path: &Path) -> Result<()> {
|
||||
let _ = tokio::fs::remove_file(socket_path).await;
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
tokio::fs::create_dir_all(parent)
|
||||
.await
|
||||
.with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
let listener = UnixListener::bind(socket_path)
|
||||
.with_context(|| format!("bind unix socket {}", socket_path.display()))?;
|
||||
tracing::info!(path = %socket_path.display(), "bash daemon socket up");
|
||||
loop {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.context("accept on bash daemon socket")?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream).await {
|
||||
tracing::warn!(error = %e, "bash socket connection error");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: UnixStream) -> Result<()> {
|
||||
let (reader, mut writer) = stream.into_split();
|
||||
let mut lines = BufReader::new(reader).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let response = match serde_json::from_str::<DaemonRequest>(&line) {
|
||||
Ok(req) => dispatch(req).await,
|
||||
Err(e) => DaemonResponse::error(format!("parse request: {e}")),
|
||||
};
|
||||
let mut json = serde_json::to_string(&response)?;
|
||||
json.push('\n');
|
||||
writer.write_all(json.as_bytes()).await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch(req: DaemonRequest) -> DaemonResponse {
|
||||
match req {
|
||||
DaemonRequest::Ping => DaemonResponse::ok(&serde_json::json!({"ok": true})),
|
||||
|
||||
DaemonRequest::BashRun {
|
||||
cmd,
|
||||
timeout_secs,
|
||||
wait_seconds,
|
||||
name,
|
||||
} => {
|
||||
let id = match runner::submit_task(cmd, timeout_secs, name) {
|
||||
Ok(id) => id,
|
||||
Err(e) => return DaemonResponse::error(format!("submit_task: {e:#}")),
|
||||
};
|
||||
// Inline wait: if requested and the task finishes quickly,
|
||||
// return the full status instead of just the task ID.
|
||||
let wait = wait_seconds.unwrap_or(0);
|
||||
if wait > 0
|
||||
&& let Some(task) = runner::wait_for_task(&id, wait).await
|
||||
&& matches!(
|
||||
task.status,
|
||||
TaskStatus::Done
|
||||
| TaskStatus::TimedOut
|
||||
| TaskStatus::Interrupted
|
||||
| TaskStatus::Killed
|
||||
)
|
||||
{
|
||||
return DaemonResponse::ok(&serde_json::json!({
|
||||
"id": id,
|
||||
"finished": true,
|
||||
"task": task,
|
||||
}));
|
||||
}
|
||||
DaemonResponse::ok(&serde_json::json!({ "id": id, "finished": false }))
|
||||
}
|
||||
|
||||
DaemonRequest::BashStatus { id, wait_seconds } => {
|
||||
let wait = wait_seconds.unwrap_or(0);
|
||||
let task = if wait > 0 {
|
||||
runner::wait_for_task(&id, wait).await
|
||||
} else {
|
||||
runner::read_task(&id)
|
||||
};
|
||||
match task {
|
||||
Some(t) => DaemonResponse::ok(&t),
|
||||
None => DaemonResponse::error(format!("unknown task id `{id}`")),
|
||||
}
|
||||
}
|
||||
|
||||
DaemonRequest::ActiveTasks => {
|
||||
let tasks = runner::active_tasks();
|
||||
DaemonResponse::ok(&tasks)
|
||||
}
|
||||
|
||||
DaemonRequest::BashKill { id, force } => {
|
||||
let (killed, was_running) = runner::kill_task(&id, force);
|
||||
if !killed {
|
||||
return DaemonResponse::error(format!(
|
||||
"no running or pending task with id `{id}` (already finished or unknown)"
|
||||
));
|
||||
}
|
||||
let payload = if was_running {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"killed": true,
|
||||
"was_running": true,
|
||||
"signal": if force { "SIGKILL" } else { "SIGINT" },
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"killed": true,
|
||||
"was_running": false,
|
||||
"note": "task was pending — cancelled before it started",
|
||||
})
|
||||
};
|
||||
DaemonResponse::ok(&payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -206,7 +206,7 @@ pub struct HiveStats {
|
|||
pub model_mix: Vec<KeyCount>,
|
||||
/// 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<KeyCount>,
|
||||
}
|
||||
|
|
@ -221,7 +221,7 @@ struct AgentAgg {
|
|||
cost: f64,
|
||||
models: HashMap<String, u64>,
|
||||
/// 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<String, u64>,
|
||||
}
|
||||
|
|
@ -286,7 +286,7 @@ fn read_agent(path: &Path, from: i64, prices: &PriceTable) -> rusqlite::Result<A
|
|||
}
|
||||
|
||||
/// Tally normalised bash-command heads from the agent's `bash_commands`
|
||||
/// table (`ts INTEGER, head TEXT`, written by hive-bash-mcp) over
|
||||
/// table (`ts INTEGER, head TEXT`, written by hive-bash-daemon) over
|
||||
/// `[from, now]`. Best-effort + isolated from `read_agent`'s error path:
|
||||
/// a missing table (capture hasn't run for this agent) or any read error
|
||||
/// yields an empty map rather than propagating, so the favorite-tools
|
||||
|
|
|
|||
|
|
@ -158,10 +158,10 @@
|
|||
# unreachable from inside a container — wrapped with
|
||||
# `wireguard-tools` for `hivectl wg`). The daemon/harness/MCP bins
|
||||
# the harness execs (hive-agent{,-mcp}, hive-bash-daemon,
|
||||
# hive-matrix-daemon, hive-bash-mcp, hive-matrix-mcp) are wired via
|
||||
# their own ExecStart/command lines in the sibling modules — they
|
||||
# don't need to be on PATH too. Only these two are actually looked
|
||||
# up on PATH by claude/shell code inside the container:
|
||||
# hive-matrix-daemon, hive-matrix-mcp) are wired via their own
|
||||
# ExecStart/command lines in the sibling modules — they don't need
|
||||
# to be on PATH too. Only these two are actually looked up on PATH
|
||||
# by claude/shell code inside the container:
|
||||
# `hive-agent-wake` (external wake CLI, docs/turn-loop/mcp.md) and
|
||||
# `hive-metric` (agent-emitted custom metrics CLI,
|
||||
# docs/observability.md).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# The MCP tool surface: the built-in hyperhive server (persistent
|
||||
# streamable-http daemon), the bash-task backend daemon + its
|
||||
# auto-injected stdio bridge, the `extraMcpServers` option they hang
|
||||
# off, and the send-recipient allowlist.
|
||||
# streamable-http daemon), the bash-task backend daemon (also a
|
||||
# persistent streamable-http MCP server, auto-injected into
|
||||
# `extraMcpServers`), the `extraMcpServers` option itself (stdio or http,
|
||||
# per entry), and the send-recipient allowlist.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -42,19 +43,51 @@ in
|
|||
type = lib.types.attrsOf (
|
||||
lib.types.submodule {
|
||||
options = {
|
||||
type = lib.mkOption {
|
||||
type = lib.types.enum [
|
||||
"stdio"
|
||||
"http"
|
||||
];
|
||||
default = "stdio";
|
||||
description = ''
|
||||
Transport for this MCP server. `"stdio"` (the default) spawns
|
||||
`command` as a fresh child process every turn, talking
|
||||
JSON-RPC over its stdin/stdout — existing entries need zero
|
||||
changes to keep this behaviour. `"http"` points claude at a
|
||||
long-lived streamable-http `url` instead: no per-turn spawn,
|
||||
no re-registration race, same shape as the built-in
|
||||
hyperhive surface (`hive-mcp-http`) — use this for a server
|
||||
backed by an always-on daemon. `command`/`args`/`env` only
|
||||
apply to `"stdio"`; `url` only to `"http"`.
|
||||
'';
|
||||
};
|
||||
command = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Absolute path to the MCP server binary. Use `\${pkgs.foo}/bin/foo` or `/run/current-system/sw/bin/foo`.";
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Absolute path to the MCP server binary. Use `''${pkgs.foo}/bin/foo`
|
||||
or `/run/current-system/sw/bin/foo`. Required when
|
||||
`type = "stdio"`; ignored (leave `null`) for `"http"`.
|
||||
'';
|
||||
};
|
||||
args = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Args passed to the MCP server binary.";
|
||||
description = "Args passed to the MCP server binary. `\"stdio\"` only.";
|
||||
};
|
||||
env = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
description = "Environment variables for the MCP server child process.";
|
||||
description = "Environment variables for the MCP server child process. `\"stdio\"` only.";
|
||||
};
|
||||
url = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Streamable-http URL (e.g. `http://127.0.0.1:8791/mcp`) of the
|
||||
always-on daemon serving this MCP surface. Required when
|
||||
`type = "http"`; ignored (leave `null`) for `"stdio"`.
|
||||
'';
|
||||
};
|
||||
allowedTools = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
|
|
@ -84,6 +117,10 @@ in
|
|||
env.MATRIX_HOMESERVER = "https://matrix.example.org";
|
||||
allowedTools = [ "send_message" "join_room" ];
|
||||
};
|
||||
bash = {
|
||||
type = "http";
|
||||
url = "http://127.0.0.1:8791/mcp";
|
||||
};
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
|
|
@ -112,8 +149,9 @@ in
|
|||
is no per-turn MCP re-registration race (a resumed stdio child could
|
||||
emit its first tool call before that turn's async
|
||||
`initialize`/`tools-list` completed, stranding the agent with `No
|
||||
such tool` — the http endpoint eliminates that). Extra MCP servers
|
||||
(matrix/bash) stay stdio bridges regardless.
|
||||
such tool` — the http endpoint eliminates that). `matrix` stays a
|
||||
stdio bridge; `bash` runs its own persistent http listener (see
|
||||
`hyperhive.mcp.bashHttpPort`).
|
||||
|
||||
Bound loopback-only; the rmcp streamable-http transport's default
|
||||
`allowed_hosts` (`localhost` / `127.0.0.1` / `::1`) rejects Host
|
||||
|
|
@ -136,15 +174,48 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
options.hyperhive.mcp.bashHttpPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8791;
|
||||
example = 8792;
|
||||
description = ''
|
||||
Loopback port `hive-bash-daemon` serves its MCP tools
|
||||
(`run`/`status`/`kill`) on. Same shape as `hyperhive.mcp.httpPort`
|
||||
for the built-in surface: HTTP is the *sole* transport (no stdio
|
||||
bridge — the daemon that owns the subprocess runner serves the MCP
|
||||
tools directly in-process), `Restart = "always"` keeps the listener
|
||||
self-healing, and loopback-only binding means no auth token is
|
||||
needed (same `allowed_hosts` reasoning as `hyperhive.mcp.httpPort`).
|
||||
Safe as a single fixed default across all agents (private
|
||||
per-container network namespace — see docs/network.md).
|
||||
'';
|
||||
};
|
||||
|
||||
config = {
|
||||
# Assert the transport-specific required field is actually set —
|
||||
# `command`/`url` are both `nullOr` so the submodule schema stays
|
||||
# backward-compatible for existing stdio entries, but a `null` in the
|
||||
# field the chosen `type` actually needs is a config mistake, not a
|
||||
# valid "unset".
|
||||
assertions =
|
||||
lib.mapAttrsToList (name: spec: {
|
||||
assertion = spec.type != "stdio" || spec.command != null;
|
||||
message = "hyperhive.extraMcpServers.${name}: type = \"stdio\" requires `command` to be set";
|
||||
}) config.hyperhive.extraMcpServers
|
||||
++ lib.mapAttrsToList (name: spec: {
|
||||
assertion = spec.type != "http" || spec.url != null;
|
||||
message = "hyperhive.extraMcpServers.${name}: type = \"http\" requires `url` to be set";
|
||||
}) config.hyperhive.extraMcpServers;
|
||||
|
||||
# Auto-inject the built-in bash MCP server — always present, every
|
||||
# agent needs bash tools. `lib.mkDefault` so the operator's own
|
||||
# agent.nix can override the entry. (The matrix sibling lives in
|
||||
# ./matrix.nix, gated on hyperhive.matrix.enable.)
|
||||
# ./matrix.nix, gated on hyperhive.matrix.enable.) `hive-bash-daemon`
|
||||
# serves its MCP tools directly over streamable-http (no stdio bridge,
|
||||
# no round-trip socket) — see the `hive-bash-daemon` service below.
|
||||
hyperhive.extraMcpServers.bash = lib.mkDefault {
|
||||
command = "${config.hyperhive.packages.hive-bash-mcp}/bin/hive-bash-mcp";
|
||||
args = [ ];
|
||||
env.HIVE_BASH_SOCKET = "/run/hive-bash/socket";
|
||||
type = "http";
|
||||
url = "http://127.0.0.1:${toString config.hyperhive.mcp.bashHttpPort}/mcp";
|
||||
allowedTools = [ "*" ];
|
||||
};
|
||||
|
||||
|
|
@ -154,13 +225,13 @@ in
|
|||
builtins.toJSON config.hyperhive.allowedRecipients;
|
||||
|
||||
# Bash task runner daemon — long-running process that owns subprocess
|
||||
# monitoring + completion wake signals. Always enabled (every agent
|
||||
# needs bash tools). The stdio MCP bridge `hive-bash-mcp` connects
|
||||
# to this daemon's socket per turn.
|
||||
# Socket dir: /run/hive-bash/ — RuntimeDirectory keeps it on tmpfs.
|
||||
# monitoring + completion wake signals, and serves the MCP tools
|
||||
# (`run`/`status`/`kill`) directly over streamable-http on
|
||||
# `hyperhive.mcp.bashHttpPort` — no stdio bridge, no per-turn spawn.
|
||||
systemd.services.hive-bash-daemon = {
|
||||
description = "bash task runner daemon for hive-bash-mcp";
|
||||
description = "bash task runner + MCP daemon for hive-bash";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "hive-agent.service" ];
|
||||
# The daemon runs every bash task via `Command::new("bash")` and the
|
||||
# commands themselves (hive-forge, git, jq, …) resolve from PATH.
|
||||
# A standalone daemon has no inherited agent PATH, so without this
|
||||
|
|
@ -174,7 +245,6 @@ in
|
|||
"/run/current-system/sw"
|
||||
];
|
||||
environment = {
|
||||
HIVE_BASH_SOCKET = "/run/hive-bash/socket";
|
||||
# In-agent todo socket the harness serves (loose-ends v2): the
|
||||
# runner pushes bash-task todos here (upsert while active, keyless
|
||||
# 'done' on completion) instead of firing a c0re wake. Must match
|
||||
|
|
@ -190,22 +260,15 @@ in
|
|||
# value but is less robust if the two vars ever diverge.
|
||||
};
|
||||
serviceConfig = {
|
||||
ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon";
|
||||
ExecStart = "${config.hyperhive.packages.hive-bash-daemon}/bin/hive-bash-daemon --http 127.0.0.1:${toString config.hyperhive.mcp.bashHttpPort}";
|
||||
SyslogIdentifier = "hive-bash-daemon";
|
||||
Restart = "on-failure";
|
||||
# `always` (not `on-failure`): since the MCP tools are served
|
||||
# in-process now, a down window is total loss of bash tools with
|
||||
# no stdio fallback — same reasoning as `hive-mcp-http` below.
|
||||
Restart = "always";
|
||||
RestartSec = 3;
|
||||
User = userName;
|
||||
Group = userName;
|
||||
RuntimeDirectory = "hive-bash";
|
||||
# Keep /run/hive-bash across restarts. With the default
|
||||
# `RuntimeDirectoryPreserve=no`, a post-rebuild restart races
|
||||
# stop-time dir cleanup against the fresh daemon's socket-dir
|
||||
# creation; the daemon loses, fails `mkdir /run/hive-bash`
|
||||
# (Permission denied, non-root in /run), and loops on
|
||||
# Restart=on-failure until the next container boot — i.e. the
|
||||
# bash daemon "doesn't come up post-rebuild". Same shape as
|
||||
# hive-matrix-daemon (./matrix.nix).
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
description = ''
|
||||
hyperhive package outputs consumed by the harness modules: the
|
||||
per-binary daemon/CLI packages (`hive-agent`, `hive-agent-mcp`,
|
||||
`hive-agent-wake`, `hive-bash-daemon`, `hive-bash-mcp`,
|
||||
`hive-agent-wake`, `hive-bash-daemon`,
|
||||
`hive-forge`, `hive-matrix-daemon`, `hive-matrix-mcp`,
|
||||
`hive-metric`, `hive-screen-mcp`) plus the `assets`, `frontend` and
|
||||
`reference-docs` trees. Wired by the flake's agent-base/ruth
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ let
|
|||
hive-agent = "hyperhive in-container agent harness serve loop";
|
||||
hive-agent-mcp = "hyperhive agent-surface MCP server";
|
||||
hive-agent-wake = "hyperhive external wake CLI — push a message into an agent's own inbox";
|
||||
hive-bash-daemon = "hyperhive per-agent bash-task runner daemon";
|
||||
hive-bash-mcp = "hyperhive bash-task MCP bridge";
|
||||
hive-bash-daemon = "hyperhive per-agent bash-task runner daemon (serves its MCP tools directly over streamable-http)";
|
||||
hive-matrix-daemon = "hyperhive per-agent matrix-sdk daemon";
|
||||
hive-matrix-mcp = "hyperhive matrix MCP bridge";
|
||||
hive-metric = "hyperhive agent-emitted custom metrics CLI";
|
||||
|
|
|
|||
Loading…
Reference in a new issue