Compare commits

..
11 changed files with 128 additions and 155 deletions

View file

@ -80,13 +80,7 @@ async fn main() -> Result<()> {
}); });
match initial { match initial {
LoginState::Online => { LoginState::Online => {
serve( serve(&cli.socket, Duration::from_millis(poll_ms), login_state, bus).await
&cli.socket,
Duration::from_millis(poll_ms),
login_state,
bus,
)
.await
} }
LoginState::NeedsLogin => { LoginState::NeedsLogin => {
// Partial-run mode: keep the harness alive (so the web UI // Partial-run mode: keep the harness alive (so the web UI
@ -158,7 +152,8 @@ async fn serve(
body: body.clone(), body: body.clone(),
}); });
let prompt = format_wake_prompt(&label, &from, &body); let prompt = format_wake_prompt(&label, &from, &body);
let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await; let outcome =
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Agent).await;
emit_turn_end(&bus, &outcome); emit_turn_end(&bus, &outcome);
} }
Ok(AgentResponse::Empty) => {} Ok(AgentResponse::Empty) => {}

View file

@ -90,7 +90,9 @@ async fn main() -> Result<()> {
} }
}); });
match initial { match initial {
LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await, LoginState::Online => {
serve(&cli.socket, Duration::from_millis(poll_ms), bus).await
}
LoginState::NeedsLogin => { LoginState::NeedsLogin => {
tracing::warn!( tracing::warn!(
claude_dir = %claude_dir.display(), claude_dir = %claude_dir.display(),
@ -172,7 +174,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
body: body.clone(), body: body.clone(),
}); });
let prompt = format_wake_prompt(&label, &from, &body); let prompt = format_wake_prompt(&label, &from, &body);
let outcome = drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await; let outcome =
drive_turn(&prompt, &mcp_config, &bus, mcp::Flavor::Manager).await;
emit_turn_end(&bus, &outcome); emit_turn_end(&bus, &outcome);
} }
Ok(ManagerResponse::Empty) => {} Ok(ManagerResponse::Empty) => {}

View file

