diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 2588a28..fb3fe50 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -655,6 +655,19 @@ pub async fn serve_manager_stdio(socket: PathBuf) -> Result<()> { // Manager tool surface // ----------------------------------------------------------------------------- +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RequestInitConfigArgs { + /// New sub-agent name (≤9 chars). Queues an InitConfig approval; on + /// approval hive-c0re seeds the proposed config repo at + /// `/agents//config/agent.nix` with the default template. + /// After the approval the manager can edit and commit the config before + /// calling `request_spawn`. + pub name: String, + /// Optional description shown on the dashboard approval card. + #[serde(default)] + pub description: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct RequestSpawnArgs { /// New sub-agent name (≤9 chars). Queues a Spawn approval; the @@ -842,8 +855,43 @@ impl ManagerServer { } #[tool( - description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ - approves on the dashboard before the container is actually created." + description = "Step 1 of 2 for creating a new agent: initialise the proposed config \ + repo and queue an InitConfig approval. On operator approval hive-c0re seeds \ + `/agents//config/agent.nix` with the default template so the manager can \ + customise it before spawning. After the ConfigReady helper event arrives, edit \ + agent.nix, commit the changes, then call `request_spawn`. Fails if a config repo \ + for this name already exists (use `request_apply_commit` to update an existing agent)." + )] + async fn request_init_config( + &self, + Parameters(args): Parameters, + ) -> String { + let log = format!("{args:?}"); + let name = args.name.clone(); + run_tool_envelope("request_init_config", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::ManagerRequest::RequestInitConfig { + name: args.name, + description: args.description, + }) + .await; + annotate_retries( + format_ack( + resp, + "request_init_config", + format!("init_config approval queued for {name}"), + ), + retries, + ) + }) + .await + } + + #[tool( + description = "Step 2 of 2 for creating a new agent: queue a Spawn approval after \ + the config has been initialised and customised via `request_init_config`. Requires \ + a prior approved InitConfig so the manager can review and edit agent.nix first. \ + Fails if no proposed config repo exists for the given name." )] async fn request_spawn(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -1177,8 +1225,9 @@ impl ManagerServer { #[tool_handler( instructions = "You are the hyperhive manager (hm1nd). You coordinate sub-agents and \ relay between them and the operator. Use `send` to talk to agents/operator, `recv` \ - to drain your inbox. Privileged: `request_spawn` (new agent, gated on operator \ - approval), `kill` (graceful stop), `request_apply_commit` (config change for \ + to drain your inbox. Privileged: `request_init_config` + `request_spawn` (two-step \ + new agent creation - init config first, customise agent.nix, then spawn, both \ + gated on operator approval), `kill` (graceful stop), `request_apply_commit` (config change for \ any agent including yourself), `ask` (structured question to the operator or a \ sub-agent — non-blocking, answer arrives later as a `question_answered` event), \ `answer` (respond to a `question_asked` event directed at you), \ @@ -1233,6 +1282,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec { Flavor::Manager => &[ "send", "recv", + "request_init_config", "request_spawn", "kill", "start", diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 41e7818..3d8e224 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -57,6 +57,18 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { } finish_approval(&coord, &approval, result, terminal_tag) } + ApprovalKind::InitConfig => { + // Seed the proposed config repo. Runs synchronously — it's just + // a few git operations with no nixos-container involvement. + let result: Result<()> = async { + lifecycle::setup_proposed(&proposed_dir, &approval.agent).await?; + lifecycle::ensure_claude_dir(&claude_dir)?; + lifecycle::ensure_state_dir(¬es_dir)?; + Ok(()) + } + .await; + finish_approval(&coord, &approval, result, None) + } ApprovalKind::Spawn => { // Run the spawn in the background so the approve POST returns // immediately. The dashboard reads `transient` to render a spinner. @@ -144,6 +156,7 @@ fn finish_approval( let approval_kind = match approval.kind { ApprovalKind::Spawn => "spawn", ApprovalKind::ApplyCommit => "apply_commit", + ApprovalKind::InitConfig => "init_config", }; let sha_short = approval .fetched_sha @@ -159,12 +172,19 @@ fn finish_approval( note.clone(), approval.description.clone(), ); - // For spawn/rebuild approvals, also surface the underlying action so - // the manager knows whether the container actually came up. The - // ApprovalResolved event already carries the same `ok` signal but + // For spawn/rebuild/init_config approvals, also surface the underlying + // action so the manager knows whether the lifecycle step succeeded. + // The ApprovalResolved event already carries the same `ok` signal but // separating it lets the manager react to the lifecycle change // without having to special-case approvals. match approval.kind { + ApprovalKind::InitConfig => { + if ok { + coord.notify_manager(&HelperEvent::ConfigReady { + agent: approval.agent.clone(), + }); + } + } ApprovalKind::Spawn => coord.notify_manager(&HelperEvent::Spawned { agent: approval.agent.clone(), ok, @@ -439,6 +459,7 @@ pub async fn deny(coord: &Coordinator, id: i64, note: Option<&str>) -> Result<() let approval_kind = match a.kind { ApprovalKind::Spawn => "spawn", ApprovalKind::ApplyCommit => "apply_commit", + ApprovalKind::InitConfig => "init_config", }; let sha_short = sha.as_deref().map(|s| s[..s.len().min(12)].to_owned()); let description = a.description.clone(); diff --git a/hive-c0re/src/approvals.rs b/hive-c0re/src/approvals.rs index 2deddf5..9b696c5 100644 --- a/hive-c0re/src/approvals.rs +++ b/hive-c0re/src/approvals.rs @@ -307,6 +307,7 @@ fn kind_to_str(kind: ApprovalKind) -> &'static str { match kind { ApprovalKind::ApplyCommit => "apply_commit", ApprovalKind::Spawn => "spawn", + ApprovalKind::InitConfig => "init_config", } } @@ -314,6 +315,7 @@ fn kind_from_str(s: &str) -> Result { Ok(match s { "apply_commit" => ApprovalKind::ApplyCommit, "spawn" => ApprovalKind::Spawn, + "init_config" => ApprovalKind::InitConfig, other => bail!("unknown approval kind '{other}'"), }) } diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index f3ad75a..977be08 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -563,6 +563,7 @@ fn history_view(a: Approval) -> ApprovalHistoryView { let kind = match a.kind { hive_sh4re::ApprovalKind::ApplyCommit => "apply_commit", hive_sh4re::ApprovalKind::Spawn => "spawn", + hive_sh4re::ApprovalKind::InitConfig => "init_config", }; ApprovalHistoryView { id: a.id, @@ -603,6 +604,14 @@ async fn build_approval_views(approvals: Vec) -> Vec { diff: None, description: a.description, }, + hive_sh4re::ApprovalKind::InitConfig => ApprovalView { + id: a.id, + agent: a.agent, + kind: "init_config", + sha_short: None, + diff: None, + description: a.description, + }, }); } out @@ -1736,9 +1745,12 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { approvals .into_iter() .filter(|a| { - // Spawn approvals are for not-yet-existent agents; the proposed - // dir is supposed to be missing. - if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) { + // Spawn and InitConfig approvals are for not-yet-existent agents; + // the proposed dir is supposed to be missing. + if matches!( + a.kind, + hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig + ) { return true; } if Coordinator::agent_proposed_dir(&a.agent).exists() { diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index ad96f6f..1e07352 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -471,7 +471,9 @@ pub async fn setup_applied( /// Create the per-agent Claude credentials dir if missing. Mode 0700 — only /// root inside the container reads/writes it. Idempotent: existing dirs are /// left untouched (an agent's OAuth tokens survive `destroy`/recreate). -fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { +/// Public for the `InitConfig` approval path in `actions.rs` which seeds +/// dirs without calling the full `spawn`. +pub fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { if !claude_dir.exists() { std::fs::create_dir_all(claude_dir) .with_context(|| format!("create {}", claude_dir.display()))?; @@ -485,7 +487,9 @@ fn ensure_claude_dir(claude_dir: &Path) -> Result<()> { Ok(()) } -fn ensure_state_dir(notes_dir: &Path) -> Result<()> { +/// Public for the `InitConfig` approval path in `actions.rs` which seeds +/// dirs without calling the full `spawn`. +pub fn ensure_state_dir(notes_dir: &Path) -> Result<()> { if !notes_dir.exists() { std::fs::create_dir_all(notes_dir) .with_context(|| format!("create {}", notes_dir.display()))?; diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 4f75f62..a46970a 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -164,8 +164,46 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp }, } } + ManagerRequest::RequestInitConfig { name, description } => { + tracing::info!(%name, "manager: request_init_config"); + let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name); + if proposed_dir.join(".git").exists() { + return ManagerResponse::Err { + message: format!( + "proposed config repo for '{name}' already exists at {} - \ + use request_apply_commit to update an existing agent's config", + proposed_dir.display() + ), + }; + } + match coord.approvals.submit_kind( + name, + hive_sh4re::ApprovalKind::InitConfig, + "", + description.as_deref(), + ) { + Ok(id) => { + tracing::info!(%id, %name, "init_config approval queued"); + coord.emit_approval_added(id, name, "init_config", None, None, description.clone()); + ManagerResponse::Ok + } + Err(e) => ManagerResponse::Err { + message: format!("{e:#}"), + }, + } + } ManagerRequest::RequestSpawn { name, description } => { tracing::info!(%name, "manager: request_spawn"); + let proposed_dir = crate::coordinator::Coordinator::agent_proposed_dir(name); + if !proposed_dir.join(".git").exists() { + return ManagerResponse::Err { + message: format!( + "no proposed config repo found for '{name}' - \ + call request_init_config first to initialise and customise \ + the config before spawning" + ), + }; + } match coord.approvals.submit_kind( name, hive_sh4re::ApprovalKind::Spawn, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 8992f74..1417155 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -99,6 +99,12 @@ pub enum ApprovalKind { ApplyCommit, /// Create + start a new sub-agent container with the given name. Spawn, + /// Initialise a new agent's proposed config repo so the manager can + /// customise it before submitting a `RequestSpawn`. On approval + /// hive-c0re seeds `proposed//` with the default `agent.nix` + /// template but does NOT create the container - that requires a + /// subsequent `RequestSpawn` approval. + InitConfig, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -577,6 +583,10 @@ pub enum HelperEvent { #[serde(default, skip_serializing_if = "Option::is_none")] tag: Option, }, + /// A new agent's proposed config repo was initialised (post-`InitConfig` + /// approval). The manager can now edit `/agents//config/agent.nix`, + /// commit the changes, and submit a `RequestSpawn` to create the container. + ConfigReady { agent: String }, /// A sub-agent's container was stopped (the systemd unit is down; /// persistent state is unchanged). Killed { agent: String }, @@ -672,9 +682,23 @@ pub enum ManagerRequest { Recent { limit: u64, }, + /// Initialise a brand-new agent's proposed config repo and queue an + /// approval for the operator to review. On approval hive-c0re seeds + /// `/agents//config/` with the default `agent.nix` template, + /// giving the manager RW access so it can customise the config and + /// commit changes before calling `request_spawn`. Fails if a proposed + /// repo for this name already exists (use `request_apply_commit` to + /// update an existing agent's config). Must precede `request_spawn`. + RequestInitConfig { + name: String, + /// Optional description shown on the dashboard approval card. + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + }, /// Submit a spawn request for the user to approve. On approval the host - /// creates and starts the container. Brand-new agent names only — if an - /// agent of the same name already exists, the approval will fail. + /// creates and starts the container. Requires a prior approved + /// `request_init_config` so the manager can customise `agent.nix` first. + /// Fails if the proposed config repo for this name does not exist yet. RequestSpawn { name: String, /// Optional description shown on the dashboard approval card.