2240 lines
97 KiB
Rust
2240 lines
97 KiB
Rust
//! 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<hive_sh4re::DeliveredMessage>),
|
|
Status(u64),
|
|
QuestionQueued(i64),
|
|
Recent(Vec<hive_sh4re::InboxRow>),
|
|
Logs(String),
|
|
HostJournal(String),
|
|
/// `list_schedules` result — used by the manager surface only;
|
|
/// `AgentResponse` has no equivalent variant.
|
|
Schedules(Vec<hive_sh4re::WireSchedule>),
|
|
LooseEnds(Vec<hive_sh4re::LooseEnd>),
|
|
PendingRemindersCount(u64),
|
|
ReminderRollup(hive_sh4re::ReminderStats),
|
|
AgentMeta {
|
|
name: String,
|
|
running: bool,
|
|
hyperhive_rev: Option<String>,
|
|
status_text: Option<String>,
|
|
status_set_at: Option<i64>,
|
|
hive_name: Option<String>,
|
|
swarm_name: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl From<hive_sh4re::Response> 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<SocketReply, anyhow::Error>, 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<SocketReply, anyhow::Error>) -> 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";
|
|
|
|
/// 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<SocketReply, anyhow::Error>) -> String {
|
|
use std::fmt::Write as _;
|
|
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:#}"),
|
|
};
|
|
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}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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<hive_sh4re::CancelLooseEndKind, String> {
|
|
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: <none>`.
|
|
/// 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 `<none>` 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<SocketReply, anyhow::Error>) -> 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("<unknown>");
|
|
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 `<none>` 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: <none>"),
|
|
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<F>(tool: &'static str, args: String, body: F) -> String
|
|
where
|
|
F: Future<Output = String>,
|
|
{
|
|
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<i64>,
|
|
}
|
|
|
|
#[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<u64>,
|
|
/// 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<u32>,
|
|
}
|
|
|
|
/// 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<u64>,
|
|
}
|
|
|
|
/// 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/<you>/state/reminders/auto-<ts>.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<u64>,
|
|
/// Fire at this absolute unix timestamp (seconds since epoch). Set
|
|
/// this OR `delay_seconds`, not both.
|
|
#[serde(default)]
|
|
pub at_unix_timestamp: Option<i64>,
|
|
/// 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/<you>/state/`.
|
|
#[serde(default)]
|
|
pub file_path: Option<String>,
|
|
}
|
|
|
|
/// 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<SocketReply, anyhow::Error>, 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<SendArgs>) -> 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: \"<agent-name>\"` 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<AskArgs>) -> 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: <your-name> }` event. \n\n\
|
|
Authorisation is strict — you can only answer questions where you are the declared \
|
|
target (i.e. the asker did `ask(to: \"<your-name>\", ...)`). Trying to answer an \
|
|
operator-targeted question or a question addressed to a different agent will fail."
|
|
)]
|
|
async fn answer(&self, Parameters(args): Parameters<AnswerArgs>) -> 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<RecvArgs>) -> 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: \"<name>\"` 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<AgentGetLooseEndsArgs>) -> String {
|
|
run_tool_envelope("get_loose_ends", String::new(), async move {
|
|
let (resp, retries) = self
|
|
.dispatch(hive_sh4re::AgentRequest::GetLooseEnds { agent: args.agent })
|
|
.await;
|
|
let mut out = annotate_retries(format_loose_ends(resp), 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<SetStatusArgs>) -> 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 `<none>` when the \
|
|
target has never called `set_status` or has cleared it."
|
|
)]
|
|
async fn get_agent_meta(&self, Parameters(args): Parameters<GetAgentMetaArgs>) -> String {
|
|
let log = args.name.clone().unwrap_or_else(|| "<self>".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 <you>]` 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<CancelLooseEndArgs>) -> 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/<you>/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<RemindArgs>) -> 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-<id>\"` 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."
|
|
)]
|
|
async fn bash_run(&self, Parameters(args): Parameters<BashRunArgs>) -> String {
|
|
let log = format!("{args:?}");
|
|
run_tool_envelope("bash_run", log, async move {
|
|
match crate::bash_runner::submit_task(args.cmd, args.timeout_secs) {
|
|
Ok(id) => format!("task started: id={id}"),
|
|
Err(e) => format!("bash_run failed: {e:#}"),
|
|
}
|
|
})
|
|
.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/<id>.out` / `.err`."
|
|
)]
|
|
async fn bash_status(&self, Parameters(args): Parameters<BashStatusArgs>) -> 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<GetHostJournalArgs>) -> 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/<name>/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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<u64>,
|
|
/// 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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// Optional description shown on the dashboard approval card.
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<u64>,
|
|
/// Optional description shown on the dashboard approval card +
|
|
/// preserved on the schedule row for later operator reference.
|
|
#[serde(default)]
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[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<Vec<String>>,
|
|
}
|
|
|
|
#[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<String>,
|
|
/// 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<String>,
|
|
/// 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<u64>,
|
|
/// 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<i64>,
|
|
/// 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<Vec<String>>,
|
|
/// 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<Vec<String>>,
|
|
}
|
|
|
|
#[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<u32>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
/// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal.
|
|
#[serde(default)]
|
|
pub container: Option<String>,
|
|
/// Number of lines to return (default 30, max 100).
|
|
#[serde(default)]
|
|
pub lines: Option<u32>,
|
|
/// Minimum syslog priority level.
|
|
#[serde(default)]
|
|
pub priority: Option<hive_sh4re::JournalPriority>,
|
|
/// Regex to match against log message fields (journalctl --grep).
|
|
#[serde(default)]
|
|
pub grep: Option<String>,
|
|
/// Show entries on or newer than this timestamp (e.g. `-1h`).
|
|
#[serde(default)]
|
|
pub since: Option<String>,
|
|
/// Show entries on or older than this timestamp.
|
|
#[serde(default)]
|
|
pub until: Option<String>,
|
|
}
|
|
|
|
#[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<SocketReply, anyhow::Error>, 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<SendArgs>) -> 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<RecvArgs>) -> 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/<name>/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<RequestInitConfigArgs>,
|
|
) -> 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<KillArgs>) -> 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<StartArgs>) -> 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<RestartArgs>) -> 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<UpdateArgs>) -> 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<UpdateMetaInputsArgs>,
|
|
) -> 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<RequestSchedulePromptArgs>,
|
|
) -> 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<FireScheduleNowArgs>) -> 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<CancelScheduleArgs>) -> 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<EditScheduleArgs>) -> 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<T>; 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: \"<agent-name>\"` 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<AskArgs>) -> 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<AnswerArgs>) -> 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<RequestApplyCommitArgs>,
|
|
) -> 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<RemindArgs>) -> 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: \
|
|
\"<name>\"` 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<GetLooseEndsArgs>) -> 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<SetStatusArgs>) -> 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 `<none>` when the target has never called \
|
|
`set_status` or has cleared it."
|
|
)]
|
|
async fn get_agent_meta(&self, Parameters(args): Parameters<GetAgentMetaArgs>) -> String {
|
|
let log = args.name.clone().unwrap_or_else(|| "<self>".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<CancelLooseEndArgs>) -> 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<GetLogsArgs>) -> 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__<this>__<tool>` (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<String> {
|
|
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<hive_sh4re::ToolGroup> {
|
|
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::<hive_sh4re::ToolGroup>(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<String> {
|
|
// Collect all tool names, deduplicating while preserving order.
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut out: Vec<String> = 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__<server>__<pattern>` so claude can call
|
|
// them without per-tool operator approval. `["*"]` (the default)
|
|
// expands to `mcp__<server>__*` — 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<String> = 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.<key>` block in the rendered claude config + a
|
|
/// `mcp__<key>__<tool>` 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 `<parent>` — 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<String> = 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<String>,
|
|
#[serde(default)]
|
|
env: std::collections::BTreeMap<String, String>,
|
|
#[serde(default = "default_allowed_tools")]
|
|
#[serde(rename = "allowedTools")]
|
|
allowed_tools: Vec<String>,
|
|
}
|
|
|
|
fn default_allowed_tools() -> Vec<String> {
|
|
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<String, ExtraMcpServer> {
|
|
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 <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.
|
|
#[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())
|
|
}
|