From edc1de3197e6065e53aa69cdd1f264e48d35cc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 15:47:58 +0200 Subject: [PATCH 1/5] tools: drop NotebookEdit from the agent whitelist --- CLAUDE.md | 9 +++++---- hive-ag3nt/src/mcp.rs | 1 - 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1744cd06..c94311c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,10 +222,11 @@ line (broker's `count_pending`). When adding new tools (manager surface, notes/state, etc.), use `run_tool` and they pick up the envelope for free. **Tool whitelist** (see `ALLOWED_BUILTIN_TOOLS` in `hive-ag3nt::mcp`): -- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `NotebookEdit`, `Read`, - `TodoWrite`, `Write`. -- Denied by omission: `WebFetch`, `WebSearch`, `Task` — no external egress - or nested-agent spawning until we have a real policy story. +- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `TodoWrite`, + `Write`. +- Denied by omission: `WebFetch`, `WebSearch`, `Task`, `NotebookEdit` — + no external egress, nested-agent spawning, or Jupyter handling until we + have a real policy story. - Allowed MCP tools: `mcp__hyperhive__send`, `mcp__hyperhive__recv`. `Bash` is on the allow-list "for now" — pending a finer-grained allow-list diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index a91cb8f9..f73040f0 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -355,7 +355,6 @@ pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &[ "Edit", "Glob", "Grep", - "NotebookEdit", "Read", "TodoWrite", "Write", From 824914807a90720b22d0230537e58abc92ca9e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 15:53:39 +0200 Subject: [PATCH 2/5] ensure_manager: rebuild hm1nd if applied flake missing (migration safety) --- hive-c0re/src/auto_update.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index e970fdae..51d6e6a5 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -78,8 +78,26 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s /// the approval queue — manager is required infrastructure. Idempotent. pub async fn ensure_manager(coord: &Arc) -> Result<()> { let existing = lifecycle::list().await.unwrap_or_default(); + let current_rev = current_flake_rev(&coord.hyperhive_flake); if existing.iter().any(|c| c == MANAGER_NAME) { - tracing::debug!("manager container already present"); + // Container exists already. If it predates the unified lifecycle + // (no applied flake on disk) we must rebuild — otherwise it's + // running whatever the host-declarative config was at create + // time, with a wrong systemd unit and port. + let applied_flake = Coordinator::agent_applied_dir(MANAGER_NAME).join("flake.nix"); + if !applied_flake.exists() + && let Some(rev) = current_rev.as_ref() + { + tracing::warn!( + "manager container exists but no applied flake — forcing rebuild to migrate" + ); + let coord_clone = coord.clone(); + if let Err(e) = rebuild_agent(&coord_clone, MANAGER_NAME, rev).await { + tracing::warn!(error = ?e, "manager migration rebuild failed"); + } + } else { + tracing::debug!("manager container already present"); + } return Ok(()); } tracing::info!("manager container missing — spawning"); @@ -96,7 +114,7 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { &claude_dir, ) .await?; - if let Some(rev) = current_flake_rev(&coord.hyperhive_flake) { + if let Some(rev) = current_rev { let _ = std::fs::write(rev_marker_path(MANAGER_NAME), rev); } Ok(()) From f1fd787f179e1719e3db06c98c7da15fc994457e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 15:57:11 +0200 Subject: [PATCH 3/5] rebuild button on agent UI (cross-origin POST to dashboard /rebuild) --- hive-ag3nt/src/web_ui.rs | 19 ++++++++++++++++++- hive-c0re/src/actions.rs | 2 ++ hive-c0re/src/auto_update.rs | 2 ++ hive-c0re/src/coordinator.rs | 7 ++++++- hive-c0re/src/lifecycle.rs | 14 +++++++++++--- hive-c0re/src/main.rs | 2 +- hive-c0re/src/server.rs | 2 ++ 7 files changed, 42 insertions(+), 6 deletions(-) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index c09e929e..d4f22ea9 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -91,8 +91,12 @@ async fn index(State(state): State) -> Html { (LoginState::NeedsLogin, None) => render_needs_login_idle(), (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), }; + let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(7000); Html(format!( - "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n", + "\n\n\n\n{label} // hyperhive\n{STYLE}\n\n\n
░▒▓█▓▒░  {label}  ░▒▓█▓▒░  hyperhive ag3nt  ░▒▓█▓▒░
\n

◆ {label} ◆ ↻ R3BU1LD

\n
══════════════════════════════════════════════════════════════
\n{body}\n\n\n\n", label = state.label, )) } @@ -435,6 +439,19 @@ const STYLE: &str = r#" .btn:hover { background: rgba(204, 102, 255, 0.1); } .btn-login { color: var(--amber); border-color: var(--amber); } .btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; } + .btn-rebuild { + color: var(--amber); + border: 1px solid var(--amber); + padding: 0.15em 0.6em; + font-size: 0.55em; + font-family: inherit; + text-decoration: none; + letter-spacing: 0.1em; + margin-left: 0.6em; + vertical-align: middle; + cursor: pointer; + } + .btn-rebuild:hover { background: rgba(255, 184, 77, 0.1); } .btn-send { color: var(--green); border-color: var(--green); } .sendform { display: flex; gap: 0.6em; margin-top: 0.5em; } .sendform input { diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 212e2549..5b0dd7cb 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -48,6 +48,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { &agent_dir, &applied_dir, &claude_dir, + coord.dashboard_port, ) .await } @@ -69,6 +70,7 @@ pub async fn approve(coord: Arc, id: i64) -> Result<()> { &proposed_dir, &applied_dir, &claude_dir, + coord_bg.dashboard_port, ) .await; coord_bg.clear_transient(&agent_bg); diff --git a/hive-c0re/src/auto_update.rs b/hive-c0re/src/auto_update.rs index 51d6e6a5..bfcc464a 100644 --- a/hive-c0re/src/auto_update.rs +++ b/hive-c0re/src/auto_update.rs @@ -64,6 +64,7 @@ pub async fn rebuild_agent(coord: &Arc, name: &str, current_rev: &s &agent_dir, &applied_dir, &claude_dir, + coord.dashboard_port, ) .await?; std::fs::write(rev_marker_path(name), current_rev) @@ -112,6 +113,7 @@ pub async fn ensure_manager(coord: &Arc) -> Result<()> { &proposed, &applied, &claude_dir, + coord.dashboard_port, ) .await?; if let Some(rev) = current_rev { diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 8267ac52..f2a0f24d 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -29,6 +29,10 @@ pub struct Coordinator { /// URL of the hyperhive flake (no fragment). Inlined into per-agent /// `flake.nix` files as `inputs.hyperhive.url`. pub hyperhive_flake: String, + /// TCP port the host's hive-c0re dashboard listens on. Inlined into + /// each per-agent flake so the agent's web UI can build the right + /// rebuild-button URL pointing back at the dashboard. + pub dashboard_port: u16, agents: Mutex>, /// Agents whose lifecycle action (currently just spawn) is in flight. /// Read by the dashboard to render a spinner; cleared when the action @@ -51,13 +55,14 @@ pub enum TransientKind { } impl Coordinator { - pub fn open(db_path: &Path, hyperhive_flake: String) -> Result { + pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result { let broker = Broker::open(db_path).context("open broker")?; let approvals = Approvals::open(db_path).context("open approvals")?; Ok(Self { broker: Arc::new(broker), approvals: Arc::new(approvals), hyperhive_flake, + dashboard_port, agents: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()), }) diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 110079e7..3a5337b2 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -101,10 +101,11 @@ pub async fn spawn( proposed_dir: &Path, applied_dir: &Path, claude_dir: &Path, + dashboard_port: u16, ) -> Result<()> { validate(name)?; setup_proposed(proposed_dir, name).await?; - setup_applied(applied_dir, name, hyperhive_flake).await?; + setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; ensure_claude_dir(claude_dir)?; let container = container_name(name); let flake_ref = format!("{}#default", applied_dir.display()); @@ -145,9 +146,10 @@ pub async fn rebuild( agent_dir: &Path, applied_dir: &Path, claude_dir: &Path, + dashboard_port: u16, ) -> Result<()> { validate(name)?; - setup_applied(applied_dir, name, hyperhive_flake).await?; + setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?; ensure_claude_dir(claude_dir)?; let container = container_name(name); let flake_ref = format!("{}#default", applied_dir.display()); @@ -205,7 +207,12 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { /// Maintain the authoritative applied repo. Rewrites `flake.nix` every call /// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` /// only on first call. `apply_commit` overwrites `agent.nix` later. -pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> { +pub async fn setup_applied( + applied_dir: &Path, + name: &str, + hyperhive_flake: &str, + dashboard_port: u16, +) -> Result<()> { std::fs::create_dir_all(applied_dir) .with_context(|| format!("create {}", applied_dir.display()))?; @@ -242,6 +249,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str systemd.services.{service}.environment = {{ HIVE_PORT = "{port}"; HIVE_LABEL = "{name}"; + HIVE_DASHBOARD_PORT = "{dashboard_port}"; }}; }} ]; diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index d27ac4cb..9de9bee3 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -85,7 +85,7 @@ async fn main() -> Result<()> { db, dashboard_port, } => { - let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); + let coord = Arc::new(Coordinator::open(&db, hyperhive_flake, dashboard_port)?); manager_server::start(coord.clone())?; // Auto-create the manager container if it isn't there yet. Block // on this — without hm1nd the system has no manager harness. diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index afbc2f1d..4e6705ff 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -72,6 +72,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { &proposed_dir, &applied_dir, &claude_dir, + coord.dashboard_port, ) .await { @@ -110,6 +111,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { &agent_dir, &applied_dir, &claude_dir, + coord.dashboard_port, ) .await?; HostResponse::success() From dfbcf2b9d140e8b8ff3e1fdf675b5e7d9b0393f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:00:31 +0200 Subject: [PATCH 4/5] agents wake on send: broker.recv_blocking + 30s long-poll on Recv --- hive-c0re/src/agent_server.rs | 10 ++++++--- hive-c0re/src/broker.rs | 36 +++++++++++++++++++++++++++++++++ hive-c0re/src/manager_server.rs | 8 +++++++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index df1f5996..e030f81a 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -65,7 +65,7 @@ async fn serve(stream: UnixStream, agent: String, broker: Arc) -> Result return Ok(()); } let resp = match serde_json::from_str::(line.trim()) { - Ok(req) => dispatch(&req, &agent, &broker), + Ok(req) => dispatch(&req, &agent, &broker).await, Err(e) => AgentResponse::Err { message: format!("parse error: {e}"), }, @@ -77,7 +77,11 @@ async fn serve(stream: UnixStream, agent: String, broker: Arc) -> Result } } -fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResponse { +/// How long the long-poll `Recv` holds a connection open waiting for new +/// mail. Set well below typical TCP/proxy idle limits. +const RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30); + +async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResponse { match req { AgentRequest::Send { to, body } => { match broker.send(&Message { @@ -91,7 +95,7 @@ fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResponse { }, } } - AgentRequest::Recv => match broker.recv(agent) { + AgentRequest::Recv => match broker.recv_blocking(agent, RECV_LONG_POLL).await { Ok(Some(msg)) => AgentResponse::Message { from: msg.from, body: msg.body, diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index eb8a2ecf..4ffc4022 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -100,6 +100,42 @@ impl Broker { Ok(u64::try_from(n.max(0)).unwrap_or(0)) } + /// Long-poll variant of `recv`: returns immediately if there's a + /// pending message; otherwise waits up to `timeout` for the broker to + /// emit a `Sent { to: recipient }` event, then retries the pop. Lets + /// agents react to new mail without polling their socket on a fixed + /// interval. + pub async fn recv_blocking( + &self, + recipient: &str, + timeout: std::time::Duration, + ) -> Result> { + if let Some(m) = self.recv(recipient)? { + return Ok(Some(m)); + } + let mut rx = self.subscribe(); + let deadline = tokio::time::Instant::now() + timeout; + loop { + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) + else { + return Ok(None); + }; + match tokio::time::timeout(remaining, rx.recv()).await { + Err(_) => return Ok(None), + // Channel lagged or closed — fall back to a single direct + // pop (in case we missed our notification while behind). + Ok(Err(_)) => return self.recv(recipient), + Ok(Ok(MessageEvent::Sent { to, .. })) if to == recipient => { + if let Some(m) = self.recv(recipient)? { + return Ok(Some(m)); + } + // Lost a race (concurrent recv elsewhere). Keep waiting. + } + Ok(Ok(_)) => {} + } + } + } + pub fn recv(&self, recipient: &str) -> Result> { let conn = self.conn.lock().unwrap(); let row: Option<(i64, String, String, String)> = conn diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 38c60057..ce21bfdd 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -69,6 +69,8 @@ async fn serve(stream: UnixStream, coord: Arc) -> Result<()> { } } +const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30); + async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse { match req { ManagerRequest::Send { to, body } => match coord.broker.send(&Message { @@ -97,7 +99,11 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse message: format!("{e:#}"), }, }, - ManagerRequest::Recv => match coord.broker.recv(MANAGER_AGENT) { + ManagerRequest::Recv => match coord + .broker + .recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL) + .await + { Ok(Some(msg)) => ManagerResponse::Message { from: msg.from, body: msg.body, From f83c0aa717834eabc28e93dbdb8a1a0c8c47b350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 16:01:58 +0200 Subject: [PATCH 5/5] agent prompt: tell sub-agents they can ask manager for config changes --- hive-ag3nt/src/bin/hive-ag3nt.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 406e661d..fb993c38 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -217,6 +217,9 @@ fn emit_turn_end(bus: &Bus, outcome: &turn::TurnOutcome) { /// about it and the MCP tools, and is expected to drive any further /// recv/send itself. fn format_wake_prompt(label: &str, from: &str, body: &str) -> String { + // Manager broker name. Lifecycle calls it `hm1nd` (container), broker + // calls it `manager`. Sub-agents address the manager via `manager`. + let manager = hive_sh4re::MANAGER_AGENT; format!( "You are hyperhive agent `{label}` in a multi-agent system.\n\ \n\ @@ -231,6 +234,14 @@ fn format_wake_prompt(label: &str, from: &str, body: &str) -> String { - `mcp__hyperhive__send(to, body)` — message a peer (by their name) \ or the operator (recipient `operator`, surfaces in the dashboard).\n\ \n\ + Need new packages, env vars, or other NixOS config for yourself? \ + You can't edit your own config directly — message the manager \ + (recipient `{manager}`) describing what you need. The manager \ + edits `/agents/{label}/config/agent.nix` on your behalf, commits, \ + and submits an approval that the operator can accept on the \ + dashboard; on approve hive-c0re rebuilds your container with the \ + new config.\n\ + \n\ Handle the inbox, then stop. Don't narrate intent — act." ) }