diff --git a/flake.nix b/flake.nix index ad0007d..4d84593 100644 --- a/flake.nix +++ b/flake.nix @@ -3,6 +3,7 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; naersk = { url = "github:nix-community/naersk"; inputs.nixpkgs.follows = "nixpkgs"; @@ -17,6 +18,7 @@ inputs@{ self, nixpkgs, + nixpkgs-unstable, naersk, treefmt-nix, }: @@ -57,8 +59,21 @@ } ); - overlays.default = final: prev: { - hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default; + overlays = { + default = final: prev: { + hyperhive = self.packages.${prev.stdenv.hostPlatform.system}.default; + }; + claude-unstable = + final: prev: + let + unstable = import nixpkgs-unstable { + inherit (prev.stdenv.hostPlatform) system; + config.allowUnfreePredicate = pkg: builtins.elem (prev.lib.getName pkg) [ "claude-code" ]; + }; + in + { + inherit (unstable) claude-code; + }; }; nixosModules = { @@ -70,7 +85,12 @@ system = "x86_64-linux"; modules = [ self.nixosModules.agent-base - { nixpkgs.overlays = [ self.overlays.default ]; } + { + nixpkgs.overlays = [ + self.overlays.default + self.overlays.claude-unstable + ]; + } ]; }; diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 58327eb..33d2ecb 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -1,10 +1,11 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::{Result, bail}; use clap::{Parser, Subcommand}; use hive_ag3nt::{DEFAULT_SOCKET, client}; use hive_sh4re::{AgentRequest, AgentResponse}; +use tokio::process::Command; #[derive(Parser)] #[command(name = "hive-ag3nt", about = "hyperhive sub-agent harness")] @@ -19,7 +20,8 @@ struct Cli { #[derive(Subcommand)] enum Cmd { - /// Run the long-lived harness loop. Polls inbox; prints messages to stdout. + /// Run the long-lived harness loop. Polls inbox; replies via `claude --print` + /// when available, falling back to a simple echo otherwise. Serve { /// Inbox poll interval in milliseconds. #[arg(long, default_value_t = 1000)] @@ -56,21 +58,26 @@ async fn main() -> Result<()> { } } -async fn serve(socket: &std::path::Path, interval: Duration) -> Result<()> { +async fn serve(socket: &Path, interval: Duration) -> Result<()> { tracing::info!(socket = %socket.display(), "hive-ag3nt serve"); loop { match client::request(socket, AgentRequest::Recv).await { Ok(AgentResponse::Message { from, body }) => { tracing::info!(%from, %body, "inbox"); - // Placeholder "turn": echo back, prefixed. Phase 3c replaces this - // with `claude --print` once API-key plumbing exists. Don't echo - // an echo, so a manual `send` produces exactly one reply. + // Don't auto-reply to echoes — prevents infinite ping-pong when + // both ends are falling back to echo. Real loop control is the + // manager's job (Phase 4+). if !body.starts_with("echo: ") { - let reply = AgentRequest::Send { - to: from.clone(), - body: format!("echo: {body}"), - }; - if let Err(e) = client::request(socket, reply).await { + let reply = compute_reply(&body).await; + if let Err(e) = client::request( + socket, + AgentRequest::Send { + to: from, + body: reply, + }, + ) + .await + { tracing::warn!(error = ?e, "send reply failed"); } } @@ -90,6 +97,36 @@ async fn serve(socket: &std::path::Path, interval: Duration) -> Result<()> { } } +async fn compute_reply(prompt: &str) -> String { + match invoke_claude(prompt).await { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "claude failed; falling back to echo"); + format!("echo: {prompt}") + } + } +} + +async fn invoke_claude(prompt: &str) -> Result { + let out = Command::new("claude") + .arg("--print") + .arg(prompt) + .output() + .await?; + if !out.status.success() { + bail!( + "claude exited {}: {}", + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + let text = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + if text.is_empty() { + bail!("claude produced empty output"); + } + Ok(text) +} + fn render(resp: &AgentResponse) -> Result<()> { println!("{}", serde_json::to_string_pretty(resp)?); Ok(()) diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 04b5e6b..20c510f 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -36,10 +36,7 @@ impl Coordinator { let socket_path = Self::socket_path(name); let socket = agent_server::start(name.to_owned(), socket_path, self.broker.clone()).await?; - self.agents - .lock() - .unwrap() - .insert(name.to_owned(), socket); + self.agents.lock().unwrap().insert(name.to_owned(), socket); Ok(agent_dir) } diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index bdb0ffa..7399ff7 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -37,33 +37,10 @@ pub async fn spawn(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<() validate(name)?; let container = container_name(name); run(&["create", &container, "--flake", agent_flake]).await?; - set_bind_flag(&container, agent_dir)?; + set_nspawn_flags(&container, agent_dir)?; run(&["start", &container]).await } -/// `nixos-container` doesn't expose `--bind` on the CLI, but its start script -/// expands `$EXTRA_NSPAWN_FLAGS` (from `/etc/nixos-containers/.conf`) -/// unquoted into the `systemd-nspawn` invocation. Idempotently replace the -/// `EXTRA_NSPAWN_FLAGS` line with the bind we want. -fn set_bind_flag(container: &str, agent_dir: &Path) -> Result<()> { - let path = format!("/etc/nixos-containers/{container}.conf"); - let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; - let mut lines: Vec = original - .lines() - .filter(|line| !line.trim_start().starts_with("EXTRA_NSPAWN_FLAGS=")) - .map(str::to_owned) - .collect(); - lines.push(format!( - "EXTRA_NSPAWN_FLAGS=\"--bind={}:{CONTAINER_RUNTIME_MOUNT}\"", - agent_dir.display() - )); - let mut content = lines.join("\n"); - content.push('\n'); - std::fs::write(&path, content).with_context(|| format!("write {path}"))?; - tracing::info!(%path, "set EXTRA_NSPAWN_FLAGS for bind mount"); - Ok(()) -} - pub async fn kill(name: &str) -> Result<()> { validate(name)?; let container = container_name(name); @@ -73,7 +50,7 @@ pub async fn kill(name: &str) -> Result<()> { pub async fn rebuild(name: &str, agent_flake: &str, agent_dir: &Path) -> Result<()> { validate(name)?; let container = container_name(name); - set_bind_flag(&container, agent_dir)?; + set_nspawn_flags(&container, agent_dir)?; run(&["update", &container, "--flake", agent_flake]).await?; // Restart so any nspawn-level changes (bind mounts, networking, etc.) apply. run(&["stop", &container]).await?; @@ -101,6 +78,29 @@ pub async fn list() -> Result> { .collect()) } +/// Idempotently rewrite the `EXTRA_NSPAWN_FLAGS` line in +/// `/etc/nixos-containers/.conf`. The start script expands this +/// variable unquoted into the `systemd-nspawn` command. +fn set_nspawn_flags(container: &str, agent_dir: &Path) -> Result<()> { + let path = format!("/etc/nixos-containers/{container}.conf"); + let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?; + let flag = format!( + "EXTRA_NSPAWN_FLAGS=\"--bind={}:{CONTAINER_RUNTIME_MOUNT}\"", + agent_dir.display() + ); + let mut lines: Vec = original + .lines() + .filter(|line| !line.trim_start().starts_with("EXTRA_NSPAWN_FLAGS=")) + .map(str::to_owned) + .collect(); + lines.push(flag); + let mut content = lines.join("\n"); + content.push('\n'); + std::fs::write(&path, content).with_context(|| format!("write {path}"))?; + tracing::info!(%path, "set EXTRA_NSPAWN_FLAGS"); + Ok(()) +} + async fn run(args: &[&str]) -> Result<()> { let out = Command::new("nixos-container") .args(args) diff --git a/nix/modules/hive-c0re.nix b/nix/modules/hive-c0re.nix index dc9c383..86ba590 100644 --- a/nix/modules/hive-c0re.nix +++ b/nix/modules/hive-c0re.nix @@ -29,7 +29,6 @@ in systemd.services.hive-c0re = { description = "hyperhive coordinator daemon"; wantedBy = [ "multi-user.target" ]; - # `nixos-container` lives in the system path; make it reachable from the unit. path = [ "/run/current-system/sw" ]; serviceConfig = { ExecStart = "${cfg.package}/bin/hive-c0re --socket /run/hyperhive/host.sock serve --agent-flake ${cfg.agentFlake}"; diff --git a/nix/templates/agent-base.nix b/nix/templates/agent-base.nix index 6635325..4a56a34 100644 --- a/nix/templates/agent-base.nix +++ b/nix/templates/agent-base.nix @@ -2,7 +2,15 @@ { boot.isNspawnContainer = true; - environment.systemPackages = [ pkgs.hyperhive ]; + nixpkgs.config.allowUnfreePredicate = + pkg: builtins.elem (pkgs.lib.getName pkg) [ "claude-code" ]; + + environment.systemPackages = with pkgs; [ + hyperhive + claude-code + git + coreutils-full + ]; systemd.services.hive-ag3nt = { description = "hive-ag3nt harness";