//! Manager harness. Talks to the manager socket (bind-mounted from the host //! at `/run/hive/mcp.sock` inside the `hm1nd` container) using the privileged //! tool surface. Phase 4 minimum: a CLI to exercise the verbs from a shell, //! plus a `serve` loop that logs the manager's inbox. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent}; use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; #[derive(Parser)] #[command(name = "hive-m1nd", about = "hyperhive manager harness")] struct Cli { /// Path to the manager MCP socket (bind-mounted from the host). #[arg(long, global = true, default_value = DEFAULT_SOCKET)] socket: PathBuf, #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand)] enum Cmd { /// Long-lived loop polling the manager inbox. Serve { #[arg(long, default_value_t = 1000)] poll_ms: u64, }, /// Send a message to a sub-agent (or anywhere — the broker doesn't validate). Send { to: String, body: String }, /// Pop one message from the manager's inbox. Recv, /// Submit a spawn request for the user to approve (creates a pending /// approval; on approval the host creates + starts the container). RequestSpawn { name: String }, /// Kill a sub-agent. Kill { name: String }, /// Submit a config commit on the agent's config repo for user approval. RequestApplyCommit { agent: String, commit_ref: String }, /// Run the manager MCP server on stdio. Spawned by claude via /// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the /// manager tool surface (`request_spawn`, `kill`, /// `request_apply_commit`). Mcp, } #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let cli = Cli::parse(); match cli.cmd { Cmd::Serve { poll_ms } => { let port = std::env::var("HIVE_PORT") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(DEFAULT_WEB_PORT); let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); let claude_dir = PathBuf::from(login::DEFAULT_CLAUDE_DIR); let initial = LoginState::from_dir(&claude_dir); tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot"); let login_state = Arc::new(Mutex::new(initial)); let ui_state = login_state.clone(); let bus = Bus::new(); let ui_bus = bus.clone(); tokio::spawn(async move { if let Err(e) = web_ui::serve(label, port, ui_state, ui_bus).await { tracing::error!(error = ?e, "web ui failed"); } }); match initial { LoginState::Online => { serve(&cli.socket, Duration::from_millis(poll_ms), bus).await } LoginState::NeedsLogin => { tracing::warn!( claude_dir = %claude_dir.display(), "manager has no claude session — staying in partial-run mode" ); needs_login_loop(&cli.socket, &claude_dir, login_state, poll_ms, bus).await } } } Cmd::Send { to, body } => one_shot(&cli.socket, ManagerRequest::Send { to, body }).await, Cmd::Recv => one_shot(&cli.socket, ManagerRequest::Recv).await, Cmd::RequestSpawn { name } => { one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await } Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await, Cmd::RequestApplyCommit { agent, commit_ref } => { one_shot( &cli.socket, ManagerRequest::RequestApplyCommit { agent, commit_ref }, ) .await } Cmd::Mcp => mcp::serve_manager_stdio(cli.socket).await, } } async fn one_shot(socket: &Path, req: ManagerRequest) -> Result<()> { let resp: ManagerResponse = client::request(socket, &req).await?; println!("{}", serde_json::to_string_pretty(&resp)?); if let ManagerResponse::Err { message } = resp { bail!("{message}"); } Ok(()) } /// Manager-side mirror of hive-ag3nt's needs-login loop: keep the web UI /// alive, poll the claude dir, enter `serve` once login lands. async fn needs_login_loop( socket: &Path, claude_dir: &Path, state: Arc>, poll_ms: u64, bus: Bus, ) -> Result<()> { let probe = Duration::from_millis(poll_ms.max(2000)); loop { tokio::time::sleep(probe).await; if login::has_session(claude_dir) { tracing::info!("manager claude session detected — entering inbox loop"); *state.lock().unwrap() = LoginState::Online; return serve(socket, Duration::from_millis(poll_ms), bus).await; } } } async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-m1nd serve"); let mcp_config = turn::write_mcp_config(socket).await?; let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into()); loop { let recv: Result = client::request(socket, &ManagerRequest::Recv).await; match recv { Ok(ManagerResponse::Message { from, body }) => { if from == SYSTEM_SENDER { // Helper events (ApprovalResolved, etc.) — log + surface // in live view but don't burn a claude turn on them. let parsed = serde_json::from_str::(&body).ok(); if let Some(event) = parsed { tracing::info!(?event, "helper event"); } else { tracing::info!(%from, %body, "system message"); } bus.emit(LiveEvent::Note(format!("[system] {body}"))); continue; } tracing::info!(%from, %body, "manager inbox"); bus.emit(LiveEvent::TurnStart { from: from.clone(), body: body.clone(), }); let prompt = format_wake_prompt(&label, &from, &body); let outcome = turn::run_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; match outcome { Ok(()) => { bus.emit(LiveEvent::TurnEnd { ok: true, note: None, }); tracing::info!("manager turn finished"); } Err(e) => { let note = format!("{e:#}"); bus.emit(LiveEvent::TurnEnd { ok: false, note: Some(note.clone()), }); tracing::warn!(error = %note, "manager turn failed"); } } } Ok(ManagerResponse::Empty) => {} Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => { tracing::warn!("recv produced unexpected response kind"); } Ok(ManagerResponse::Err { message }) => { tracing::warn!(%message, "recv error"); } Err(e) => { tracing::warn!(error = ?e, "recv failed; retrying"); } } tokio::time::sleep(interval).await; } } /// Manager-flavored wake prompt. Mentions the privileged tools the sub-agent /// prompt doesn't have access to, and points the manager at its own /// editable config repo for self-modification. fn format_wake_prompt(label: &str, from: &str, body: &str) -> String { format!( "You are the hyperhive manager `{label}` in a multi-agent system. You \ coordinate sub-agents and relay between them and the operator.\n\ \n\ Incoming message from `{from}`:\n\ ---\n\ {body}\n\ ---\n\ \n\ Tools (hyperhive surface):\n\ - `mcp__hyperhive__recv()` — drain one more message from your inbox.\n\ - `mcp__hyperhive__send(to, body)` — message an agent (by name), \ another peer, or the operator (`operator` surfaces in the dashboard).\n\ - `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent \ for operator approval (≤9 char name).\n\ - `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent.\n\ - `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit \ a config change for any agent (`hm1nd` for self) for operator \ approval.\n\ \n\ Your own editable config lives at `/agents/hm1nd/config/agent.nix`; \ every sub-agent's lives at `/agents//config/agent.nix`. Use \ file/git tools to edit + commit, then `request_apply_commit`.\n\ \n\ Handle the inbox, then stop. Don't narrate intent — act." ) }