refactor(hive-ag3nt): split hive bin into hive-agent / hive-agent-mcp / hive-agent-wake

This commit is contained in:
müde 2026-07-06 23:48:05 +02:00
commit 5b062dca55
14 changed files with 182 additions and 144 deletions

View file

@ -32,8 +32,9 @@ hand-maintained per-file tree drifts out of sync with the code.
the meta flake, lifecycle (`nixos-container` shellouts), gateway /
forge / matrix provisioning, per-container stats, and the axum
operator dashboard (`dashboard.rs`). Largest crate.
- **`hive-ag3nt/`** — in-container harness; one `hive` binary for every
agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
- **`hive-ag3nt/`** — in-container harness; three sibling binaries for
every agent (`hive-agent` serve loop, `hive-agent-mcp`,
`hive-agent-wake`). Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer
(`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config`
render), per-agent web UI (`web_ui/` module dir), event + turn-stats

View file

@ -214,7 +214,8 @@ nspawn agent. Open questions, not yet wired:
## Harness systemd unit shape
One harness binary (`hive`), one `harness-base.nix` template, one
One harness serve binary (`hive-agent`, with its `hive-agent-mcp` /
`hive-agent-wake` siblings), one `harness-base.nix` template, one
service unit (`systemd.services.hive-ag3nt`) for all agents. There
is no longer a separate manager service name or role distinction in
the harness — privilege differences live server-side in the broker
@ -259,7 +260,8 @@ bit set` regardless of `hyperhive.user.passwordlessSudo`.
### `serviceConfig` highlights
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary.
- `ExecStart = pkgs.hyperhive/bin/hive-agent` — same binary for
every agent.
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness
resilient across transient crashes without thundering retries.
- `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by

View file

@ -5,7 +5,8 @@ claude has access to in return.
## The loop
Each agent harness (`hive serve` — one binary for all agents) runs:
Each agent harness (`hive-agent` — one serve-loop binary for all
agents) runs:
1. Long-poll `Recv` on its socket. The host-side broker
(`broker.rs::recv_blocking_batch`) returns immediately if there's
@ -51,38 +52,37 @@ Each agent harness (`hive serve` — one binary for all agents) runs:
## Harness binary shape
One `hive` binary for all agents. The earlier split into
`hive-ag3nt` + `hive-m1nd` was collapsed because the privilege
boundary lives server-side at the broker socket
(`/run/hive/mcp.sock`): `ManagerRequest` calls are refused by the
standard agent socket regardless of who sends them.
Three sibling binaries out of the one `hive-ag3nt` crate, all
role-agnostic. (The earlier split into `hive-ag3nt` + `hive-m1nd`
was collapsed because the privilege boundary lives server-side at
the broker socket (`/run/hive/mcp.sock`): `ManagerRequest` calls are
refused by the standard agent socket regardless of who sends them.)
Three subcommands:
- `serve` — long-running harness loop (the inbox poll +
- `hive-agent` — long-running harness loop (the inbox poll +
claude-pump + ack/requeue cycle described above).
- `mcp` — MCP server. Default: stdio child claude spawns via
`--mcp-config` per turn. With `--http <addr>`, runs as a persistent
streamable-HTTP daemon instead (used by the `hive-mcp-http`
systemd unit when `hyperhive.mcp.httpPort` is set).
- `wake --from <name> --body <body>` — push a message into our own
inbox so the next turn fires with the given body. Used by
co-process daemons (matrix bridge, scraper, webhook listeners)
to nudge claude on external events. `--body -` reads from stdin.
- `hive-agent-mcp` — MCP server. Default: stdio child claude spawns
via `--mcp-config` per turn (the serve loop renders the config to
point at this sibling of its own `/proc/self/exe`). With
`--http <addr>`, runs as a persistent streamable-HTTP daemon
instead (used by the `hive-mcp-http` systemd unit when
`hyperhive.mcp.httpPort` is set).
- `hive-agent-wake --from <name> --body <body>` — push a message into
our own inbox so the next turn fires with the given body. Used by
co-process helpers (scrapers, webhook listeners) to nudge claude on
external events. `--body -` reads from stdin.
### `Surface` trait + zero-sized type tags
`AgentRequest` / `AgentResponse` (= `ManagerRequest` / `ManagerResponse`
type aliases) are the wire types. There is one role: agent.
`bin/hive.rs` factors the turn loop through a `Surface` trait with one
zero-sized impl (`AgentSurface`) wrapping:
`bin/hive-agent.rs` factors the turn loop through a `Surface` trait
with one zero-sized impl (`AgentSurface`) wrapping:
- One async method per wire op: `ack_turn`, `requeue_inflight`,
`inbox_unread`, `post_turn_counts`, `send_to_parent`,
`recv_next`, `wake_external`.
`inbox_unread`, `post_turn_counts`, `send_to_parent`, `recv_next`.
`main()` calls `serve_main::<AgentSurface>` for all roles. The turn
loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches.
loop (`serve_loop` / `handle_turn`) has no per-role branches.
### Boot wiring

View file

@ -138,7 +138,7 @@ External MCP servers (and any other in-container process) can
inject a wake-up event into the agent's inbox via the per-agent
socket at `/run/hive/mcp.sock`. Two equivalent paths:
- **Shell out to `hive wake --from <label> --body <text>`**
- **Shell out to `hive-agent-wake --from <label> --body <text>`**
(use `--body -` to read body from stdin). Already on the
container's `PATH` since the harness binary is in
`systemPackages`. Convenient for shell-script integrations and

View file

@ -28,9 +28,8 @@ tracing-subscriber.workspace = true
[dev-dependencies]
tempfile = "3"
[[bin]]
# Unified harness binary for all agents. Privilege boundary is
# Three sibling harness binaries for all agents (auto-discovered from
# `src/bin/`): `hive-agent` (serve loop), `hive-agent-mcp` (MCP
# server), `hive-agent-wake` (wake CLI). Privilege boundary is
# enforced server-side at the socket (tool groups / manager surface).
# See `docs/turn-loop.md::Harness binary shape`.
name = "hive"
path = "src/bin/hive.rs"

View file

@ -0,0 +1,43 @@
//! MCP-server binary. Default is stdio — spawned by `claude` via
//! `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
//! the hyperhive broker. Pass `--http <addr>` to instead run a long-lived
//! streamable-http listener (persistent daemon, the `hive-mcp-http`
//! systemd unit) that claude reconnects to each turn, avoiding the
//! per-turn stdio re-registration race. Sibling of `hive-agent` (the
//! serve loop that renders the `--mcp-config` blob pointing here) and
//! `hive-agent-wake`.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use hive_ag3nt::{DEFAULT_SOCKET, mcp};
#[derive(Parser)]
#[command(name = "hive-agent-mcp", about = "hyperhive MCP server")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
/// Serve over streamable-http on this address (e.g.
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
#[arg(long)]
http: Option<std::net::SocketAddr>,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
match cli.http {
Some(addr) => mcp::serve_http(cli.socket, addr).await,
None => mcp::serve_agent_stdio(cli.socket).await,
}
}

