diff --git a/docs/approvals.md b/docs/approvals.md index eaa6f938..b48eefd4 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -643,7 +643,7 @@ live dial of the target agent's in-container todo socket (same `UpsertTodo` request in-container producers use); the `Spawn` approval uses this path, not a `HelperEvent`. Legacy approval rows that predate the submitter column fall back to the -root agent. Variants (`hive_sh4re::HelperEvent`): +root agent. Variants (`hive_sh4re::manager::HelperEvent`): - `ApprovalResolved { id, agent, commit_ref, status, note }` — fired by `actions::approve` + `actions::deny` whenever an diff --git a/docs/conventions.md b/docs/conventions.md index a390dbbd..44839b1c 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -318,7 +318,7 @@ straight to `new Date(s)` for display. ## Tool groups The MCP tool surface an agent receives is derived from a set of named -`ToolGroup` values (`hive_sh4re::ToolGroup`), not from a hardcoded +`ToolGroup` values (`hive_sh4re::permissions::ToolGroup`), not from a hardcoded binary flavor. | Group | Tools | diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 354aa5d2..2b158426 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -975,7 +975,7 @@ impl AgentServer { let target_count = args.targets.len(); let (resp, retries) = self .dispatch(hive_core_agent_sock::Request::RequestSchedulePrompt( - hive_sh4re::SchedulePromptPayload { + hive_sh4re::manager::SchedulePromptPayload { targets: args.targets, body: args.body, first_fire_at_unix: args.first_fire_at_unix, diff --git a/hive-agent-mcp/src/send_allow.rs b/hive-agent-mcp/src/send_allow.rs index d7a18ad6..cb46409f 100644 --- a/hive-agent-mcp/src/send_allow.rs +++ b/hive-agent-mcp/src/send_allow.rs @@ -18,7 +18,7 @@ const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; /// the refusal as the tool result so claude knows the message didn't /// land and can react (e.g. route via `` instead). pub fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::PARENT_RECIPIENT { + if to == hive_sh4re::manager::PARENT_RECIPIENT { // Always allow `` — the allow-list constrains peer // chatter, not the structural reporting line; the operator // can rewire who the parent IS via `set_parent` without @@ -53,6 +53,6 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> { (configured in agent.nix). Allowed: {allow:?}. Your structural \ parent is always reachable — route through `send(to: \"{}\", …)` \ if you need to reach someone outside the allow-list.", - hive_sh4re::PARENT_RECIPIENT + hive_sh4re::manager::PARENT_RECIPIENT )) } diff --git a/hive-agent-sock/src/lib.rs b/hive-agent-sock/src/lib.rs index 83d92b34..0045c9d7 100644 --- a/hive-agent-sock/src/lib.rs +++ b/hive-agent-sock/src/lib.rs @@ -147,7 +147,9 @@ pub enum Response { /// `CountPendingReminders` result. PendingRemindersCount { count: u64 }, /// `ReminderRollup` result. - ReminderRollup { stats: hive_sh4re::ReminderStats }, + ReminderRollup { + stats: hive_sh4re::approvals::ReminderStats, + }, /// Op failed; `message` is operator-facing. Err { message: String }, } diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index c3045e7b..53da488c 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -71,7 +71,7 @@ use crate::turn_stats::TurnStats; use anyhow::Result; use clap::Parser; use hive_core_agent_sock::{Request, Response}; -use hive_sh4re::{HelperEvent, SYSTEM_SENDER}; +use hive_sh4re::manager::{HelperEvent, SYSTEM_SENDER}; use hive_sock_client::Retry; #[derive(Parser)] @@ -406,7 +406,7 @@ impl Surface for AgentSurface { let res = hive_sock_client::request::<_, Response>( socket, &Request::Send { - to: hive_sh4re::PARENT_RECIPIENT.into(), + to: hive_sh4re::manager::PARENT_RECIPIENT.into(), body, in_reply_to: None, }, diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index a36fce3a..ec23b8d6 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -35,14 +35,14 @@ pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790; pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Skill", "Write"]; /// Env var written by the meta renderer with a comma-separated list of -/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). +/// `hive_sh4re::permissions::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). /// When present, the harness expands the groups into per-tool allow entries /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; /// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the /// operator grants capabilities to this agent. Comma-separated -/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. +/// `hive_sh4re::permissions::Capability` `snake_case` names. Absent = no extra capabilities. const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; /// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are @@ -87,18 +87,18 @@ fn allowed_capability_tools() -> Vec { /// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, /// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. /// Falls back to `AGENT_DEFAULT` when the env var is absent or empty. -fn effective_tool_groups() -> Vec { +fn effective_tool_groups() -> Vec { let raw = match std::env::var(TOOL_GROUPS_ENV) { Ok(v) if !v.trim().is_empty() => v, - _ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), + _ => return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec(), }; let mut groups = Vec::new(); for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); // Parse via serde_json (the canonical deserialization path). - if let Ok(g) = - serde_json::from_value::(serde_json::Value::String(t.clone())) - { + if let Ok(g) = serde_json::from_value::( + serde_json::Value::String(t.clone()), + ) { groups.push(g); } else { tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); @@ -109,7 +109,7 @@ fn effective_tool_groups() -> Vec { "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ falling back to AGENT_DEFAULT" ); - return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(); + return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec(); } groups } @@ -124,9 +124,9 @@ fn effective_tool_groups() -> Vec { /// an out-of-process server has **no** later enforcement point — once it is /// in the claude MCP config the agent can call it. So this gate, applied at /// config-render time, is the security boundary for those servers. -fn extra_server_required_group(server: &str) -> Option { +fn extra_server_required_group(server: &str) -> Option { match server { - "bash" => Some(hive_sh4re::ToolGroup::Execution), + "bash" => Some(hive_sh4re::permissions::ToolGroup::Execution), _ => None, } } @@ -134,14 +134,14 @@ fn extra_server_required_group(server: &str) -> Option { /// Whether an extra MCP server should be exposed to claude given the active /// tool `groups`. A gated server (see [`extra_server_required_group`]) is /// suppressed when the agent lacks its required group. -fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool { +fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool { extra_server_required_group(server).is_none_or(|required| groups.contains(&required)) } #[cfg(test)] mod extra_server_gate_tests { use super::{extra_server_enabled, extra_server_required_group}; - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; #[test] fn bash_is_gated_behind_execution() { @@ -173,13 +173,13 @@ mod extra_server_gate_tests { /// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re /// (single source of truth). See `docs/conventions.md::Tool groups`. #[must_use] -pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { +pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec { // Collect all tool names, deduplicating while preserving order. // Always-on tools (e.g. `set_status`) come first so they're present // regardless of which groups the agent is granted — a misconfigured // agent still has to be able to report its dashboard status. let mut seen = std::collections::HashSet::new(); - let mut out: Vec = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS + let mut out: Vec = hive_sh4re::permissions::ToolGroup::ALWAYS_ON_TOOLS .iter() .copied() .chain(groups.iter().flat_map(|g| g.tools().iter().copied())) @@ -406,7 +406,7 @@ fn build_mcp_servers() -> serde_json::Map { #[cfg(test)] mod tests { use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") diff --git a/hive-agent/src/reminders.rs b/hive-agent/src/reminders.rs index f5837d91..df3da591 100644 --- a/hive-agent/src/reminders.rs +++ b/hive-agent/src/reminders.rs @@ -18,7 +18,7 @@ use std::sync::Mutex; use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; -use hive_sh4re::ReminderStats; +use hive_sh4re::approvals::ReminderStats; use hive_sh4re::wire_time; use rusqlite::{Connection, params}; diff --git a/hive-agent/src/stats.rs b/hive-agent/src/stats.rs index ff018018..20dbc370 100644 --- a/hive-agent/src/stats.rs +++ b/hive-agent/src/stats.rs @@ -15,7 +15,7 @@ use chrono::Utc; use rusqlite::{Connection, OpenFlags}; use serde::Serialize; -use hive_sh4re::ReminderStats; +use hive_sh4re::approvals::ReminderStats; /// Window param accepted by `/api/stats?window=`. Each maps to a /// total span + the bucket width used to roll up trend series. diff --git a/hive-agent/src/web_ui/stats.rs b/hive-agent/src/web_ui/stats.rs index 3cbc3ae3..f2f83614 100644 --- a/hive-agent/src/web_ui/stats.rs +++ b/hive-agent/src/web_ui/stats.rs @@ -29,7 +29,7 @@ pub(super) async fn api_stats( /// `HIVE_AGENT_SOCKET` — was a broker RPC before reminders moved /// in-container. Returns `None` on any transport / decode failure or /// when the socket is unset — the stats are decorative, not authoritative. -async fn fetch_reminder_stats(window_secs: u64) -> Option { +async fn fetch_reminder_stats(window_secs: u64) -> Option { match crate::todo_server::dial(&hive_agent_sock::Request::ReminderRollup { since_secs: window_secs, }) diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 11c94c2f..231fe442 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -6,7 +6,8 @@ use std::sync::Arc; use anyhow::{Context as _, Result, bail}; -use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent}; +use hive_sh4re::approvals::{ApprovalKind, ApprovalStatus}; +use hive_sh4re::manager::HelperEvent; use crate::coordinator::Coordinator; use crate::lifecycle; @@ -142,7 +143,7 @@ fn rollback_ref(approval_id: i64) -> String { /// `pr` and `reviewed` are fields of the approval row the operator signed off /// on — so re-reading is both cheap and the authoritative source of truth. struct DeployCtx { - approval: hive_sh4re::Approval, + approval: hive_sh4re::approvals::Approval, /// PR number, parsed from `approval.commit_ref`. pr: u64, /// The PR head sha the operator reviewed (`approval.fetched_sha`). @@ -410,7 +411,7 @@ const PR_FAIL_LOG_TAIL_BYTES: usize = 4000; /// only the error text. async fn post_merge_failure_to_pr( coord: &Arc, - approval: &hive_sh4re::Approval, + approval: &hive_sh4re::approvals::Approval, err: &anyhow::Error, ) { let Ok(pr) = approval.commit_ref.parse::() else { @@ -472,11 +473,12 @@ fn tail_bytes(s: &str, max_bytes: usize) -> String { /// The worker takes over from here — fan-out at fire time. async fn run_approval_schedule_prompt( coord: &Coordinator, - approval: hive_sh4re::Approval, + approval: hive_sh4re::approvals::Approval, ) -> Result<()> { let result: Result<()> = async { - let payload: hive_sh4re::SchedulePromptPayload = serde_json::from_str(&approval.commit_ref) - .context("decode SchedulePromptPayload from approval.commit_ref")?; + let payload: hive_sh4re::manager::SchedulePromptPayload = + serde_json::from_str(&approval.commit_ref) + .context("decode SchedulePromptPayload from approval.commit_ref")?; coord .scheduled_prompts .submit(&crate::scheduled_prompts::NewSchedule { @@ -598,7 +600,7 @@ fn fetch_approval_for_worker( coord: &Coordinator, approval_id: i64, expected_kind: ApprovalKind, -) -> Result { +) -> Result { let approval = coord .approvals .get(approval_id) @@ -641,7 +643,7 @@ async fn forge_after_first_spawn(coord: &Arc, agent: &str) { /// work that doesn't justify a queue card. async fn run_approval_init_config( coord: &Coordinator, - approval: hive_sh4re::Approval, + approval: hive_sh4re::approvals::Approval, proposed_dir: std::path::PathBuf, claude_dir: std::path::PathBuf, notes_dir: std::path::PathBuf, @@ -681,7 +683,7 @@ async fn run_approval_init_config( async fn finish_approval( coord: &Coordinator, - approval: &hive_sh4re::Approval, + approval: &hive_sh4re::approvals::Approval, result: Result<()>, terminal_tag: Option, ) -> Result<()> { @@ -960,7 +962,7 @@ pub async fn destroy(coord: &Arc, name: &str, purge: bool) -> Resul drop(guard); let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("destroyed:{name}")), format!("agent '{name}' destroyed"), diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index dd8e4b33..187cb3b7 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -3,7 +3,7 @@ //! and `tool-groups.json`. //! //! Format: a JSON object mapping agent name to an array of -//! `hive_sh4re::Capability` `snake_case` strings: +//! `hive_sh4re::permissions::Capability` `snake_case` strings: //! //! ```json //! { @@ -52,7 +52,7 @@ pub fn caps_for(name: &str) -> Vec { /// Check whether an agent holds a specific capability. #[must_use] -pub fn has_cap(name: &str, cap: hive_sh4re::Capability) -> bool { +pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> bool { caps_for(name) .iter() .any(|s| s.eq_ignore_ascii_case(cap.as_str())) diff --git a/hive-c0re/src/agent_config/tool_groups.rs b/hive-c0re/src/agent_config/tool_groups.rs index 4bb1fe08..4f73f7ba 100644 --- a/hive-c0re/src/agent_config/tool_groups.rs +++ b/hive-c0re/src/agent_config/tool_groups.rs @@ -3,7 +3,7 @@ //! and the meta `flake.nix`. //! //! Format: a JSON object mapping agent name to an array of -//! `hive_sh4re::ToolGroup` `snake_case` strings: +//! `hive_sh4re::permissions::ToolGroup` `snake_case` strings: //! //! ```json //! { @@ -70,7 +70,7 @@ fn write(map: &BTreeMap>) -> std::io::Result<()> { /// Returns `Ok(())` when all names are known, or `Err` listing the /// unrecognised names so callers can surface a useful error message. pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { - let valid: std::collections::BTreeSet<&str> = hive_sh4re::ToolGroup::ALL + let valid: std::collections::BTreeSet<&str> = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect(); @@ -85,7 +85,7 @@ pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { anyhow::bail!( "unknown tool group(s): {}; valid names are: {}", unknown.join(", "), - hive_sh4re::ToolGroup::ALL + hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect::>() diff --git a/hive-c0re/src/agent_config/topology.rs b/hive-c0re/src/agent_config/topology.rs index ba030b86..69991727 100644 --- a/hive-c0re/src/agent_config/topology.rs +++ b/hive-c0re/src/agent_config/topology.rs @@ -104,7 +104,7 @@ pub fn top_level_agents_in(topo: &BTreeMap>) -> Vec>) -> Vec String { // Early exit: only sentinel recipients need topology lookup. This // keeps the cost of a normal `send` at one string comparison. - if to != hive_sh4re::PARENT_RECIPIENT { + if to != hive_sh4re::manager::PARENT_RECIPIENT { return to.to_owned(); } resolve_recipient_in(&read(), sender, to) @@ -131,11 +131,11 @@ pub fn resolve_recipient_in( sender: &str, to: &str, ) -> String { - if to == hive_sh4re::PARENT_RECIPIENT { + if to == hive_sh4re::manager::PARENT_RECIPIENT { topo.get(sender) .cloned() .flatten() - .unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned()) + .unwrap_or_else(|| hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned()) } else { to.to_owned() } @@ -722,8 +722,8 @@ mod tests { assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice"); assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*"); assert_eq!( - resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT), - hive_sh4re::OPERATOR_RECIPIENT + resolve_recipient_in(&topo, "bob", hive_sh4re::manager::OPERATOR_RECIPIENT), + hive_sh4re::manager::OPERATOR_RECIPIENT ); } @@ -732,12 +732,12 @@ mod tests { let topo = topo_three_level(); // bob's parent is alice → `` from bob goes to alice. assert_eq!( - resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT), + resolve_recipient_in(&topo, "bob", hive_sh4re::manager::PARENT_RECIPIENT), "alice" ); // alice's parent is the manager — same one-hop rewrite. assert_eq!( - resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT), + resolve_recipient_in(&topo, "alice", hive_sh4re::manager::PARENT_RECIPIENT), crate::lifecycle::MANAGER_NAME ); } @@ -752,9 +752,9 @@ mod tests { resolve_recipient_in( &topo, crate::lifecycle::MANAGER_NAME, - hive_sh4re::PARENT_RECIPIENT + hive_sh4re::manager::PARENT_RECIPIENT ), - hive_sh4re::OPERATOR_RECIPIENT + hive_sh4re::manager::OPERATOR_RECIPIENT ); } @@ -766,8 +766,8 @@ mod tests { // its row. let topo = topo_three_level(); assert_eq!( - resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT), - hive_sh4re::OPERATOR_RECIPIENT + resolve_recipient_in(&topo, "nobody", hive_sh4re::manager::PARENT_RECIPIENT), + hive_sh4re::manager::OPERATOR_RECIPIENT ); } diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index f7e44411..6f4ffec3 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -696,7 +696,7 @@ impl Coordinator { /// rebuild-queue worker after a `PermChange` / Capabilities entry /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_capabilities_snapshot(self: &Arc) { - use hive_sh4re::Capability; + use hive_sh4re::permissions::Capability; let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() @@ -723,7 +723,7 @@ impl Coordinator { /// rebuild-queue worker after a `PermChange` / `ToolGroups` entry /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_tool_groups_snapshot(self: &Arc) { - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; let groups = ToolGroup::ALL.iter().map(|g| g.as_str()).collect(); let descriptions = ToolGroup::ALL .iter() @@ -1025,7 +1025,7 @@ impl Coordinator { /// `` sentinel's "root → operator" routing (see /// `docs/conventions.md::Recipient sentinels`). The /// notifications fire as ordinary broker messages with - /// `from = hive_sh4re::SYSTEM_SENDER` so the dashboard renders + /// `from = hive_sh4re::manager::SYSTEM_SENDER` so the dashboard renders /// them under the existing system-source styling. /// /// First validation failure aborts the whole batch with no disk writes. @@ -1056,7 +1056,7 @@ impl Coordinator { let new_label = new_parent.unwrap_or(""); if let Some(op) = old_parent.as_deref() { let _ = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::trusted_sender(hive_sh4re::SYSTEM_SENDER), + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), to: op.to_owned(), body: format!("{child} moved out of your subtree to {new_label}"), in_reply_to: None, @@ -1064,7 +1064,7 @@ impl Coordinator { } if let Some(np) = new_parent { let _ = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::trusted_sender(hive_sh4re::SYSTEM_SENDER), + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), to: np.to_owned(), body: format!( "{child} just moved into your subtree (was previously under {old_label})" @@ -1345,7 +1345,7 @@ impl Coordinator { still in your window." ); if let Err(e) = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::trusted_sender(hive_sh4re::SYSTEM_SENDER), + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::SYSTEM_SENDER), to: name.to_owned(), body, in_reply_to: None, @@ -1358,8 +1358,8 @@ impl Coordinator { /// `Message::body`; sender = `SYSTEM_SENDER`. The manager harness /// recognises the sender and parses the body. Best-effort: a serde or /// broker error is logged but does not propagate. - pub fn notify_manager(&self, event: &hive_sh4re::HelperEvent) { - self.notify_agent(hive_sh4re::MANAGER_AGENT, event); + pub fn notify_manager(&self, event: &hive_sh4re::manager::HelperEvent) { + self.notify_agent(hive_sh4re::manager::MANAGER_AGENT, event); } /// Route an approval-scoped helper event to the agent that submitted @@ -1367,7 +1367,7 @@ impl Coordinator { /// time). Legacy rows with no recorded submitter — and any lookup /// failure — fall back to the root agent, preserving the prior /// always-root behaviour. - pub fn notify_submitter(&self, approval_id: i64, event: &hive_sh4re::HelperEvent) { + pub fn notify_submitter(&self, approval_id: i64, event: &hive_sh4re::manager::HelperEvent) { let target = self.submitter_or_manager(approval_id); self.notify_agent(&target, event); } @@ -1381,7 +1381,7 @@ impl Coordinator { self.approvals .submitter_of(approval_id) .unwrap_or_default() - .unwrap_or_else(|| hive_sh4re::MANAGER_AGENT.to_owned()) + .unwrap_or_else(|| hive_sh4re::manager::MANAGER_AGENT.to_owned()) } /// Push a todo directly into `agent`'s in-container todo store — a @@ -1471,15 +1471,20 @@ impl Coordinator { /// body = JSON-encoded event). Used to route `QuestionAnswered` /// events back to the agent that called `ask`, `QuestionAsked` /// events to the target of a peer question, etc. - pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) { - self.notify_agent_from(hive_sh4re::SYSTEM_SENDER, agent, event); + pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::manager::HelperEvent) { + self.notify_agent_from(hive_sh4re::manager::SYSTEM_SENDER, agent, event); } /// Same as `notify_agent` but with an explicit sender. Use this /// when the event originates from a known agent or the operator /// (e.g. `QuestionAnswered` — the answerer should be the `from`, /// not `system`) so the recipient's terminal shows the right name. - pub fn notify_agent_from(&self, from: &str, agent: &str, event: &hive_sh4re::HelperEvent) { + pub fn notify_agent_from( + &self, + from: &str, + agent: &str, + event: &hive_sh4re::manager::HelperEvent, + ) { let body = match serde_json::to_string(event) { Ok(s) => s, Err(e) => { @@ -1488,7 +1493,7 @@ impl Coordinator { } }; if let Err(e) = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::trusted_sender(from), + from: hive_sh4re::manager::trusted_sender(from), to: agent.to_owned(), body, in_reply_to: None, @@ -1510,7 +1515,7 @@ impl Coordinator { continue; } if let Err(e) = self.broker.send(&hive_sh4re::Message { - from: hive_sh4re::trusted_sender(from), + from: hive_sh4re::manager::trusted_sender(from), to: agent_name.clone(), body: broadcast_body.clone(), in_reply_to: None, diff --git a/hive-c0re/src/dashboard/approvals.rs b/hive-c0re/src/dashboard/approvals.rs index 5deef931..6a29f033 100644 --- a/hive-c0re/src/dashboard/approvals.rs +++ b/hive-c0re/src/dashboard/approvals.rs @@ -8,7 +8,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use hive_sh4re::Approval; +use hive_sh4re::approvals::Approval; use serde::Deserialize; use utoipa::ToSchema; @@ -87,7 +87,8 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec, ) -> axum::Json { - let groups = hive_sh4re::ToolGroup::ALL + let groups = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect(); - let descriptions = hive_sh4re::ToolGroup::ALL + let descriptions = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| (g.as_str(), g.description())) .collect(); @@ -80,7 +80,7 @@ pub(super) async fn get_tool_groups( /// the container actually runs with. #[must_use] pub(crate) fn tool_group_default_names() -> Vec<&'static str> { - hive_sh4re::ToolGroup::AGENT_DEFAULT + hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT .iter() .map(|g| g.as_str()) .collect() @@ -204,7 +204,7 @@ pub(super) struct CapabilitiesSnapshot { pub(super) async fn get_capabilities( State(state): State, ) -> axum::Json { - use hive_sh4re::Capability; + use hive_sh4re::permissions::Capability; let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() @@ -256,7 +256,7 @@ pub(super) async fn post_capabilities( if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } - let known: Vec<&str> = hive_sh4re::Capability::ALL + let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); @@ -334,7 +334,7 @@ pub(super) async fn post_permissions( State(state): State, axum::Json(body): axum::Json, ) -> Result { - let known_caps: Vec<&str> = hive_sh4re::Capability::ALL + let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); diff --git a/hive-c0re/src/dashboard/questions.rs b/hive-c0re/src/dashboard/questions.rs index f3d998b7..c56417cf 100644 --- a/hive-c0re/src/dashboard/questions.rs +++ b/hive-c0re/src/dashboard/questions.rs @@ -64,33 +64,34 @@ pub(super) async fn post_answer_question( .with_detail("answer: required"), ); } - let resp = match state - .coord - .questions - .answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT) - { - Ok((question, asker, target)) => { - tracing::info!(%id, %asker, "operator answered question"); - state.coord.notify_agent( - &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { + let resp = + match state + .coord + .questions + .answer(id, answer, hive_sh4re::manager::OPERATOR_RECIPIENT) + { + Ok((question, asker, target)) => { + tracing::info!(%id, %asker, "operator answered question"); + state.coord.notify_agent( + &asker, + &hive_sh4re::manager::HelperEvent::QuestionAnswered { + id, + question, + answer: answer.to_owned(), + answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), + }, + ); + state.coord.emit_question_resolved( id, - question, - answer: answer.to_owned(), - answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), - }, - ); - state.coord.emit_question_resolved( - id, - answer, - hive_sh4re::OPERATOR_RECIPIENT, - false, - target.as_deref(), - ); - (StatusCode::OK, "ok").into_response() - } - Err(e) => error_response(&format!("answer {id} failed: {e:#}")), - }; + answer, + hive_sh4re::manager::OPERATOR_RECIPIENT, + false, + target.as_deref(), + ); + (StatusCode::OK, "ok").into_response() + } + Err(e) => error_response(&format!("answer {id} failed: {e:#}")), + }; with_cors(resp) } @@ -120,25 +121,25 @@ pub(super) async fn post_cancel_question( match state .coord .questions - .answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT) + .answer(id, SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT) { Ok((question, asker, target)) => { tracing::info!(%id, %asker, "operator cancelled question"); state.coord.emit_question_resolved( id, SENTINEL, - hive_sh4re::OPERATOR_RECIPIENT, + hive_sh4re::manager::OPERATOR_RECIPIENT, true, target.as_deref(), ); state.coord.notify_agent_from( - hive_sh4re::OPERATOR_RECIPIENT, + hive_sh4re::manager::OPERATOR_RECIPIENT, &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { + &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: SENTINEL.to_owned(), - answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), }, ); (StatusCode::OK, "ok").into_response() diff --git a/hive-c0re/src/dashboard/schedules.rs b/hive-c0re/src/dashboard/schedules.rs index 89291974..321a31aa 100644 --- a/hive-c0re/src/dashboard/schedules.rs +++ b/hive-c0re/src/dashboard/schedules.rs @@ -69,7 +69,7 @@ pub(super) async fn api_schedules(State(state): State) -> Response { #[utoipa::path( post, path = "/api/schedules", - // `hive_sh4re::SchedulePromptPayload` (the actual body) has no `ToSchema` — + // `hive_sh4re::manager::SchedulePromptPayload` (the actual body) has no `ToSchema` — // same reasoning as the `Vec` placeholder on // `api_schedules` above. request_body(content = serde_json::Value, description = "SchedulePromptPayload wire shape"), @@ -82,7 +82,7 @@ pub(super) async fn api_schedules(State(state): State) -> Response { )] pub(super) async fn post_schedule_new( State(state): State, - axum::Json(payload): axum::Json, + axum::Json(payload): axum::Json, ) -> Result { if payload.targets.is_empty() { return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST) @@ -97,7 +97,7 @@ pub(super) async fn post_schedule_new( .with_detail("interval_seconds must be > 0 (use None for one-shot)")); } let new = crate::scheduled_prompts::NewSchedule { - owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + owner: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), targets: payload.targets, body: payload.body, first_fire_at_unix: payload.first_fire_at_unix, diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index c32ccc74..1726a4ec 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -14,7 +14,7 @@ use axum::{ }, }; use chrono::{DateTime, Utc}; -use hive_sh4re::Approval; +use hive_sh4re::approvals::Approval; use serde::{Deserialize, Serialize}; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -561,12 +561,12 @@ fn history_view(a: Approval) -> ApprovalHistoryView { Some(displayed[..displayed.len().min(12)].to_owned()) }; let status = match a.status { - hive_sh4re::ApprovalStatus::Approved => "approved", - hive_sh4re::ApprovalStatus::Denied => "denied", - hive_sh4re::ApprovalStatus::Failed => "failed", - hive_sh4re::ApprovalStatus::Cancelled => "cancelled", + hive_sh4re::approvals::ApprovalStatus::Approved => "approved", + hive_sh4re::approvals::ApprovalStatus::Denied => "denied", + hive_sh4re::approvals::ApprovalStatus::Failed => "failed", + hive_sh4re::approvals::ApprovalStatus::Cancelled => "cancelled", // Pending shouldn't appear in recent_resolved, but be defensive. - hive_sh4re::ApprovalStatus::Pending => "pending", + hive_sh4re::approvals::ApprovalStatus::Pending => "pending", }; let kind = a.kind.as_str(); ApprovalHistoryView { @@ -584,7 +584,7 @@ fn build_approval_views(approvals: Vec) -> Vec { let mut out = Vec::with_capacity(approvals.len()); for a in approvals { out.push(match a.kind { - hive_sh4re::ApprovalKind::Spawn => ApprovalView { + hive_sh4re::approvals::ApprovalKind::Spawn => ApprovalView { id: a.id, agent: a.agent.to_string(), kind: "spawn", @@ -594,7 +594,7 @@ fn build_approval_views(approvals: Vec) -> Vec { commit_ref: None, requested_at: a.requested_at, }, - hive_sh4re::ApprovalKind::InitConfig => ApprovalView { + hive_sh4re::approvals::ApprovalKind::InitConfig => ApprovalView { id: a.id, agent: a.agent.to_string(), kind: "init_config", @@ -604,7 +604,7 @@ fn build_approval_views(approvals: Vec) -> Vec { commit_ref: None, requested_at: a.requested_at, }, - hive_sh4re::ApprovalKind::UpdateMetaInputs => ApprovalView { + hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView { id: a.id, agent: a.agent.to_string(), kind: "update_meta_inputs", @@ -614,7 +614,7 @@ fn build_approval_views(approvals: Vec) -> Vec { commit_ref: Some(a.commit_ref), requested_at: a.requested_at, }, - hive_sh4re::ApprovalKind::SchedulePrompt => ApprovalView { + hive_sh4re::approvals::ApprovalKind::SchedulePrompt => ApprovalView { id: a.id, agent: a.agent.to_string(), kind: "schedule_prompt", @@ -624,7 +624,7 @@ fn build_approval_views(approvals: Vec) -> Vec { commit_ref: Some(a.commit_ref), requested_at: a.requested_at, }, - hive_sh4re::ApprovalKind::MergeConfigPr => { + hive_sh4re::approvals::ApprovalKind::MergeConfigPr => { // commit_ref = PR number; fetched_sha = the reviewed PR // head. Show the head sha; the config diff surface lives // on the forge PR itself. diff --git a/hive-c0re/src/dashboard/webhook.rs b/hive-c0re/src/dashboard/webhook.rs index 4b220994..248c6c03 100644 --- a/hive-c0re/src/dashboard/webhook.rs +++ b/hive-c0re/src/dashboard/webhook.rs @@ -3,7 +3,7 @@ //! - **`/webhook/knowledge`** — push events on `internal/knowledge` trigger a //! `git pull` on the local clone so agents see up-to-date docs. //! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*` -//! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row +//! repo queue a [`hive_sh4re::approvals::ApprovalKind::MergeConfigPr`] approval row //! so the operator can review + approve the merge from the dashboard. //! //! Both endpoints are reached via the gateway (HTTPS, public domain URL) so diff --git a/hive-c0re/src/forge/config_pr_poll.rs b/hive-c0re/src/forge/config_pr_poll.rs index fe8ef2b2..5cb76fce 100644 --- a/hive-c0re/src/forge/config_pr_poll.rs +++ b/hive-c0re/src/forge/config_pr_poll.rs @@ -145,7 +145,7 @@ fn reconcile_stale_config_pr_approvals( } }; for a in pending { - if a.kind != hive_sh4re::ApprovalKind::MergeConfigPr + if a.kind != hive_sh4re::approvals::ApprovalKind::MergeConfigPr || !scanned_agents.contains(a.agent.as_str()) { continue; diff --git a/hive-c0re/src/job_queue/exec.rs b/hive-c0re/src/job_queue/exec.rs index d8866327..fc75b524 100644 --- a/hive-c0re/src/job_queue/exec.rs +++ b/hive-c0re/src/job_queue/exec.rs @@ -164,7 +164,7 @@ async fn run_emit_rebuilt(coord: &Arc, agent: &str, dag_id: Option< let summary = crate::coordinator::rebuilt_todo_summary(agent, ok, note.as_deref(), None, None); let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("rebuilt:{agent}")), summary, @@ -429,7 +429,7 @@ async fn run_stop(coord: &Arc, name: &str) -> Result<()> { coord.unregister_agent(name); let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("killed:{name}")), format!("agent '{name}' killed"), diff --git a/hive-c0re/src/questions.rs b/hive-c0re/src/questions.rs index 391d6451..b924b8be 100644 --- a/hive-c0re/src/questions.rs +++ b/hive-c0re/src/questions.rs @@ -47,7 +47,7 @@ pub fn handle_ask( // "is this string the operator?". let target = match to { None => None, - Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None, + Some(t) if t == hive_sh4re::manager::OPERATOR_RECIPIENT => None, Some("") => { return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned()); } @@ -78,7 +78,7 @@ pub fn handle_ask( if let Some(target_agent) = target { coord.notify_agent( target_agent, - &hive_sh4re::HelperEvent::QuestionAsked { + &hive_sh4re::manager::HelperEvent::QuestionAsked { id, asker: asker.to_owned(), question: question.to_owned(), @@ -125,7 +125,7 @@ pub fn handle_answer( coord.notify_agent_from( answerer, &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { + &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: answer.to_owned(), @@ -174,7 +174,7 @@ pub fn handle_cancel_loose_end( coord.notify_agent_from( canceller, &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { + &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: sentinel.clone(), diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 5e87597c..799a3bf1 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -88,7 +88,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { tracing::info!(%name, "request_spawn"); let id = coord.approvals.submit_kind( name.as_str(), - hive_sh4re::ApprovalKind::Spawn, + hive_sh4re::approvals::ApprovalKind::Spawn, "", None, "operator", @@ -298,7 +298,7 @@ async fn handle_spawn(coord: &Arc, name: &str) -> Result, name: &str) -> Result, agent: &str, name: &st Ok(()) => { let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("killed:{name}")), format!("agent '{name}' killed"), diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index d2c9f2e8..a166cfc2 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -14,7 +14,8 @@ use std::sync::Arc; use anyhow::{Context, Result}; use hive_core_agent_sock::{Request, Response}; -use hive_sh4re::{MANAGER_AGENT, Message}; +use hive_sh4re::Message; +use hive_sh4re::manager::MANAGER_AGENT; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::{UnixListener, UnixStream}; use tokio::task::JoinHandle; @@ -340,7 +341,7 @@ fn handle_wake( body: &str, ) -> hive_core_agent_sock::Response { match coord.broker.send(&Message { - from: hive_sh4re::trusted_sender(from), + from: hive_sh4re::manager::trusted_sender(from), to: agent.to_owned(), body: body.to_owned(), in_reply_to: None, @@ -483,7 +484,7 @@ fn handle_operator_msg( body: &str, ) -> hive_core_agent_sock::Response { match coord.broker.send(&Message { - from: hive_sh4re::trusted_sender(hive_sh4re::OPERATOR_RECIPIENT), + from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::OPERATOR_RECIPIENT), to: agent.to_owned(), body: body.to_owned(), in_reply_to: None, @@ -756,7 +757,10 @@ fn require_new_child(agent: &str, target: &str, action: &str) -> Option, agent: &str, target: Option<&str>) -> Response { let result = if target == Some("*") { - if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::QueryAgentState) { + if !crate::capabilities::has_cap( + agent, + hive_sh4re::permissions::Capability::QueryAgentState, + ) { return Response::Err { message: "query_agent_state capability required for hive-wide loose ends" .to_owned(), @@ -800,7 +804,10 @@ fn resolve_agent_state_target<'a>( if crate::topology::is_descendant_of(name, caller) { return Ok(name); } - if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { + if crate::capabilities::has_cap( + caller, + hive_sh4re::permissions::Capability::QueryAgentState, + ) { Ok(name) } else { Err(format!( @@ -841,7 +848,7 @@ pub async fn dispatch_host_journal(agent: &str, args: HostJournalArgs<'_>) -> Re since, until, } = args; - if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::ReadHostJournal) { + if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::ReadHostJournal) { return Response::Err { message: "agent does not have the read_host_journal capability".to_owned(), }; @@ -938,7 +945,7 @@ pub(crate) fn fan_out_send( continue; } if let Err(e) = coord.broker.send(&Message { - from: hive_sh4re::trusted_sender(from), + from: hive_sh4re::manager::trusted_sender(from), to: target.clone(), body: body.to_owned(), in_reply_to, @@ -977,7 +984,7 @@ pub(crate) fn handle_send( // topology.json. Bypasses the allow-list check — structural fan-out // targets are never user-listed peers. No-op (returns Ok) for leaf // agents that have no children. - if to == hive_sh4re::CHILDREN_RECIPIENT { + if to == hive_sh4re::manager::CHILDREN_RECIPIENT { let children = crate::topology::children_of(agent); let errors = fan_out_send(coord, agent, body, in_reply_to, &children); return if errors.is_empty() { @@ -1007,7 +1014,7 @@ pub(crate) fn handle_send( ), }; } - if resolved != hive_sh4re::OPERATOR_RECIPIENT { + if resolved != hive_sh4re::manager::OPERATOR_RECIPIENT { // A name that doesn't parse as an Ident can't be a local agent, so // it collapses into the same "unknown recipient" error as a valid // name with no state dir. @@ -1023,7 +1030,7 @@ pub(crate) fn handle_send( } } match coord.broker.send(&Message { - from: hive_sh4re::trusted_sender(agent), + from: hive_sh4re::manager::trusted_sender(agent), to: resolved, body: body.to_owned(), in_reply_to, @@ -1086,12 +1093,12 @@ pub fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) if let Ok((question, asker, target)) = coord .questions - .answer(id, TTL_SENTINEL, hive_sh4re::OPERATOR_RECIPIENT) + .answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT) { tracing::info!(%id, %asker, "question expired (ttl)"); coord.notify_agent( &asker, - &hive_sh4re::HelperEvent::QuestionAnswered { + &hive_sh4re::manager::HelperEvent::QuestionAnswered { id, question, answer: TTL_SENTINEL.to_owned(), diff --git a/hive-c0re/src/socket_server/schedules.rs b/hive-c0re/src/socket_server/schedules.rs index aa43e99c..3e8c96d0 100644 --- a/hive-c0re/src/socket_server/schedules.rs +++ b/hive-c0re/src/socket_server/schedules.rs @@ -31,7 +31,7 @@ pub(super) fn handle_list_schedules(coord: &Arc) -> Response { pub(super) fn handle_request_schedule_prompt( coord: &Arc, requester: &str, - payload: &hive_sh4re::SchedulePromptPayload, + payload: &hive_sh4re::manager::SchedulePromptPayload, ) -> Response { if payload.targets.is_empty() { return Response::Err { @@ -58,7 +58,7 @@ pub(super) fn handle_request_schedule_prompt( }; let id = match coord.approvals.submit_kind( requester, - hive_sh4re::ApprovalKind::SchedulePrompt, + hive_sh4re::approvals::ApprovalKind::SchedulePrompt, &commit_ref, payload.description.as_deref(), requester, @@ -275,7 +275,7 @@ fn cancel_authorized(requester: &str, owner: &str) -> bool { if requester == owner { return true; } - if requester == hive_sh4re::OPERATOR_RECIPIENT { + if requester == hive_sh4re::manager::OPERATOR_RECIPIENT { return true; } // Manager can cancel anything owned by an agent in its subtree. @@ -310,8 +310,9 @@ pub(crate) fn filter_ghost_schedule_targets( live: &std::collections::HashSet, ) { for s in schedules.iter_mut() { - s.targets - .retain(|t| t.target == hive_sh4re::OPERATOR_RECIPIENT || live.contains(&t.target)); + s.targets.retain(|t| { + t.target == hive_sh4re::manager::OPERATOR_RECIPIENT || live.contains(&t.target) + }); } } diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index 651af265..11b16dd9 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -8,7 +8,7 @@ use std::sync::Mutex; use anyhow::{Context, Result, bail}; use chrono::Utc; -use hive_sh4re::{Approval, ApprovalKind, ApprovalStatus}; +use hive_sh4re::approvals::{Approval, ApprovalKind, ApprovalStatus}; use rusqlite::{Connection, OptionalExtension, params}; use crate::db::Migration; @@ -445,7 +445,7 @@ fn kind_from_str(s: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use hive_sh4re::ApprovalKind; + use hive_sh4re::approvals::ApprovalKind; fn open_temp() -> (tempfile::TempDir, std::path::PathBuf, Approvals) { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/hive-c0re/src/stores/broker.rs b/hive-c0re/src/stores/broker.rs index 9a84bfde..3d4e500a 100644 --- a/hive-c0re/src/stores/broker.rs +++ b/hive-c0re/src/stores/broker.rs @@ -215,7 +215,8 @@ impl Broker { // Operator messages get elevated priority so they surface before // queued wakes (bash completions, forge events, etc.) when the // harness pops the next turn driver. All other senders stay at 0. - let priority: i64 = i64::from(message.from.as_str() == hive_sh4re::OPERATOR_RECIPIENT); + let priority: i64 = + i64::from(message.from.as_str() == hive_sh4re::manager::OPERATOR_RECIPIENT); conn.execute( "INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to, priority) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6)", @@ -382,7 +383,11 @@ impl Broker { AND delivered_at IS NULL AND acked_at IS NULL LIMIT 1", - params![child, hive_sh4re::SYSTEM_SENDER, format!("{PREFIX}%")], + params![ + child, + hive_sh4re::manager::SYSTEM_SENDER, + format!("{PREFIX}%") + ], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), ) .optional()?; @@ -400,7 +405,7 @@ impl Broker { drop(conn); let _ = self.events.send(MessageEvent::Sent { id: row_id, - from: hive_sh4re::SYSTEM_SENDER.to_owned(), + from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), to: child.to_owned(), body: new_body, at: now, @@ -411,13 +416,13 @@ impl Broker { conn.execute( "INSERT INTO messages (sender, recipient, body, sent_at, in_reply_to) VALUES (?1, ?2, ?3, ?4, NULL)", - params![hive_sh4re::SYSTEM_SENDER, child, body, now], + params![hive_sh4re::manager::SYSTEM_SENDER, child, body, now], )?; let row_id = conn.last_insert_rowid(); drop(conn); let _ = self.events.send(MessageEvent::Sent { id: row_id, - from: hive_sh4re::SYSTEM_SENDER.to_owned(), + from: hive_sh4re::manager::SYSTEM_SENDER.to_owned(), to: child.to_owned(), body, at: now, @@ -583,7 +588,7 @@ impl Broker { id, redelivered, message: Message { - from: hive_sh4re::trusted_sender(&from), + from: hive_sh4re::manager::trusted_sender(&from), to, body, in_reply_to, diff --git a/hive-c0re/src/stores/operator_questions.rs b/hive-c0re/src/stores/operator_questions.rs index 089eef90..876eaf72 100644 --- a/hive-c0re/src/stores/operator_questions.rs +++ b/hive-c0re/src/stores/operator_questions.rs @@ -165,13 +165,15 @@ impl OperatorQuestions { // can additionally override agent-to-agent questions to close // stuck threads). let authorised = match target.as_deref() { - None => answerer == hive_sh4re::OPERATOR_RECIPIENT, - Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT, + None => answerer == hive_sh4re::manager::OPERATOR_RECIPIENT, + Some(t) => answerer == t || answerer == hive_sh4re::manager::OPERATOR_RECIPIENT, }; if !authorised { bail!( "question {id} not addressed to '{answerer}' (target = {:?})", - target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT) + target + .as_deref() + .unwrap_or(hive_sh4re::manager::OPERATOR_RECIPIENT) ); } conn.execute( @@ -216,8 +218,9 @@ impl OperatorQuestions { if answered_at.is_some() { bail!("question {id} already answered/cancelled"); } - let authorised = - privileged || canceller == asker || canceller == hive_sh4re::OPERATOR_RECIPIENT; + let authorised = privileged + || canceller == asker + || canceller == hive_sh4re::manager::OPERATOR_RECIPIENT; if !authorised { bail!("question {id}: '{canceller}' not allowed to cancel (asker = '{asker}')"); } diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 0084b102..6c4c5621 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -171,7 +171,7 @@ fn seed_manager_tool_groups() { tracing::debug!("manager tool groups already set — leaving as-is"); return; } - let all_groups: Vec = hive_sh4re::ToolGroup::MANAGER_DEFAULT + let all_groups: Vec = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT .iter() .map(|g| g.as_str().to_owned()) .collect(); diff --git a/hive-c0re/src/workers/crash_watch.rs b/hive-c0re/src/workers/crash_watch.rs index 113072a8..41c20735 100644 --- a/hive-c0re/src/workers/crash_watch.rs +++ b/hive-c0re/src/workers/crash_watch.rs @@ -105,7 +105,7 @@ fn emit_crash_transitions(coord: &Coordinator, prev: &HashSet, current: } tracing::warn!(agent = %stopped, "container crash detected"); coord.record_crash(stopped); - coord.notify_manager(&hive_sh4re::HelperEvent::ContainerCrash { + coord.notify_manager(&hive_sh4re::manager::HelperEvent::ContainerCrash { agent: stopped.clone(), note: Some("container stopped without an operator action".into()), }); @@ -138,7 +138,7 @@ async fn emit_login_transitions( tracing::info!(%agent, "agent logged in"); let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("logged_in:{agent}")), format!("agent '{agent}' logged in"), @@ -169,7 +169,7 @@ async fn emit_login_transitions( tracing::info!(%agent, "agent needs login"); let _ = coord .push_todo( - hive_sh4re::MANAGER_AGENT, + hive_sh4re::manager::MANAGER_AGENT, "core", Some(format!("needs_login:{agent}")), format!("agent '{agent}' needs login"), diff --git a/hive-c0re/src/workers/knowledge.rs b/hive-c0re/src/workers/knowledge.rs index 180183c6..e8e2cdc5 100644 --- a/hive-c0re/src/workers/knowledge.rs +++ b/hive-c0re/src/workers/knowledge.rs @@ -344,7 +344,7 @@ async fn broadcast_change(coord: &Coordinator, before: &str, after: &str) { } else { format!("[system] /knowledge updated:\n{stat}") }; - let errors = coord.broadcast_send(hive_sh4re::SYSTEM_SENDER, &body); + let errors = coord.broadcast_send(hive_sh4re::manager::SYSTEM_SENDER, &body); if !errors.is_empty() { tracing::warn!(?errors, "knowledge: broadcast had per-agent failures"); } diff --git a/hive-c0re/src/workers/scheduled_prompts_worker.rs b/hive-c0re/src/workers/scheduled_prompts_worker.rs index 1f1940a3..315916da 100644 --- a/hive-c0re/src/workers/scheduled_prompts_worker.rs +++ b/hive-c0re/src/workers/scheduled_prompts_worker.rs @@ -100,7 +100,7 @@ async fn fire_schedule(coord: &Arc, schedule: &Schedule, now: i64) // no in-container todo inbox, so it keeps the regular broker // `Message` path; the dashboard mirrors `to == operator` into // its own pane. - if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) { + if target != hive_sh4re::manager::OPERATOR_RECIPIENT && !known.contains(target) { let reason = format!("no such agent: {target}"); if let Err(e) = coord @@ -169,9 +169,9 @@ async fn deliver_to_target( target: &str, body: &str, ) -> Result<(), String> { - if target == hive_sh4re::OPERATOR_RECIPIENT { + if target == hive_sh4re::manager::OPERATOR_RECIPIENT { let msg = Message { - from: hive_sh4re::trusted_sender("scheduled"), + from: hive_sh4re::manager::trusted_sender("scheduled"), to: target.to_owned(), body: body.to_owned(), in_reply_to: None, @@ -205,8 +205,8 @@ fn notify_operator_missing_target(coord: &Coordinator, schedule: &Schedule, targ body = schedule.body ); let msg = Message { - from: hive_sh4re::trusted_sender("scheduled"), - to: hive_sh4re::OPERATOR_RECIPIENT.to_owned(), + from: hive_sh4re::manager::trusted_sender("scheduled"), + to: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(), body, in_reply_to: None, }; @@ -291,7 +291,7 @@ pub async fn fire_now( continue; } let target = &target_row.target; - if target != hive_sh4re::OPERATOR_RECIPIENT && !known.contains(target) { + if target != hive_sh4re::manager::OPERATOR_RECIPIENT && !known.contains(target) { let reason = format!("manual fire: no such agent: {target}"); if let Err(e) = coord @@ -371,7 +371,7 @@ pub async fn fire_now( async fn known_agents() -> std::collections::HashSet { use std::collections::HashSet; let mut out: HashSet = HashSet::new(); - out.insert(hive_sh4re::MANAGER_AGENT.to_owned()); + out.insert(hive_sh4re::manager::MANAGER_AGENT.to_owned()); match crate::lifecycle::list().await { Ok(list) => { for raw in list { diff --git a/hive-core-agent-sock/src/lib.rs b/hive-core-agent-sock/src/lib.rs index cb390201..bf2ac37c 100644 --- a/hive-core-agent-sock/src/lib.rs +++ b/hive-core-agent-sock/src/lib.rs @@ -7,9 +7,10 @@ //! shared payload types it references (`Message`, `LooseEnd`, `Approval`, …) //! stay in `hive-sh4re`, which this crate depends on. +use hive_sh4re::manager::SchedulePromptPayload; use hive_sh4re::{ CancelLooseEndKind, ContainerInfo, DeliveredMessage, InboxRow, JournalPriority, LooseEnd, - MatrixIdentity, SchedulePromptPayload, WireSchedule, + MatrixIdentity, WireSchedule, }; use hive_types::Ident; use serde::{Deserialize, Serialize}; diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 5073c72f..cfb2c748 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -12,7 +12,8 @@ use std::path::PathBuf; -use hive_sh4re::{AgentStatusRow, Approval}; +use hive_sh4re::AgentStatusRow; +use hive_sh4re::approvals::Approval; use hive_types::Ident; use serde::{Deserialize, Serialize}; diff --git a/hive-sh4re/src/approvals.rs b/hive-sh4re/src/approvals.rs new file mode 100644 index 00000000..a9df3c50 --- /dev/null +++ b/hive-sh4re/src/approvals.rs @@ -0,0 +1,111 @@ +//! The approval queue wire shape: one row (`Approval`) per pending/resolved +//! operator decision, its `kind` discriminator, and the terminal-state enum. +//! `ReminderStats` lives here too — small enough not to earn its own file, +//! and unrelated to any other topic module. + +use chrono::{DateTime, Utc}; +use hive_types::Ident; +use serde::{Deserialize, Serialize}; + +/// 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, + pub requested_at: DateTime, + pub status: ApprovalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolved_at: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + /// 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, +} + +/// 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/` 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, +} diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index eb1da503..b076e00c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -4,9 +4,12 @@ use chrono::{DateTime, Utc}; use hive_types::Ident; use serde::{Deserialize, Serialize}; +pub mod approvals; pub mod assets; pub mod bash_task; +pub mod manager; pub mod paths; +pub mod permissions; pub mod wire_time; /// Server-side hard cap on `Recv.max` (see the `Recv` request). Bounds @@ -56,109 +59,6 @@ pub fn pending_hint(remaining: u64) -> String { ) } -/// 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, - pub requested_at: DateTime, - pub status: ApprovalStatus, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub resolved_at: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub note: Option, - /// 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, -} - -/// 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/` 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//mcp.sock on the host, // bind-mounted into the container at /run/hive/mcp.sock. @@ -391,319 +291,6 @@ pub struct MatrixIdentity { 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: "", ...)` 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 = ""; - -/// Reserved magic recipient — `send(to: "", ...)` 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 = ""; - -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - tag: Option, - }, - /// 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, - }, - /// 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, - #[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, - /// 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, - /// 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, -} - -/// 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 @@ -743,83 +330,6 @@ impl JournalPriority { } } -/// 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 diff --git a/hive-sh4re/src/manager.rs b/hive-sh4re/src/manager.rs new file mode 100644 index 00000000..dcfd72e8 --- /dev/null +++ b/hive-sh4re/src/manager.rs @@ -0,0 +1,133 @@ +//! Manager socket — `/run/hyperhive/manager/mcp.sock` on the host, +//! bind-mounted into the manager container at `/run/hive/mcp.sock`. +//! Reserved recipient names/senders, the out-of-band `HelperEvent` +//! payload hive-c0re pushes into the manager's inbox, and the +//! schedule-prompt submission payload. + +use hive_types::Ident; +use serde::{Deserialize, Serialize}; + +use crate::approvals::ApprovalStatus; + +/// 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: "", ...)` 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 = ""; + +/// Reserved magic recipient — `send(to: "", ...)` 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 = ""; + +/// 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + sha: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + tag: Option, + }, + /// 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, + }, + /// 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, + #[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, + /// 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, + /// 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, +} diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs new file mode 100644 index 00000000..61d67cd3 --- /dev/null +++ b/hive-sh4re/src/permissions.rs @@ -0,0 +1,272 @@ +//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and +//! system-level capability grants (`Capability`). Both are declared in +//! per-agent config (`tool-groups.json` / `capabilities.json`), injected +//! into the container as env vars, and read by the harness to decide +//! which MCP tools claude actually sees. + +use serde::{Deserialize, Serialize}; + +/// 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 hive-c0re enforces 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" + } + } + } +}