refactor(hive-ag3nt): split mcp into submodules

mod.rs keeps the #[tool_router] impl + server wiring untouched;
rendering/format helpers move to mcp/render.rs, the 24 tool arg
structs to mcp/args.rs
This commit is contained in:
müde 2026-07-06 22:00:30 +02:00
commit b0736e6f3e
3 changed files with 827 additions and 797 deletions

359
hive-ag3nt/src/mcp/args.rs Normal file
View file

@ -0,0 +1,359 @@
//! Argument structs for the MCP tools: `serde::Deserialize` +
//! `schemars::JsonSchema` derives whose field doc-comments become the
//! parameter descriptions claude sees in each tool's input schema.
use rmcp::schemars;
#[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 5 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 `ack_until`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AckUntilArgs {
/// Highest broker message id to mark handled: every inbox message
/// with `id <= up_to` (ids show as `[msg #<id>]` in recv output)
/// is acked in one sweep. Pass the highest id you've actually
/// seen/triaged — anything above it stays queued for later turns.
pub up_to: i64,
}
/// 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>,
}
// -----------------------------------------------------------------------------
// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics)
// -----------------------------------------------------------------------------
#[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 CreateRepoArgs {
/// Repo name — a single segment of letters, digits, `-`, `_`, `.`
/// (no leading `-`/`.`). The repo is created as `agents/<repo>`.
pub repo: 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 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.
#[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.
/// Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure
/// containers use their full name (e.g. `hive-ci`, `hive-forge`,
/// `hive-matrix`, `hive-gateway`).
#[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>,
}

View file

