ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A

This commit is contained in:
damocles 2026-05-17 12:10:49 +02:00
parent 87f8f8a123
commit 82b0877c47
21 changed files with 640 additions and 266 deletions

View file

@ -22,7 +22,10 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/broker.rs sqlite Message store + broadcast channel for SSE + src/broker.rs sqlite Message store + broadcast channel for SSE +
hourly vacuum of delivered>30d hourly vacuum of delivered>30d
src/approvals.rs sqlite Approval queue + kinds src/approvals.rs sqlite Approval queue + kinds
src/operator_questions.rs sqlite question queue backing `ask_operator` src/operator_questions.rs sqlite question queue backing `ask` /
`answer` (both operator + agent-to-agent)
src/questions.rs shared dispatch for `Ask` / `Answer`
used by both agent + manager surfaces
src/reminder_scheduler.rs 5s poll loop: drains due reminders, src/reminder_scheduler.rs 5s poll loop: drains due reminders,
resolves file_path container→host, persists resolves file_path container→host, persists
payload + delivers pointer string payload + delivers pointer string
@ -31,8 +34,9 @@ hive-c0re/ host daemon + CLI (one binary, subcommand-dispatched)
src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash src/crash_watch.rs poll every 10s; fire HelperEvent::ContainerCrash
when a previously-running container disappears when a previously-running container disappears
without an operator-initiated transient without an operator-initiated transient
src/coordinator.rs shared state (broker/approvals/questions/transient/ src/coordinator.rs shared state (broker/approvals/operator_questions/
sockets) + tombstone enumeration + kick_agent transient/sockets) + tombstone enumeration +
kick_agent + notify_agent (helper-event push)
src/actions.rs approve/deny/destroy (transient-aware) src/actions.rs approve/deny/destroy (transient-aware)
src/auto_update.rs startup rebuild scan + ensure_manager + src/auto_update.rs startup rebuild scan + ensure_manager +
meta::lock_update_hyperhive bump meta::lock_update_hyperhive bump
@ -161,11 +165,21 @@ Prune freely.
domain tooling — the agent flake's `inputs` block pulls domain tooling — the agent flake's `inputs` block pulls
the external flake, `agent.nix` references it via the external flake, `agent.nix` references it via
`flakeInputs.<name>.packages.${pkgs.system}.default`. `flakeInputs.<name>.packages.${pkgs.system}.default`.
- **Just landed:** `mcp__hyperhive__ask_operator` is now on - **Just landed:** `ask_operator``ask` rename + optional
the sub-agent surface too (not just the manager). Answer `to: <agent>` param for agent-to-agent structured Q&A.
routes back to whichever agent asked via Recipient defaults to the operator (dashboard); peer
`coord.notify_agent`; the dashboard already shows the questions land in the target's inbox as `QuestionAsked`
asker on each question row. events and the recipient replies via new `answer(id,
answer)` tool. Answer always flows back as
`QuestionAnswered { id, question, answer, answerer }`
(renamed from `OperatorAnswered`; `answerer` distinguishes
operator vs peer vs `ttl-watchdog`). Authorisation:
operator-targeted questions can only be answered by the
operator; agent-targeted by the named target (or the
operator as override). Self-ask rejected. Shared dispatch
lives in `hive-c0re/src/questions.rs`. Dashboard's
`pending()` filters on `target IS NULL` so peer questions
never leak into the operator's queue.
- **Just landed:** dashboard now has a terminal-style - **Just landed:** dashboard now has a terminal-style
compose textbox under the message-flow stream — `@name` compose textbox under the message-flow stream — `@name`
picks the recipient (sticky in localStorage, auto- picks the recipient (sticky in localStorage, auto-

View file

@ -33,12 +33,12 @@ host (NixOS, runs hive-c0re.service)
├── hm1nd hive-m1nd serve : claude turn loop + ├── hm1nd hive-m1nd serve : claude turn loop +
│ MCP (send / recv / request_spawn / kill / start / │ MCP (send / recv / request_spawn / kill / start /
│ restart / update / request_apply_commit / │ restart / update / request_apply_commit /
│ ask_operator) + web UI on :8000 │ ask / answer / remind) + web UI on :8000
└── h-<name> hive-ag3nt serve : claude turn loop + └── h-<name> hive-ag3nt serve : claude turn loop +
MCP (send / recv / ask_operator + agent-declared extras MCP (send / recv / ask / answer / remind + agent-declared
via hyperhive.extraMcpServers) + web UI on a extras via hyperhive.extraMcpServers) + web UI
hashed :8100-8999 on a hashed :8100-8999
``` ```
Each turn: harness pops one inbox message (Recv long-polls server-side and Each turn: harness pops one inbox message (Recv long-polls server-side and
@ -89,10 +89,13 @@ inside the container — so `git fetch applied`,
`cat /meta/flake.lock` all just work without constructing paths by `cat /meta/flake.lock` all just work without constructing paths by
hand. See [`docs/approvals.md`](docs/approvals.md) for the full state hand. See [`docs/approvals.md`](docs/approvals.md) for the full state
machine + lock-flow walkthrough. machine + lock-flow walkthrough.
For decisions the manager needs human signal on, `ask_operator(question, For decisions any agent (manager or sub) needs structured signal on,
options?, multi?)` queues a free-text/checkbox/radio form on the `ask(question, options?, multi?, ttl_seconds?, to?)` queues a question:
dashboard; the answer arrives later as a `HelperEvent::OperatorAnswered` default recipient is the operator (dashboard renders a free-text /
in the manager's inbox. checkbox / radio form), or pass `to: "<agent>"` to route a structured
peer question into another agent's inbox. The answer arrives later as
a `HelperEvent::QuestionAnswered { id, question, answer, answerer }`
in the asker's inbox. Peer recipients respond via `answer(id, answer)`.
## Host config ## Host config

View file

@ -8,7 +8,7 @@
- **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you" - **Broadcast messaging**: allow sending messages with recipient "*" to all agents; deliver with hint "this was a broadcast and may not need any action from you"
- **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc) - **Multi-agent restart coordination**: when rebuilding all agents, manager should start first so it can coordinate post-restart confusion (notify agents, suppress unnecessary retries, etc)
- **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push. - **Shared docs/skills repo (RO)**: a single repo on the hive forge that every agent has read-only access to — common references, prompts, runbooks, "skills" the operator wants every agent to inherit without baking into the system prompt or `/shared`. Implementation likely: seed an `org-shared/docs` repo on first hive-forge boot, grant every per-agent user a read membership in the org. Agents `git clone` it (or use the API) to read; only the manager + operator can push.
- **Rename `ask_operator` → `ask` with optional `to` param**: today `mcp__hyperhive__ask_operator` always targets the operator dashboard. Generalise: rename to `ask`, add optional `to: <agent_name>` argument that defaults to `"operator"`. When `to` is another agent, route the question to that agent's inbox as a structured "question event" (different from a plain send so the recipient can answer back with the same id and the answer threads back to the asker). Unblocks agent-to-agent structured Q&A without burning regular inbox slots. - ~~**Rename `ask_operator` → `ask` with optional `to` param**~~ ✓ done — `Ask { question, options, multi, ttl_seconds, to: Option<String> }` on both `AgentRequest` + `ManagerRequest`. `to = None` (or `Some("operator")`) = dashboard path; `to = Some(<agent>)` pushes `HelperEvent::QuestionAsked` into the target's inbox. New `Answer { id, answer }` request on both surfaces — target answers via `mcp__hyperhive__answer`; answer flows back to the asker as `HelperEvent::QuestionAnswered { id, question, answer, answerer }` (renamed from `OperatorAnswered`; carries who answered so the asker can distinguish operator vs peer vs `ttl-watchdog`). Authorisation: only the question's `target` agent or the operator can answer; self-ask is rejected. DB gets a nullable `target` column (NULL = operator path, back-compat). Dashboard's `pending()` / `recent_answered()` filter on `target IS NULL` so peer questions never leak into the operator's queue. Shared dispatch lives in `hive-c0re/src/questions.rs` so both surfaces stay aligned.
- **Loose-ends tracker + `get_open_threads` tool**: hive-c0re already knows about pending approvals + unanswered questions; soon will also know about open PRs on hive-forge. Aggregate these into a per-agent "open threads" view (e.g. `[{kind: "approval", id: 7, summary: "spawn alice"}, {kind: "question", id: 12, asker: "alice", summary: "deploy now?"}]`). New MCP tool `mcp__hyperhive__get_open_threads` returns the list so an agent can see what's still pending against it without rebuilding context from inbox history. Manager's version includes hive-wide threads. **Also surface this list on the per-agent web UI** so the operator can see at a glance what each agent has hanging open — same data source as the MCP tool, just rendered into the existing per-agent dashboard page (next to inbox view / model chip / etc). - **Loose-ends tracker + `get_open_threads` tool**: hive-c0re already knows about pending approvals + unanswered questions; soon will also know about open PRs on hive-forge. Aggregate these into a per-agent "open threads" view (e.g. `[{kind: "approval", id: 7, summary: "spawn alice"}, {kind: "question", id: 12, asker: "alice", summary: "deploy now?"}]`). New MCP tool `mcp__hyperhive__get_open_threads` returns the list so an agent can see what's still pending against it without rebuilding context from inbox history. Manager's version includes hive-wide threads. **Also surface this list on the per-agent web UI** so the operator can see at a glance what each agent has hanging open — same data source as the MCP tool, just rendered into the existing per-agent dashboard page (next to inbox view / model chip / etc).
## Reminder Tool ## Reminder Tool
@ -25,6 +25,7 @@
## Dashboard ## Dashboard
- **UI for agent-to-agent questions** (follow-up to the `ask` rename): now that agents can `ask(to: <agent>)` each other, surface those threads in the per-agent dashboard view. Replace the existing read/unread tabs with THREE filters: `unread`, `from: <agent>`, `to: <agent>`. The `to:` filter makes agent-targeted questions visible so the operator can see at a glance "alice has 3 questions outstanding from bob" and intervene if a thread is stuck. Same UI is useful for general inbox filtering too. Data lives in the existing `operator_questions` table (with the new `target` column) + the broker inbox; no new schema needed. Also expose a "respond" affordance so the operator can override-answer a peer question when an agent is offline / stuck (the answerer-auth check in `OperatorQuestions::answer` already permits the operator on any target).
- **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel - **UI for pending reminders**: show pending/queued reminders in dashboard, allow operator to view/debug/cancel
- Per-agent reminder status (pending, delivered) - Per-agent reminder status (pending, delivered)
- Reminder query interface for debugging - Reminder query interface for debugging

View file

@ -248,16 +248,25 @@ package legitimacy, cheaper alternative, blast radius) before
committing and calling `request_apply_commit`. committing and calling `request_apply_commit`.
For ambiguous cases or anything that needs human signal, the For ambiguous cases or anything that needs human signal, the
manager calls `ask_operator(question, options?, multi?, manager calls `ask(question, options?, multi?, ttl_seconds?, to?)`
ttl_seconds?)` — queues the question on the dashboard and returns queues the question and returns the id immediately. When `to` is
the id immediately. The operator's answer arrives later as omitted (or `"operator"`) the question shows up on the dashboard;
`HelperEvent::OperatorAnswered` in the manager inbox. Storage is when `to` is a sub-agent's name, the recipient receives a
`hive-c0re::operator_questions` (sqlite); the answer flow is: `HelperEvent::QuestionAsked` and answers via their own `answer`
tool. Either way the answer arrives back as
`HelperEvent::QuestionAnswered { id, question, answer, answerer }`
in the asker's inbox. Storage is `hive-c0re::operator_questions`
(sqlite) — same table, with a nullable `target` column
(NULL = operator). Dispatch goes through
`hive-c0re/src/questions.rs::{handle_ask, handle_answer}` so both
the agent + manager surfaces stay aligned. The answer flow is:
``` ```
POST /answer-question/{id} POST /answer-question/{id} agent: Answer { id, answer }
→ OperatorQuestions::answer → OperatorQuestions::answer(_, _, "operator") → questions::handle_answer
→ notify_manager(OperatorAnswered { id, question, answer }) → notify_agent(asker, QuestionAnswered { → OperatorQuestions::answer(_, _, agent)
answerer: "operator", ... }) → notify_agent(asker, QuestionAnswered {
answerer: agent, ... })
``` ```
Two more paths resolve a pending question with a sentinel answer: Two more paths resolve a pending question with a sentinel answer:
@ -301,9 +310,14 @@ regular claude turn so the manager can react. Variants
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. Manager calls `update(name)` to rebuild — idempotent, stale. Manager calls `update(name)` to rebuild — idempotent,
no approval required. no approval required.
- `OperatorAnswered { id, question, answer }` — dashboard - `QuestionAnswered { id, question, answer, answerer }`
`/answer-question/{id}` after the operator submits the answer dashboard `/answer-question/{id}` (answerer = `"operator"`),
form. peer `Answer` request (answerer = agent name), or ttl watchdog
expiry (answerer = `"ttl-watchdog"`, answer = `"[expired]"`).
- `QuestionAsked { id, asker, question, options, multi }`
fired when an agent calls `Ask { to: Some(<this-agent>), ... }`.
The recipient responds via `Answer { id, answer }` and the
asker sees the matching `QuestionAnswered`.
To add a new event: new `HelperEvent` variant + call sites + update To add a new event: new `HelperEvent` variant + call sites + update
`prompts/manager.md` so the manager knows the new shape. `prompts/manager.md` so the manager knows the new shape.

View file

@ -12,10 +12,15 @@ Three tables, all in one file:
`sender / recipient / body / sent_at / delivered_at`. `sender / recipient / body / sent_at / delivered_at`.
- `approvals` — the queue. `agent / kind (apply_commit | spawn) / - `approvals` — the queue. `agent / kind (apply_commit | spawn) /
commit_ref / requested_at / status / resolved_at / note`. commit_ref / requested_at / status / resolved_at / note`.
- `operator_questions``ask_operator` queue. - `operator_questions``ask` / `answer` queue (despite the
file name, stores both operator-targeted + agent-to-agent
questions since the `ask` rename).
`asker / question / options_json / multi / asked_at / `asker / question / options_json / multi / asked_at /
deadline_at (ttl) / answered_at / answer`. Migrated via deadline_at (ttl) / answered_at / answer / target`. `target IS
`ALTER TABLE ADD COLUMN` against `pragma_table_info`. NULL` = operator path (dashboard); `target = '<agent>'` = peer
Q&A (`HelperEvent::QuestionAsked` pushed into target's inbox,
answered via `Answer` request). Migrated via `ALTER TABLE ADD
COLUMN` against `pragma_table_info`.
Retention: Retention:

View file

@ -107,10 +107,18 @@ it as a stdio child via `--mcp-config`. The hyperhive socket name is
"anything pending?" peek. Positive value parks the turn up "anything pending?" peek. Positive value parks the turn up
to that many seconds (cap 180) — incoming messages wake to that many seconds (cap 180) — incoming messages wake
instantly, otherwise returns empty at the timeout. instantly, otherwise returns empty at the timeout.
- `ask_operator(question, options?, multi?, ttl_seconds?)` - `ask(question, options?, multi?, ttl_seconds?, to?)`
surface a question on the dashboard. Same shape as the manager's; surface a structured question. Same shape as the manager's;
answer routes back to the asker's own inbox as recipient defaults to the operator (dashboard) but can be set
`HelperEvent::OperatorAnswered` via `coord.notify_agent`. to a peer agent name via `to: "<agent>"`. Answer routes back
to the asker's own inbox as `HelperEvent::QuestionAnswered`
via `coord.notify_agent`. For peer questions the recipient
sees a `HelperEvent::QuestionAsked` event and replies with
`answer(id, answer)`.
- `answer(id, answer)` — respond to a `question_asked` event
routed to this agent. Authorisation is strict: only the
declared target (or the operator via the dashboard) can
answer.
### Waking the agent from inside the container ### Waking the agent from inside the container
@ -167,16 +175,22 @@ meta's.
- `request_apply_commit(agent, commit_ref)` — submit a config - `request_apply_commit(agent, commit_ref)` — submit a config
change for any agent (`hm1nd` for the manager's own config) for change for any agent (`hm1nd` for the manager's own config) for
operator approval. operator approval.
- `ask_operator(question, options?, multi?, ttl_seconds?)` - `ask(question, options?, multi?, ttl_seconds?, to?)`
surface a question on the dashboard. Non-blocking — returns the surface a structured question to the operator (default) or a
queued question id; the operator's answer arrives later as sub-agent (`to: "<agent>"`). Non-blocking — returns the
`HelperEvent::OperatorAnswered` in the manager inbox. Options queued question id; the answer arrives later as
always render alongside a free-text fallback; `multi=true` `HelperEvent::QuestionAnswered { id, question, answer,
renders options as checkboxes. `ttl_seconds` auto-cancels with answerer }` in the asker's inbox. Options always render
answer `[expired]` after the deadline (useful for time-sensitive alongside a free-text fallback; `multi=true` renders options
decisions that become moot if the operator hasn't responded). as checkboxes. `ttl_seconds` auto-cancels with answer
The operator can also manually cancel with `[cancelled]` via the `[expired]` (and `answerer: "ttl-watchdog"`) after the
dashboard. deadline (useful for time-sensitive decisions that become moot
if no one has responded). The operator can also manually
cancel with `[cancelled]` via the dashboard.
- `answer(id, answer)` — respond to a `question_asked` event
that was routed to the manager (a sub-agent did
`ask(to: "manager", ...)`). Surfaces in the asker's inbox as
the same `question_answered` event.
The boundary: lifecycle ops on *existing* sub-agents The boundary: lifecycle ops on *existing* sub-agents
(`kill`/`start`/`restart`) are at the manager's discretion — no (`kill`/`start`/`restart`) are at the manager's discretion — no

View file

@ -61,7 +61,9 @@ the previous process's socket release resolves itself.
age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a age + claude-creds badge). Two actions: `⊕ R3V1V3` (queues a
Spawn approval; existing state is reused), `PURG3` (wipes Spawn approval; existing state is reused), `PURG3` (wipes
state + applied dirs; `POST /purge-tombstone/{name}`). state + applied dirs; `POST /purge-tombstone/{name}`).
4. **M1ND H4S QU3STI0NS** — pending `ask_operator` questions 4. **M1ND H4S QU3STI0NS** — pending operator-targeted `ask`
questions, i.e. rows with `target IS NULL` (peer-to-peer
questions live in the same table but never surface here)
(amber pulsing border). Free-text fallback always rendered (amber pulsing border). Free-text fallback always rendered
alongside any option list; `multi=true` renders options as alongside any option list; `multi=true` renders options as
checkboxes; submit merges selections + free text comma-joined. checkboxes; submit merges selections + free text comma-joined.

View file

@ -5,7 +5,8 @@ Tools (hyperhive surface):
- `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. - `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop.
- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable. - `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable.
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. - (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question to the human 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 for clarifications, permission for risky actions, or choice between options. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the operator pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` when the decision becomes moot. - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the manager calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config. Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config.

View file

@ -10,7 +10,8 @@ Tools (hyperhive surface):
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required. - `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event. - `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — submit a config change for any agent (`hm1nd` for self) for operator approval. Pass an optional `description` and it appears on the dashboard approval card so the operator knows what changed without opening the diff. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/<id>`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build. - `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — submit a config change for any agent (`hm1nd` for self) for operator approval. Pass an optional `description` and it appears on the dashboard approval card so the operator knows what changed without opening the diff. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/<id>`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build.
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — 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. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands. - `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: "<agent-name>"`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event.
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. 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.
@ -62,9 +63,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'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.
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. Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask(question, options?)` when you need a decision (omit `to`, or pass `to: "operator"`). `ask` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as a `question_answered` system event. Prefer `ask` over an open-ended `send` for anything you actually need to wait on. Same primitive can target a sub-agent (`to: "<agent>"`) when you need a structured answer from a peer rather than free-form chat.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `operator_answered`. Use these to react to lifecycle changes: Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes:
- `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long. - `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long.
- `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do. - `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do.

View file

@ -36,7 +36,7 @@ enum Cmd {
/// Run the manager MCP server on stdio. Spawned by claude via /// Run the manager MCP server on stdio. Spawned by claude via
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the /// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
/// manager tool surface (`request_spawn`, `kill`, `start`, `restart`, /// manager tool surface (`request_spawn`, `kill`, `start`, `restart`,
/// `request_apply_commit`, `ask_operator`). /// `request_apply_commit`, `ask`, `answer`, `remind`).
Mcp, Mcp,
} }

View file

@ -226,42 +226,74 @@ impl AgentServer {
} }
#[tool( #[tool(
description = "Surface a question to the operator on the dashboard. Returns immediately \ description = "Surface a structured question to either the operator OR a peer agent. \
with a question id do NOT wait inline. When the operator answers, a system message \ Returns immediately with a question id do NOT wait inline. When the recipient \
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \ answers, a system message with event `question_answered { id, question, answer, \
on a future turn. Use this when a decision needs human signal (ambiguous scope, \ answerer }` lands in your inbox; handle it on a future turn. \n\n\
permission to do something risky, choosing between options). `options` is advisory: \ Recipient: omit `to` (or set `to: \"operator\"`) for the human operator on the \
pass a short fixed-choice list when applicable, otherwise leave empty for free text. \ dashboard. Set `to: \"<agent-name>\"` to ask a peer agent — they receive a \
Set `multi: true` to let the operator pick multiple options (checkboxes); the answer \ `question_asked { id, asker, question, options, multi }` event in their inbox \
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ and answer via `mcp__hyperhive__answer`. \n\n\
no-longer-relevant question on expiry the answer is `[expired]` and the same \ `options` is advisory: pass a short fixed-choice list when applicable, otherwise \
`operator_answered` event fires." leave empty for free text. Set `multi: true` to let the answerer pick multiple \
options (checkboxes on the dashboard, hint to the agent otherwise) answer comes \
back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \
no-longer-relevant question on expiry the answer is `[expired]` (with \
`answerer: \"ttl-watchdog\"`) and the same `question_answered` event fires."
)] )]
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String { async fn ask(&self, Parameters(args): Parameters<AskArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("ask_operator", log, async move { run_tool_envelope("ask", log, async move {
let (resp, retries) = self let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::AskOperator { .dispatch(hive_sh4re::AgentRequest::Ask {
question: args.question, question: args.question,
options: args.options, options: args.options,
multi: args.multi, multi: args.multi,
ttl_seconds: args.ttl_seconds, ttl_seconds: args.ttl_seconds,
to: args.to,
}) })
.await; .await;
let s = match resp { let s = match resp {
Ok(SocketReply::QuestionQueued(id)) => format!( Ok(SocketReply::QuestionQueued(id)) => format!(
"question queued (id={id}); operator's answer will arrive as a system \ "question queued (id={id}); answer will arrive as a system \
`operator_answered` event in your inbox" `question_answered` event in your inbox"
), ),
Ok(SocketReply::Err(m)) => format!("ask_operator failed: {m}"), Ok(SocketReply::Err(m)) => format!("ask failed: {m}"),
Ok(other) => format!("ask_operator unexpected response: {other:?}"), Ok(other) => format!("ask unexpected response: {other:?}"),
Err(e) => format!("ask_operator transport error: {e:#}"), Err(e) => format!("ask transport error: {e:#}"),
}; };
annotate_retries(s, retries) annotate_retries(s, retries)
}) })
.await .await
} }
#[tool(
description = "Answer a question that was routed to YOU via a `question_asked` system \
event in your inbox. Pass the `id` from that event and your `answer` string. The \
answer will surface in the asker's inbox as a `question_answered { id, question, \
answer, answerer: <your-name> }` event. \n\n\
Authorisation is strict you can only answer questions where you are the declared \
target (i.e. the asker did `ask(to: \"<your-name>\", ...)`). Trying to answer an \
operator-targeted question or a question addressed to a different agent will fail."
)]
async fn answer(&self, Parameters(args): Parameters<AnswerArgs>) -> String {
let log = format!("{args:?}");
let id = args.id;
run_tool_envelope("answer", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::Answer {
id,
answer: args.answer,
})
.await;
annotate_retries(
format_ack(resp, "answer", format!("answered question {id}")),
retries,
)
})
.await
}
#[tool( #[tool(
description = "Pop one message from this agent's inbox. Returns the sender and body, \ description = "Pop one message from this agent's inbox. Returns the sender and body, \
or an empty marker if nothing is waiting. Without `wait_seconds` (or with 0) the \ or an empty marker if nothing is waiting. Without `wait_seconds` (or with 0) the \
@ -389,25 +421,44 @@ pub struct UpdateArgs {
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AskOperatorArgs { pub struct AskArgs {
/// The question to surface on the dashboard. /// The question to surface.
pub question: String, pub question: String,
/// Optional fixed-choice answers. The dashboard always renders a /// Optional fixed-choice answers. The dashboard renders these as
/// free-text fallback ("Other…") so the operator is never trapped /// chips alongside a free-text fallback ("Other…") so the operator
/// by an incomplete list. /// is never trapped by an incomplete list; peer-agent recipients
/// see the list in their inbox event and can return any string.
#[serde(default)] #[serde(default)]
pub options: Vec<String>, pub options: Vec<String>,
/// When true, options are rendered as checkboxes — operator can pick /// When true, options are rendered as checkboxes — the answerer
/// any subset. The answer comes back as a single string with /// can pick any subset. The answer comes back as a single string
/// selections joined by ", ". Ignored when `options` is empty. /// with selections joined by ", ". Ignored when `options` is empty.
#[serde(default)] #[serde(default)]
pub multi: bool, pub multi: bool,
/// Optional auto-cancel after `ttl_seconds`. On expiry the question /// Optional auto-cancel after `ttl_seconds` (capped server-side at
/// resolves with answer `[expired]` and the manager receives the /// 6 hours). On expiry the question resolves with answer
/// usual `operator_answered` system event. `None` (default) = /// `[expired]` and the asker receives the usual
/// wait indefinitely. /// `question_answered` system event (with `answerer:
/// "ttl-watchdog"`). `None` (default) = wait indefinitely.
#[serde(default)] #[serde(default)]
pub ttl_seconds: Option<u64>, pub ttl_seconds: Option<u64>,
/// Recipient. Omit (or pass `"operator"`) to ask the human
/// operator via the dashboard. Pass another agent's logical name
/// to ask that peer — they receive a `question_asked` event in
/// their inbox and answer via `mcp__hyperhive__answer`.
#[serde(default)]
pub to: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AnswerArgs {
/// Id of the question being answered — comes from the
/// `question_asked` event in your inbox.
pub id: i64,
/// Free-text answer body. Soft-capped at 1 KiB by the same
/// `MESSAGE_MAX_BYTES` limit as `send`; keep it short or write the
/// detail to a file and pass a path.
pub answer: String,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] #[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
@ -597,42 +648,71 @@ impl ManagerServer {
} }
#[tool( #[tool(
description = "Surface a question to the operator on the dashboard. Returns immediately \ description = "Surface a structured question to either the operator OR a sub-agent. \
with a question id do NOT wait inline. When the operator answers, a system message \ Returns immediately with a question id do NOT wait inline. When the recipient \
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \ answers, a system message with event `question_answered { id, question, answer, \
on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \ answerer }` lands in your inbox; handle it on a future turn. \n\n\
request, policy call, scope clarification). `options` is advisory: pass a short \ Recipient: omit `to` (or set `to: \"operator\"`) for the human operator on the \
fixed-choice list when applicable, otherwise leave empty for free text. Set \ dashboard. Set `to: \"<agent-name>\"` to ask a sub-agent — they receive a \
`multi: true` to let the operator pick multiple options (checkboxes); the answer \ `question_asked` event in their inbox and answer via their `mcp__hyperhive__answer` \
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ tool. Useful for delegating decisions / clarifications without losing the \
no-longer-relevant question instead of blocking forever on expiry the answer \ question id correlation. \n\n\
is `[expired]` and the same `operator_answered` event fires." `options` is advisory: pass a short fixed-choice list when applicable, otherwise \
leave empty for free text. Set `multi: true` to render checkboxes; the answer \
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel on \
expiry the answer is `[expired]` (with `answerer: \"ttl-watchdog\"`) and the same \
`question_answered` event fires."
)] )]
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String { async fn ask(&self, Parameters(args): Parameters<AskArgs>) -> String {
let log = format!("{args:?}"); let log = format!("{args:?}");
run_tool_envelope("ask_operator", log, async move { run_tool_envelope("ask", log, async move {
let (resp, retries) = self let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::AskOperator { .dispatch(hive_sh4re::ManagerRequest::Ask {
question: args.question, question: args.question,
options: args.options, options: args.options,
multi: args.multi, multi: args.multi,
ttl_seconds: args.ttl_seconds, ttl_seconds: args.ttl_seconds,
to: args.to,
}) })
.await; .await;
let s = match resp { let s = match resp {
Ok(SocketReply::QuestionQueued(id)) => format!( Ok(SocketReply::QuestionQueued(id)) => format!(
"question queued (id={id}); operator's answer will arrive as a system \ "question queued (id={id}); answer will arrive as a system \
`operator_answered` event in your inbox" `question_answered` event in your inbox"
), ),
Ok(SocketReply::Err(m)) => format!("ask_operator failed: {m}"), Ok(SocketReply::Err(m)) => format!("ask failed: {m}"),
Ok(other) => format!("ask_operator unexpected response: {other:?}"), Ok(other) => format!("ask unexpected response: {other:?}"),
Err(e) => format!("ask_operator transport error: {e:#}"), Err(e) => format!("ask transport error: {e:#}"),
}; };
annotate_retries(s, retries) annotate_retries(s, retries)
}) })
.await .await
} }
#[tool(
description = "Answer a question that was routed to the manager via a `question_asked` \
system event in the manager's inbox (i.e. a sub-agent did `ask(to: \"manager\", \
...)`). Pass the `id` from the event and your `answer`. The answer surfaces in the \
asker's inbox as a `question_answered` event."
)]
async fn answer(&self, Parameters(args): Parameters<AnswerArgs>) -> String {
let log = format!("{args:?}");
let id = args.id;
run_tool_envelope("answer", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::Answer {
id,
answer: args.answer,
})
.await;
annotate_retries(
format_ack(resp, "answer", format!("answered question {id}")),
retries,
)
})
.await
}
#[tool( #[tool(
description = "Submit a config change for operator approval. Pass the agent name \ 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 \ (e.g. `alice` or `hm1nd` for the manager's own config) and a commit sha in that \
@ -744,9 +824,10 @@ impl ManagerServer {
relay between them and the operator. Use `send` to talk to agents/operator, `recv` \ 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 \ to drain your inbox. Privileged: `request_spawn` (new agent, gated on operator \
approval), `kill` (graceful stop), `request_apply_commit` (config change for \ approval), `kill` (graceful stop), `request_apply_commit` (config change for \
any agent including yourself), `ask_operator` (block on a human answer via the \ any agent including yourself), `ask` (structured question to the operator or a \
dashboard). The manager's own config lives at \ sub-agent non-blocking, answer arrives later as a `question_answered` event), \
`/agents/hm1nd/config/agent.nix`." `answer` (respond to a `question_asked` event directed at you). The manager's own \
config lives at `/agents/hm1nd/config/agent.nix`."
)] )]
impl ServerHandler for ManagerServer {} impl ServerHandler for ManagerServer {}
@ -780,7 +861,7 @@ pub enum Flavor {
#[must_use] #[must_use]
pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> { pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
let names: &[&str] = match flavor { let names: &[&str] = match flavor {
Flavor::Agent => &["send", "recv", "ask_operator", "remind"], Flavor::Agent => &["send", "recv", "ask", "answer", "remind"],
Flavor::Manager => &[ Flavor::Manager => &[
"send", "send",
"recv", "recv",
@ -790,7 +871,8 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"restart", "restart",
"update", "update",
"request_apply_commit", "request_apply_commit",
"ask_operator", "ask",
"answer",
"get_logs", "get_logs",
"remind", "remind",
], ],

View file

@ -37,7 +37,7 @@
// ─── browser notifications ────────────────────────────────────────────── // ─── browser notifications ──────────────────────────────────────────────
// Fires OS notifications on three operator-bound signals: // Fires OS notifications on three operator-bound signals:
// - new approval landed in the queue // - new approval landed in the queue
// - new operator question queued (ask_operator) // - new operator question queued (ask, target IS NULL)
// - broker message sent `to: "operator"` // - broker message sent `to: "operator"`
// permission grant is per-browser; a localStorage "muted" toggle lets // permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS / // the operator silence without revoking. Secure-context only (HTTPS /

View file

@ -97,34 +97,7 @@ fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse { async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse {
let broker = &coord.broker; let broker = &coord.broker;
match req { match req {
AgentRequest::Send { to, body } => { AgentRequest::Send { to, body } => handle_send(coord, agent, to, body),
if let Err(message) = crate::limits::check_size("send", body) {
return AgentResponse::Err { message };
}
// Handle broadcast sends (recipient = "*")
if to == "*" {
let errors = coord.broadcast_send(agent, body);
if errors.is_empty() {
AgentResponse::Ok
} else {
AgentResponse::Err {
message: format!("broadcast failed for agents: {}", errors.join(", ")),
}
}
} else {
// Normal unicast send
match broker.send(&Message {
from: agent.to_owned(),
to: to.clone(),
body: body.clone(),
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err {
message: format!("{e:#}"),
},
}
}
}
AgentRequest::Recv { wait_seconds } => match broker AgentRequest::Recv { wait_seconds } => match broker
.recv_blocking(agent, recv_timeout(*wait_seconds)) .recv_blocking(agent, recv_timeout(*wait_seconds))
.await .await
@ -170,12 +143,32 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
message: format!("{e:#}"), message: format!("{e:#}"),
}, },
}, },
AgentRequest::AskOperator { AgentRequest::Ask {
question, question,
options, options,
multi, multi,
ttl_seconds, ttl_seconds,
} => handle_ask_operator(coord, agent, question, options, *multi, *ttl_seconds), to,
} => crate::questions::handle_ask(
coord,
agent,
question,
options,
*multi,
*ttl_seconds,
to.as_deref(),
)
.map_or_else(
|message| AgentResponse::Err { message },
|id| AgentResponse::QuestionQueued { id },
),
AgentRequest::Answer { id, answer } => crate::questions::handle_answer(
coord, agent, *id, answer,
)
.map_or_else(
|message| AgentResponse::Err { message },
|()| AgentResponse::Ok,
),
AgentRequest::Remind { AgentRequest::Remind {
message, message,
timing, timing,
@ -184,36 +177,31 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
} }
} }
fn handle_ask_operator( /// Common Send handler shared between dispatch arms. Applies the
coord: &Arc<Coordinator>, /// 1 KiB body cap, then routes broadcast (`to == "*"`) vs unicast
agent: &str, /// through their respective broker calls. Pulled out of `dispatch`
question: &str, /// to keep that function under the clippy too-many-lines limit; the
options: &[String], /// behaviour is identical to inlining.
multi: bool, fn handle_send(coord: &Arc<Coordinator>, agent: &str, to: &str, body: &str) -> AgentResponse {
ttl_seconds: Option<u64>, if let Err(message) = crate::limits::check_size("send", body) {
) -> AgentResponse {
if let Err(message) = crate::limits::check_size("question", question) {
return AgentResponse::Err { message }; return AgentResponse::Err { message };
} }
let deadline_at = ttl_seconds.and_then(|s| { if to == "*" {
let now = std::time::SystemTime::now() let errors = coord.broadcast_send(agent, body);
.duration_since(std::time::UNIX_EPOCH) return if errors.is_empty() {
.ok() AgentResponse::Ok
.and_then(|d| i64::try_from(d.as_secs()).ok()) } else {
.unwrap_or(0); AgentResponse::Err {
i64::try_from(s).ok().map(|s| now + s) message: format!("broadcast failed for agents: {}", errors.join(", ")),
});
match coord
.questions
.submit(agent, question, options, multi, deadline_at)
{
Ok(id) => {
tracing::info!(%id, %agent, ?deadline_at, "agent question queued");
if let Some(ttl) = ttl_seconds {
crate::manager_server::spawn_question_watchdog(coord, id, ttl);
} }
AgentResponse::QuestionQueued { id } };
} }
match coord.broker.send(&Message {
from: agent.to_owned(),
to: to.to_owned(),
body: body.to_owned(),
}) {
Ok(()) => AgentResponse::Ok,
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
message: format!("{e:#}"), message: format!("{e:#}"),
}, },

View file

@ -171,7 +171,7 @@ impl Coordinator {
let socket_path = Self::socket_path(name); let socket_path = Self::socket_path(name);
// Hand the full Coordinator to the per-agent socket — it // Hand the full Coordinator to the per-agent socket — it
// needs broker + operator_questions to handle the agent-side // needs broker + operator_questions to handle the agent-side
// `ask_operator` tool, not just the broker. // `ask` / `answer` tools, not just the broker.
let socket = agent_server::start(name, &socket_path, self.clone())?; let socket = agent_server::start(name, &socket_path, self.clone())?;
self.agents.lock().unwrap().insert(name.to_owned(), socket); self.agents.lock().unwrap().insert(name.to_owned(), socket);
Ok(agent_dir) Ok(agent_dir)
@ -264,9 +264,9 @@ impl Coordinator {
/// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded /// Push a `HelperEvent` into an arbitrary agent's inbox. Encoded
/// the same way as `notify_manager` (sender = `SYSTEM_SENDER`, /// the same way as `notify_manager` (sender = `SYSTEM_SENDER`,
/// body = JSON-encoded event). Used to route `OperatorAnswered` /// body = JSON-encoded event). Used to route `QuestionAnswered`
/// events back to the agent that called `ask_operator`, not just /// events back to the agent that called `ask`, `QuestionAsked`
/// the manager. /// events to the target of a peer question, etc.
pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) { pub fn notify_agent(&self, agent: &str, event: &hive_sh4re::HelperEvent) {
let body = match serde_json::to_string(event) { let body = match serde_json::to_string(event) {
Ok(s) => s, Ok(s) => s,

View file

@ -160,10 +160,12 @@ struct StateSnapshot {
/// Last 30 resolved approvals (approved / denied / failed), newest- /// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section. /// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>, approval_history: Vec<ApprovalHistoryView>,
/// Pending operator questions (currently only from the manager). /// Pending operator-targeted questions (`target IS NULL`). Any
/// `ask_operator` returns immediately with the id; on `/answer-question` /// agent can `ask` the operator and `ask` returns immediately with
/// we mark the row answered and fire `HelperEvent::OperatorAnswered` /// the id; on `/answer-question` we mark the row answered and
/// into the manager's inbox. /// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`).
questions: Vec<crate::operator_questions::OpQuestion>, questions: Vec<crate::operator_questions::OpQuestion>,
/// Last 20 answered questions, newest-first. /// Last 20 answered questions, newest-first.
question_history: Vec<crate::operator_questions::OpQuestion>, question_history: Vec<crate::operator_questions::OpQuestion>,
@ -827,15 +829,20 @@ async fn post_answer_question(
if answer.is_empty() { if answer.is_empty() {
return error_response("answer: required"); return error_response("answer: required");
} }
match state.coord.questions.answer(id, answer) { match state
Ok((question, asker)) => { .coord
.questions
.answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
tracing::info!(%id, %asker, "operator answered question"); tracing::info!(%id, %asker, "operator answered question");
state.coord.notify_agent( state.coord.notify_agent(
&asker, &asker,
&hive_sh4re::HelperEvent::OperatorAnswered { &hive_sh4re::HelperEvent::QuestionAnswered {
id, id,
question, question,
answer: answer.to_owned(), answer: answer.to_owned(),
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
}, },
); );
Redirect::to("/").into_response() Redirect::to("/").into_response()
@ -845,8 +852,8 @@ async fn post_answer_question(
} }
/// Resolve a pending operator question with a sentinel answer when /// Resolve a pending operator question with a sentinel answer when
/// the operator decides not to / can't answer. The manager harness /// the operator decides not to / can't answer. The asker harness
/// receives an `OperatorAnswered` event with `answer = "[cancelled]"` /// receives a `QuestionAnswered` event with `answer = "[cancelled]"`
/// so it can fall back on whatever default it had. Same code path as /// so it can fall back on whatever default it had. Same code path as
/// a real answer — just lets the operator close the loop instead of /// a real answer — just lets the operator close the loop instead of
/// letting the question dangle forever. /// letting the question dangle forever.
@ -855,15 +862,20 @@ async fn post_cancel_question(
AxumPath(id): AxumPath<i64>, AxumPath(id): AxumPath<i64>,
) -> Response { ) -> Response {
const SENTINEL: &str = "[cancelled]"; const SENTINEL: &str = "[cancelled]";
match state.coord.questions.answer(id, SENTINEL) { match state
Ok((question, asker)) => { .coord
.questions
.answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
{
Ok((question, asker, _target)) => {
tracing::info!(%id, %asker, "operator cancelled question"); tracing::info!(%id, %asker, "operator cancelled question");
state.coord.notify_agent( state.coord.notify_agent(
&asker, &asker,
&hive_sh4re::HelperEvent::OperatorAnswered { &hive_sh4re::HelperEvent::QuestionAnswered {
id, id,
question, question,
answer: SENTINEL.to_owned(), answer: SENTINEL.to_owned(),
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
}, },
); );
Redirect::to("/").into_response() Redirect::to("/").into_response()

View file

@ -10,10 +10,10 @@
//! about it — oversized reminder bodies get persisted to disk //! about it — oversized reminder bodies get persisted to disk
//! transparently and the inbox sees a pointer. //! transparently and the inbox sees a pointer.
/// Per-message body cap. Applies to `send`, `ask_operator` question /// Per-message body cap. Applies to `send`, `ask` question text,
/// text, and the stored inline form of a reminder. 1 KiB is small /// `answer` body, and the stored inline form of a reminder. 1 KiB
/// enough that 100 unread messages don't dominate a wake prompt, /// is small enough that 100 unread messages don't dominate a wake
/// large enough for routine cross-agent chatter. /// prompt, large enough for routine cross-agent chatter.
pub const MESSAGE_MAX_BYTES: usize = 1024; pub const MESSAGE_MAX_BYTES: usize = 1024;
/// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a /// Validate that `body` fits under [`MESSAGE_MAX_BYTES`]. Returns a

View file

@ -23,6 +23,7 @@ mod manager_server;
mod meta; mod meta;
mod migrate; mod migrate;
mod operator_questions; mod operator_questions;
mod questions;
mod reminder_scheduler; mod reminder_scheduler;
mod server; mod server;

View file

@ -244,39 +244,30 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
}, },
} }
} }
ManagerRequest::AskOperator { ManagerRequest::Ask {
question, question,
options, options,
multi, multi,
ttl_seconds, ttl_seconds,
} => { to,
if let Err(message) = crate::limits::check_size("question", question) { } => crate::questions::handle_ask(
return ManagerResponse::Err { message }; coord,
} MANAGER_AGENT,
tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator"); question,
let deadline_at = ttl_seconds.and_then(|s| { options,
let now = std::time::SystemTime::now() *multi,
.duration_since(std::time::UNIX_EPOCH) *ttl_seconds,
.ok() to.as_deref(),
.and_then(|d| i64::try_from(d.as_secs()).ok()) )
.unwrap_or(0); .map_or_else(
i64::try_from(s).ok().map(|s| now + s) |message| ManagerResponse::Err { message },
}); |id| ManagerResponse::QuestionQueued { id },
match coord ),
.questions ManagerRequest::Answer { id, answer } => {
.submit(MANAGER_AGENT, question, options, *multi, deadline_at) crate::questions::handle_answer(coord, MANAGER_AGENT, *id, answer).map_or_else(
{ |message| ManagerResponse::Err { message },
Ok(id) => { |()| ManagerResponse::Ok,
tracing::info!(%id, ?deadline_at, "operator question queued"); )
if let Some(ttl) = *ttl_seconds {
spawn_question_watchdog(coord, id, ttl);
}
ManagerResponse::QuestionQueued { id }
}
Err(e) => ManagerResponse::Err {
message: format!("{e:#}"),
},
}
} }
ManagerRequest::GetLogs { agent, lines } => { ManagerRequest::GetLogs { agent, lines } => {
let n = lines.unwrap_or(50); let n = lines.unwrap_or(50);
@ -402,28 +393,41 @@ async fn submit_apply_commit(
Ok((id, sha)) Ok((id, sha))
} }
/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then /// On `Ask { ttl_seconds: Some(n) }`, sleep n seconds and then try to
/// try to resolve the question with `[expired]`. If the operator (or /// resolve the question with `[expired]`. If the operator (or any
/// any other path) already answered it, `answer()` returns Err and /// other path) already answered it, `answer()` returns Err and we
/// we no-op silently. Otherwise fire the usual `OperatorAnswered` /// no-op silently. Otherwise fire a `QuestionAnswered` helper event
/// helper event so the manager sees a terminal state. /// with `answerer = "ttl-watchdog"` so the asker can distinguish a
/// real answer from a deadline trip without parsing the answer text.
const TTL_SENTINEL: &str = "[expired]"; const TTL_SENTINEL: &str = "[expired]";
/// Synthetic `answerer` label used when the ttl watchdog resolves a
/// question instead of a real human / agent. Lives in a distinct
/// namespace from agent names + the operator so the asker can pattern
/// match `event.answerer == "ttl-watchdog"`.
const TTL_ANSWERER: &str = "ttl-watchdog";
pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) { pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64) {
let coord = coord.clone(); let coord = coord.clone();
tokio::spawn(async move { tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await; tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await;
// `answer` returns Err if already resolved — that's the // Watchdog has its own answerer label so the authorisation
// normal path when the operator responded before the ttl // check in `answer()` permits it for any target. We bypass
// fired, so no-op silently. // the public `answer()` path by calling it with the operator
if let Ok((question, asker)) = coord.questions.answer(id, TTL_SENTINEL) { // identity, since the operator is always permitted; the
tracing::info!(%id, %asker, "operator question expired (ttl)"); // event we fire carries the real watchdog label for observers.
if let Ok((question, asker, _target)) =
coord
.questions
.answer(id, TTL_SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
{
tracing::info!(%id, %asker, "question expired (ttl)");
coord.notify_agent( coord.notify_agent(
&asker, &asker,
&hive_sh4re::HelperEvent::OperatorAnswered { &hive_sh4re::HelperEvent::QuestionAnswered {
id, id,
question, question,
answer: TTL_SENTINEL.to_owned(), answer: TTL_SENTINEL.to_owned(),
answerer: TTL_ANSWERER.to_owned(),
}, },
); );
} }

View file

@ -1,7 +1,13 @@
//! Operator question queue. Manager submits via `AskOperator`; the //! Question queue. Agents submit via `Ask`; the answer comes from
//! operator answers via the dashboard. The manager-socket handler long-polls //! either the operator (via the dashboard, for `target IS NULL`) or
//! the store until the answer lands, so claude's `ask_operator` tool call //! a peer agent (via `Answer`, for agent-to-agent questions).
//! returns the answer directly as its result. //!
//! Despite the file name (kept for git history sanity), this table
//! now stores *all* asynchronous questions in the hive — both the
//! operator-targeted ones and the peer-to-peer ones. `target IS
//! NULL` is the operator path (back-compat with rows written before
//! the column existed); `target = '<agent-name>'` is the
//! agent-to-agent path.
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
@ -38,6 +44,15 @@ fn ensure_columns(conn: &Connection) -> Result<()> {
"deadline_at", "deadline_at",
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;", "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
), ),
// `target` = recipient of the question. NULL = operator
// (back-compat default for rows written before agent-to-agent
// questions existed); a non-null agent name = peer-to-peer
// question. Dashboard's `pending()` filters on `target IS NULL`
// so peer questions never leak into the operator's queue.
(
"target",
"ALTER TABLE operator_questions ADD COLUMN target TEXT;",
),
] { ] {
let has: bool = conn let has: bool = conn
.prepare(&format!( .prepare(&format!(
@ -67,6 +82,12 @@ pub struct OpQuestion {
pub deadline_at: Option<i64>, pub deadline_at: Option<i64>,
pub answered_at: Option<i64>, pub answered_at: Option<i64>,
pub answer: Option<String>, pub answer: Option<String>,
/// Recipient of the question. `None` = the operator (dashboard
/// path); `Some(<agent>)` = a peer agent asked via
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
/// never appear in `pending()` so the operator's queue stays clean.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
} }
pub struct OperatorQuestions { pub struct OperatorQuestions {
@ -97,57 +118,89 @@ impl OperatorQuestions {
options: &[String], options: &[String],
multi: bool, multi: bool,
deadline_at: Option<i64>, deadline_at: Option<i64>,
target: Option<&str>,
) -> Result<i64> { ) -> Result<i64> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into()); let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
conn.execute( conn.execute(
"INSERT INTO operator_questions "INSERT INTO operator_questions
(asker, question, options_json, multi, deadline_at, asked_at) (asker, question, options_json, multi, deadline_at, target, asked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![ params![
asker, asker,
question, question,
options_json, options_json,
i64::from(multi), i64::from(multi),
deadline_at, deadline_at,
target,
now_unix(), now_unix(),
], ],
)?; )?;
Ok(conn.last_insert_rowid()) Ok(conn.last_insert_rowid())
} }
/// Mark the question answered. Returns the original question text so the /// Mark a pending question answered. `answerer` is who's actually
/// Mark a pending question answered. Returns `(question, asker)` /// answering: `"operator"` for the dashboard path, or an agent's
/// so the caller can both echo the question back in a helper /// own name when responding via `Answer`. Authorisation:
/// event AND route that event to whichever agent originally ///
/// asked it. /// - Operator-targeted questions (`target IS NULL`) can only be
pub fn answer(&self, id: i64, answer: &str) -> Result<(String, String)> { /// answered by `"operator"`. (Agents must not be able to spoof
/// answers to operator questions — the dashboard is the
/// privileged path.)
/// - Agent-targeted questions can only be answered by the
/// declared target agent, OR by `"operator"` (operator override
/// for stuck threads — useful when an agent is offline/down
/// and someone has to close the loop).
///
/// Returns `(question, asker, target)` so the caller can fire the
/// `QuestionAnswered` event with the right answerer label and route
/// it back to the original asker.
pub fn answer(
&self,
id: i64,
answer: &str,
answerer: &str,
) -> Result<(String, String, Option<String>)> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let row: Option<(String, String, Option<i64>)> = conn let row: Option<(String, String, Option<String>, Option<i64>)> = conn
.query_row( .query_row(
"SELECT question, asker, answered_at FROM operator_questions WHERE id = ?1", "SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
params![id], params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
) )
.optional()?; .optional()?;
let Some((question, asker, answered_at)) = row else { let Some((question, asker, target, answered_at)) = row else {
bail!("question {id} not found"); bail!("question {id} not found");
}; };
if answered_at.is_some() { if answered_at.is_some() {
bail!("question {id} already answered"); bail!("question {id} already answered");
} }
// Authorisation check: must match the target, or be the operator
// (operator-targeted questions are operator-only; the operator
// can additionally override agent-to-agent questions to close
// stuck threads).
let authorised = match target.as_deref() {
None => answerer == hive_sh4re::OPERATOR_RECIPIENT,
Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT,
};
if !authorised {
bail!(
"question {id} not addressed to '{answerer}' (target = {:?})",
target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT)
);
}
conn.execute( conn.execute(
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3", "UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
params![answer, now_unix(), id], params![answer, now_unix(), id],
)?; )?;
Ok((question, asker)) Ok((question, asker, target))
} }
#[allow(dead_code)] #[allow(dead_code)]
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> { pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions WHERE id = ?1", FROM operator_questions WHERE id = ?1",
params![id], params![id],
row_to_question, row_to_question,
@ -156,12 +209,15 @@ impl OperatorQuestions {
.map_err(Into::into) .map_err(Into::into)
} }
/// Pending operator-targeted questions only (`target IS NULL`).
/// Drives the dashboard's pending-question pane — agent-to-agent
/// questions never appear here so the operator's queue stays clean.
pub fn pending(&self) -> Result<Vec<OpQuestion>> { pub fn pending(&self) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions FROM operator_questions
WHERE answered_at IS NULL WHERE answered_at IS NULL AND target IS NULL
ORDER BY id ASC", ORDER BY id ASC",
)?; )?;
let rows = stmt.query_map([], row_to_question)?; let rows = stmt.query_map([], row_to_question)?;
@ -169,13 +225,15 @@ impl OperatorQuestions {
.map_err(Into::into) .map_err(Into::into)
} }
/// Last `limit` answered questions, newest-first. /// Last `limit` answered operator-targeted questions, newest-first.
/// Same `target IS NULL` filter as `pending()` so the dashboard's
/// history view only shows operator-relevant rows.
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> { pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
let conn = self.conn.lock().unwrap(); let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
FROM operator_questions FROM operator_questions
WHERE answered_at IS NOT NULL WHERE answered_at IS NOT NULL AND target IS NULL
ORDER BY answered_at DESC ORDER BY answered_at DESC
LIMIT ?1", LIMIT ?1",
)?; )?;
@ -199,6 +257,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
answered_at: row.get(6)?, answered_at: row.get(6)?,
answer: row.get(7)?, answer: row.get(7)?,
deadline_at: row.get(8)?, deadline_at: row.get(8)?,
target: row.get(9)?,
}) })
} }

128
hive-c0re/src/questions.rs Normal file
View file

@ -0,0 +1,128 @@
//! Shared dispatch helpers for the `Ask` / `Answer` flow. Both the
//! agent socket and the manager socket call into here so the routing
//! semantics — recipient = operator vs. peer agent, answerer
//! authorisation, asker-notification — only live in one place.
//!
//! Routing rules at a glance:
//!
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
//! the dashboard's `pending()` query surfaces it; operator answers
//! via the dashboard.
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
//! inbox so they can `Answer { id, answer }` on their own socket.
//! - `Answer { id, answer }` → permission-checked in
//! `OperatorQuestions::answer` (only the target agent or the
//! operator can answer; both paths fire the same
//! `QuestionAnswered` event to the asker).
use std::sync::Arc;
use crate::coordinator::Coordinator;
use crate::limits;
use crate::manager_server::spawn_question_watchdog;
/// Cap on how long an asker can demand an answer before the watchdog
/// auto-resolves with `[expired]`. Six hours mirrors typical agent
/// session lifetimes — beyond that an unanswered question is
/// effectively a dead thread and should be re-asked, not blocked on.
const MAX_TTL_SECONDS: u64 = 6 * 60 * 60;
/// Handle either surface's `Ask` request. Returns the queued
/// question id on success or a caller-ready error string. Caller is
/// responsible for wrapping in the matching `*Response::Err` /
/// `QuestionQueued` variant.
pub fn handle_ask(
coord: &Arc<Coordinator>,
asker: &str,
question: &str,
options: &[String],
multi: bool,
ttl_seconds: Option<u64>,
to: Option<&str>,
) -> Result<i64, String> {
limits::check_size("question", question)?;
// Normalise `Some("operator")` → None so the storage layer
// only has to think about NULL vs. non-NULL targets, not
// "is this string the operator?".
let target = match to {
None => None,
Some(t) if t == hive_sh4re::OPERATOR_RECIPIENT => None,
Some("") => {
return Err("ask: `to` cannot be empty (omit it for the operator path)".to_owned());
}
Some(t) if t == asker => {
return Err("ask: cannot ask yourself a question (would loop forever)".to_owned());
}
Some(t) => Some(t),
};
let ttl = ttl_seconds.map(|s| s.min(MAX_TTL_SECONDS));
let deadline_at = ttl.and_then(|s| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
i64::try_from(s).ok().map(|s| now + s)
});
let id = coord
.questions
.submit(asker, question, options, multi, deadline_at, target)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %asker, ?target, ?deadline_at, "question queued");
// Agent-targeted questions need to wake the recipient — drop a
// QuestionAsked event into their inbox so the answerer doesn't
// have to poll. Operator-targeted questions show up on the
// dashboard's pending pane via `pending()` instead.
if let Some(target_agent) = target {
coord.notify_agent(
target_agent,
&hive_sh4re::HelperEvent::QuestionAsked {
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
},
);
}
if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t);
}
Ok(id)
}
/// Handle either surface's `Answer` request. Returns `Ok(())` on
/// success or a caller-ready error string. Authorisation lives in
/// `OperatorQuestions::answer` — we only have to wire the result
/// back to the asker as a `QuestionAnswered` event.
pub fn handle_answer(
coord: &Arc<Coordinator>,
answerer: &str,
id: i64,
answer: &str,
) -> Result<(), String> {
limits::check_size("answer", answer)?;
let (question, asker, _target) = coord
.questions
.answer(id, answer, answerer)
.map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %answerer, %asker, "question answered");
coord.notify_agent(
&asker,
&hive_sh4re::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
},
);
Ok(())
}
// Real coverage needs a `Coordinator` fixture (broker + sqlite +
// in-memory questions). Skipped for now — the normalisation branches
// in `handle_ask` are short enough to read line-by-line; once we add
// a coord test harness, drop integration tests here for: self-target
// rejection, operator-string passthrough, agent-to-agent QuestionAsked
// emission, and `Answer` authorisation.

View file

@ -221,11 +221,17 @@ pub enum AgentRequest {
/// Non-mutating — pulls from the broker without delivering. The /// Non-mutating — pulls from the broker without delivering. The
/// per-agent web UI uses this to render its own inbox section. /// per-agent web UI uses this to render its own inbox section.
Recent { limit: u64 }, Recent { limit: u64 },
/// Surface a question to the operator on the dashboard. Same /// Surface a question to either the operator or another agent.
/// shape as `ManagerRequest::AskOperator` — any agent can ask; /// `to = None` (or `Some("operator")`) routes the question to the
/// the answer routes back to the asker's inbox as a /// dashboard's operator-question queue (legacy `AskOperator`
/// `HelperEvent::OperatorAnswered`. /// behaviour). `to = Some(<agent>)` routes it to that agent's
AskOperator { /// inbox as a `HelperEvent::QuestionAsked` so the recipient can
/// answer back via `AgentRequest::Answer` (or
/// `ManagerRequest::Answer`); the answer threads back to the asker
/// as a `HelperEvent::QuestionAnswered` event. Either way the
/// response shape is `QuestionQueued { id }` — the asker uses the
/// id to correlate the asynchronous answer event.
Ask {
question: String, question: String,
#[serde(default)] #[serde(default)]
options: Vec<String>, options: Vec<String>,
@ -233,7 +239,18 @@ pub enum AgentRequest {
multi: bool, multi: bool,
#[serde(default)] #[serde(default)]
ttl_seconds: Option<u64>, ttl_seconds: Option<u64>,
/// Recipient of the question. `None` or `Some("operator")` =
/// the human operator (dashboard); `Some(<agent_name>)` = a
/// peer agent (their inbox).
#[serde(default)]
to: Option<String>,
}, },
/// Answer a question previously routed to this agent via
/// `HelperEvent::QuestionAsked`. The caller is implicitly the
/// answerer; only the question's `target` agent (or the operator,
/// via the dashboard) is authorised. Wires through to
/// `HelperEvent::QuestionAnswered` in the asker's inbox.
Answer { id: i64, answer: String },
/// Schedule a reminder message to be delivered to this agent at a /// Schedule a reminder message to be delivered to this agent at a
/// future time. The reminder lands in the agent's inbox as an auto-sent /// future time. The reminder lands in the agent's inbox as an auto-sent
/// message from `"reminder"`. Use for agent follow-ups (e.g. check task /// message from `"reminder"`. Use for agent follow-ups (e.g. check task
@ -264,8 +281,8 @@ pub enum AgentResponse {
Status { unread: u64 }, Status { unread: u64 },
/// `Recent` result: newest-first inbox rows. /// `Recent` result: newest-first inbox rows.
Recent { rows: Vec<InboxRow> }, Recent { rows: Vec<InboxRow> },
/// `AskOperator` result: the queued question id. The answer lands /// `Ask` result: the queued question id. The answer lands later
/// later as `HelperEvent::OperatorAnswered` in this agent's inbox. /// as `HelperEvent::QuestionAnswered` in this agent's inbox.
QuestionQueued { id: i64 }, QuestionQueued { id: i64 },
} }
@ -375,14 +392,32 @@ pub enum HelperEvent {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
note: Option<String>, note: Option<String>,
}, },
/// The operator answered a question that was queued via /// A question queued via `Ask` was answered (by the operator via
/// `AskOperator`. `id` matches the `QuestionQueued.id` returned to the /// the dashboard, or by another agent via `Answer`). `id` matches
/// asker; `question` echoes the original prompt so the manager can /// the `QuestionQueued.id` returned to the asker; `question`
/// stitch the answer back to context across compactions. /// echoes the original prompt so the asker can stitch the answer
OperatorAnswered { /// back to context across compactions; `answerer` is who answered
/// (`"operator"` or a peer agent name).
QuestionAnswered {
id: i64, id: i64,
question: String, question: String,
answer: String, answer: String,
answerer: String,
},
/// A peer (or the manager) asked this agent a question via
/// `Ask { to: Some(<this-agent>), ... }`. The recipient should
/// answer via `Answer { id, answer }` on their socket; the answer
/// will route back to the asker as a `QuestionAnswered` event.
/// `options` + `multi` mirror the original `Ask` args so the
/// answerer knows what shape of reply is expected.
QuestionAsked {
id: i64,
asker: String,
question: String,
#[serde(default)]
options: Vec<String>,
#[serde(default)]
multi: bool,
}, },
} }
@ -452,9 +487,10 @@ pub enum ManagerRequest {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>, description: Option<String>,
}, },
/// Ask the operator a question. Returns immediately with the queued /// Surface a question to either the operator or another agent.
/// question id; the operator's answer arrives later as a /// Mirrors `AgentRequest::Ask` exactly — see that doc for the
/// `HelperEvent::OperatorAnswered` in the manager inbox. /// routing semantics (operator = dashboard queue; agent = the
/// peer's inbox via `HelperEvent::QuestionAsked`).
/// ///
/// - `options` is advisory: empty = free-text only; non-empty = the /// - `options` is advisory: empty = free-text only; non-empty = the
/// dashboard renders the choices alongside a free-text fallback /// dashboard renders the choices alongside a free-text fallback
@ -464,9 +500,11 @@ pub enum ManagerRequest {
/// selections joined by ", ". /// selections joined by ", ".
/// - `ttl_seconds`: optional auto-cancel after that many seconds. On /// - `ttl_seconds`: optional auto-cancel after that many seconds. On
/// expiry the question is resolved with answer `[expired]` and the /// expiry the question is resolved with answer `[expired]` and the
/// manager gets the usual `OperatorAnswered` event. None = wait /// asker gets the usual `QuestionAnswered` event. None = wait
/// forever for an operator answer (or manual cancel). /// forever for an answer (or manual cancel).
AskOperator { /// - `to`: recipient (None / `Some("operator")` = operator;
/// `Some(<agent>)` = peer agent).
Ask {
question: String, question: String,
#[serde(default)] #[serde(default)]
options: Vec<String>, options: Vec<String>,
@ -474,7 +512,13 @@ pub enum ManagerRequest {
multi: bool, multi: bool,
#[serde(default)] #[serde(default)]
ttl_seconds: Option<u64>, ttl_seconds: Option<u64>,
#[serde(default)]
to: Option<String>,
}, },
/// Answer a question previously routed to the manager via
/// `HelperEvent::QuestionAsked` (i.e. an agent asked the manager
/// for input). Mirror of `AgentRequest::Answer`.
Answer { id: i64, answer: String },
/// Fetch recent journal lines for a sub-agent container. hive-c0re /// Fetch recent journal lines for a sub-agent container. hive-c0re
/// runs `journalctl -M <agent> -n <lines> --no-pager` and returns /// runs `journalctl -M <agent> -n <lines> --no-pager` and returns
/// the output as a string. Useful for diagnosing MCP registration /// the output as a string. Useful for diagnosing MCP registration
@ -514,9 +558,10 @@ pub enum ManagerResponse {
Status { Status {
unread: u64, unread: u64,
}, },
/// Result of `AskOperator`: the queued question id. The actual answer /// Result of `Ask`: the queued question id. The actual answer
/// arrives later as a `HelperEvent::OperatorAnswered` in the manager /// arrives later as a `HelperEvent::QuestionAnswered` in the
/// inbox, so this returns immediately rather than blocking the turn. /// asker's inbox, so this returns immediately rather than blocking
/// the turn.
QuestionQueued { QuestionQueued {
id: i64, id: i64,
}, },