ask_operator tool: non-blocking; operator answer arrives as helper event

new mcp tool on the manager surface that queues a question on the
dashboard and returns the question id immediately. operator submits an
answer via /answer-question/<id>; the dashboard fires
HelperEvent::OperatorAnswered { id, question, answer } into the manager
inbox so the next turn picks it up.

also: fix async-form button stuck on spinner after successful submit
(refreshState skipped re-rendering, so the button was never re-enabled).
This commit is contained in:
müde 2026-05-15 18:44:42 +02:00
parent abfd2cce4b
commit 2770630f33
17 changed files with 426 additions and 79 deletions

View file

@ -48,6 +48,10 @@
}
// Clear text inputs the operator typed into (the form value was sent).
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
// Re-enable the button — refreshState() often skips re-rendering the
// form (status unchanged), so without this the spinner sticks and
// the operator can't submit again.
if (btn) { btn.disabled = false; btn.innerHTML = original; }
refreshState();
} catch (err) {
alert('action failed: ' + err);

View file

@ -7,6 +7,7 @@ Tools (hyperhive surface):
- `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__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.
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`.
@ -19,9 +20,9 @@ Sub-agents are NOT trusted by default. When one asks for a config change (new pa
You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend.
You can surface questions to the operator. (NOT YET IMPLEMENTED: a dedicated `mcp__hyperhive__ask_operator` tool will land soon — it pauses the turn, drops a prompt on the dashboard, and resumes with the answer.) For now, send to `operator` with a clear question and wait for the next turn to see their reply; the cadence is slower but the shape is the same.
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask_operator(question, options?)` when you need a decision. `ask_operator` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as an `operator_answered` system event. Prefer `ask_operator` over an open-ended `send` for anything you actually need to wait on.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or note the change to the operator.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `operator_answered`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or pick up the operator's answer to a question you previously asked.
Durable knowledge:

View file

@ -176,7 +176,9 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
let pending = if unread == 0 {
String::new()
} else {
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
)
};
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
}

View file

@ -170,7 +170,11 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
turn::emit_turn_end(&bus, &outcome);
}
Ok(ManagerResponse::Empty) => {}
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
Ok(
ManagerResponse::Ok
| ManagerResponse::Status { .. }
| ManagerResponse::QuestionQueued { .. },
) => {
tracing::warn!("recv produced unexpected response kind");
}
Ok(ManagerResponse::Err { message }) => {
@ -184,7 +188,6 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
}
}
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
/// (`prompts/manager.md` → `claude --system-prompt-file`); this is just
/// the wake signal. `unread` is the inbox depth after this message was
@ -193,7 +196,9 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
let pending = if unread == 0 {
String::new()
} else {
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
format!(
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
)
};
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
}

View file

@ -37,6 +37,7 @@ pub enum SocketReply {
Message { from: String, body: String },
Empty,
Status(u64),
QuestionQueued(i64),
}
impl From<hive_sh4re::AgentResponse> for SocketReply {
@ -59,6 +60,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
hive_sh4re::ManagerResponse::Message { from, body } => Self::Message { from, body },
hive_sh4re::ManagerResponse::Empty => Self::Empty,
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
}
}
}
@ -209,6 +211,16 @@ pub struct KillArgs {
pub name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AskOperatorArgs {
/// The question to surface on the dashboard.
pub question: String,
/// Optional fixed-choice answers. If empty, the dashboard renders a
/// free-text input. Otherwise renders a select list of these options.
#[serde(default)]
pub options: Vec<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestApplyCommitArgs {
/// Agent whose config repo the commit lives in (use `"hm1nd"` for the
@ -310,6 +322,36 @@ impl ManagerServer {
.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 \
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \
on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \
request, policy call, scope clarification). `options` is advisory: pass a short \
fixed-choice list when applicable, otherwise leave empty for free text."
)]
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("ask_operator", log, async move {
let resp = self
.dispatch(hive_sh4re::ManagerRequest::AskOperator {
question: args.question,
options: args.options,
})
.await;
match resp {
Ok(SocketReply::QuestionQueued(id)) => format!(
"question queued (id={id}); operator's answer will arrive as a system \
`operator_answered` event in your inbox"
),
Ok(SocketReply::Err(m)) => format!("ask_operator failed: {m}"),
Ok(other) => format!("ask_operator unexpected response: {other:?}"),
Err(e) => format!("ask_operator transport error: {e:#}"),
}
})
.await
}
#[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 \
@ -322,23 +364,19 @@ 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,
async move {
let resp = self
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
agent: args.agent,
commit_ref: args.commit_ref,
})
.await;
format_ack(
resp,
"request_apply_commit",
format!("apply approval queued for {agent} @ {commit_ref}"),
)
},
)
run_tool_envelope("request_apply_commit", log, async move {
let resp = self
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
agent: args.agent,
commit_ref: args.commit_ref,
})
.await;
format_ack(
resp,
"request_apply_commit",
format!("apply approval queued for {agent} @ {commit_ref}"),
)
})
.await
}
}
@ -348,7 +386,8 @@ impl ManagerServer {
relay between them and the operator. Use `send` to talk to agents/operator, `recv` \
to drain your inbox. Privileged: `request_spawn` (new agent, gated on operator \
approval), `kill` (graceful stop), `request_apply_commit` (config change for \
any agent including yourself). The manager's own config lives at \
any agent including yourself), `ask_operator` (block on a human answer via the \
dashboard). The manager's own config lives at \
`/agents/hm1nd/config/agent.nix`."
)]
impl ServerHandler for ManagerServer {}
@ -388,6 +427,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"request_spawn",
"kill",
"request_apply_commit",
"ask_operator",
],
};
names