@ -18,12 +18,31 @@ use std::path::PathBuf;
use anyhow::Result;
use rmcp::{
ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler,
ServerHandler, ServiceExt, handler::server::wrapper::Parameters, tool, tool_handler,
tool_router, transport::stdio,
};
use crate::client;
mod args;
mod render;
pub use args::{
AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs,
CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs,
GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs,
RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs,
StartArgs, UpdateArgs, UpdateMetaInputsArgs,
};
pub use render::{
IDLE_WAIT_HINT, REDELIVERY_HINT, annotate_retries, format_ack, format_agent_meta, format_recv,
};
use render::{
format_matrix_summary, loose_end_kind_label, matrix_unread_summary, parse_loose_end_kind,
render_loose_ends, reply_err,
};
/// Write (or remove) the status file in the agent's own `state/` directory.
/// Called by `AgentServer::set_status` for both agent and manager flavors
/// before dispatching the wire `SetStatus` request (which only triggers a
@ -69,406 +88,6 @@ fn write_status_file(text: &str) -> Result<(), String> {
result.map_err(|e| format!("set_status write failed: {e}"))
}
/// Render the three identical failure arms every data-returning tool handler
/// repeats: a broker `Err` → `"{tool} failed: {m}"`, an unexpected `Ok` variant
/// → `"{tool} unexpected response: …"`, and a transport error → `"{tool}
/// transport error: …"`. Handlers match their own happy-path variant and route
/// everything else here via a catch-all arm (`other => reply_err(other, tool)`),
/// so the triplet lives in exactly one place.
fn reply_err(resp: Result<hive_sh4re::Response, anyhow::Error>, tool: &str) -> String {
match resp {
Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"),
Ok(other) => format!("{tool} unexpected response: {other:?}"),
Err(e) => format!("{tool} transport error: {e:#}"),
}
}
/// 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<hive_sh4re::Response, anyhow::Error>,
tool: &str,
ok_msg: String,
) -> String {
match resp {
Ok(hive_sh4re::Response::Ok) => ok_msg,
other => reply_err(other, tool),
}
}
/// Format helper for `recv`: renders zero, one, or many popped
/// messages. Empty list collapses to "(empty)" so claude doesn't go
/// hunting for content; when `waited` is set (the call parked on a
/// long-poll that timed out) the empty result also carries
/// [`IDLE_WAIT_HINT`] nudging the model toward other work. 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<hive_sh4re::Response, anyhow::Error>, waited: bool) -> String {
match resp {
Ok(hive_sh4re::Response::Messages { messages }) => render_recv_messages(&messages, waited),
// A graceful stop is pending — the inbox is fenced. Render a single
// explicit directive (not an empty inbox, which claude's "park on recv"
// habit would long-poll again, stalling the stop-checkpoint turn until
// the drain wait times out into a hard stop) so every recv during the
// stop unmissably tells claude to flush + end.
Ok(hive_sh4re::Response::GracefulStop) => {
render_recv_messages(&[graceful_stop_message()], waited)
}
other => reply_err(other, "recv"),
}
}
/// The synthetic single-message directive rendered for a fenced (graceful-stop)
/// inbox — see the `GracefulStop` arm of [`format_recv`].
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
hive_sh4re::DeliveredMessage {
from: "graceful-stop".into(),
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
ends. Flush anything worth keeping to your durable /state files, then END \
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
only keep returning this same notice."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Render the popped-message payload of a successful `recv` (see `format_recv`
/// for the empty/single/batch shapes).
fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String {
use std::fmt::Write as _;
if messages.is_empty() {
return if waited {
format!("(empty){IDLE_WAIT_HINT}")
} else {
"(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{}", msg_id_tag(m.id), 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{}",
msg_id_tag(m.id),
m.from,
m.body
);
}
out
}
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
/// pings carry the sentinel id 0 (in-memory only, nothing in the
/// broker to ack) and render without the marker.
fn msg_id_tag(id: i64) -> String {
if id > 0 {
format!("[msg #{id}] ")
} else {
String::new()
}
}
/// 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";
/// Appended to the `recv` empty result when the agent parked on a
/// long-poll (`wait_seconds > 0`) that timed out with nothing new.
/// Nudges the model to spend the idle time on other useful work
/// instead of immediately re-blocking on `recv`.
pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \
If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \
notes to update), do that now rather than immediately parking on recv again.";
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
/// reply. Called by the `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::PendingMessages { count } => {
let _ = writeln!(
out,
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
);
}
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
}
/// 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<String>,
last_sender: Option<String>,
}
/// 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<Vec<MatrixRoomUnread>> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let socket = std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(
|| std::path::PathBuf::from("/run/hive-matrix/socket"),
std::path::PathBuf::from,
);
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<MatrixRoomUnread>` 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
&& 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<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`,
/// `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/mcp.md::Core tools` (`get_agent_meta`).
#[must_use]
pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String {
match resp {
Ok(hive_sh4re::Response::AgentMeta {
name,
running,
hyperhive_rev,
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
}) => {
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}");
}
}
}
}
// Matrix identities the agent can act as (the `account` arg on
// the matrix tools). Listed only when matrix is provisioned, so
// non-matrix agents don't see an empty line.
if !matrix_accounts.is_empty() {
use std::fmt::Write as _;
out.push_str("\nmatrix_accounts:");
for acct in &matrix_accounts {
let uid = acct.user_id.as_deref().unwrap_or("?");
let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver);
}
}
out
}
other => reply_err(other, "get_agent_meta"),
}
}
/// 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. Tool results stay clean — the inbox-status hint lives in
/// the wake prompt + UI header, not appended here.
@ -482,100 +101,6 @@ where
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 5 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 `ack_until`.
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AckUntilArgs {
/// Highest broker message id to mark handled: every inbox message
/// with `id <= up_to` (ids show as `[msg #<id>]` in recv output)
/// is acked in one sweep. Pass the highest id you've actually
/// seen/triaged — anything above it stays queued for later turns.
pub up_to: i64,
}
/// 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>,
}
/// Unified MCP tool surface for both sub-agent and manager roles.
///
/// `AgentRequest = ManagerRequest = Request` and `AgentResponse =
@ -1531,305 +1056,3 @@ pub async fn serve_http(socket: PathBuf, addr: std::net::SocketAddr) -> Result<(
axum::serve(listener, app).await?;
Ok(())
}
// -----------------------------------------------------------------------------
// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics)
// -----------------------------------------------------------------------------
#[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 CreateRepoArgs {
/// Repo name — a single segment of letters, digits, `-`, `_`, `.`
/// (no leading `-`/`.`). The repo is created as `agents/<repo>`.
pub repo: 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 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.
#[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.
/// Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure
/// containers use their full name (e.g. `hive-ci`, `hive-forge`,
/// `hive-matrix`, `hive-gateway`).
#[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>,
}
#[cfg(test)]
mod tests {
use super::{IDLE_WAIT_HINT, format_recv};
#[test]
fn empty_recv_after_wait_appends_idle_hint() {
let out = format_recv(
Ok(hive_sh4re::Response::Messages { messages: vec![] }),
true,
);
assert!(out.starts_with("(empty)"));
assert!(out.contains(IDLE_WAIT_HINT));
}
#[test]
fn empty_recv_without_wait_has_no_hint() {
let out = format_recv(
Ok(hive_sh4re::Response::Messages { messages: vec![] }),
false,
);
assert_eq!(out, "(empty)");
}
}

View file