@ -21,8 +21,10 @@ use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use rmcp::{ use rmcp::{
ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler, ServerHandler, ServiceExt,
tool_router, transport::stdio, handler::server::wrapper::Parameters,
schemars, tool, tool_handler, tool_router,
transport::stdio,
}; };
use crate::client; use crate::client;
@ -31,7 +33,12 @@ use crate::client;
/// a status line → post-log. Free function so both `AgentServer` and /// a status line → post-log. Free function so both `AgentServer` and
/// `ManagerServer` use the same shape; the per-server `status_line` /// `ManagerServer` use the same shape; the per-server `status_line`
/// closure is what differs (different `Status` wire types). /// closure is what differs (different `Status` wire types).
pub async fn run_tool_envelope<F, S>(tool: &'static str, args: String, status: S, body: F) -> String pub async fn run_tool_envelope<F, S>(
tool: &'static str,
args: String,
status: S,
body: F,
) -> String
where where
F: Future<Output = String>, F: Future<Output = String>,
S: Future<Output = String>, S: Future<Output = String>,
@ -216,10 +223,8 @@ impl ManagerServer {
#[tool_router] #[tool_router]
impl ManagerServer { impl ManagerServer {
#[tool( #[tool(description = "Send a message to a sub-agent (by logical name), to another agent, \
description = "Send a message to a sub-agent (by logical name), to another agent, \ or to the operator (recipient `operator`, surfaces in the dashboard).")]
or to the operator (recipient `operator`, surfaces in the dashboard)."
)]
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String { async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let to = args.to.clone(); let to = args.to.clone();
@ -240,10 +245,8 @@ impl ManagerServer {
.await .await
} }
#[tool( #[tool(description = "Pop one message from the manager inbox. Returns sender + body, or \
description = "Pop one message from the manager inbox. Returns sender + body, or \ empty.")]
empty."
)]
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String { async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("recv", log, self.status_line(), async move { run_tool_envelope("recv", log, self.status_line(), async move {
@ -253,9 +256,7 @@ impl ManagerServer {
format!("from: {from}\n\n{body}") format!("from: {from}\n\n{body}")
} }
Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(), Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(),
Ok(hive_sh4re::ManagerResponse::Err { message }) => { Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("recv failed: {message}"),
format!("recv failed: {message}")
}
Ok(other) => format!("recv unexpected response: {other:?}"), Ok(other) => format!("recv unexpected response: {other:?}"),
Err(e) => format!("recv transport error: {e:#}"), Err(e) => format!("recv transport error: {e:#}"),
} }
@ -263,10 +264,8 @@ impl ManagerServer {
.await .await
} }
#[tool( #[tool(description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \ approves on the dashboard before the container is actually created.")]
approves on the dashboard before the container is actually created."
)]
async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String { async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let name = args.name.clone(); let name = args.name.clone();
@ -284,10 +283,8 @@ impl ManagerServer {
.await .await
} }
#[tool( #[tool(description = "Stop a sub-agent container (graceful). The state dir is kept; \
description = "Stop a sub-agent container (graceful). The state dir is kept; \ recreating reuses prior config + Claude credentials.")]
recreating reuses prior config + Claude credentials."
)]
async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String { async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
let name = args.name.clone(); let name = args.name.clone();
@ -295,9 +292,7 @@ impl ManagerServer {
let req = hive_sh4re::ManagerRequest::Kill { name: args.name }; let req = hive_sh4re::ManagerRequest::Kill { name: args.name };
match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await { match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await {
Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"), Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"),
Ok(hive_sh4re::ManagerResponse::Err { message }) => { Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("kill failed: {message}"),
format!("kill failed: {message}")
}
Ok(other) => format!("kill unexpected response: {other:?}"), Ok(other) => format!("kill unexpected response: {other:?}"),
Err(e) => format!("kill transport error: {e:#}"), Err(e) => format!("kill transport error: {e:#}"),
} }
@ -305,11 +300,9 @@ impl ManagerServer {
.await .await
} }
#[tool( #[tool(description = "Submit a config change for operator approval. Pass the agent name \
description = "Submit a config change for operator approval. Pass the agent name \
(e.g. `alice` or `hm1nd` for the manager's own config) and a commit sha in that \ (e.g. `alice` or `hm1nd` for the manager's own config) and a commit sha in that \
agent's proposed config repo. On approval hive-c0re rebuilds the container." agent's proposed config repo. On approval hive-c0re rebuilds the container.")]
)]
async fn request_apply_commit( async fn request_apply_commit(
&self, &self,
Parameters(args): Parameters<RequestApplyCommitArgs>, Parameters(args): Parameters<RequestApplyCommitArgs>,
@ -317,27 +310,22 @@ impl ManagerServer {
let log = format!("{args:?}"); let log = format!("{args:?}");
let agent = args.agent.clone(); let agent = args.agent.clone();
let commit_ref = args.commit_ref.clone(); let commit_ref = args.commit_ref.clone();
run_tool_envelope( run_tool_envelope("request_apply_commit", log, self.status_line(), async move {
"request_apply_commit", let req = hive_sh4re::ManagerRequest::RequestApplyCommit {
log, agent: args.agent,
self.status_line(), commit_ref: args.commit_ref,
async move { };
let req = hive_sh4re::ManagerRequest::RequestApplyCommit { match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await {
agent: args.agent, Ok(hive_sh4re::ManagerResponse::Ok) => {
commit_ref: args.commit_ref, format!("apply approval queued for {agent} @ {commit_ref}")
};
match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await {
Ok(hive_sh4re::ManagerResponse::Ok) => {
format!("apply approval queued for {agent} @ {commit_ref}")
}
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
format!("request_apply_commit failed: {message}")
}
Ok(other) => format!("request_apply_commit unexpected response: {other:?}"),
Err(e) => format!("request_apply_commit transport error: {e:#}"),
} }
}, Ok(hive_sh4re::ManagerResponse::Err { message }) => {
) format!("request_apply_commit failed: {message}")
}
Ok(other) => format!("request_apply_commit unexpected response: {other:?}"),
Err(e) => format!("request_apply_commit transport error: {e:#}"),
}
})
.await .await
} }
} }
@ -362,8 +350,15 @@ pub const SERVER_NAME: &str = "hyperhive";
/// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a /// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a
/// finer-grained allow-list system for shell command patterns. Edit later /// finer-grained allow-list system for shell command patterns. Edit later
/// as our trust model evolves. /// as our trust model evolves.
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &[
&["Bash", "Edit", "Glob", "Grep", "Read", "TodoWrite", "Write"]; "Bash",
"Edit",
"Glob",
"Grep",
"Read",
"TodoWrite",
"Write",
];
/// Which MCP tool surface to advertise via `--allowedTools`. The agent /// Which MCP tool surface to advertise via `--allowedTools`. The agent
/// list is the strict subset of the manager list, so we just thread the /// list is the strict subset of the manager list, so we just thread the
@ -381,13 +376,7 @@ pub enum Flavor {
pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> { pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
let names: &[&str] = match flavor { let names: &[&str] = match flavor {
Flavor::Agent => &["send", "recv"], Flavor::Agent => &["send", "recv"],
Flavor::Manager => &[ Flavor::Manager => &["send", "recv", "request_spawn", "kill", "request_apply_commit"],
"send",
"recv",
"request_spawn",
"kill",
"request_apply_commit",
],
}; };
names names
.iter() .iter()
@ -399,10 +388,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
/// both the built-ins and the MCP surface. /// both the built-ins and the MCP surface.
#[must_use] #[must_use]
pub fn allowed_tools_arg(flavor: Flavor) -> String { pub fn allowed_tools_arg(flavor: Flavor) -> String {
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS.iter().map(|s| (*s).to_owned()).collect();
.iter()
.map(|s| (*s).to_owned())
.collect();
all.extend(allowed_mcp_tools(flavor)); all.extend(allowed_mcp_tools(flavor));
all.join(",") all.join(",")
} }

View file

@ -323,9 +323,9 @@ async fn events_stream(
let rx = state.bus.subscribe(); let rx = state.bus.subscribe();
// Drop a "hello" note into the bus so every new subscriber sees at // Drop a "hello" note into the bus so every new subscriber sees at
// least one event immediately and can clear the connecting placeholder. // least one event immediately and can clear the connecting placeholder.
state.bus.emit(crate::events::LiveEvent::Note( state
"live stream attached".into(), .bus
)); .emit(crate::events::LiveEvent::Note("live stream attached".into()));
let stream = BroadcastStream::new(rx).filter_map(|res| { let stream = BroadcastStream::new(rx).filter_map(|res| {
let ev = res.ok()?; let ev = res.ok()?;
let json = serde_json::to_string(&ev).ok()?; let json = serde_json::to_string(&ev).ok()?;
@ -356,7 +356,10 @@ struct CodeForm {
code: String, code: String,
} }
async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response { async fn post_login_code(
State(state): State<AppState>,
Form(form): Form<CodeForm>,
) -> Response {
let session = state.session.lock().unwrap().clone(); let session = state.session.lock().unwrap().clone();
let Some(session) = session else { let Some(session) = session else {
return error_response("no login session running"); return error_response("no login session running");

View file

@ -6,9 +6,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use hive_sh4re::{ use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER};
ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER,
};
use crate::coordinator::{Coordinator, TransientKind}; use crate::coordinator::{Coordinator, TransientKind};
use crate::lifecycle::{self, MANAGER_NAME}; use crate::lifecycle::{self, MANAGER_NAME};
@ -85,11 +83,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
} }
} }
fn finish_approval( fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> {
coord: &Coordinator,
approval: &hive_sh4re::Approval,
result: Result<()>,
) -> Result<()> {
match result { match result {
Ok(()) => { Ok(()) => {
notify_manager( notify_manager(

View file

@ -72,6 +72,7 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
Ok(()) Ok(())
} }
/// Auto-create the manager container on startup if it isn't already there. /// Auto-create the manager container on startup if it isn't already there.
/// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no /// hive-c0re manages hm1nd end-to-end (Phase 8 follow-up): operators no
/// longer declare `containers.hm1nd` in their host NixOS config. Bypasses /// longer declare `containers.hm1nd` in their host NixOS config. Bypasses

View file

@ -75,11 +75,7 @@ pub fn is_manager(name: &str) -> bool {
/// extends. Manager → `manager`; everyone else → `agent-base`. /// extends. Manager → `manager`; everyone else → `agent-base`.
#[must_use] #[must_use]
pub fn flake_base(name: &str) -> &'static str { pub fn flake_base(name: &str) -> &'static str {
if is_manager(name) { if is_manager(name) { "manager" } else { "agent-base" }
"manager"
} else {
"agent-base"
}
} }
fn validate(name: &str) -> Result<()> { fn validate(name: &str) -> Result<()> {
@ -243,10 +239,13 @@ pub async fn setup_applied(
modules = [ modules = [
./agent.nix ./agent.nix
{{ {{
programs.git.config.user = {{ environment.etc."gitconfig".text = ''
name = "{name}"; [user]
email = "{name}@hyperhive"; name = {name}
}}; email = {name}@hyperhive
[init]
defaultBranch = main
'';
systemd.services.{service}.environment = {{ systemd.services.{service}.environment = {{
HIVE_PORT = "{port}"; HIVE_PORT = "{port}";
HIVE_LABEL = "{name}"; HIVE_LABEL = "{name}";

View file

@ -218,9 +218,7 @@ pub enum ManagerRequest {
Status, Status,
/// Operator-injected message TO the manager (from the manager's own web /// Operator-injected message TO the manager (from the manager's own web
/// UI). Same shape as `AgentRequest::OperatorMsg`. /// UI). Same shape as `AgentRequest::OperatorMsg`.
OperatorMsg { OperatorMsg { body: String },
body: String,
},
/// Submit a spawn request for the user to approve. On approval the host /// 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 /// creates and starts the container. Brand-new agent names only — if an
/// agent of the same name already exists, the approval will fail. /// agent of the same name already exists, the approval will fail.

View file

@ -1,18 +1,31 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
imports = [ ./harness-base.nix ]; boot.isNspawnContainer = true;
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
environment.systemPackages = with pkgs; [
hyperhive
claude-code
bashInteractive
git
coreutils-full
];
# claude's Bash tool refuses to run without a POSIX shell + $SHELL set.
environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash";
systemd.services.hive-ag3nt = { systemd.services.hive-ag3nt = {
description = "hive-ag3nt harness"; description = "hive-ag3nt harness";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]; after = [ "network.target" ];
# systemd units get a minimal PATH by default and don't inherit # The harness shells out to `claude` (turn loop + login flow). systemd
# `environment.systemPackages`. Pointing at `/run/current-system/sw` # units get a minimal PATH by default, so we have to put claude-code on
# gives the harness (and any tools claude shells out to via Bash) # it explicitly even though it's in environment.systemPackages above.
# access to everything declared in `systemPackages` — including # bash is on PATH so claude's Bash tool can spawn `$SHELL`.
# anything an agent adds to its own `agent.nix` — without having to path = [
# touch the service definition. pkgs.claude-code
path = [ "/run/current-system/sw" ]; pkgs.bashInteractive
];
environment.SHELL = "${pkgs.bashInteractive}/bin/bash"; environment.SHELL = "${pkgs.bashInteractive}/bin/bash";
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve"; ExecStart = "${pkgs.hyperhive}/bin/hive-ag3nt serve";
@ -20,4 +33,6 @@
RestartSec = 2; RestartSec = 2;
}; };
}; };
system.stateVersion = "25.11";
} }

View file

@ -1,40 +0,0 @@
{ pkgs, ... }:
{
# Shared scaffolding for any hyperhive harness container — both
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend
# this. The systemd service that actually runs the harness binary
# differs per role and lives in the child module.
boot.isNspawnContainer = true;
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
environment.systemPackages = with pkgs; [
hyperhive
claude-code
bashInteractive
coreutils-full
];
# Git is needed by claude's Bash tool (for the agent <-> manager config
# request flow) and by hive-c0re's own setup_applied / setup_proposed.
# `programs.git.enable` installs the binary + manages `/etc/gitconfig`
# declaratively so the inline module in `applied/<name>/flake.nix` can
# override `user.name` / `user.email` per agent without fighting a raw
# `environment.etc."gitconfig"` block.
programs.git = {
enable = true;
config = {
user = {
name = "hyperhive";
email = "hyperhive@local";
};
init.defaultBranch = "main";
};
};
# claude's Bash tool refuses to run without a POSIX shell + $SHELL set.
environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash";
system.stateVersion = "25.11";
}

View file

@ -1,11 +1,27 @@
{ pkgs, ... }: { pkgs, ... }:
{ {
imports = [ ./harness-base.nix ]; boot.isNspawnContainer = true;
nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ];
environment.systemPackages = with pkgs; [
hyperhive
claude-code
bashInteractive
git
coreutils-full
];
# claude's Bash tool refuses to run without a POSIX shell + $SHELL set.
environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash";
environment.etc."gitconfig".text = ''
[user]
name = hm1nd
email = hm1nd@hyperhive
[init]
defaultBranch = main
'';
# HIVE_PORT/HIVE_LABEL/gitconfig are also injected by the generated
# `applied/hm1nd/flake.nix` (see `lifecycle::setup_applied`); the values
# here are the base config so the container stays sensible if anyone
# ever evaluates `nixosConfigurations.manager` standalone.
systemd.services.hive-m1nd = { systemd.services.hive-m1nd = {
description = "hive-m1nd manager harness"; description = "hive-m1nd manager harness";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
@ -13,17 +29,20 @@
environment = { environment = {
HIVE_PORT = "8000"; HIVE_PORT = "8000";
HIVE_LABEL = "hm1nd"; HIVE_LABEL = "hm1nd";
SHELL = "${pkgs.bashInteractive}/bin/bash";
}; };
# See note in agent-base.nix — `/run/current-system/sw` makes the # See note in agent-base.nix — `claude` and a POSIX shell have to be on
# harness service PATH track `environment.systemPackages` so anything # the service PATH explicitly for the harness + claude's Bash tool.
# an agent adds to its own `agent.nix` is visible without editing the path = [
# service definition. pkgs.claude-code
path = [ "/run/current-system/sw" ]; pkgs.bashInteractive
];
environment.SHELL = "${pkgs.bashInteractive}/bin/bash";
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve"; ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
Restart = "on-failure"; Restart = "on-failure";
RestartSec = 2; RestartSec = 2;
}; };
}; };
system.stateVersion = "25.11";
} }