Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-sh4re/src/lib.rs
atlas 7a726d1e4e docs: InitConfig creates the repo; tailoring it is a MergeConfigPr
Caught by argus reviewing the seven-site correction that just landed:
ApprovalKind::InitConfig still read "for manager customisation", the
same phrase family, two variants above the MergeConfigPr doc that
already described the PR flow correctly.

Ambiguous rather than flatly wrong, but it sits in the wire type both
surfaces read, so it gets the same treatment as the rest.
2026-08-04 22:53:22 +02:00

915 lines
39 KiB
Rust

//! Wire types shared between `hive-c0re` and the in-container harness.
use chrono::{DateTime, Utc};
use hive_types::Ident;
use serde::{Deserialize, Serialize};
pub mod assets;
pub mod paths;
pub mod wire_time;
/// Server-side hard cap on `Recv.max` (see the `Recv` request). Bounds
/// the size of a single round-trip so a confused caller can't drain the
/// entire inbox in one go and blow past wire-buffer sizes; everything
/// above the cap silently clamps. 5 keeps individual turns small — a big
/// backlog is drained over several recv calls instead of one giant pop.
/// Lives here so both the enforcing side (hive-c0re's `socket_server`) and
/// the hinting side (hive-agent's wake prompt + tool docs) reference one
/// constant instead of a scattered magic value.
pub const RECV_BATCH_MAX: u32 = 5;
/// Banner prepended to a wake prompt / `recv` result when the message was
/// redelivered after a harness restart (the turn that first drove it never
/// acked). Shared between the harness serve loop (wake prompt) and the MCP
/// server (`recv` tool result) so both surfaces phrase it identically.
pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n";
/// Banner prepended to a wake prompt when the previous turn was cut off by
/// an explicit operator `/cancel` (SIGINT) rather than ending normally. Set
/// once, read-and-cleared by the next turn's wake-prompt build — see
/// `hive-agent`'s `post_cancel_turn` (sets it) and `handle_turn` (clears
/// it). Lives here for the same reason as `REDELIVERY_HINT`: a single
/// phrasing, not duplicated between call sites.
pub const INTERRUPTED_HINT: &str = "[your previous turn was interrupted by the operator (/cancel) \
before it finished — check for new messages before resuming prior work]\n";
/// Shared "(N more message(s) pending …)" advisory appended after both the
/// wake prompt body and the `recv` tool result whenever the inbox still has
/// queued messages once the current message/batch is popped. Returns an empty
/// string when `remaining == 0`. The leading `\n\n` separates it from the
/// preceding body/message block, and the suggested `max` is clamped to the
/// server-side recv cap so the hint never asks for more than one round-trip
/// can deliver. One builder so the wake prompt (harness serve loop) and the
/// in-turn recv result (MCP server) stay identical.
#[must_use]
pub fn pending_hint(remaining: u64) -> String {
if remaining == 0 {
return String::new();
}
let batch = remaining.min(u64::from(RECV_BATCH_MAX));
format!(
"\n\n({remaining} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
with `max: {batch}` to drain the next batch before acting. If the \
backlog is stale/already handled, `ack_until(up_to: <highest [msg #N] seen>)` \
clears everything up to that id in one call instead.)"
)
}
/// One row in the approval queue. `commit_ref` is overloaded per
/// `kind` — see `docs/approvals.md::Approval kinds (wire shapes)`
/// for the encoding table and lifecycle.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Approval {
pub id: i64,
pub agent: Ident,
#[serde(default)]
pub kind: ApprovalKind,
/// Kind-specific payload (git sha / inputs array / schedule
/// payload / empty). See the Approval struct doc.
pub commit_ref: String,
/// The canonical hive-c0re-vouched sha. For `MergeConfigPr`: the
/// reviewed PR head pinned at submit; if the PR head drifts off it
/// before merge, hive-c0re cancels the stale approval and re-queues a
/// fresh one for re-review.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_sha: Option<String>,
pub requested_at: DateTime<Utc>,
pub status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resolved_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
/// Free-text description the manager attached at submission time;
/// shown on the dashboard approval card.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// What action the approval, when granted, will trigger.
/// Variant-specific payload encoding + flow lives in
/// `docs/approvals.md::Approval kinds (wire shapes)`.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalKind {
/// Create + start a new sub-agent container with the given name
/// (under the default `agent.nix` template).
Spawn,
/// Create an agent's config repo and seed it from the default
/// template (step 1 of the two-step spawn flow). Creating it is the
/// whole of this step — tailoring what the template seeded is not a
/// separate mechanism, it's a `MergeConfigPr` like every later
/// change.
InitConfig,
/// Run `nix flake update [inputs...]` on the meta flake and commit
/// the resulting lock changes.
UpdateMetaInputs,
/// Add a scheduled prompt to the broker queue.
SchedulePrompt,
/// Merge an operator-reviewed config PR: hive-c0re verifies the
/// reviewed PR head, fast-forwards the forge config repo's `main`
/// to it, marks the PR merged, then runs the deploy tail. This is the
/// sole config-change flow — a manager opens a PR on its
/// `agent-configs/<agent>` repo and the operator reviews + approves it.
/// `commit_ref` = PR number; `fetched_sha` = the reviewed PR head
/// pinned at submit. See `docs/approvals.md`.
#[default]
MergeConfigPr,
}
impl ApprovalKind {
/// Wire/UI string — the same value serde's `snake_case` rename
/// produces. The single source of truth for every place that needs
/// the kind as a `&'static str` (sqlite storage, dashboard events),
/// so adding a variant can't silently miss a hand-rolled match.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ApprovalKind::Spawn => "spawn",
ApprovalKind::InitConfig => "init_config",
ApprovalKind::UpdateMetaInputs => "update_meta_inputs",
ApprovalKind::SchedulePrompt => "schedule_prompt",
ApprovalKind::MergeConfigPr => "merge_config_pr",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalStatus {
Pending,
Approved,
Denied,
Failed,
/// Manager withdrew the request before the operator acted on it.
/// Distinct from `Denied` (operator decision) and `Failed`
/// (post-approval lifecycle error). See
/// `docs/approvals.md::Withdrawing a pending approval`.
Cancelled,
}
/// Reminder activity statistics for an agent over a time window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReminderStats {
/// Total reminders scheduled in the window (`created_at` >= cutoff).
pub scheduled: u64,
/// Reminders that have been delivered in the window (`sent_at` IS NOT NULL).
pub delivered: u64,
/// Reminders still pending in the window (`sent_at` IS NULL).
pub pending: u64,
}
// -----------------------------------------------------------------------------
// Per-agent socket — /run/hyperhive/agents/<name>/mcp.sock on the host,
// bind-mounted into the container at /run/hive/mcp.sock.
// -----------------------------------------------------------------------------
/// A logical message between agents.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub from: Ident,
pub to: String,
pub body: String,
/// Optional broker row-id of the message this is a reply to.
/// Stored in the DB and echoed back on `Recv` so the dashboard can
/// render conversation threads. `None` for messages that start a
/// new thread. Ignored if the referenced id is unknown or out of
/// retention — purely advisory.
pub in_reply_to: Option<i64>,
}
/// One row of a broker inbox query — what the dashboard renders in
/// its operator-inbox section and what a per-agent web UI returns
/// from a `Recent` request. Lives in `hive_sh4re` so it can travel
/// over both the dashboard's `/api/state` and the agent socket
/// without an internal-to-wire conversion.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InboxRow {
pub id: i64,
pub from: String,
pub body: String,
pub at: i64,
/// Row-id of the message this is a reply to, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<i64>,
}
/// One delivered message in a `Recv` response.
/// See `docs/conventions.md::Broker delivery + ack cycle` for the
/// full delivery/ack/requeue story.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeliveredMessage {
pub from: String,
pub body: String,
/// Broker row id, tracked by the harness for `AckTurn`. Opaque to
/// claude. `default` for wire backwards-compat.
#[serde(default)]
pub id: i64,
/// `true` if this row was resurfaced by `RequeueInflight` (previously
/// popped, never acked). Formatter prepends a "may already be handled"
/// hint when set.
#[serde(default)]
pub redelivered: bool,
/// Row-id of the message this is a reply to, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub in_reply_to: Option<i64>,
}
/// Reminder timing: either relative (wait N seconds) or absolute (at unix
/// timestamp).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "timing_type", rename_all = "snake_case")]
pub enum ReminderTiming {
/// Remind after this many seconds from now.
InSeconds { seconds: u64 },
/// Remind at this unix timestamp (seconds since epoch).
At { unix_timestamp: i64 },
}
/// One row in the response to `GetLooseEnds`. Tagged enum so new
/// thread kinds can land without breaking existing handlers.
/// Per-flavour scoping + per-variant fields + clock-anomaly
/// saturation behaviour live in
/// `docs/conventions.md::Loose-ends wire shape`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LooseEnd {
/// A pending approval row.
Approval {
id: i64,
agent: String,
commit_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
age_seconds: u64,
},
/// An unanswered question row.
Question {
id: i64,
asker: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
target: Option<String>,
question: String,
age_seconds: u64,
},
/// A scheduled but un-delivered reminder row.
Reminder {
id: i64,
owner: String,
message: String,
due_at: DateTime<Utc>,
age_seconds: u64,
},
/// Undelivered inbox messages waiting to be `recv`'d by this agent.
/// Not cancellable — drain them with `recv`. Surfaced so an agent
/// doing a between-turns `get_loose_ends` sweep sees it still owes
/// itself a `recv` without having to poll the inbox separately. Only
/// emitted when `count > 0`.
PendingMessages {
/// Number of undelivered messages queued for this agent.
count: u64,
},
/// Unread matrix notifications in one or more rooms. Not cancellable —
/// use `mark_read` via the matrix MCP to clear. Injected by the
/// in-container harness (not hive-c0re) because the matrix daemon
/// runs inside the agent container.
UnreadMatrix {
/// Number of rooms with at least one unread notification.
rooms: u32,
/// Per-room summary: one line per room with truncated last-message
/// body when count is 1, or just the unread count otherwise. Empty
/// when the daemon returned no per-room detail.
#[serde(default)]
summary: String,
},
/// A dynamic, subsystem-pushed todo (loose-ends v2). Produced by an
/// in-container subsystem via `UpsertTodo` — matrix/bash/forge are the
/// *built-in* producers that ship today, but `subsystem` is a plain
/// string, not a closed set: any user-configured MCP server declared
/// in an agent's `agent.nix` can dial the in-agent socket and push its
/// own todos the same way. Cleared by that subsystem (`ClearTodo`) or
/// by the agent itself (`MarkTodoDone`, by `id`).
Todo {
id: i64,
/// Producing subsystem marker (`"matrix"`, `"forge"`, `"bash"`, …
/// — built-in producers; a user-configured MCP server can push
/// its own arbitrary marker here too, nothing enforces the set).
subsystem: String,
/// Optional subsystem-specific key (matrix room id, bash task id).
#[serde(default, skip_serializing_if = "Option::is_none")]
subsystem_key: Option<String>,
summary: String,
/// Optional free-text provenance (room name / task label).
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<String>,
age_seconds: u64,
},
}
/// Kind discriminator for `CancelLooseEnd`. Per-kind store +
/// authorisation rules live in
/// `docs/conventions.md::Loose-ends wire shape`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CancelLooseEndKind {
Question,
Reminder,
/// Withdraw a pending approval (manager surface only).
Approval,
}
// ---------------------------------------------------------------------------
// Bash-task on-disk schema (shared with `hive-bash-mcp`)
// ---------------------------------------------------------------------------
/// Lifecycle state of a bash task.
///
/// Canonical home for the bash-task persisted schema: `hive-bash-mcp`
/// (the daemon that writes the files) re-exports these from its
/// `protocol` module, and `hive-agent` (the agent web UI that reads
/// them back for the running-tasks panel) deserializes the same type, so
/// the on-disk shape can't drift between writer and reader.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TaskStatus {
Pending,
Running,
Done,
TimedOut,
/// Daemon was restarted while the task was running; process is gone.
Interrupted,
/// Killed on request via `BashKill` (SIGINT or SIGKILL to the task's
/// process group). Distinct from `Interrupted` (daemon-restart) and
/// `TimedOut` (exceeded `timeout_secs`).
Killed,
}
/// Task metadata + result written to `<id>.json` under the bash-tasks dir.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFile {
pub id: String,
pub cmd: String,
/// Kill timeout in seconds. `None` means no timeout — task runs until
/// natural exit. Old task files with a numeric value are still readable
/// (serde coerces `u64` → `Some(u64)` is handled by the caller).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_secs: Option<u64>,
pub status: TaskStatus,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
/// Last `SUMMARY_BYTES` of stdout (see `hive-bash-mcp` runner).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdout_tail: Option<String>,
/// Last `SUMMARY_BYTES` of stderr (see `hive-bash-mcp` runner).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stderr_tail: Option<String>,
}
/// One entry in a `ListDescendants` result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContainerInfo {
/// Logical agent name (no `h-` prefix).
pub name: String,
/// Whether the container is currently running.
pub running: bool,
}
/// One row in a `HostRequest::AgentStatus` result — the operator-CLI
/// projection of the dashboard's per-agent `ContainerView`. Carries the
/// agent's running/health flags plus the technical state an operator
/// wants in a roster overview (`hivectl list-agents`).
//
// Four orthogonal, independently-observed facts about one agent, each
// rendered as its own column/token by `hivectl list-agents` and read
// individually by `--json` consumers. Any combination is meaningful
// (a stopped agent can be paused and need an update), so folding them
// into a state machine or nested flag structs would only add
// `serde(flatten)` indirection to preserve the same flat JSON. Same
// rationale as `LifecycleScope` in hive-host-sock.
#[allow(
clippy::struct_excessive_bools,
reason = "flat wire projection of independent per-agent flags"
)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatusRow {
/// Logical agent name (no `h-` prefix).
pub name: String,
/// Whether the container is currently running.
pub running: bool,
/// Config commit is pending — the locked rev differs from the
/// agent's proposed/applied config (a rebuild would change it).
pub needs_update: bool,
/// The agent has no live claude session and is parked waiting for
/// the operator's re-auth flow.
pub needs_login: bool,
/// First 12 chars of the sha the meta flake currently has locked for
/// this agent's input. `None` when the agent has no locked rev yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deployed_sha: Option<String>,
/// Count of this agent's pending reminders.
#[serde(default)]
pub pending_reminders: u64,
/// The agent's turn loop is parked (pause marker present in its
/// harness dir): the container may well be up and serving, it just
/// drives no turns. Orthogonal to `running` — an agent can be
/// paused while stopped, and pause survives a restart.
#[serde(default)]
pub paused: bool,
/// Parent in the topology tree. `None` marks a root-level agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// One matrix identity an agent can act as, surfaced in `GetAgentMeta`'s
/// `matrix_accounts`. Field names match the daemon's `matrix-accounts.json`
/// snapshot (written by `hive-matrix-mcp`'s account registry) so hive-c0re
/// deserializes the snapshot straight into `Vec<MatrixIdentity>`; the
/// snapshot's `live` / `is_primary` fields are ignored here (only live
/// accounts are listed).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixIdentity {
/// Logical account name (the `account` arg on the matrix MCP tools).
pub name: String,
/// Matrix user id (`@user:server`). `None` if the session restored
/// without a known user id yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
/// Homeserver base URL this account is on.
pub homeserver: String,
}
// -----------------------------------------------------------------------------
// Manager socket — /run/hyperhive/manager/mcp.sock on the host, bind-mounted
// into the manager container at /run/hive/mcp.sock.
// -----------------------------------------------------------------------------
/// Logical name the broker uses for the manager.
pub const MANAGER_AGENT: &str = "ruth";
/// Logical name the broker uses for the human operator. Messages with
/// `to = OPERATOR_RECIPIENT` accumulate in sqlite and surface on the
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
pub const OPERATOR_RECIPIENT: &str = "operator";
/// Reserved magic recipient — `send(to: "<parent>", ...)` is rewritten
/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)`
/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent
/// (no parent). Lets agents address their parent without hardcoding the
/// label, so runtime reparenting requires no agent-side restart. The
/// angle brackets are not valid in agent names (validators reject
/// `<`/`>`), so this name can never collide with a real recipient.
pub const PARENT_RECIPIENT: &str = "<parent>";
/// Reserved magic recipient — `send(to: "<children>", ...)` fans out to
/// every agent whose direct parent (per `topology.json`) is the sender.
/// Lets a sub-manager nudge its subtree without enumerating labels at
/// call-time; topology changes propagate for free. The angle brackets
/// are structurally safe — agent name validation rejects `<`/`>`.
/// Delivers to an empty set (no-op) for leaf agents that have no children.
pub const CHILDREN_RECIPIENT: &str = "<children>";
/// Sender hive-c0re uses for events it pushes into the manager's inbox.
/// Manager harness recognises this and parses the body as a `HelperEvent`.
pub const SYSTEM_SENDER: &str = "system";
/// Parse `s` as a [`Ident`] for use as `Message.from`, falling back to
/// [`SYSTEM_SENDER`] on the (should-be-unreachable) case that `s` isn't
/// ident-shaped. `Message.from` is always either a fixed sentinel literal
/// (`SYSTEM_SENDER`, `OPERATOR_RECIPIENT`, `"scheduled"`, …) or an
/// already-registered agent's own name reaching this point through
/// hive-c0re's internal dispatch — never arbitrary external input — so
/// this is a defensive fallback for a programming-bug case, not a
/// validation gate.
///
/// # Panics
///
/// Never, unless [`SYSTEM_SENDER`] itself stops being ident-shaped (which
/// would also be a programming bug, caught by `hive-types`' own tests).
#[must_use]
pub fn trusted_sender(s: &str) -> Ident {
Ident::parse(s)
.unwrap_or_else(|_| Ident::parse(SYSTEM_SENDER).expect("SYSTEM_SENDER is a valid Ident"))
}
/// Out-of-band events the host-side daemon pushes to the manager's inbox.
/// Serialised as JSON in `Message::body` (sender = `SYSTEM_SENDER`).
/// Per-variant triggers + the optional `sha`/`tag` semantics live in
/// `docs/approvals.md::Helper events to the manager`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum HelperEvent {
/// An approval transitioned to a terminal state.
ApprovalResolved {
id: i64,
agent: String,
commit_ref: String,
status: ApprovalStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tag: Option<String>,
},
/// A sub-agent's recorded flake rev is stale relative to hyperhive.
NeedsUpdate { agent: String },
/// Container exited without an operator-initiated stop (crash).
ContainerCrash {
agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>,
},
/// A question queued via `Ask` was answered. `id` matches the
/// originating `QuestionQueued.id`; `answerer` is `"operator"` /
/// a peer agent name / `"ttl-watchdog"` on expiry.
QuestionAnswered {
id: i64,
question: String,
answer: String,
answerer: String,
},
/// A peer (or the manager) asked this agent a question. Recipient
/// replies via `Answer { id, answer }`; the answer routes back to
/// the asker as `QuestionAnswered`.
QuestionAsked {
id: i64,
asker: String,
question: String,
#[serde(default)]
options: Vec<String>,
#[serde(default)]
multi: bool,
},
}
/// Submission payload for `RequestSchedulePrompt`. Lives outside the
/// enum so it can also serialize into the approval row's `commit_ref`
/// (the dispatcher re-parses it on approve and inserts the schedule).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SchedulePromptPayload {
/// Names of recipient agents. Operator + `root` allowed.
pub targets: Vec<String>,
/// Message body delivered to each target's inbox at fire time.
/// Same size budget as `Send.body` — soft cap at the broker level.
pub body: String,
/// Absolute unix timestamp (seconds) for the FIRST fire. For
/// recurring schedules the worker then re-arms in
/// `interval_seconds` steps.
pub first_fire_at_unix: i64,
/// `None` = one-shot. `Some(n > 0)` = recurring every `n` seconds.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
/// Optional description shown on the dashboard approval card AND
/// stored on the resulting schedule row for the operator's
/// "what is this?" reference later.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
/// Named group of MCP tools an agent may be granted. The harness reads
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
/// `snake_case` group names written by the meta renderer from per-agent
/// config) and expands it to the matching tool names for `--allowedTools`.
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
/// See `docs/conventions.md::Tool groups`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolGroup {
/// `send`, `recv`, `ask`, `answer`
Messaging,
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
Meta,
/// `get_loose_ends`, `cancel_loose_end`, `remind`
Inbox,
/// `kill`, `start`, `restart`, `update` - *(privileged)*
Lifecycle,
/// `request_init_config`, `request_update_meta_inputs` - *(privileged)*
Approvals,
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
/// `edit_schedule`, `list_schedules` - *(privileged)*
Scheduling,
/// `get_logs` - *(privileged)*
Diagnostics,
/// `create_repo` — create git repos through hive-c0re (the only path
/// now that agents can't create them directly). Opt-in per
/// agent so the operator controls who can spin up repos.
Forge,
/// `run`, `status` (via `mcp__bash__*`)
Execution,
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
/// `WebSearch` (search the web). Both are omitted from `--tools` by
/// default; adding this group to an agent enables them in the session
/// and in `--allowedTools` so they run without a confirmation prompt.
/// Does not gate any MCP tools — `tools()` returns `&[]`.
WebTools,
}
impl ToolGroup {
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
/// Returns `&[]` for `WebTools` — it enables Claude built-in tools,
/// not MCP tools; see `builtin_tools()`.
#[must_use]
pub fn tools(self) -> &'static [&'static str] {
match self {
Self::Messaging => &["send", "recv", "ack_until", "ask", "answer"],
Self::Meta => &["get_agent_meta"],
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"],
Self::Approvals => &["request_init_config", "request_update_meta_inputs"],
Self::Scheduling => &[
"request_schedule_prompt",
"fire_schedule_now",
"cancel_schedule",
"edit_schedule",
"list_schedules",
],
Self::Diagnostics => &["get_logs"],
Self::Forge => &["create_repo"],
Self::Execution => &["run", "status"],
Self::WebTools => &[],
}
}
/// MCP tools that are always exposed regardless of which tool groups an
/// agent is granted. `set_status` lives here because the operator
/// dashboard depends on every agent being able to report its status
/// chip — gating it behind a group would let a misconfigured agent go
/// dark on the dashboard. The server-side `SetStatus` handler has no
/// tool-group check either (only length validation), so listing it here
/// keeps the `--allowedTools` list honest with that reality.
///
/// `compact` lives here too: it's pure self-management (no cross-agent
/// effect, no privilege), gated server-side on context usage rather
/// than on tool groups, and every agent should be able to reach for it
/// regardless of which optional groups it's been granted — same
/// reasoning as `set_status`.
///
/// `mark_todos_done` too (a critical bug every agent hit): it was
/// declared as a `#[tool]` fn but never added to *any*
/// group's [`tools`](Self::tools), including `Inbox`, so no agent could
/// ever get it into `--allowedTools` and every call prompted for
/// approval it can't get. Todos are pushed to an agent independent of
/// whether it holds `Inbox` (that group only gates
/// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without
/// `Inbox` could accumulate todos it can never clear — same
/// "every agent needs this regardless of optional groups" shape as
/// `set_status`/`compact`, not a narrower `Inbox`-only fix.
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
&["set_status", "compact", "mark_todos_done"];
/// The Claude built-in tool names enabled by this group. Only
/// `WebTools` returns a non-empty slice; all other groups return `&[]`
/// (they control MCP tools via `tools()` instead).
#[must_use]
pub fn builtin_tools(self) -> &'static [&'static str] {
match self {
Self::WebTools => &["WebFetch", "WebSearch"],
_ => &[],
}
}
/// Default tool groups for an agent harness. Used when `HIVE_TOOL_GROUPS` is unset.
pub const AGENT_DEFAULT: &'static [Self] =
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
/// Convenience preset for a fully-privileged agent (all groups).
/// Use this as a starting point in `tool-groups.json` for root/manager agents.
pub const MANAGER_DEFAULT: &'static [Self] = &[
Self::Messaging,
Self::Meta,
Self::Inbox,
Self::Lifecycle,
Self::Approvals,
Self::Scheduling,
Self::Diagnostics,
Self::Execution,
];
/// Every known tool group in a stable order. Use this to enumerate
/// columns in the capabilities UI or any other place that needs the
/// full list without hard-coding it at the call site.
pub const ALL: &'static [Self] = &[
Self::Messaging,
Self::Meta,
Self::Inbox,
Self::Lifecycle,
Self::Approvals,
Self::Scheduling,
Self::Diagnostics,
Self::Forge,
Self::Execution,
Self::WebTools,
];
/// The `snake_case` wire name for this group (matches `serde(rename_all =
/// "snake_case")` serialisation).
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Messaging => "messaging",
Self::Meta => "meta",
Self::Inbox => "inbox",
Self::Lifecycle => "lifecycle",
Self::Approvals => "approvals",
Self::Scheduling => "scheduling",
Self::Diagnostics => "diagnostics",
Self::Forge => "forge",
Self::Execution => "execution",
Self::WebTools => "web_tools",
}
}
/// Short human-readable description suitable for a tooltip or help text.
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::Messaging => "send, recv, ask, answer — core agent communication",
Self::Meta => {
"get_agent_meta — identity introspection (set_status is always available)"
}
Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling",
Self::Lifecycle => {
"kill, start, restart, update, list_containers — container lifecycle (privileged)"
}
Self::Approvals => {
"request_init_config, request_update_meta_inputs — config change flow (privileged)"
}
Self::Scheduling => {
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"
}
Self::Diagnostics => {
"get_logs — read a sub-agent container's systemd journal (privileged)"
}
Self::Forge => {
"create_repo — create git repos through hive-c0re (operator-gated merge)"
}
Self::Execution => {
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
}
Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools",
}
}
}
/// Per-agent capability grants. Stored in `meta/capabilities.json`
/// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`).
/// Capabilities control system-level access that hive-c0re enforces
/// Syslog priority levels for `GetHostJournal`. Serialised as lowercase
/// strings matching journalctl `-p` accepted values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum JournalPriority {
Emerg,
Alert,
Crit,
Err,
Warning,
Notice,
Info,
Debug,
}
impl JournalPriority {
/// Returns the lowercase string journalctl expects for `-p`.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Emerg => "emerg",
Self::Alert => "alert",
Self::Crit => "crit",
Self::Err => "err",
Self::Warning => "warning",
Self::Notice => "notice",
Self::Info => "info",
Self::Debug => "debug",
}
}
}
/// at dispatch time; they are orthogonal to tool groups (which control
/// which MCP tools the harness exposes to claude).
///
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
/// `snake_case`) via `meta::render_flake`. The harness reads this to
/// conditionally register capability-gated MCP tools so claude only
/// sees tools it can actually invoke. See `docs/conventions.md::Capabilities`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Capability {
/// Agent can lifecycle-manage the root agent (kill/start/restart)
/// on behalf of the hive when the root has crashed. Named capability
/// for the existing manager privilege — future topology enforcement
/// will gate this via the capability system instead of the hardcoded
/// `container == MANAGER_CONTAINER` check.
ManageRootAgent,
/// Agent can read the full host journal via `GetHostJournal`.
/// hive-c0re checks this capability before running journalctl.
/// MCP tool `get_host_journal` is only registered in the harness
/// when this capability is present.
ReadHostJournal,
/// Agent can query non-child agents via `GetLooseEnds`,
/// `CountPendingReminders`, and `ReminderRollup` on the agent
/// socket. Without this capability, targeting a non-child agent is
/// rejected with an error (direct children are always accessible
/// without any capability). The `"*"` hive-wide value is not
/// available on the agent socket even with this capability — use the
/// manager socket for swarm-wide scans.
QueryAgentState,
/// Agent can restart hive infrastructure containers (hive-ci,
/// hive-gateway, hive-forge) via the `restart` MCP tool. hive-c0re
/// checks this capability before routing the restart through
/// hive-priv; the concrete service allowlist lives root-side in
/// hive-priv. Deliberately generic ("infra admin") so future
/// privileged infra ops can hang off the same grant.
InfraAdmin,
}
impl Capability {
/// Every known capability in a stable order. Use this to enumerate
/// columns in the permissions UI or validate incoming capability strings.
pub const ALL: &'static [Self] = &[
Self::ManageRootAgent,
Self::ReadHostJournal,
Self::QueryAgentState,
Self::InfraAdmin,
];
/// Canonical `snake_case` name for this capability (matches serde).
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::ManageRootAgent => "manage_root_agent",
Self::ReadHostJournal => "read_host_journal",
Self::QueryAgentState => "query_agent_state",
Self::InfraAdmin => "infra_admin",
}
}
/// Short human-readable description suitable for a tooltip or help text.
#[must_use]
pub fn description(self) -> &'static str {
match self {
Self::ManageRootAgent => {
"lifecycle-manage the root/manager agent on hive crash recovery"
}
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
Self::QueryAgentState => {
"query non-child agents' loose ends and reminder state via get_loose_ends"
}
Self::InfraAdmin => {
"restart hive infrastructure containers (hive-ci, hive-gateway, hive-forge) via the restart tool"
}
}
}
}
/// Schedule row shape on the wire — mirror of
/// `scheduled_prompts::Schedule` but in the public crate so the
/// dashboard and agent surfaces can deserialize without depending
/// on hive-c0re-internal types. Kept structurally identical to the
/// in-process type; the conversion is field-by-field in
/// `manager_server` / `dashboard`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireSchedule {
pub id: i64,
pub owner: String,
pub body: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_seconds: Option<u64>,
pub next_fire_at_unix: DateTime<Utc>,
pub created_at_unix: DateTime<Utc>,
pub source: WireScheduleSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<DateTime<Utc>>,
/// Set while the schedule is paused. Worker skips paused rows;
/// they keep their `next_fire_at_unix` so resuming at any time
/// fires at the next intended instant (no catch-up clamp needed
/// — a paused schedule simply slips its next fire).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paused_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub targets: Vec<WireScheduleTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum WireScheduleSource {
Operator,
Approval { id: i64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WireScheduleTarget {
pub target: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cancelled_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_fired_at_unix: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_result: Option<String>,
}