@ -0,0 +1,448 @@
//! Formatting / render helpers for the MCP tool surface: ack / recv /
//! loose-end / agent-meta reply shaping plus the retry annotation.
//! Stateless string builders, with one exception —
//! [`matrix_unread_summary`] queries the local matrix daemon socket
//! (best-effort) so `get_loose_ends` can prepend an unread-rooms entry.
/// Render the three identical failure arms every data-returning tool handler
/// repeats: a broker `Err` → `"{tool} failed: {m}"`, an unexpected `Ok` variant
/// → `"{tool} unexpected response: …"`, and a transport error → `"{tool}
/// transport error: …"`. Handlers match their own happy-path variant and route
/// everything else here via a catch-all arm (`other => reply_err(other, tool)`),
/// so the triplet lives in exactly one place.
pub(super) fn reply_err(resp: Result<hive_sh4re::Response, anyhow::Error>, tool: &str) -> String {
match resp {
Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"),
Ok(other) => format!("{tool} unexpected response: {other:?}"),
Err(e) => format!("{tool} transport error: {e:#}"),
}
}
/// 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<hive_sh4re::Response, anyhow::Error>,
tool: &str,
ok_msg: String,
) -> String {
match resp {
Ok(hive_sh4re::Response::Ok) => ok_msg,
other => reply_err(other, tool),
}
}
/// Format helper for `recv`: renders zero, one, or many popped
/// messages. Empty list collapses to "(empty)" so claude doesn't go
/// hunting for content; when `waited` is set (the call parked on a
/// long-poll that timed out) the empty result also carries
/// [`IDLE_WAIT_HINT`] nudging the model toward other work. 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<hive_sh4re::Response, anyhow::Error>, waited: bool) -> String {
match resp {
Ok(hive_sh4re::Response::Messages { messages }) => render_recv_messages(&messages, waited),
// A graceful stop is pending — the inbox is fenced. Render a single
// explicit directive (not an empty inbox, which claude's "park on recv"
// habit would long-poll again, stalling the stop-checkpoint turn until
// the drain wait times out into a hard stop) so every recv during the
// stop unmissably tells claude to flush + end.
Ok(hive_sh4re::Response::GracefulStop) => {
render_recv_messages(&[graceful_stop_message()], waited)
}
other => reply_err(other, "recv"),
}
}
/// The synthetic single-message directive rendered for a fenced (graceful-stop)
/// inbox — see the `GracefulStop` arm of [`format_recv`].
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
hive_sh4re::DeliveredMessage {
from: "graceful-stop".into(),
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
ends. Flush anything worth keeping to your durable /state files, then END \
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
only keep returning this same notice."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Render the popped-message payload of a successful `recv` (see `format_recv`
/// for the empty/single/batch shapes).
fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String {
use std::fmt::Write as _;
if messages.is_empty() {
return if waited {
format!("(empty){IDLE_WAIT_HINT}")
} else {
"(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{}", msg_id_tag(m.id), 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{}",
msg_id_tag(m.id),
m.from,
m.body
);
}
out
}
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
/// pings carry the sentinel id 0 (in-memory only, nothing in the
/// broker to ack) and render without the marker.
fn msg_id_tag(id: i64) -> String {
if id > 0 {
format!("[msg #{id}] ")
} else {
String::new()
}
}
/// 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";
/// Appended to the `recv` empty result when the agent parked on a
/// long-poll (`wait_seconds > 0`) that timed out with nothing new.
/// Nudges the model to spend the idle time on other useful work
/// instead of immediately re-blocking on `recv`.
pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \
If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \
notes to update), do that now rather than immediately parking on recv again.";
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
/// reply. Called by the `get_loose_ends` handler, which injects the
/// `UnreadMatrix` entry before formatting.
pub(super) 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::PendingMessages { count } => {
let _ = writeln!(
out,
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
);
}
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
}
/// 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)]
pub(super) struct MatrixRoomUnread {
label: String,
count: u32,
last_body: Option<String>,
last_sender: Option<String>,
}
/// 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.
pub(super) async fn matrix_unread_summary() -> Option<Vec<MatrixRoomUnread>> {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
let socket = std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(
|| std::path::PathBuf::from("/run/hive-matrix/socket"),
std::path::PathBuf::from,
);
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<MatrixRoomUnread>` 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.
pub(super) 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
&& 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).
pub(super) 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"`).
pub(super) 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`,
/// `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/mcp.md::Core tools` (`get_agent_meta`).
#[must_use]
pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String {
match resp {
Ok(hive_sh4re::Response::AgentMeta {
name,
running,
hyperhive_rev,
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
}) => {
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}");
}
}
}
}
// Matrix identities the agent can act as (the `account` arg on
// the matrix tools). Listed only when matrix is provisioned, so
// non-matrix agents don't see an empty line.
if !matrix_accounts.is_empty() {
use std::fmt::Write as _;
out.push_str("\nmatrix_accounts:");
for acct in &matrix_accounts {
let uid = acct.user_id.as_deref().unwrap_or("?");
let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver);
}
}
out
}
other => reply_err(other, "get_agent_meta"),
}
}
/// 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)
}
}
/// 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
}
#[cfg(test)]
mod tests {
use super::{IDLE_WAIT_HINT, format_recv};
#[test]
fn empty_recv_after_wait_appends_idle_hint() {
let out = format_recv(
Ok(hive_sh4re::Response::Messages { messages: vec![] }),
true,
);
assert!(out.starts_with("(empty)"));
assert!(out.contains(IDLE_WAIT_HINT));
}
#[test]
fn empty_recv_without_wait_has_no_hint() {
let out = format_recv(
Ok(hive_sh4re::Response::Messages { messages: vec![] }),
false,
);
assert_eq!(out, "(empty)");
}
}