manager: start/restart at will, no approval; refuse self

new manager tools mcp__hyperhive__{start,restart} that delegate to the
existing lifecycle::start / lifecycle::restart on the host. kill was
already at the manager's discretion; rounding out start + restart for
parity so day-to-day container care doesn't have to round-trip through
the operator.

guard: refuse self-targeting on kill/start/restart — the manager would
just be cutting its own legs. spawn (request_spawn) and config changes
(request_apply_commit) still go through the approval queue, since those
are the actual gate. prompt + claude.md updated to make the boundary
explicit. kill now also emits HelperEvent::Killed (it didn't before).
This commit is contained in:
müde 2026-05-15 18:57:25 +02:00
parent d943bddd9e
commit ac1b5fde8e
6 changed files with 104 additions and 3 deletions

View file

@ -166,7 +166,9 @@ Sub-agent tools:
Manager additionally:
- `mcp__hyperhive__request_spawn(name)` — queue Spawn approval.
- `mcp__hyperhive__kill(name)` — graceful stop.
- `mcp__hyperhive__kill(name)` — graceful stop. No approval.
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval.
- `mcp__hyperhive__restart(name)` — stop + start. No approval.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a
config change for any agent (including `hm1nd` for self-mods).
- `mcp__hyperhive__ask_operator(question, options?)` — non-blocking;

View file

@ -5,10 +5,14 @@ Tools (hyperhive surface):
- `mcp__hyperhive__recv()` — drain one more message from your inbox.
- `mcp__hyperhive__send(to, body)` — message an agent (by name), another peer, or the operator (`operator` surfaces in the dashboard).
- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name).
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent.
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent. No approval required.
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
- `mcp__hyperhive__ask_operator(question, options?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Do not poll inside the same turn — finish the current work and react when the event lands.
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.
Your own editable config lives at `/agents/hm1nd/config/agent.nix`; every sub-agent's lives at `/agents/<name>/config/agent.nix`. Use file/git tools to edit + commit, then `request_apply_commit`.
Sub-agents are NOT trusted by default. When one asks for a config change (new packages, env vars, etc.), verify the request before staging:

View file

@ -41,6 +41,10 @@ enum Cmd {
RequestSpawn { name: String },
/// Kill a sub-agent.
Kill { name: String },
/// Start a stopped sub-agent.
Start { name: String },
/// Restart a sub-agent (stop + start).
Restart { name: String },
/// Submit a config commit on the agent's config repo for user approval.
RequestApplyCommit { agent: String, commit_ref: String },
/// Run the manager MCP server on stdio. Spawned by claude via
@ -103,6 +107,8 @@ async fn main() -> Result<()> {
one_shot(&cli.socket, ManagerRequest::RequestSpawn { name }).await
}
Cmd::Kill { name } => one_shot(&cli.socket, ManagerRequest::Kill { name }).await,
Cmd::Start { name } => one_shot(&cli.socket, ManagerRequest::Start { name }).await,
Cmd::Restart { name } => one_shot(&cli.socket, ManagerRequest::Restart { name }).await,
Cmd::RequestApplyCommit { agent, commit_ref } => {
one_shot(
&cli.socket,

View file

@ -211,6 +211,18 @@ pub struct KillArgs {
pub name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct StartArgs {
/// Sub-agent name (without the `h-` container prefix).
pub name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RestartArgs {
/// Sub-agent name (without the `h-` container prefix).
pub name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AskOperatorArgs {
/// The question to surface on the dashboard.
@ -308,7 +320,7 @@ impl ManagerServer {
#[tool(
description = "Stop a sub-agent container (graceful). The state dir is kept; \
recreating reuses prior config + Claude credentials."
recreating reuses prior config + Claude credentials. No approval required."
)]
async fn kill(&self, Parameters(args): Parameters<KillArgs>) -> String {
let log = format!("{args:?}");
@ -322,6 +334,35 @@ impl ManagerServer {
.await
}
#[tool(
description = "Start a stopped sub-agent container. No approval required — \
lifecycle ops on existing containers are at the manager's discretion."
)]
async fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
let log = format!("{args:?}");
let name = args.name.clone();
run_tool_envelope("start", log, async move {
let resp = self
.dispatch(hive_sh4re::ManagerRequest::Start { name: args.name })
.await;
format_ack(resp, "start", format!("started {name}"))
})
.await
}
#[tool(description = "Restart a sub-agent container (stop + start). No approval required.")]
async fn restart(&self, Parameters(args): Parameters<RestartArgs>) -> String {
let log = format!("{args:?}");
let name = args.name.clone();
run_tool_envelope("restart", log, async move {
let resp = self
.dispatch(hive_sh4re::ManagerRequest::Restart { name: args.name })
.await;
format_ack(resp, "restart", format!("restarted {name}"))
})
.await
}
#[tool(
description = "Surface a question to the operator on the dashboard. Returns immediately \
with a question id do NOT wait inline. When the operator answers, a system message \
@ -426,6 +467,8 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"recv",
"request_spawn",
"kill",
"start",
"restart",
"request_apply_commit",
"ask_operator",
],

View file

@ -131,6 +131,11 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
}
ManagerRequest::Kill { name } => {
tracing::info!(%name, "manager: kill");
if name == crate::lifecycle::MANAGER_NAME {
return ManagerResponse::Err {
message: "refusing to kill the manager".into(),
};
}
let result: Result<()> = async {
lifecycle::kill(name).await?;
coord.unregister_agent(name);
@ -138,6 +143,39 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
}
.await;
match result {
Ok(()) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
agent: name.clone(),
});
ManagerResponse::Ok
}
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
}
ManagerRequest::Start { name } => {
tracing::info!(%name, "manager: start");
if name == crate::lifecycle::MANAGER_NAME {
return ManagerResponse::Err {
message: "refusing to start the manager from itself".into(),
};
}
match lifecycle::start(name).await {
Ok(()) => ManagerResponse::Ok,
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
}
ManagerRequest::Restart { name } => {
tracing::info!(%name, "manager: restart");
if name == crate::lifecycle::MANAGER_NAME {
return ManagerResponse::Err {
message: "refusing to restart the manager from itself".into(),
};
}
match lifecycle::restart(name).await {
Ok(()) => ManagerResponse::Ok,
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),

View file

@ -267,6 +267,14 @@ pub enum ManagerRequest {
Kill {
name: String,
},
/// Start a previously-stopped sub-agent container.
Start {
name: String,
},
/// Restart a sub-agent container (stop + start).
Restart {
name: String,
},
/// Submit a config commit for the user to approve. `commit_ref` is opaque
/// to the host (typically a git sha pointing into the agent's config repo).
/// On approval the host applies the change via `nixos-container update`.