nix templates: factor harness-base.nix (shared scaffolding incl. gitconfig)

This commit is contained in:
müde 2026-05-15 16:10:55 +02:00
parent cb62e15d4f
commit e1289a3e4c
11 changed files with 137 additions and 113 deletions

View file

@ -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) => {}

View file

@ -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) => {}

View file

@ -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,22 +317,27 @@ 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 {
let req = hive_sh4re::ManagerRequest::RequestApplyCommit {
agent: args.agent,
commit_ref: args.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}")
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,
};
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
}
}
@ -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(",")
}

View file

@ -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");