View file

@ -0,0 +1,60 @@
//! Wake CLI: inject a wake-up event into this container's harness inbox
//! so the next turn fires with the given body. Intended for extra MCP
//! servers / helpers (scraper, webhook listener, etc.) that need to
//! nudge claude on external events; the built-in daemons (matrix, bash)
//! talk to the socket directly instead. Sibling of `hive-agent` and
//! `hive-agent-mcp`.
use std::path::PathBuf;
use anyhow::Result;
use clap::Parser;
use hive_ag3nt::{DEFAULT_SOCKET, client};
use hive_sh4re::{AgentRequest, AgentResponse};
#[derive(Parser)]
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[arg(long)]
from: String,
/// Body of the wake message. Pass `-` to read from stdin.
#[arg(long)]
body: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
let body = if cli.body == "-" {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
cli.body
};
let resp: AgentResponse = client::request(
&cli.socket,
&AgentRequest::Wake {
from: cli.from,
body,
},
)
.await?;
match resp {
AgentResponse::Ok => Ok(()),
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
other => anyhow::bail!("wake: unexpected response {other:?}"),
}
}

View file

@ -1,7 +1,9 @@
//! Unified hyperhive harness binary. Dispatches one of three subcommands
//! (`serve` / `mcp` / `wake`). There is one role: agent. The `Surface`
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
//! claude turn per message. There is one role: agent. The `Surface`
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
//! generic and testable. Architecture lives in
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
//! Architecture lives in
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
use std::path::{Path, PathBuf};
@ -9,58 +11,23 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap::Parser;
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
use hive_ag3nt::login::{self, LoginState};
use hive_ag3nt::turn_stats::TurnStats;
use hive_ag3nt::{
DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui,
};
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, plugins, serve_common, turn, web_ui};
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
#[derive(Parser)]
#[command(name = "hive", about = "hyperhive harness")]
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Run the long-lived harness loop. Polls inbox; replies via
/// `claude --print` when available.
Serve {
/// Inbox poll interval in milliseconds.
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
},
/// Run the MCP server. Default is stdio — spawned by `claude` via
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back
/// into the hyperhive broker. Pass `--http <addr>` to instead run a
/// long-lived streamable-http listener (persistent daemon) that
/// claude reconnects to each turn, avoiding the per-turn stdio
/// re-registration race.
Mcp {
/// Serve over streamable-http on this address (e.g.
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
#[arg(long)]
http: Option<std::net::SocketAddr>,
},
/// Inject a wake-up event into this harness's inbox so the next
/// turn fires with the given body. Intended for extra MCP servers
/// / helpers (matrix bridge, scraper, webhook listener, etc.) that
/// need to nudge claude on external events.
Wake {
#[arg(long)]
from: String,
/// Body of the wake message. Pass `-` to read from stdin.
#[arg(long)]
body: String,
},
/// Inbox poll interval in milliseconds.
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
}
#[tokio::main]
@ -73,15 +40,7 @@ async fn main() -> Result<()> {
.init();
let cli = Cli::parse();
match cli.cmd {
Cmd::Serve { poll_ms } => serve_main::<AgentSurface>(&cli.socket, poll_ms).await,
Cmd::Mcp { http } => match http {
Some(addr) => mcp::serve_http(cli.socket, addr).await,
None => mcp::serve_agent_stdio(cli.socket).await,
},
Cmd::Wake { from, body } => wake::<AgentSurface>(&cli.socket, from, body).await,
}
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
}
// ---------- shared turn helpers ----------
@ -261,13 +220,6 @@ trait Surface {
/// generic `serve_loop` doesn't need the per-role Response enum
/// at all.
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
/// External `wake` subcommand (the `hive wake` CLI command, used
/// by co-process daemons like matrix to push events into the
/// harness inbox). Errors out via `anyhow::bail!` so the calling
/// binary surfaces them on stderr.
fn wake_external(socket: &Path, from: String, body: String)
-> impl Future<Output = Result<()>>;
}
// ---------- AgentSurface ----------
@ -383,16 +335,6 @@ impl Surface for AgentSurface {
}
}
}
async fn wake_external(socket: &Path, from: String, body: String) -> Result<()> {
let resp: AgentResponse =
client::request(socket, &AgentRequest::Wake { from, body }).await?;
match resp {
AgentResponse::Ok => Ok(()),
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
other => anyhow::bail!("wake: unexpected response {other:?}"),
}
}
}
// ---------- generic turn loop ----------
@ -673,20 +615,6 @@ async fn handle_turn<S: Surface>(
}
}
/// External `hive wake` subcommand — push a message into our own
/// inbox so the next turn fires with the given body. Reads the body
/// from stdin when `body == "-"`.
async fn wake<S: Surface>(socket: &Path, from: String, body: String) -> Result<()> {
let body = if body == "-" {
let mut buf = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
buf
} else {
body
};
S::wake_external(socket, from, body).await
}
#[cfg(test)]
mod continue_tests {
use super::{TurnControl, should_self_continue, synthetic_continue};

View file

@ -52,7 +52,7 @@ const TOKEN_RETRY_MAX: u32 = 20;
/// claim it's "new" — see docs/forge.md, "new vs activity on".
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
/// Spawn point: called once from `hive serve`. Returns immediately if the forge is not
/// Spawn point: called once from the `hive-agent` serve loop. Returns immediately if the forge is not
/// configured. Otherwise loops forever, polling every
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
///

View file

@ -1,6 +1,7 @@
//! Shared in-container harness code for the single `hive` binary that
//! serves every agent role (the manager is just an agent role, not a
//! separate binary).
//! Shared in-container harness code for the sibling `hive-agent` /
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
//! agent role (the manager is just an agent role, not a separate set
//! of binaries).
pub mod client;
pub mod events;

View file

@ -330,13 +330,13 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
}
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
/// the container (forwarded to the child as `--socket <path>`). Merges in
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
/// agent's NixOS config.
/// `mcp_binary` is the path (or PATH-resolvable name) of the
/// `hive-agent-mcp` bridge executable; `socket` is the hyperhive per-agent
/// socket bind-mounted into the container (forwarded to the child as
/// `--socket <path>`). Merges in any extra MCP servers declared via
/// `hyperhive.extraMcpServers` in the agent's NixOS config.
#[must_use]
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
pub fn render_claude_config(mcp_binary: &str, socket: &std::path::Path) -> String {
let mut servers = serde_json::Map::new();
// When the harness is configured to run the built-in server as a
// persistent streamable-http daemon (loopback port in
@ -353,8 +353,8 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
"url": format!("http://127.0.0.1:{port}/mcp"),
}),
None => serde_json::json!({
"command": agent_binary,
"args": ["--socket", socket.display().to_string(), "mcp"],
"command": mcp_binary,
"args": ["--socket", socket.display().to_string()],
"env": {}
}),
};

