nix templates: factor harness-base.nix (shared scaffolding incl. gitconfig)
This commit is contained in:
parent
cb62e15d4f
commit
e1289a3e4c
11 changed files with 137 additions and 113 deletions
|
|
@ -80,7 +80,13 @@ async fn main() -> Result<()> {
|
|||
});
|
||||
match initial {
|
||||
LoginState::Online => {
|
||||
serve(&cli.socket, Duration::from_millis(poll_ms), login_state, bus).await
|
||||
serve(
|
||||
&cli.socket,
|
||||
Duration::from_millis(poll_ms),
|
||||
login_state,
|
||||
bus,
|
||||
)
|
||||
.await
|
||||
}
|
||||
LoginState::NeedsLogin => {
|
||||
// Partial-run mode: keep the harness alive (so the web UI
|
||||
|
|
@ -152,8 +158,7 @@ async fn serve(
|
|||
body: body.clone(),
|
||||
});
|
||||
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);
|
||||
}
|
||||
Ok(AgentResponse::Empty) => {}
|
||||
|
|
|
|||
|
|
@ -90,9 +90,7 @@ async fn main() -> Result<()> {
|
|||
}
|
||||
});
|
||||
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 => {
|
||||
tracing::warn!(
|
||||
claude_dir = %claude_dir.display(),
|
||||
|
|
@ -174,8 +172,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
body: body.clone(),
|
||||
});
|
||||
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);
|
||||
}
|
||||
Ok(ManagerResponse::Empty) => {}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,8 @@ use std::path::PathBuf;
|
|||
|
||||
use anyhow::Result;
|
||||
use rmcp::{
|
||||
ServerHandler, ServiceExt,
|
||||
handler::server::wrapper::Parameters,
|
||||
schemars, tool, tool_handler, tool_router,
|
||||
transport::stdio,
|
||||
ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler,
|
||||
tool_router, transport::stdio,
|
||||
};
|
||||
|
||||
use crate::client;
|
||||
|
|
@ -33,12 +31,7 @@ use crate::client;
|
|||
/// a status line → post-log. Free function so both `AgentServer` and
|
||||
/// `ManagerServer` use the same shape; the per-server `status_line`
|
||||
/// 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
|
||||
F: Future<Output = String>,
|
||||
S: Future<Output = String>,
|
||||
|
|
@ -223,8 +216,10 @@ impl ManagerServer {
|
|||
|
||||
#[tool_router]
|
||||
impl ManagerServer {
|
||||
#[tool(description = "Send a message to a sub-agent (by logical name), to another agent, \
|
||||
or to the operator (recipient `operator`, surfaces in the dashboard).")]
|
||||
#[tool(
|
||||
description = "Send a message to a sub-agent (by logical name), to another agent, \
|
||||
or to the operator (recipient `operator`, surfaces in the dashboard)."
|
||||
)]
|
||||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let to = args.to.clone();
|
||||
|
|
@ -245,8 +240,10 @@ impl ManagerServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(description = "Pop one message from the manager inbox. Returns sender + body, or \
|
||||
empty.")]
|
||||
#[tool(
|
||||
description = "Pop one message from the manager inbox. Returns sender + body, or \
|
||||
empty."
|
||||
)]
|
||||
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("recv", log, self.status_line(), async move {
|
||||
|
|
@ -256,7 +253,9 @@ impl ManagerServer {
|
|||
format!("from: {from}\n\n{body}")
|
||||
}
|
||||
Ok(hive_sh4re::ManagerResponse::Empty) => "(empty)".into(),
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("recv failed: {message}"),
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
|
||||
format!("recv failed: {message}")
|
||||
}
|
||||
Ok(other) => format!("recv unexpected response: {other:?}"),
|
||||
Err(e) => format!("recv transport error: {e:#}"),
|
||||
}
|
||||
|
|
@ -264,8 +263,10 @@ impl ManagerServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
|
||||
approves on the dashboard before the container is actually created.")]
|
||||
#[tool(
|
||||
description = "Queue a Spawn approval for a brand-new sub-agent. The operator \
|
||||
approves on the dashboard before the container is actually created."
|
||||
)]
|
||||
async fn request_spawn(&self, Parameters(args): Parameters<RequestSpawnArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let name = args.name.clone();
|
||||
|
|
@ -283,8 +284,10 @@ impl ManagerServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(description = "Stop a sub-agent container (graceful). The state dir is kept; \
|
||||
recreating reuses prior config + Claude credentials.")]
|
||||
#[tool(
|
||||
description = "Stop a sub-agent container (graceful). The state dir is kept; \
|
||||
recreating reuses prior config + Claude credentials."
|
||||
)]
|
||||
async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let name = args.name.clone();
|
||||
|
|
@ -292,7 +295,9 @@ impl ManagerServer {
|
|||
let req = hive_sh4re::ManagerRequest::Kill { name: args.name };
|
||||
match client::request::<_, hive_sh4re::ManagerResponse>(&self.socket, &req).await {
|
||||
Ok(hive_sh4re::ManagerResponse::Ok) => format!("killed {name}"),
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => format!("kill failed: {message}"),
|
||||
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
|
||||
format!("kill failed: {message}")
|
||||
}
|
||||
Ok(other) => format!("kill unexpected response: {other:?}"),
|
||||
Err(e) => format!("kill transport error: {e:#}"),
|
||||
}
|
||||
|
|
@ -300,9 +305,11 @@ impl ManagerServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[tool(description = "Submit a config change for operator approval. Pass the agent name \
|
||||
#[tool(
|
||||
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 \
|
||||
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(
|
||||
&self,
|
||||
Parameters(args): Parameters<RequestApplyCommitArgs>,
|
||||
|
|
@ -310,7 +317,11 @@ impl ManagerServer {
|
|||
let log = format!("{args:?}");
|
||||
let agent = args.agent.clone();
|
||||
let commit_ref = args.commit_ref.clone();
|
||||
run_tool_envelope("request_apply_commit", log, self.status_line(), async move {
|
||||
run_tool_envelope(
|
||||
"request_apply_commit",
|
||||
log,
|
||||
self.status_line(),
|
||||
async move {
|
||||
let req = hive_sh4re::ManagerRequest::RequestApplyCommit {
|
||||
agent: args.agent,
|
||||
commit_ref: args.commit_ref,
|
||||
|
|
@ -325,7 +336,8 @@ impl ManagerServer {
|
|||
Ok(other) => format!("request_apply_commit unexpected response: {other:?}"),
|
||||
Err(e) => format!("request_apply_commit transport error: {e:#}"),
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -350,15 +362,8 @@ pub const SERVER_NAME: &str = "hyperhive";
|
|||
/// (`Task`) are intentionally omitted for now; `Bash` is allowed pending a
|
||||
/// finer-grained allow-list system for shell command patterns. Edit later
|
||||
/// as our trust model evolves.
|
||||
pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &[
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Read",
|
||||
"TodoWrite",
|
||||
"Write",
|
||||
];
|
||||
pub const ALLOWED_BUILTIN_TOOLS: &[&str] =
|
||||
&["Bash", "Edit", "Glob", "Grep", "Read", "TodoWrite", "Write"];
|
||||
|
||||
/// Which MCP tool surface to advertise via `--allowedTools`. The agent
|
||||
/// list is the strict subset of the manager list, so we just thread the
|
||||
|
|
@ -376,7 +381,13 @@ pub enum Flavor {
|
|||
pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
||||
let names: &[&str] = match flavor {
|
||||
Flavor::Agent => &["send", "recv"],
|
||||
Flavor::Manager => &["send", "recv", "request_spawn", "kill", "request_apply_commit"],
|
||||
Flavor::Manager => &[
|
||||
"send",
|
||||
"recv",
|
||||
"request_spawn",
|
||||
"kill",
|
||||
"request_apply_commit",
|
||||
],
|
||||
};
|
||||
names
|
||||
.iter()
|
||||
|
|
@ -388,7 +399,10 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
|||
/// both the built-ins and the MCP surface.
|
||||
#[must_use]
|
||||
pub fn allowed_tools_arg(flavor: Flavor) -> String {
|
||||
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS.iter().map(|s| (*s).to_owned()).collect();
|
||||
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
|
||||
.iter()
|
||||
.map(|s| (*s).to_owned())
|
||||
.collect();
|
||||
all.extend(allowed_mcp_tools(flavor));
|
||||
all.join(",")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,9 +323,9 @@ async fn events_stream(
|
|||
let rx = state.bus.subscribe();
|
||||
// Drop a "hello" note into the bus so every new subscriber sees at
|
||||
// least one event immediately and can clear the connecting placeholder.
|
||||
state
|
||||
.bus
|
||||
.emit(crate::events::LiveEvent::Note("live stream attached".into()));
|
||||
state.bus.emit(crate::events::LiveEvent::Note(
|
||||
"live stream attached".into(),
|
||||
));
|
||||
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
||||
let ev = res.ok()?;
|
||||
let json = serde_json::to_string(&ev).ok()?;
|
||||
|
|
@ -356,10 +356,7 @@ struct CodeForm {
|
|||
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 Some(session) = session else {
|
||||
return error_response("no login session running");
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use hive_sh4re::{ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER};
|
||||
use hive_sh4re::{
|
||||
ApprovalKind, ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER,
|
||||
};
|
||||
|
||||
use crate::coordinator::{Coordinator, TransientKind};
|
||||
use crate::lifecycle::{self, MANAGER_NAME};
|
||||
|
|
@ -83,7 +85,11 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
fn finish_approval(coord: &Coordinator, approval: &hive_sh4re::Approval, result: Result<()>) -> Result<()> {
|
||||
fn finish_approval(
|
||||
coord: &Coordinator,
|
||||
approval: &hive_sh4re::Approval,
|
||||
result: Result<()>,
|
||||
) -> Result<()> {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
notify_manager(
|
||||
|
|
|
|||
|
|
@ -72,7 +72,6 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
|
|||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// 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
|
||||
/// longer declare `containers.hm1nd` in their host NixOS config. Bypasses
|
||||
|
|
|
|||
|
|
@ -75,7 +75,11 @@ pub fn is_manager(name: &str) -> bool {
|
|||
/// extends. Manager → `manager`; everyone else → `agent-base`.
|
||||
#[must_use]
|
||||
pub fn flake_base(name: &str) -> &'static str {
|
||||
if is_manager(name) { "manager" } else { "agent-base" }
|
||||
if is_manager(name) {
|
||||
"manager"
|
||||
} else {
|
||||
"agent-base"
|
||||
}
|
||||
}
|
||||
|
||||
fn validate(name: &str) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -218,7 +218,9 @@ pub enum ManagerRequest {
|
|||
Status,
|
||||
/// Operator-injected message TO the manager (from the manager's own web
|
||||
/// UI). Same shape as `AgentRequest::OperatorMsg`.
|
||||
OperatorMsg { body: String },
|
||||
OperatorMsg {
|
||||
body: String,
|
||||
},
|
||||
/// 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.
|
||||
|
|
|
|||
|
|
@ -1,27 +1,14 @@
|
|||
{ pkgs, ... }:
|
||||
{
|
||||
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";
|
||||
imports = [ ./harness-base.nix ];
|
||||
|
||||
systemd.services.hive-ag3nt = {
|
||||
description = "hive-ag3nt harness";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
after = [ "network.target" ];
|
||||
# The harness shells out to `claude` (turn loop + login flow). systemd
|
||||
# units get a minimal PATH by default, so we have to put claude-code on
|
||||
# it explicitly even though it's in environment.systemPackages above.
|
||||
# bash is on PATH so claude's Bash tool can spawn `$SHELL`.
|
||||
# `claude` for the turn loop + `bash` for claude's Bash tool. systemd
|
||||
# units get a minimal PATH by default; entries in
|
||||
# `environment.systemPackages` aren't on it.
|
||||
path = [
|
||||
pkgs.claude-code
|
||||
pkgs.bashInteractive
|
||||
|
|
@ -33,6 +20,4 @@
|
|||
RestartSec = 2;
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
}
|
||||
|
|
|
|||
35
nix/templates/harness-base.nix
Normal file
35
nix/templates/harness-base.nix
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{ 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
|
||||
git
|
||||
coreutils-full
|
||||
];
|
||||
# claude's Bash tool refuses to run without a POSIX shell + $SHELL set.
|
||||
environment.variables.SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
|
||||
# Default gitconfig for any commits the harness makes. The per-agent
|
||||
# `applied/<name>/flake.nix` overrides this with the agent's own name +
|
||||
# email; this fallback only kicks in if the container is built straight
|
||||
# from `agent-base` / `manager` without the per-agent extension.
|
||||
environment.etc."gitconfig".text = ''
|
||||
[user]
|
||||
name = hyperhive
|
||||
email = hyperhive@local
|
||||
[init]
|
||||
defaultBranch = main
|
||||
'';
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
}
|
||||
|
|
@ -1,27 +1,11 @@
|
|||
{ pkgs, ... }:
|
||||
{
|
||||
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
|
||||
'';
|
||||
imports = [ ./harness-base.nix ];
|
||||
|
||||
# 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 = {
|
||||
description = "hive-m1nd manager harness";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
|
@ -29,20 +13,16 @@
|
|||
environment = {
|
||||
HIVE_PORT = "8000";
|
||||
HIVE_LABEL = "hm1nd";
|
||||
SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
};
|
||||
# See note in agent-base.nix — `claude` and a POSIX shell have to be on
|
||||
# the service PATH explicitly for the harness + claude's Bash tool.
|
||||
path = [
|
||||
pkgs.claude-code
|
||||
pkgs.bashInteractive
|
||||
];
|
||||
environment.SHELL = "${pkgs.bashInteractive}/bin/bash";
|
||||
serviceConfig = {
|
||||
ExecStart = "${pkgs.hyperhive}/bin/hive-m1nd serve";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
};
|
||||
};
|
||||
|
||||
system.stateVersion = "25.11";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue