//! Embedded MCP server. Claude Code (running inside the agent container) //! launches this as a stdio child via `--mcp-config`; tool calls land here //! and are translated to `AgentRequest::*` / `ManagerRequest::*` against //! hyperhive's own per-container unix socket at `/run/hive/mcp.sock`. //! //! Two protocols, two surfaces: //! - **hyperhive socket** at `/run/hive/mcp.sock` — JSON-line, our //! broker-routed protocol. Unaffected by this module. //! - **MCP stdio** owned by this module — what claude actually speaks. //! //! Two server flavors: //! - `AgentServer` — sub-agent tools (`send`, `recv`). //! - `ManagerServer` — agent tools + lifecycle (`kill`, //! `request_init_config`, `request_apply_commit`). //! //! Both go through the same `run_tool_envelope` helper so logging + status //! line stay uniform. use std::future::Future; use std::path::PathBuf; use anyhow::Result; use rmcp::{ ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler, tool_router, transport::stdio, }; use crate::client; /// Wire-protocol-agnostic view of a hyperhive socket response. Both /// `AgentResponse` and `ManagerResponse` convert into this so the tool /// formatters can be shared between `AgentServer` and `ManagerServer`. #[derive(Debug)] pub enum SocketReply { Ok, Err(String), /// Unified `recv` result: zero or more messages popped in one /// round-trip. Empty vec = "(empty)" path; single-message = the /// standard wake body; multi = batch render with per-message /// separators. Per-row `id` is opaque to claude (the bin loops /// drive ack via `AckTurn`, not per-id); `redelivered` triggers /// the "may already be handled" banner in `format_recv` for that /// specific row. Messages(Vec), Status(u64), QuestionQueued(i64), Recent(Vec), Logs(String), HostJournal(String), /// `list_schedules` result — used by the manager surface only; /// `AgentResponse` has no equivalent variant. Schedules(Vec), LooseEnds(Vec), PendingRemindersCount(u64), ReminderRollup(hive_sh4re::ReminderStats), AgentMeta { name: String, running: bool, hyperhive_rev: Option, status_text: Option, status_set_at: Option, hive_name: Option, swarm_name: Option, }, } impl From for SocketReply { fn from(r: hive_sh4re::Response) -> Self { match r { hive_sh4re::Response::Ok => Self::Ok, hive_sh4re::Response::Err { message } => Self::Err(message), hive_sh4re::Response::Messages { messages } => Self::Messages(messages), hive_sh4re::Response::Status { unread } => Self::Status(unread), hive_sh4re::Response::Recent { rows } => Self::Recent(rows), hive_sh4re::Response::QuestionQueued { id } => Self::QuestionQueued(id), hive_sh4re::Response::LooseEnds { loose_ends } => Self::LooseEnds(loose_ends), hive_sh4re::Response::PendingRemindersCount { count } => { Self::PendingRemindersCount(count) } hive_sh4re::Response::ReminderRollup(stats) => Self::ReminderRollup(stats), hive_sh4re::Response::Logs { content } => Self::Logs(content), hive_sh4re::Response::HostJournal { content } => Self::HostJournal(content), hive_sh4re::Response::Schedules { schedules } => Self::Schedules(schedules), hive_sh4re::Response::AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, } => Self::AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, }, } } } /// Format helper for "send-like" tools (anything that expects an `Ok`). /// `tool` and `ok_msg` only appear in the result string; they don't change /// behavior. #[must_use] pub fn format_ack(resp: Result, tool: &str, ok_msg: String) -> String { match resp { Ok(SocketReply::Ok) => ok_msg, Ok(SocketReply::Err(m)) => format!("{tool} failed: {m}"), Ok(other) => format!("{tool} unexpected response: {other:?}"), Err(e) => format!("{tool} transport error: {e:#}"), } } /// Format helper for `recv`: renders zero, one, or many popped /// messages. Empty list collapses to "(empty)" so claude doesn't go /// hunting for content. A single message renders as the historical /// `from: X\n\nbody` block (banner first if `redelivered`). A /// multi-message batch renders with a `popped N message(s):` header /// and `---` separators between bodies so the model can tell where /// one ends and the next begins; per-message redelivery banners /// included. #[must_use] pub fn format_recv(resp: Result) -> String { use std::fmt::Write as _; let messages = match resp { Ok(SocketReply::Messages(m)) => m, Ok(SocketReply::Err(m)) => return format!("recv failed: {m}"), Ok(other) => return format!("recv unexpected response: {other:?}"), Err(e) => return format!("recv transport error: {e:#}"), }; if messages.is_empty() { return "(empty)".to_owned(); } if messages.len() == 1 { let m = &messages[0]; let banner = if m.redelivered { REDELIVERY_HINT } else { "" }; return format!("{banner}from: {}\n\n{}", m.from, m.body); } let n = messages.len(); let mut out = format!("popped {n} message(s):\n\n"); for (i, m) in messages.iter().enumerate() { if i > 0 { out.push_str("\n---\n\n"); } let banner = if m.redelivered { REDELIVERY_HINT } else { "" }; let _ = write!(out, "{banner}from: {}\n\n{}", m.from, m.body); } out } /// Header prepended to message bodies that were popped by a prior /// harness session, never acked (turn crash / OOM / restart), and /// resurfaced by `RequeueInflight` on this session's boot. Same /// string surfaces in the wake prompt (see the bin loops) and the /// in-turn `recv` tool result so claude sees the warning either way. pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n"; /// Inner renderer for a `Vec` already extracted from the /// socket reply. Called by both `format_loose_ends` (which handles the /// `Result` wrapper) and the augmented `get_loose_ends` /// handler (which injects the `UnreadMatrix` entry before formatting). fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { use std::fmt::Write as _; if loose_ends.is_empty() { return "(no loose ends)".to_owned(); } let mut out = format!("{} loose end(s):\n", loose_ends.len()); for t in loose_ends { match t { hive_sh4re::LooseEnd::Approval { id, agent, commit_ref, description, age_seconds, } => { let desc = description .as_deref() .map(|d| format!(" — {d}")) .unwrap_or_default(); let _ = writeln!( out, "- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}" ); } hive_sh4re::LooseEnd::Question { id, asker, target, question, age_seconds, } => { let to = target.as_deref().unwrap_or("operator"); let _ = writeln!( out, "- question #{id} ({asker} → {to}, {age_seconds}s old): {question}" ); } hive_sh4re::LooseEnd::Reminder { id, owner, message, due_at, age_seconds, } => { let _ = writeln!( out, "- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}" ); } hive_sh4re::LooseEnd::UnreadMatrix { rooms, summary } => { let _ = write!(out, "- unread matrix messages in {rooms} room(s)"); if summary.is_empty() { let _ = writeln!( out, " — use list_rooms + read_room to view, mark_read to clear" ); } else { let _ = writeln!(out, ":"); for line in summary.lines() { let _ = writeln!(out, " {line}"); } let _ = writeln!( out, " use list_rooms + read_room to view, mark_read to clear" ); } } } } out } /// Format helper for `get_loose_ends`: renders a short bulleted list /// of pending approvals + questions + reminders. Empty list collapses /// to a clear marker so claude doesn't go hunting for a payload that /// isn't there. #[must_use] pub fn format_loose_ends(resp: Result) -> String { let loose_ends = match resp { Ok(SocketReply::LooseEnds(t)) => t, Ok(SocketReply::Err(m)) => return format!("get_loose_ends failed: {m}"), Ok(other) => return format!("get_loose_ends unexpected response: {other:?}"), Err(e) => return format!("get_loose_ends transport error: {e:#}"), }; render_loose_ends(&loose_ends) } /// Per-room unread entry returned by `matrix_unread_summary`. Mirrors /// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a /// cross-crate dep on the matrix-sdk crate tree. #[derive(Debug, serde::Deserialize)] struct MatrixRoomUnread { label: String, count: u32, last_body: Option, last_sender: Option, } /// Query the local matrix daemon for per-room unread summaries. Returns /// `None` if the daemon socket is absent or the query fails. Best-effort: /// agents without matrix configured are not penalised. async fn matrix_unread_summary() -> Option> { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; let socket = std::env::var_os("HIVE_MATRIX_SOCKET") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from("/run/hive-matrix/socket")); if !socket.exists() { return None; } let mut stream = UnixStream::connect(&socket).await.ok()?; stream .write_all(b"{\"method\":\"unread_summary\"}\n") .await .ok()?; let mut lines = BufReader::new(stream).lines(); let line = lines.next_line().await.ok()??; let val: serde_json::Value = serde_json::from_str(&line).ok()?; // Response: {"kind":"ok","payload":[{label, count, last_body?, last_sender?}]} let arr = val.get("payload")?.as_array()?; serde_json::from_value(serde_json::Value::Array(arr.clone())).ok() } /// Format a `Vec` into a per-room summary string. /// Single room / single message collapses to one line; multi-room /// expands to a bulleted list. Returns an empty string for empty input. fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String { use std::fmt::Write as _; if rooms.is_empty() { return String::new(); } let mut out = String::new(); for r in rooms { if r.count == 1 { if let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender) { let _ = writeln!(out, "- {}: {sender}: {body}", r.label); continue; } } let _ = writeln!(out, "- {}: {} unread", r.label, r.count); } // Remove trailing newline. if out.ends_with('\n') { out.pop(); } out } /// Parse the user-facing `kind` string for `cancel_loose_end` into the /// wire enum. Accepts a small alias set so claude doesn't have to /// remember the exact spelling (`"q"` / `"r"` shorthand falls out /// for free). fn parse_loose_end_kind(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question), "reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder), "approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval), other => Err(format!( "cancel_loose_end: unknown kind '{other}' \ (expected \"question\", \"reminder\", or \"approval\")" )), } } /// Canonical user-facing label for a `CancelLooseEndKind` — used in /// the success ack so the caller always sees `"question"` / /// `"reminder"` instead of whatever alias they passed in (`"q"` / /// `"r"`). fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { match kind { hive_sh4re::CancelLooseEndKind::Question => "question", hive_sh4re::CancelLooseEndKind::Reminder => "reminder", hive_sh4re::CancelLooseEndKind::Approval => "approval", } } /// Format helper for `get_agent_meta`: renders an agent's identity + /// current status as a short human-readable block. `name`, `role`, /// `hyperhive_rev`, and `running` are always shown; `status` only /// appears when one is set, otherwise the line reads `status: `. /// When `running` is false the host has already cleared `status_text` /// (it would be a stale snapshot from before the stop) so the status /// line is implicitly `` in that case — but the explicit /// `running: no` line tells the caller WHY. See /// `docs/turn-loop.md::Sub-agent tools` (`get_agent_meta`). #[must_use] pub fn format_agent_meta(resp: Result) -> String { match resp { Ok(SocketReply::AgentMeta { name, running, hyperhive_rev, status_text, status_set_at, hive_name, swarm_name, }) => { let rev = hyperhive_rev.as_deref().unwrap_or(""); let run = if running { "yes" } else { "no" }; let mut out = format!("name: {name}\nhyperhive_rev: {rev}\nrunning: {run}"); // Surface hive + swarm display names only when set, so // single-hive deployments don't see noisy `` lines. if let Some(hn) = hive_name.as_deref() { use std::fmt::Write as _; let _ = write!(out, "\nhive_name: {hn}"); } if let Some(sn) = swarm_name.as_deref() { use std::fmt::Write as _; let _ = write!(out, "\nswarm_name: {sn}"); } match status_text { None => out.push_str("\nstatus: "), Some(s) => { use std::fmt::Write as _; let age = status_set_at.and_then(|ts| { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .ok()? .as_secs(); // `ts` is a unix epoch second the agent itself // sourced from `SystemTime` — always positive // in normal operation. Clamp the negative // (clock-skew) edge to 0 before the unsigned // cast so the cast loses no real precision. let ts_secs = u64::try_from(ts).unwrap_or(0); let secs = now.saturating_sub(ts_secs); Some(format_age_secs(secs)) }); // `write!` into the buffer instead of `push_str(&format!(…))` — // avoids the intermediate allocation clippy::format_push_string // flags. The infallible `String` writer makes this safe to // `let _ =`-ignore. match age { Some(a) => { let _ = write!(out, "\nstatus: {s} (set {a} ago)"); } None => { let _ = write!(out, "\nstatus: {s}"); } } } } out } Ok(SocketReply::Err(m)) => format!("get_agent_meta failed: {m}"), Ok(other) => format!("get_agent_meta unexpected response: {other:?}"), Err(e) => format!("get_agent_meta transport error: {e:#}"), } } /// Format a duration in seconds as a human-readable age string. fn format_age_secs(secs: u64) -> String { if secs < 60 { format!("{secs}s") } else if secs < 3600 { format!("{}m", secs / 60) } else if secs < 86400 { format!("{}h", secs / 3600) } else { format!("{}d", secs / 86400) } } /// Common envelope around every MCP tool handler: pre-log → run → /// post-log. The inbox-status hint used to be appended to every tool /// result; that lives in the wake prompt + UI header now, so tool /// results stay clean. pub async fn run_tool_envelope(tool: &'static str, args: String, body: F) -> String where F: Future, { tracing::info!(tool, %args, "tool: request"); let result = body.await; tracing::info!(tool, result = %result, "tool: result"); result } /// Append a short note to a tool result when the underlying socket call /// took retries to land. Lets claude distinguish "my request was wrong" /// from "c0re flickered and the harness rode it out" — without the /// hint, a tool result that took 30s to come back looks identical to a /// content failure and the model would burn a turn retrying it. #[must_use] pub fn annotate_retries(mut s: String, retries: u32) -> String { if retries > 0 { use std::fmt::Write as _; let suffix = if retries == 1 { "retry" } else { "retries" }; let _ = write!( s, "\n\n(note: hive socket connect needed {retries} {suffix} — c0re likely \ restarted. Your request did succeed on the final attempt; no action needed.)" ); } s } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SendArgs { /// Logical agent name to deliver the message to (e.g. `"manager"`, /// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box). pub to: String, /// Message body. Plain text; the broker doesn't parse it. pub body: String, /// Optional broker row-id of the message this is a reply to. Lets /// the dashboard render conversation threads. Pass the `id` from the /// `DeliveredMessage` you're responding to; omit for new threads. /// Silently ignored if the id is unknown or out of retention. #[serde(default)] pub in_reply_to: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RecvArgs { /// How long to long-poll for the FIRST message before returning /// the empty marker. Capped at 60s server-side. Default (None) /// is 30s. Useful when an agent wants to park its turn waiting /// for any new work — pick a longer wait to coalesce bursts. #[serde(default)] pub wait_seconds: Option, /// Maximum number of messages to pop in this round-trip. Default /// (None) is 1 (single-message behaviour — exactly what you want /// when you're called to drive a turn off the first wake). Pass /// a higher value (capped at 32 server-side) when you've been /// told the inbox has more queued (the wake prompt mentions /// pending count) and want to drain everything in one tool call. /// Once the long-poll wakes up, the call drains up to `max` in /// total before returning — no extra round-trip needed. #[serde(default)] pub max: Option, } /// MCP tool args for `bash_run`. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct BashRunArgs { /// Shell command to run (passed to `sh -c`). pub cmd: String, /// Timeout in seconds. Defaults to 180. Task is killed and marked /// `timed_out` when the limit is exceeded. #[serde(default)] pub timeout_secs: Option, /// Optional inline wait: if set, `bash_run` polls for up to /// `wait_seconds` (capped at 30) before returning. When the task /// finishes within the window the full status is returned immediately /// — no separate `bash_status` call needed. When the timeout expires /// before the command finishes, the task keeps running in the /// background and the usual `task started: id=` response is /// returned. Useful for fast commands (< 5 s) that would otherwise /// force an unnecessary round-trip. #[serde(default)] pub wait_seconds: Option, } /// MCP tool args for `bash_status`. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct BashStatusArgs { /// Task ID returned by `bash_run`. pub id: String, } /// Format the result of `bash_status` from a task ID. /// /// Includes inline output tails (up to [`crate::bash_runner::SUMMARY_BYTES`]) /// and, when the full output file is larger than the inline tail, appends /// the file path so the caller can read the rest with the `Read` tool. #[must_use] fn format_bash_status(id: &str) -> String { use std::fmt::Write as _; let Some(task) = crate::bash_runner::read_task(id) else { return format!("bash_status: unknown task id `{id}`"); }; let mut out = format!("task `{id}`: status={status:?}", status = task.status); if let Some(code) = task.exit_code { let _ = write!(out, ", exit={code}"); } if let Some(t) = task.started_at && task.completed_at.is_none() { let age = crate::serve_common::now_unix() - t; let _ = write!(out, ", running for {age}s"); } if let Some(t) = task.completed_at { if let Some(s) = task.started_at { let _ = write!(out, ", took {}s", t - s); } } // Inline tail for stdout. let out_path = crate::bash_runner::task_out_path(id); let out_file_len = std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0); if let Some(ref stdout) = task.stdout_tail { if !stdout.trim().is_empty() { let _ = write!(out, "\n\nstdout:\n```\n{}\n```", stdout.trim()); } } if out_file_len > crate::bash_runner::SUMMARY_BYTES as u64 { let _ = write!(out, "\n\nFull stdout lives in `{}`", out_path.display()); } // Inline tail for stderr. let err_path = crate::bash_runner::task_err_path(id); let err_file_len = std::fs::metadata(&err_path).map(|m| m.len()).unwrap_or(0); if let Some(ref stderr) = task.stderr_tail { if !stderr.trim().is_empty() { let _ = write!(out, "\n\nstderr:\n```\n{}\n```", stderr.trim()); } } if err_file_len > crate::bash_runner::SUMMARY_BYTES as u64 { let _ = write!(out, "\n\nFull stderr lives in `{}`", err_path.display()); } out } /// MCP tool args for `remind`. Exactly one of `delay_seconds` or /// `at_unix_timestamp` must be set; both / neither is a tool-side error. /// Hides the tagged `ReminderTiming` enum behind a flatter schema so the /// model picks one field instead of building `{"timing_type": "in_seconds", /// "seconds": 60}` shaped objects. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RemindArgs { /// Body that lands in your inbox when the reminder fires (sender /// will appear as `reminder`). Soft cap at 4 KiB inline — anything /// larger gets auto-persisted to a file under /// `/agents//state/reminders/auto-.md` and the inbox /// message becomes a short pointer. Pass `file_path` if you want /// to control the destination yourself. pub message: String, /// Fire `delay_seconds` from now (relative). Set this OR /// `at_unix_timestamp`, not both. #[serde(default)] pub delay_seconds: Option, /// Fire at this absolute unix timestamp (seconds since epoch). Set /// this OR `delay_seconds`, not both. #[serde(default)] pub at_unix_timestamp: Option, /// Optional path to a file the scheduler should reference instead of /// inlining a long `message`. Use this for large payloads (research /// notes, file lists, intermediate state). Path must be reachable from /// the agent's container — typically under `/agents//state/`. #[serde(default)] pub file_path: Option, } /// Per-agent tool surface. Holds the socket path so each tool call doesn't /// re-derive it; the socket itself is the per-container `/run/hive/mcp.sock`. #[derive(Debug, Clone)] pub struct AgentServer { socket: PathBuf, } impl AgentServer { #[must_use] pub fn new(socket: PathBuf) -> Self { Self { socket } } /// Issue any `AgentRequest` through the retry-aware client and pull /// the reply through `SocketReply`. Returns the retry count so tool /// handlers can annotate their result (see `annotate_retries`). async fn dispatch( &self, req: hive_sh4re::AgentRequest, ) -> (Result, u32) { match client::request_retried::<_, hive_sh4re::AgentResponse>(&self.socket, &req).await { Ok((r, n)) => (Ok(SocketReply::from(r)), n), Err(e) => (Err(e), 0), } } } // IMPORTANT: when adding a new `#[tool]` fn to this impl, also add // its name to the matching `ToolGroup::tools()` slice in hive-sh4re. // Claude Code's permission gate refuses uninlisted MCP tools in // non-interactive `--print` mode with "permissions not granted yet". #[tool_router] impl AgentServer { #[tool( description = "Send a message to another hyperhive agent (or to the operator). \ Use this to talk to peers or to surface output for the human at the dashboard." )] async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); if let Err(refusal) = check_send_allowed(&to) { return run_tool_envelope("send", log, async move { refusal }).await; } run_tool_envelope("send", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::Send { to: args.to, body: args.body, in_reply_to: args.in_reply_to, }) .await; annotate_retries(format_ack(resp, "send", format!("sent to {to}")), retries) }) .await } #[tool( description = "Surface a structured question to either the operator OR a peer agent. \ Returns immediately with a question id — do NOT wait inline. When the recipient \ answers, a system message with event `question_answered { id, question, answer, \ answerer }` lands in your inbox; handle it on a future turn. \n\n\ Recipient: omit `to` (or set `to: \"operator\"`) for the human operator on the \ dashboard. Set `to: \"\"` to ask a peer agent — they receive a \ `question_asked { id, asker, question, options, multi }` event in their inbox \ and answer via `mcp__hyperhive__answer`. \n\n\ `options` is advisory: pass a short fixed-choice list when applicable, otherwise \ leave empty for free text. Set `multi: true` to let the answerer pick multiple \ options (checkboxes on the dashboard, hint to the agent otherwise) — answer comes \ back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ no-longer-relevant question — on expiry the answer is `[expired]` (with \ `answerer: \"ttl-watchdog\"`) and the same `question_answered` event fires." )] async fn ask(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("ask", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::Ask { question: args.question, options: args.options, multi: args.multi, ttl_seconds: args.ttl_seconds, to: args.to, }) .await; let s = match resp { Ok(SocketReply::QuestionQueued(id)) => format!( "question queued (id={id}); answer will arrive as a system \ `question_answered` event in your inbox" ), Ok(SocketReply::Err(m)) => format!("ask failed: {m}"), Ok(other) => format!("ask unexpected response: {other:?}"), Err(e) => format!("ask transport error: {e:#}"), }; annotate_retries(s, retries) }) .await } #[tool( description = "Answer a question that was routed to YOU via a `question_asked` system \ event in your inbox. Pass the `id` from that event and your `answer` string. The \ answer will surface in the asker's inbox as a `question_answered { id, question, \ answer, answerer: }` event. \n\n\ Authorisation is strict — you can only answer questions where you are the declared \ target (i.e. the asker did `ask(to: \"\", ...)`). Trying to answer an \ operator-targeted question or a question addressed to a different agent will fail." )] async fn answer(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let id = args.id; run_tool_envelope("answer", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::Answer { id, answer: args.answer, }) .await; annotate_retries( format_ack(resp, "answer", format!("answered question {id}")), retries, ) }) .await } #[tool( description = "Pop messages from this agent's inbox. Returns one or more messages, or \ an empty marker if nothing is waiting. \n\n\ **Single-message default**: with no args (or `max: 1`) you get the next message — \ same behaviour the harness uses to drive a turn. Without `wait_seconds` (or with 0) \ the call returns immediately — a cheap 'anything pending?' peek. Pass a positive \ `wait_seconds` (capped at 180) to park the turn waiting for new work — incoming \ messages wake you instantly, otherwise the call returns empty at the timeout. \ That's strictly better than a fixed shell `sleep`. \n\n\ **Batch drain**: pass `max: N` (capped at 32) to drain up to N messages in one \ round-trip. Use this when the wake prompt told you the inbox has more queued, or \ any time you expect a burst — one tool call beats N consecutive single recvs. \ `wait_seconds` still applies to the FIRST message; once one arrives the call drains \ up to `max` in total. Empty result reported the same way regardless of `max`. \n\n\ Typical pattern: when you have nothing else useful to do, call \ `recv(wait_seconds: 180)` to park until something arrives." )] async fn recv(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("recv", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::Recv { wait_seconds: args.wait_seconds, max: args.max, }) .await; annotate_retries(format_recv(resp), retries) }) .await } #[tool( description = "List loose ends pending against this agent: unanswered questions \ where you are the asker (waiting on someone) or the target (someone's waiting on \ you), pending reminders you scheduled, plus — for the manager only — pending \ approvals you submitted that the operator hasn't acted on yet. Also lists any \ local bash tasks still in pending or running state. Cheap sweep, no args. Useful \ at turn start to remember what you owe / what's owed to you without scrolling \ inbox history. Output is a short bulleted list with ids, ages in seconds, and \ the relevant context. Each `question` or `reminder` row can be cancelled by \ passing its id + kind to `cancel_loose_end`. Empty result is reported clearly.\n\ Pass `agent: \"\"` to inspect a specific peer agent's threads. Direct \ child agents are always accessible. For non-children, the `query_agent_state` \ capability is required — without it the request is rejected with an error." )] async fn get_loose_ends(&self, Parameters(args): Parameters) -> String { run_tool_envelope("get_loose_ends", String::new(), async move { let is_self_query = args.agent.is_none(); let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent }) .await; // Extract the vec so we can augment before rendering. let mut loose_ends = match resp { Ok(SocketReply::LooseEnds(t)) => t, Ok(SocketReply::Err(m)) => { return annotate_retries(format!("get_loose_ends failed: {m}"), retries); } Ok(other) => { return annotate_retries( format!("get_loose_ends unexpected response: {other:?}"), retries, ); } Err(e) => { return annotate_retries( format!("get_loose_ends transport error: {e:#}"), retries, ); } }; // Prepend matrix unread entry for self-queries only (can't // reach another agent's matrix daemon from here). if is_self_query { if let Some(unread_rooms) = matrix_unread_summary().await { let total = unread_rooms.len() as u32; if total > 0 { let summary = format_matrix_summary(&unread_rooms); loose_ends.insert( 0, hive_sh4re::LooseEnd::UnreadMatrix { rooms: total, summary, }, ); } } } let mut out = annotate_retries(render_loose_ends(&loose_ends), retries); // Append any local bash tasks still in pending/running state so // the agent sees all outstanding work in one call. let active = crate::bash_runner::active_tasks(); if !active.is_empty() { use std::fmt::Write as _; let _ = write!(out, "\n\n{} active bash task(s):", active.len()); for task in &active { let age = crate::serve_common::now_unix() - task.created_at; let _ = write!( out, "\n- `{}` status={:?}, cmd: `{}`, age {}s", task.id, task.status, task.cmd, age ); } } out }) .await } #[tool( description = "Set a free-text status string visible on the operator dashboard. \ Call this at the START of every task to describe what you're working on (e.g. \ `\"processing matrix messages\"`, `\"fixing bitburner crash\"`, `\"idle\"`). Pass an empty \ string to clear. The status is shown on your dashboard card and persists across \ harness restarts." )] async fn set_status(&self, Parameters(args): Parameters) -> String { run_tool_envelope("set_status", args.text.clone(), async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::SetStatus { text: args.text }) .await; annotate_retries( format_ack(resp, "set_status", "status updated".to_owned()), retries, ) }) .await } #[tool( description = "Fetch identity + status metadata for an agent. Returns canonical \ `name`, the current `hyperhive_rev` hive-c0re is \ running against, and the target's self-reported `status` text (set via \ `set_status`) plus how long ago it was set. Pass `name` to query a peer (e.g. \ check whether iris is idle before pinging them); omit `name` to get your own \ identity stamp — handy for state files / commit messages / cross-agent \ attribution that won't drift across renames or session-continue boundaries \ where the system-prompt label could be stale. Status reads `` when the \ target has never called `set_status` or has cleared it." )] async fn get_agent_meta(&self, Parameters(args): Parameters) -> String { let log = args.name.clone().unwrap_or_else(|| "".to_owned()); run_tool_envelope("get_agent_meta", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::GetAgentMeta { name: args.name }) .await; annotate_retries(format_agent_meta(resp), retries) }) .await } #[tool( description = "Cancel an open thread you own — a `question` you asked (the \ asker gets `[cancelled by ]` as the answer and unblocks) or a `reminder` \ you scheduled (hard-deleted before it fires). `kind` is `\"question\"` or \ `\"reminder\"`; `id` is the row id from the matching `get_loose_ends` entry \ or the `question_queued` reply you got when you submitted. Auth: you can only \ cancel rows where you're the asker / owner. Returns `ok` or an error string." )] async fn cancel_loose_end(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let id = args.id; run_tool_envelope("cancel_loose_end", log, async move { let kind = match parse_loose_end_kind(&args.kind) { Ok(k) => k, Err(e) => return e, }; let kind_label = loose_end_kind_label(kind); let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::CancelLooseEnd { kind, id }) .await; annotate_retries( format_ack( resp, "cancel_loose_end", format!("cancelled {kind_label} {id}"), ), retries, ) }) .await } #[tool( description = "Schedule a reminder that lands in this agent's own inbox at a future \ time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \ status in 60s', 'retry failed deploy at 14:00 UTC', 'nudge me when the operator's \ deploy window opens'. Set EXACTLY ONE of `delay_seconds` (fire N seconds from now) \ or `at_unix_timestamp` (fire at absolute epoch second). Body soft-caps at 4 KiB \ inline — anything larger gets auto-persisted to a file under your \ `/agents//state/reminders/` dir and the inbox message becomes a short pointer; \ pass `file_path` if you want to control the destination yourself. Returns \ immediately — the reminder lives in the broker until due." )] async fn remind(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("remind", log, async move { let timing = match (args.delay_seconds, args.at_unix_timestamp) { (Some(_), Some(_)) => { return "remind failed: pass exactly one of `delay_seconds` or \ `at_unix_timestamp`, not both" .to_string(); } (None, None) => { return "remind failed: pass exactly one of `delay_seconds` or \ `at_unix_timestamp`" .to_string(); } (Some(s), None) => hive_sh4re::ReminderTiming::InSeconds { seconds: s }, (None, Some(t)) => hive_sh4re::ReminderTiming::At { unix_timestamp: t }, }; let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::Remind { message: args.message, timing, file_path: args.file_path, }) .await; annotate_retries( format_ack(resp, "remind", "reminder scheduled".to_string()), retries, ) }) .await } #[tool( description = "Run a shell command in the background. Returns a task ID immediately — \ do NOT wait inline. When the command finishes, the harness fires a wake with \ `from: \"bash-task-\"` and the exit code + last stdout lines in the body; \ handle it on a future turn. Use `bash_status` to poll the task status within \ the same turn if needed. `timeout_secs` defaults to 180. \ Pass `wait_seconds` (capped at 30) to wait inline for fast commands: when the \ task finishes within the window the full status is returned immediately and no \ wake is fired; when the timeout expires the task keeps running and the normal \ `task started: id=` response is returned." )] async fn bash_run(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("bash_run", log, async move { let id = match crate::bash_runner::submit_task(args.cmd, args.timeout_secs) { Ok(id) => id, Err(e) => return format!("bash_run failed: {e:#}"), }; // Inline wait: poll until done or deadline, whichever comes first. if let Some(wait) = args.wait_seconds { const MAX_WAIT_SECS: u64 = 30; const POLL_MS: u64 = 100; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait.min(MAX_WAIT_SECS)); loop { tokio::time::sleep(std::time::Duration::from_millis(POLL_MS)).await; if let Some(task) = crate::bash_runner::read_task(&id) { use crate::bash_runner::TaskStatus; if matches!( task.status, TaskStatus::Done | TaskStatus::TimedOut | TaskStatus::Interrupted ) { return format_bash_status(&id); } } if tokio::time::Instant::now() >= deadline { break; } } } format!("task started: id={id}") }) .await } #[tool( description = "Check the status of a background bash task by its ID (from `bash_run`). \ Returns the current status (pending/running/done/timed_out/interrupted), exit code \ if finished, and a tail of stdout/stderr. Full output lives in \ `harness/bash-tasks/.out` / `.err`." )] async fn bash_status(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope( "bash_status", log, async move { format_bash_status(&args.id) }, ) .await } #[tool( description = "Ask the harness to start another turn immediately after this one \ completes, even if the inbox is empty. Use this when you have ongoing work that \ spans multiple turns (long builds, multi-step tasks) and you want to continue \ without waiting for an external message. The next turn will start with \ `from: \"self\"` and `body: \"continue\"`. Has no effect if a new inbox message \ arrives before this turn ends — the harness already loops immediately on pending \ messages. No args." )] async fn request_next_turn(&self) -> String { run_tool_envelope("request_next_turn", String::new(), async move { let sentinel = crate::paths::state_dir().join("hyperhive-continue"); match std::fs::write(&sentinel, b"") { Ok(()) => "ok — harness will start another turn immediately after this one", Err(e) => { tracing::warn!(error = %e, path = %sentinel.display(), "request_next_turn: write failed"); return format!("request_next_turn failed: {e}"); } } .to_string() }) .await } // IMPORTANT: this tool is capability-gated (`read_host_journal`). // It is added to `--allowedTools` by `allowed_capability_tools` only // when `HIVE_CAPABILITIES` contains `read_host_journal`. hive-c0re // performs a second capability check server-side before running journalctl. #[tool( description = "Fetch recent lines from the host journal (requires `read_host_journal` \ capability). All filters are optional - omit to get the last N host journal lines. \ `unit`: filter to a systemd unit (e.g. `hive-c0re.service`). \ `container`: nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. \ `lines`: how many lines (default 30, max 100). \ `priority`: minimum syslog level enum. \ `grep`: regex matched against log message fields (journalctl --grep). \ `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). \ `until`: show entries on or older than this." )] async fn get_host_journal(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("get_host_journal", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::AgentRequest::GetHostJournal { unit: args.unit, container: args.container, lines: args.lines, priority: args.priority, grep: args.grep, since: args.since, until: args.until, }) .await; let result = match resp { Ok(SocketReply::HostJournal(content)) => content, Ok(SocketReply::Err(m)) => format!("get_host_journal failed: {m}"), Ok(other) => format!("get_host_journal unexpected response: {other:?}"), Err(e) => format!("get_host_journal transport error: {e:#}"), }; annotate_retries(result, retries) }) .await } } #[tool_handler( instructions = "You are a hyperhive agent. Use `send` to talk to peers (by their logical \ name) or to the operator (recipient `operator`). Use `recv` to drain your inbox one \ message at a time. Use `remind` to schedule a future wake-up message for yourself." )] impl ServerHandler for AgentServer {} /// Run the agent MCP server over stdio. Returns when the client disconnects. /// /// # Errors /// /// Returns an error if the MCP server fails to initialize or the transport /// encounters a fatal error. pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> { let server = AgentServer::new(socket); let service = server.serve(stdio()).await?; service.waiting().await?; Ok(()) } /// Run the manager MCP server over stdio. Same idea, different tool surface. /// /// # Errors /// /// Returns an error if the MCP server fails to initialize or the transport /// encounters a fatal error. pub async fn serve_manager_stdio(socket: PathBuf) -> Result<()> { let server = ManagerServer::new(socket); let service = server.serve(stdio()).await?; service.waiting().await?; Ok(()) } // ----------------------------------------------------------------------------- // Manager tool surface // ----------------------------------------------------------------------------- #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RequestInitConfigArgs { /// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on /// approval hive-c0re seeds the proposed config repo at /// `/agents//config/agent.nix` with the default template. After /// the approval the manager edits + commits the config and calls /// `request_apply_commit` to pin the customised sha for the container's /// first build. pub name: String, /// Optional description shown on the dashboard approval card. #[serde(default)] pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct KillArgs { /// Sub-agent name (without the `h-` container prefix). pub name: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct SetStatusArgs { /// Status text to display on the dashboard card. Pass an empty string to clear. pub text: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetAgentMetaArgs { /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). /// Omit to query your own identity + status — replaces the /// previous `whoami` self-introspection tool. #[serde(default)] pub name: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct StartArgs { /// Sub-agent name (without the `h-` container prefix). pub name: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RestartArgs { /// Sub-agent name (without the `h-` container prefix). pub name: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateArgs { /// Sub-agent name (without the `h-` container prefix). pub name: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AskArgs { /// The question to surface. pub question: String, /// Optional fixed-choice answers. The dashboard renders these as /// chips alongside a free-text fallback ("Other…") so the operator /// is never trapped by an incomplete list; peer-agent recipients /// see the list in their inbox event and can return any string. #[serde(default)] pub options: Vec, /// When true, options are rendered as checkboxes — the answerer /// can pick any subset. The answer comes back as a single string /// with selections joined by ", ". Ignored when `options` is empty. #[serde(default)] pub multi: bool, /// Optional auto-cancel after `ttl_seconds` (capped server-side at /// 6 hours). On expiry the question resolves with answer /// `[expired]` and the asker receives the usual /// `question_answered` system event (with `answerer: /// "ttl-watchdog"`). `None` (default) = wait indefinitely. #[serde(default)] pub ttl_seconds: Option, /// Recipient. Omit (or pass `"operator"`) to ask the human /// operator via the dashboard. Pass another agent's logical name /// to ask that peer — they receive a `question_asked` event in /// their inbox and answer via `mcp__hyperhive__answer`. #[serde(default)] pub to: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AnswerArgs { /// Id of the question being answered — comes from the /// `question_asked` event in your inbox. pub id: i64, /// Free-text answer body. Soft-capped at 4 KiB by the same /// `MESSAGE_MAX_BYTES` limit as `send`; keep it short or write the /// detail to a file and pass a path. pub answer: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct CancelLooseEndArgs { /// Which kind of thread to cancel — `"question"` for an open /// `ask` that's still waiting on an answer, `"reminder"` for a /// scheduled `remind` that hasn't fired yet. Use the `kind` /// field straight off the `get_loose_ends` row. pub kind: String, /// Row id from the matching `get_loose_ends` entry (or the /// `question_queued` reply when you submitted it). pub id: i64, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetLooseEndsArgs { /// Whose loose ends to list. Omit (or `null`) for your own — the /// manager's: approvals you submitted + questions where you are /// asker/target + your own pending reminders. Pass `"*"` for a /// hive-wide view of EVERY pending approval, unanswered question, /// and reminder across the swarm. Pass a specific agent name to /// inspect just that agent's threads. #[serde(default)] pub agent: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AgentGetLooseEndsArgs { /// Whose loose ends to list. Omit (or `null`) for your own. You may /// also pass a direct child agent's name without any extra capability. /// Pass any other agent name to inspect their threads — requires the /// `query_agent_state` capability; without it the request is rejected /// with an error. The `"*"` hive-wide value is not available on the /// agent socket; use the manager socket for swarm-wide scans. #[serde(default)] pub agent: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RequestApplyCommitArgs { /// Logical agent name whose config repo the commit lives in. pub agent: String, /// Commit sha (full or short, 7-40 hex chars) in that agent's /// proposed config repo. Must be a sha — a branch or tag name /// (e.g. `main`) is rejected; the approval pins the exact commit. pub commit_ref: String, /// Optional description shown on the dashboard approval card so the /// operator knows what the change does without opening the diff. #[serde(default)] pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateMetaInputsArgs { /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). /// Pass an empty list to update ALL inputs. #[serde(default)] pub inputs: Vec, /// Optional description shown on the dashboard approval card. #[serde(default)] pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RequestSchedulePromptArgs { /// Recipient agents — one schedule fires to many inboxes at the /// scheduled time. `operator` is a legitimate target (mara: "we /// want to get rid of the manager special case so yes manager /// can be recipient" — the operator slot follows the same rule). pub targets: Vec, /// Message body delivered to each target's inbox at fire time. /// Same size budget as `send` bodies. pub body: String, /// Absolute unix timestamp (seconds) for the FIRST fire. For /// recurring schedules the worker re-arms in /// `interval_seconds` steps from this point on. pub first_fire_at_unix: i64, /// `None` / absent = one-shot. `Some(n > 0)` = recurring every /// `n` seconds. The worker clamps catch-up so a long downtime /// fires ONCE on resume (skipped-cycle count surfaces in the /// per-target `last_result`), not N delayed pulses in a row. #[serde(default)] pub interval_seconds: Option, /// Optional description shown on the dashboard approval card + /// preserved on the schedule row for later operator reference. #[serde(default)] pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct FireScheduleNowArgs { /// Schedule id to fire out of band. Get this from a prior /// `list_schedules` call or the approval-resolved event for /// the originating `request_schedule_prompt`. pub id: i64, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct CancelScheduleArgs { /// Schedule id from a prior `list_schedules` call or the /// approval-resolved event for a `request_schedule_prompt`. pub id: i64, /// Optional target list. `None` / empty = cancel the entire /// schedule. `Some(["alice", "bob"])` = cancel just those /// recipients (the schedule keeps firing for any remaining /// active targets, and auto-cancels its parent row when every /// target is gone). #[serde(default)] pub targets: Option>, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct EditScheduleArgs { /// Schedule id from a prior `list_schedules` call or the /// approval-resolved event for a `request_schedule_prompt`. pub id: i64, /// New body text. Omit to keep the existing one. #[serde(default)] pub body: Option, /// New description. Omit to keep the existing one. (To CLEAR /// the description, use the dashboard PATCH endpoint /// directly — the agent surface intentionally keeps the args /// flat / non-nullable to dodge the doubly-wrapped Option /// schemars quirk; clearing fields is rare and operator-side.) #[serde(default)] pub description: Option, /// Recurring interval in seconds. Omit to keep the existing /// cadence; pass an explicit value to set a new one. Toggling /// recurring↔one-shot (clearing the interval) is operator-only /// for the same reason as `description` above. #[serde(default)] pub interval_seconds: Option, /// New absolute unix timestamp for the next fire. Omit to /// leave the schedule on its current cadence. #[serde(default)] pub next_fire_at_unix: Option, /// Names of new targets to add. Replace-on-conflict: re-adding /// a previously cancelled target resets its history (operator /// intent on re-add = "this target is active again"). #[serde(default)] pub targets_add: Option>, /// Names of targets to cancel. Tombstones preserve per-target /// audit; when no active targets remain the schedule /// auto-cancels. #[serde(default)] pub targets_remove: Option>, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetLogsArgs { /// Logical agent name to fetch logs for (e.g. `gui`, `iris`). /// hive-c0re maps it to the underlying machine name (`h-gui`) /// itself — pass the plain agent name, not the `h-` form. pub agent: String, /// How many journal lines to return (default: 50, max: 500). #[serde(default)] pub lines: Option, } /// Arguments for `get_host_journal` (capability-gated: `read_host_journal`). #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetHostJournalArgs { /// Systemd unit to filter (e.g. `hive-c0re.service`). Omit for all units. #[serde(default)] pub unit: Option, /// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. #[serde(default)] pub container: Option, /// Number of lines to return (default 30, max 100). #[serde(default)] pub lines: Option, /// Minimum syslog priority level. #[serde(default)] pub priority: Option, /// Regex to match against log message fields (journalctl --grep). #[serde(default)] pub grep: Option, /// Show entries on or newer than this timestamp (e.g. `-1h`). #[serde(default)] pub since: Option, /// Show entries on or older than this timestamp. #[serde(default)] pub until: Option, } #[derive(Debug, Clone)] pub struct ManagerServer { socket: PathBuf, } impl ManagerServer { #[must_use] pub fn new(socket: PathBuf) -> Self { Self { socket } } /// Helper: issue any `ManagerRequest` through the retry-aware /// client, convert the reply through `SocketReply`, and return the /// retry count alongside so the tool handler can `annotate_retries` /// on the final string. async fn dispatch( &self, req: hive_sh4re::ManagerRequest, ) -> (Result, u32) { match client::request_retried::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { Ok((r, n)) => (Ok(SocketReply::from(r)), n), Err(e) => (Err(e), 0), } } } // IMPORTANT: when adding a new `#[tool]` fn to this impl, also add // its name to the matching `ToolGroup::tools()` slice in hive-sh4re. // Claude Code's permission gate refuses uninlisted MCP tools in // non-interactive `--print` mode with "permissions not granted yet". #[tool_router] impl ManagerServer { #[tool( description = "Send a message to a sub-agent (by logical name), to another agent, \ or to the operator (recipient `operator`, surfaces in the dashboard)." )] async fn send(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let to = args.to.clone(); run_tool_envelope("send", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Send { to: args.to, body: args.body, in_reply_to: args.in_reply_to, }) .await; annotate_retries(format_ack(resp, "send", format!("sent to {to}")), retries) }) .await } #[tool( description = "Pop messages from the manager inbox. Default returns one (sender + \ body) or empty. Without `wait_seconds` (or 0) returns immediately — a cheap inbox \ peek. Pass a positive value (capped at 180) to park until either a message arrives \ or the timeout fires; prefer a long wait (120 or 180) over ending a turn early \ when you have nothing else to do. \n\n\ Pass `max: N` (capped at 32) to drain up to N messages in one round-trip — useful \ when the wake prompt tells you the inbox has more queued. `wait_seconds` still \ applies to the FIRST message; once one lands the call drains up to `max` in total." )] async fn recv(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("recv", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Recv { wait_seconds: args.wait_seconds, max: args.max, }) .await; annotate_retries(format_recv(resp), retries) }) .await } #[tool( description = "Step 1 of 2 for creating a new agent: initialise the proposed config \ repo and queue an InitConfig approval. On operator approval hive-c0re seeds \ `/agents//config/agent.nix` with the default template so the manager can \ customise it before spawning. After the ConfigReady helper event arrives, edit \ agent.nix, commit the changes, then call `request_apply_commit` with the commit \ sha — that's what creates the container. Fails if a config repo for this name \ already exists (use `request_apply_commit` directly to update an existing agent)." )] async fn request_init_config( &self, Parameters(args): Parameters, ) -> String { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("request_init_config", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::RequestInitConfig { name: args.name, description: args.description, }) .await; annotate_retries( format_ack( resp, "request_init_config", format!("init_config approval queued for {name}"), ), retries, ) }) .await } #[tool( description = "Stop a sub-agent container (graceful). The state dir is kept; \ recreating reuses prior config + Claude credentials. No approval required." )] async fn kill(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("kill", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Kill { name: args.name }) .await; annotate_retries(format_ack(resp, "kill", format!("killed {name}")), retries) }) .await } #[tool( description = "Start a stopped sub-agent container. No approval required — \ lifecycle ops on existing containers are at the manager's discretion." )] async fn start(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("start", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Start { name: args.name }) .await; annotate_retries( format_ack(resp, "start", format!("started {name}")), retries, ) }) .await } #[tool(description = "Restart a sub-agent container (stop + start). No approval required.")] async fn restart(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("restart", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Restart { name: args.name }) .await; annotate_retries( format_ack(resp, "restart", format!("restarted {name}")), retries, ) }) .await } #[tool( description = "Rebuild a sub-agent: re-applies the current hyperhive flake + agent.nix \ and restarts the container. No approval required — idempotent. Use when you receive a \ `needs_update` system event for an agent." )] async fn update(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); run_tool_envelope("update", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Update { name: args.name }) .await; annotate_retries( format_ack(resp, "update", format!("updated {name}")), retries, ) }) .await } #[tool( description = "Queue an approval for the operator to run `nix flake update` on the \ meta flake and commit the resulting lock changes. Pass specific input names to update \ only those inputs (e.g. `[\"bitburner-agent\"]`), or pass an empty list to update ALL \ inputs. Returns immediately — the lock update runs when the operator approves. \ Does NOT trigger container rebuilds — call `update` on each affected agent \ separately after the approval resolves." )] async fn request_update_meta_inputs( &self, Parameters(args): Parameters, ) -> String { let log = format!("{args:?}"); run_tool_envelope("request_update_meta_inputs", log, async move { let label = if args.inputs.is_empty() { "all inputs".to_string() } else { args.inputs.join(", ") }; let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::RequestUpdateMetaInputs { inputs: args.inputs, description: args.description, }) .await; annotate_retries( format_ack( resp, "request_update_meta_inputs", format!("approval queued: {label}"), ), retries, ) }) .await } #[tool( description = "Queue an approval to add a scheduled prompt — one body delivered to \ N agent inboxes at a target time, optionally recurring every `interval_seconds`. \ The operator approves; on approve hive-c0re inserts the schedule and the worker \ fans it out. Even self-targeted schedules go through this flow (the operator pays \ for the wake-up tokens); the existing `remind` MCP tool stays the quick \ no-approval self-wake path. \n\n\ Catch-up clamp: if hive-c0re is down across multiple intervals, only ONE delayed \ fire happens on resume (per recurring schedule). The skipped-cycle count surfaces \ in the per-target `last_result` for the operator's audit trail. \n\n\ Per-target failure: a target name that doesn't resolve to a live agent → operator \ gets a one-line advisory `Message` from `system`; the schedule keeps firing for \ the other (live) targets." )] async fn request_schedule_prompt( &self, Parameters(args): Parameters, ) -> String { let log = format!("{args:?}"); run_tool_envelope("request_schedule_prompt", log, async move { let target_count = args.targets.len(); let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::RequestSchedulePrompt( hive_sh4re::SchedulePromptPayload { targets: args.targets, body: args.body, first_fire_at_unix: args.first_fire_at_unix, interval_seconds: args.interval_seconds, description: args.description, }, )) .await; annotate_retries( format_ack( resp, "request_schedule_prompt", format!("approval queued: {target_count} target(s)"), ), retries, ) }) .await } #[tool( description = "Fire a scheduled prompt out of band — runs the per-target fan-out \ once immediately without disturbing the schedule's cadence. Recurring schedules \ keep their next_fire_at unchanged (the manual fire is additive). One-shot \ schedules are CONSUMED by the manual fire (cancelled afterwards): the operator's \ intent on a one-shot is 'send this now, the scheduled time was wrong'. \n\n\ Authorization mirrors `cancel_schedule`: you can fire your own schedules + any \ owned by a sub-agent in your subtree per topology.json." )] async fn fire_schedule_now(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("fire_schedule_now", log, async move { let id = args.id; let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::FireScheduleNow { id }) .await; annotate_retries( format_ack(resp, "fire_schedule_now", format!("fired #{id} now")), retries, ) }) .await } #[tool( description = "Cancel a scheduled prompt. With no `targets` field, cancels the \ whole schedule (all recipients flipped). With a non-empty `targets` list, cancels \ just those recipients; the schedule keeps firing for any remaining active targets \ and auto-cancels its parent row when every target is cancelled. \n\n\ Authorization: the manager can cancel its own schedules + any schedule whose \ owner is one of its sub-agents per topology.json. Other owners are refused." )] async fn cancel_schedule(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("cancel_schedule", log, async move { let id = args.id; let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::CancelSchedule { id: args.id, targets: args.targets, }) .await; annotate_retries( format_ack(resp, "cancel_schedule", format!("cancelled #{id}")), retries, ) }) .await } #[tool( description = "Edit an existing scheduled prompt's mutable fields. Pass only \ the fields you want to change — anything omitted keeps its current value. Editable: \ `body`, `description`, `interval_seconds` (positive only via this tool; flipping \ recurring→one-shot is operator-only via the dashboard), `next_fire_at_unix`, and \ the target set via `targets_add` / `targets_remove`. Both target lists are \ applied in the same transaction with removes-before-adds, so a single edit can \ swap a target atomically. Re-adding a previously-removed target starts a fresh \ per-target history (drops the tombstone). Draining all targets auto-cancels the \ parent schedule. \n\n\ Authorization mirrors `cancel_schedule` / `fire_schedule_now`: you can edit your \ own schedules + any owned by a sub-agent in your subtree per topology.json. \ Refuses cancelled schedules (the row's terminal — submit a fresh one)." )] async fn edit_schedule(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("edit_schedule", log, async move { let id = args.id; let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::EditSchedule { id: args.id, body: args.body, // The agent-side args use plain Option; the // manager wire type's `Some(None)` ("set to // null") cases stay operator-exclusive, so we // promote agent-supplied values into // `Some(Some(v))` and omit when the agent // didn't pass a value. description: args.description.map(Some), interval_seconds: args.interval_seconds.map(Some), next_fire_at_unix: args.next_fire_at_unix, targets_add: args.targets_add, targets_remove: args.targets_remove, }) .await; annotate_retries( format_ack(resp, "edit_schedule", format!("edited #{id}")), retries, ) }) .await } #[tool( description = "List every scheduled prompt in the queue (active + cancelled but \ not yet reaped). Returns the full snapshot — schedule id, owner, body, target set \ with per-target last_fired_at + last_result, next fire time, recurring interval. \ Use this to look up an id before calling `cancel_schedule`, or to audit what \ the swarm is going to be woken up about next." )] async fn list_schedules(&self) -> String { run_tool_envelope("list_schedules", String::new(), async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::ListSchedules) .await; let body = match resp { Ok(SocketReply::Schedules(schedules)) => serde_json::to_string(&schedules) .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")), Ok(SocketReply::Err(m)) => format!("list_schedules: {m}"), Ok(other) => format!("list_schedules unexpected response: {other:?}"), Err(e) => format!("list_schedules transport error: {e:#}"), }; annotate_retries(body, retries) }) .await } #[tool( description = "Surface a structured question to either the operator OR a sub-agent. \ Returns immediately with a question id — do NOT wait inline. When the recipient \ answers, a system message with event `question_answered { id, question, answer, \ answerer }` lands in your inbox; handle it on a future turn. \n\n\ Recipient: omit `to` (or set `to: \"operator\"`) for the human operator on the \ dashboard. Set `to: \"\"` to ask a sub-agent — they receive a \ `question_asked` event in their inbox and answer via their `mcp__hyperhive__answer` \ tool. Useful for delegating decisions / clarifications without losing the \ question id correlation. \n\n\ `options` is advisory: pass a short fixed-choice list when applicable, otherwise \ leave empty for free text. Set `multi: true` to render checkboxes; the answer \ comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel — on \ expiry the answer is `[expired]` (with `answerer: \"ttl-watchdog\"`) and the same \ `question_answered` event fires." )] async fn ask(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("ask", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Ask { question: args.question, options: args.options, multi: args.multi, ttl_seconds: args.ttl_seconds, to: args.to, }) .await; let s = match resp { Ok(SocketReply::QuestionQueued(id)) => format!( "question queued (id={id}); answer will arrive as a system \ `question_answered` event in your inbox" ), Ok(SocketReply::Err(m)) => format!("ask failed: {m}"), Ok(other) => format!("ask unexpected response: {other:?}"), Err(e) => format!("ask transport error: {e:#}"), }; annotate_retries(s, retries) }) .await } #[tool( description = "Answer a question that was routed to the manager via a `question_asked` \ system event in the manager's inbox (i.e. a sub-agent did `ask(to: \"manager\", \ ...)`). Pass the `id` from the event and your `answer`. The answer surfaces in the \ asker's inbox as a `question_answered` event." )] async fn answer(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let id = args.id; run_tool_envelope("answer", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Answer { id, answer: args.answer, }) .await; annotate_retries( format_ack(resp, "answer", format!("answered question {id}")), retries, ) }) .await } #[tool( description = "Submit a config change for operator approval. Pass the agent name \ (e.g. `alice`) and a commit sha (7-40 hex \ chars, full or short) in that agent's proposed config repo — a branch/tag name like \ `main` is rejected, the approval pins the exact commit. On approval hive-c0re \ rebuilds the container." )] async fn request_apply_commit( &self, Parameters(args): Parameters, ) -> String { let log = format!("{args:?}"); let agent = args.agent.clone(); let commit_ref = args.commit_ref.clone(); run_tool_envelope("request_apply_commit", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit { agent: args.agent, commit_ref: args.commit_ref, description: args.description, }) .await; annotate_retries( format_ack( resp, "request_apply_commit", format!("apply approval queued for {agent} @ {commit_ref}"), ), retries, ) }) .await } #[tool( description = "Schedule a reminder that lands in the manager's own inbox at a future \ time (sender will appear as `reminder`). Use for self-paced manager follow-ups: \ 'recheck pending approval in 10m', 'nudge alice if she hasn't replied by 14:00 \ UTC'. Set EXACTLY ONE of `delay_seconds` (fire N seconds from now) or \ `at_unix_timestamp` (fire at absolute epoch second). Body soft-caps at 4 KiB \ inline — anything larger gets auto-persisted to a file under `/state/reminders/` \ (the manager's own state mount) and the inbox message becomes a short pointer. \ Pass `file_path` if you want to control the destination yourself." )] async fn remind(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); run_tool_envelope("remind", log, async move { let timing = match (args.delay_seconds, args.at_unix_timestamp) { (Some(_), Some(_)) => { return "remind failed: pass exactly one of `delay_seconds` or \ `at_unix_timestamp`, not both" .to_string(); } (None, None) => { return "remind failed: pass exactly one of `delay_seconds` or \ `at_unix_timestamp`" .to_string(); } (Some(s), None) => hive_sh4re::ReminderTiming::InSeconds { seconds: s }, (None, Some(t)) => hive_sh4re::ReminderTiming::At { unix_timestamp: t }, }; let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::Remind { message: args.message, timing, file_path: args.file_path, }) .await; annotate_retries( format_ack(resp, "remind", "reminder scheduled".to_string()), retries, ) }) .await } #[tool( description = "List loose ends. By default returns your OWN — the manager's: \ pending approvals you submitted + unanswered questions where you are \ asker/target + your own pending reminders. Pass `agent: \"*\"` for a \ hive-wide scan (EVERY pending approval, unanswered question, and reminder \ across the swarm) — use it to spot stalled coordination, e.g. questions \ sub-agents asked each other that nobody's answering. Pass `agent: \ \"\"` to inspect one agent's threads. Cancel any question or reminder \ row via `cancel_loose_end` (manager bypasses the owner check)." )] async fn get_loose_ends(&self, Parameters(args): Parameters) -> String { run_tool_envelope("get_loose_ends", String::new(), async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::GetLooseEnds { agent: args.agent }) .await; annotate_retries(format_loose_ends(resp), retries) }) .await } #[tool( description = "Set a free-text status string visible on the operator dashboard. \ Call this at the START of every task to describe what you're working on. \ Pass an empty string to clear. Persists across harness restarts." )] async fn set_status(&self, Parameters(args): Parameters) -> String { run_tool_envelope("set_status", args.text.clone(), async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::SetStatus { text: args.text }) .await; annotate_retries( format_ack(resp, "set_status", "status updated".to_owned()), retries, ) }) .await } #[tool( description = "Fetch identity + status metadata for an agent. Returns canonical \ `name`, the current `hyperhive_rev` hive-c0re is \ running against, and the target's self-reported `status` text (set via \ `set_status`) plus how long ago it was set. Pass `name` to query a sub-agent or \ peer manager; omit `name` for the manager's own identity stamp — useful for \ boot announcements, state-file headers, or cross-agent attribution that won't \ drift across renames. Status reads `` when the target has never called \ `set_status` or has cleared it." )] async fn get_agent_meta(&self, Parameters(args): Parameters) -> String { let log = args.name.clone().unwrap_or_else(|| "".to_owned()); run_tool_envelope("get_agent_meta", log, async move { let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::GetAgentMeta { name: args.name }) .await; annotate_retries(format_agent_meta(resp), retries) }) .await } #[tool( description = "Cancel any open thread in the swarm — a `question` (cancels \ with the operator-override sentinel so the asker unblocks), a `reminder` \ (hard-deleted before fire), or an `approval` (withdraws a pending approval \ you submitted; the dashboard pulls the card from pending and the row resolves \ as `cancelled` instead of approved/denied/failed). `kind` is \ `\"question\"`, `\"reminder\"`, or `\"approval\"`; `id` is the row id from \ `get_loose_ends` or the original submission reply. Manager surface bypasses \ the owner check on the sub-agent flavour — use for hive-wide cleanup of \ stuck or stale threads, or to drop your own approvals that got superseded." )] async fn cancel_loose_end(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let id = args.id; run_tool_envelope("cancel_loose_end", log, async move { let kind = match parse_loose_end_kind(&args.kind) { Ok(k) => k, Err(e) => return e, }; let kind_label = loose_end_kind_label(kind); let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::CancelLooseEnd { kind, id }) .await; annotate_retries( format_ack( resp, "cancel_loose_end", format!("cancelled {kind_label} {id}"), ), retries, ) }) .await } #[tool( description = "Fetch recent journal log lines for a sub-agent container. Useful \ for diagnosing MCP server registration failures, startup crashes, plugin install \ errors, or any harness issue you can't see from inside the container. Pass the \ plain logical agent name (e.g. `gui`) — hive-c0re resolves the machine name. \ `lines` defaults to 50 (max capped at 500 on the host side)." )] async fn get_logs(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let agent = args.agent.clone(); run_tool_envelope("get_logs", log, async move { let lines = args.lines.map(|n| n.min(500)); let (resp, retries) = self .dispatch(hive_sh4re::ManagerRequest::GetLogs { agent: agent.clone(), lines, }) .await; let s = match resp { Ok(SocketReply::Logs(content)) => { if content.is_empty() { format!("(no journal output for {agent})") } else { content } } Ok(SocketReply::Err(m)) => format!("get_logs failed: {m}"), Ok(other) => format!("get_logs unexpected response: {other:?}"), Err(e) => format!("get_logs transport error: {e:#}"), }; annotate_retries(s, retries) }) .await } } #[tool_handler( instructions = "You are the hyperhive manager (root). You coordinate sub-agents and \ relay between them and the operator. Use `send` to talk to agents/operator, `recv` \ to drain your inbox. Privileged: `request_init_config` (step 1 of new-agent \ creation — seeds the proposed config repo so you can customise agent.nix; \ operator-approved), `kill` (graceful stop), `request_apply_commit` (config \ change for any agent including yourself — also doubles as step 2 of new-agent \ creation: the first ApplyCommit on a freshly-init'd config creates the \ container), `ask` (structured question to the operator or a \ sub-agent — non-blocking, answer arrives later as a `question_answered` event), \ `answer` (respond to a `question_asked` event directed at you), \ `get_loose_ends` (hive-wide loose ends — pending approvals + unanswered \ questions + pending reminders across the swarm), `cancel_loose_end` (cancel any \ question or reminder row by id), `set_status` / `get_agent_meta` (publish your \ own status text + query identity/status of any agent — `get_agent_meta` with \ no arg replaces the old `whoami` self-introspection)." )] impl ServerHandler for ManagerServer {} /// Name of the hyperhive MCP server inside claude's view. Claude prefixes /// tools as `mcp____` (e.g. `mcp__hyperhive__send`). pub const SERVER_NAME: &str = "hyperhive"; /// Built-in claude tools always present in every session. Anything not /// in this list (or added by `extra_builtin_tools`) literally doesn't /// exist in the session. Web egress (`WebFetch`/`WebSearch`) are /// tool-group-gated (`web_tools`) — off by default. Nested agents /// (`Task`) are intentionally omitted. `Bash` is disallowed — shell /// execution goes through `mcp__hyperhive__bash_run` (background tasks /// with structured output) instead of a raw interactive shell. `TodoWrite` /// is omitted because the todo list lives in claude's in-process session /// state and silently evaporates on /compact or session reset — agents /// should plan in /state notes instead. pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"]; /// Which MCP tool surface to advertise via `--allowedTools`. The agent /// list is the strict subset of the manager list, so we just thread the /// flavor through. #[derive(Debug, Clone, Copy)] pub enum Flavor { Agent, Manager, } /// Env var written by the meta renderer with a comma-separated list of /// `hive_sh4re::ToolGroup` snake_case names (e.g. `"messaging,inbox,meta"`). /// When present, the harness expands the groups into per-tool allow entries /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; /// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the /// operator grants capabilities to this agent. Comma-separated /// `hive_sh4re::Capability` snake_case names. Absent = no extra capabilities. const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; /// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are /// unlocked by the agent's current capability set. These are added to the /// `--allowedTools` list so claude can call them without prompting, and /// hive-c0re performs a second server-side capability check before executing. fn allowed_capability_tools() -> Vec { let raw = match std::env::var(CAPABILITIES_ENV) { Ok(v) if !v.trim().is_empty() => v, _ => return vec![], }; let mut tools = Vec::new(); for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); match t.as_str() { "read_host_journal" => tools.push("get_host_journal".to_owned()), // manage_root_agent doesn't expose new MCP tools (it gates // existing lifecycle tools via the topology enforcement). "manage_root_agent" => {} // query_agent_state doesn't expose new MCP tools; it unlocks // the `agent` field in get_loose_ends / count_pending_reminders // / reminder_rollup on the agent socket (c0re enforces the cap // server-side; the harness honours it by passing the field). "query_agent_state" => {} unknown => { tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); } } } tools } /// Resolve the active tool groups for a harness session. /// /// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated /// token is matched (case-insensitive) against the `ToolGroup` serde names /// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, /// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. /// Falls back to the flavor default when the env var is absent or empty. fn effective_tool_groups(flavor: Flavor) -> Vec { let raw = match std::env::var(TOOL_GROUPS_ENV) { Ok(v) if !v.trim().is_empty() => v, _ => { // No env var — use the flavor default unchanged. let defaults = match flavor { Flavor::Agent => hive_sh4re::ToolGroup::AGENT_DEFAULT, Flavor::Manager => hive_sh4re::ToolGroup::MANAGER_DEFAULT, }; return defaults.to_vec(); } }; let mut groups = Vec::new(); for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); // Parse via serde_json (the canonical deserialization path). match serde_json::from_value::(serde_json::Value::String(t.clone())) { Ok(g) => groups.push(g), Err(_) => tracing::warn!( token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping" ), } } if groups.is_empty() { tracing::warn!( "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ falling back to flavor default" ); return match flavor { Flavor::Agent => hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), Flavor::Manager => hive_sh4re::ToolGroup::MANAGER_DEFAULT.to_vec(), }; } groups } /// MCP tools claude is allowed to call without prompting, derived from /// the supplied tool groups. Adding a new `#[tool]` fn to a server impl /// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re /// (single source of truth). See `docs/conventions.md::Tool groups`. #[must_use] pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { // Collect all tool names, deduplicating while preserving order. let mut seen = std::collections::HashSet::new(); let mut out: Vec = groups .iter() .flat_map(|g| g.tools()) .filter(|t| seen.insert(*t)) .map(|t| format!("mcp__{SERVER_NAME}__{t}")) .collect(); // Extra MCP servers declared via `hyperhive.extraMcpServers` in // the agent's NixOS config. Each entry maps its `allowedTools` // pattern list to `mcp____` so claude can call // them without per-tool operator approval. `["*"]` (the default) // expands to `mcp____*` — every tool from that server. for (server, spec) in load_extra_mcp() { if server == SERVER_NAME { continue; } for pat in spec.allowed_tools { out.push(format!("mcp__{server}__{pat}")); } } out } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers /// both the built-ins and the MCP surface. #[must_use] pub fn allowed_tools_arg(flavor: Flavor) -> String { let groups = effective_tool_groups(flavor); // Base built-ins always present. let mut all: Vec = ALLOWED_BUILTIN_TOOLS .iter() .map(|s| (*s).to_owned()) .collect(); // Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools). for group in &groups { for tool in group.builtin_tools() { if !all.iter().any(|t| t == *tool) { all.push((*tool).to_owned()); } } } all.extend(allowed_mcp_tools(&groups)); // Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES // includes the corresponding capability. hive-c0re performs a second // server-side check, so this is a usability gate (no annoying prompts), // not the security boundary. for tool in allowed_capability_tools() { all.push(format!("mcp__{SERVER_NAME}__{tool}")); } all.join(",") } /// Built-in tools list for `--tools` (which built-ins exist in this /// session). Base set plus any group-gated built-ins (e.g. /// `WebFetch`/`WebSearch` when the `web_tools` group is active). #[must_use] pub fn builtin_tools_arg() -> String { builtin_tools_arg_for_flavor(Flavor::Agent) } /// Flavor-aware variant used by `turn.rs` via `builtin_tools_arg`. Reads /// the effective tool groups for `flavor` so `--tools` matches what /// `--allowedTools` includes for the same session. #[must_use] pub fn builtin_tools_arg_for_flavor(flavor: Flavor) -> String { let groups = effective_tool_groups(flavor); let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec(); for group in &groups { for t in group.builtin_tools() { if !tools.contains(t) { tools.push(t); } } } tools.join(",") } /// Where the NixOS module writes the per-agent extra-MCP spec (see /// `nix/templates/harness-base.nix`). Each entry becomes an additional /// `mcpServers.` block in the rendered claude config + a /// `mcp____` pattern in `--allowedTools`. const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; /// Where the NixOS module writes the per-agent send allow-list (see /// `nix/templates/harness-base.nix`). Empty list = unrestricted (the /// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` /// field; the manager is always implicitly permitted regardless of /// the list contents. const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// Enforce the per-agent send allow-list. Returns `Ok` when the /// recipient is permitted (no list configured, manager always /// allowed, or `to` is in the list); returns `Err(refusal)` with a /// claude-readable string when blocked — the harness surfaces the /// refusal as the tool result so claude knows the message didn't /// land and can react (e.g. route via the manager instead). fn check_send_allowed(to: &str) -> Result<(), String> { if to == hive_sh4re::MANAGER_AGENT { // Always allow agents to talk to the manager — otherwise a // misconfigured allow-list could leave a sub-agent unable // to ask for help. return Ok(()); } if to == hive_sh4re::PARENT_RECIPIENT { // Always allow `` — same escape-hatch rationale as // the manager exception. The allow-list constrains peer // chatter, not the structural reporting line; the operator // can rewire who the parent IS via `set_parent` without // having to remember to update the per-agent allow-list. // The broker resolves the sentinel to the real parent label // on the host side per topology.json (falls back to `operator` // for root agents). return Ok(()); } let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { return Ok(()); // file missing → no policy configured → unrestricted }; let allow: Vec = match serde_json::from_str(&raw) { Ok(v) => v, Err(e) => { tracing::warn!( path = SEND_ALLOW_PATH, error = ?e, "send allow-list parse failed; falling back to unrestricted", ); return Ok(()); } }; if allow.is_empty() { return Ok(()); // empty list = unrestricted (back-compat) } if allow.iter().any(|n| n == to) { return Ok(()); } Err(format!( "send refused: recipient '{to}' not in hyperhive.allowedRecipients \ (configured in agent.nix). Allowed: {allow:?}. The manager is \ always reachable — route through `send(to: \"manager\", …)` if \ you need to reach someone outside the allow-list." )) } #[derive(Debug, serde::Deserialize)] struct ExtraMcpServer { command: String, #[serde(default)] args: Vec, #[serde(default)] env: std::collections::BTreeMap, #[serde(default = "default_allowed_tools")] #[serde(rename = "allowedTools")] allowed_tools: Vec, } fn default_allowed_tools() -> Vec { vec!["*".to_owned()] } /// Read + parse the extra-MCP spec. Returns an empty map when /// the file is missing or unparsable (the agent has none configured, /// or the file is malformed — both cases degrade to "no extra servers"). fn load_extra_mcp() -> std::collections::BTreeMap { let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { return std::collections::BTreeMap::new(); }; serde_json::from_str(&raw).unwrap_or_else(|e| { tracing::warn!( path = EXTRA_MCP_PATH, error = ?e, "extra-mcp spec parse failed; ignoring", ); std::collections::BTreeMap::new() }) } /// Render the MCP config blob claude reads from `--mcp-config `. /// `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 `). 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 { let mut servers = serde_json::Map::new(); servers.insert( SERVER_NAME.to_owned(), serde_json::json!({ "command": agent_binary, "args": ["--socket", socket.display().to_string(), "mcp"], "env": {} }), ); // 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. let state_dir = crate::paths::state_dir(); 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", ); 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 config = serde_json::json!({ "mcpServers": servers }); serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) }