View file

@ -1,4 +1,5 @@
//! Pure helpers factored out of the `hive` serve loop (`bin/hive.rs`).
//! Pure helpers factored out of the harness serve loop
//! (`bin/hive-agent.rs`).
//! Only functions with no wire-type dependency live here;
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
//! stay in the binary because they touch the request enum variants directly.
@ -60,7 +61,7 @@ pub struct TurnRowArgs<'a> {
}
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
/// (rather than inline in the `hive` serve loop) so it stays wire-type-free
/// (rather than inline in the serve loop) so it stays wire-type-free
/// and unit-testable; the binary just feeds it the post-turn counts.
#[must_use]
pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow {

View file

@ -108,8 +108,9 @@ impl TurnFiles {
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
/// `socket` is the hyperhive per-container socket (forwarded to the child
/// as `--socket <path>`). The MCP subcommand is always `mcp` on the single
/// `hive` binary resolved from `/proc/self/exe`.
/// as `--socket <path>`). The MCP server is the `hive-agent-mcp` binary
/// installed next to the running `hive-agent` (resolved as a sibling of
/// `/proc/self/exe`; PATH-resolvable name as the fallback).
///
/// # Errors
///
@ -120,7 +121,8 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
let path = parent.join("claude-mcp-config.json");
let exe = std::env::current_exe()
.ok()
.map_or_else(|| "hive".into(), |p| p.display().to_string());
.and_then(|p| Some(p.parent()?.join("hive-agent-mcp")))
.map_or_else(|| "hive-agent-mcp".into(), |p| p.display().to_string());
let body = mcp_config::render_claude_config(&exe, socket);
tokio::fs::write(&path, body).await?;
tracing::info!(path = %path.display(), "wrote claude MCP config");
@ -146,7 +148,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
/// `hive` serve loop).
/// `hive-agent` serve loop).
pub type TurnOutcome = std::result::Result<bool, TurnError>;
/// The ways a turn can end without a usable result. Each is deliberately *not*
@ -287,7 +289,7 @@ pub fn make_session(bus: &Bus) -> AgentSession {
/// the whole turn is retried a single time before bubbling `AuthFailed` to
/// the serve loop (which parks for re-login).
///
/// Called once per turn by the `hive` serve loop, which owns the shared
/// Called once per turn by the `hive-agent` serve loop, which owns the shared
/// `session` ([`make_session`]) and threads it in.
pub async fn drive_turn(
prompt: &str,

View file

@ -898,12 +898,13 @@ in
streamable-http daemon on this loopback port instead of the
default per-turn stdio child.
When null (the default) claude spawns a fresh `hive mcp` stdio
subprocess every turn the shape that carries the per-turn MCP
re-registration race (a resumed turn can emit its first tool call
before that turn's async `initialize`/`tools-list` completes,
When null (the default) claude spawns a fresh `hive-agent-mcp`
stdio subprocess every turn the shape that carries the per-turn
MCP re-registration race (a resumed turn can emit its first tool
call before that turn's async `initialize`/`tools-list` completes,
stranding the agent with `No such tool`). When set, a long-lived
`hive-mcp-http` systemd unit runs `hive mcp --http 127.0.0.1:<port>`
`hive-mcp-http` systemd unit runs
`hive-agent-mcp --http 127.0.0.1:<port>`
and `render_claude_config` points claude at the stable
`http://127.0.0.1:<port>/mcp` URL, which survives the per-turn
claude re-spawn (and a host-side hive-c0re restart each tool call
@ -1846,7 +1847,7 @@ in
# `render_claude_config`). Long-lived so claude reconnects to the
# stable URL each turn instead of respawning + re-registering a stdio
# subprocess (the per-turn MCP registration race). It dials the
# control socket (`/run/hive/mcp.sock`, the `hive` binary default)
# control socket (`/run/hive/mcp.sock`, the harness binaries' default)
# fresh on every tool call, so a host-side hive-c0re restart is
# transparent. `before = hive-ag3nt` so the URL is already listening
# by the time the harness renders the first turn's config; the
@ -1858,7 +1859,7 @@ in
before = [ "hive-ag3nt.service" ];
environment.RUST_LOG = "info";
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
ExecStart = "${pkgs.hyperhive}/bin/hive-agent-mcp --http 127.0.0.1:${toString config.hyperhive.mcp.httpPort}";
SyslogIdentifier = "hive-mcp-http";
# `always` (not `on-failure`): this endpoint is load-bearing when
# `httpPort` is set — a down window is total hyperhive-MCP loss with
@ -1936,7 +1937,7 @@ in
# appends /bin to every entry.
systemd.services.hive-ag3nt =
let
binary = "hive";
binary = "hive-agent";
# OTEL is shipped declaratively via the managed claude settings
# json (`environment.etc."claude-code/managed-settings.json"`,
# `otelSettingsEnv` in the top-level let) — claude reads it for
@ -1982,7 +1983,7 @@ in
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
};
serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";
ExecStart = "${pkgs.hyperhive}/bin/${binary}";
# Pin the journal identity to the binary name (otherwise systemd
# derives SyslogIdentifier from the ExecStart basename).
SyslogIdentifier = binary;