Compare commits
17 changed files with 100 additions and 523 deletions
132
CLAUDE.md
132
CLAUDE.md
|
|
@ -35,11 +35,7 @@ hive-ag3nt/ in-container harness crate; produces TWO binaries
|
||||||
src/login_session.rs drives `claude auth login` over stdio pipes
|
src/login_session.rs drives `claude auth login` over stdio pipes
|
||||||
src/bin/hive-ag3nt.rs sub-agent main
|
src/bin/hive-ag3nt.rs sub-agent main
|
||||||
src/bin/hive-m1nd.rs manager main
|
src/bin/hive-m1nd.rs manager main
|
||||||
assets/ index.html, agent.css, app.js (include_str!)
|
assets/ CSS + JS for the per-agent UI
|
||||||
prompts/ static role/tools/settings for claude (include_str!):
|
|
||||||
agent.md — sub-agent system prompt
|
|
||||||
manager.md — manager system prompt
|
|
||||||
claude-settings.json — --settings JSON
|
|
||||||
|
|
||||||
hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response,
|
hive-sh4re/ wire types (HostRequest/Response, AgentRequest/Response,
|
||||||
ManagerRequest/Response, Message, Approval, HelperEvent)
|
ManagerRequest/Response, Message, Approval, HelperEvent)
|
||||||
|
|
@ -75,11 +71,11 @@ nix/
|
||||||
- **Actions are factored.** `approve` / `deny` / `destroy` live in
|
- **Actions are factored.** `approve` / `deny` / `destroy` live in
|
||||||
`actions.rs`; the admin socket and the dashboard POST handlers both call
|
`actions.rs`; the admin socket and the dashboard POST handlers both call
|
||||||
into them so the two surfaces never drift.
|
into them so the two surfaces never drift.
|
||||||
- **Async forms.** Dashboard + per-agent mutating forms carry `data-async`;
|
- **Async forms.** Dashboard mutating forms carry `data-async`; the
|
||||||
a delegated `submit` listener in `assets/app.js` intercepts, shows a
|
`assets/async_forms.js` helper intercepts submit, shows a spinner, and
|
||||||
spinner, POSTs with `application/x-www-form-urlencoded` (axum `Form`
|
fetches with `application/x-www-form-urlencoded` (axum `Form` extractor
|
||||||
extractor rejects multipart), calls `refreshState()` on success. New
|
rejects multipart). New mutating forms should add `data-async` and
|
||||||
mutating forms should add `data-async` and optionally `data-confirm`.
|
optionally `data-confirm`.
|
||||||
|
|
||||||
## Gotchas / lessons learned
|
## Gotchas / lessons learned
|
||||||
|
|
||||||
|
|
@ -169,33 +165,19 @@ Manager additionally:
|
||||||
- `mcp__hyperhive__kill(name)` — graceful stop.
|
- `mcp__hyperhive__kill(name)` — graceful stop.
|
||||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a
|
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a
|
||||||
config change for any agent (including `hm1nd` for self-mods).
|
config change for any agent (including `hm1nd` for self-mods).
|
||||||
- `mcp__hyperhive__ask_operator(question, options?)` — non-blocking;
|
|
||||||
queues a question on the dashboard, returns the question id. Operator's
|
|
||||||
answer arrives later as a `HelperEvent::OperatorAnswered` in the
|
|
||||||
manager inbox.
|
|
||||||
|
|
||||||
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
|
The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config,
|
||||||
write_settings, write_system_prompt, run_turn, drive_turn, emit_turn_end,
|
run_turn, drive_turn, emit_turn_end, wait_for_login}` so the two binaries
|
||||||
wait_for_login}` so the two binaries can't drift.
|
can't drift.
|
||||||
|
|
||||||
On harness boot, three files get dropped next to the mcp socket at
|
|
||||||
`/run/hive/`:
|
|
||||||
|
|
||||||
- `claude-mcp-config.json` — re-invokes the running binary as `mcp` child.
|
|
||||||
- `claude-settings.json` — `--settings` blob (auto-compact/auto-memory
|
|
||||||
off, effortLevel medium).
|
|
||||||
- `claude-system-prompt.md` — rendered from `prompts/{agent,manager}.md`
|
|
||||||
with `{label}` substituted. Passed via `--system-prompt-file`.
|
|
||||||
|
|
||||||
Each turn:
|
Each turn:
|
||||||
|
|
||||||
```
|
```
|
||||||
claude --print --verbose --output-format stream-json --model haiku \
|
claude --print --verbose --output-format stream-json --model haiku \
|
||||||
--continue --settings /run/hive/claude-settings.json \
|
--continue --settings '{"autoCompactEnabled":false,"autoMemoryEnabled":false}' \
|
||||||
--system-prompt-file /run/hive/claude-system-prompt.md \
|
--mcp-config <path> --strict-mcp-config \
|
||||||
--mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
|
|
||||||
--tools <builtins> --allowedTools <builtins+mcp>
|
--tools <builtins> --allowedTools <builtins+mcp>
|
||||||
# wake prompt piped over stdin — minimal, just from/body + optional unread hint
|
# prompt piped over stdin
|
||||||
```
|
```
|
||||||
|
|
||||||
`--continue` keeps a persistent session per agent (claude stores sessions in
|
`--continue` keeps a persistent session per agent (claude stores sessions in
|
||||||
|
|
@ -205,15 +187,14 @@ overflow, retry once).
|
||||||
|
|
||||||
**Loop control.** The harness pops one inbox message per cycle (the wake
|
**Loop control.** The harness pops one inbox message per cycle (the wake
|
||||||
signal — Recv long-polls server-side for up to 30s waking instantly on a new
|
signal — Recv long-polls server-side for up to 30s waking instantly on a new
|
||||||
broker `Sent` event for this agent), peeks the remaining inbox depth with
|
broker `Sent` event for this agent) and hands claude a prompt naming the
|
||||||
`Status`, and emits `TurnStart { from, body, unread }`. The wake prompt
|
agent, the sender, the body, and the MCP tools. Claude drives any further
|
||||||
piped to claude includes a one-line `({unread} more pending — drain via …)`
|
`recv`/`send` itself.
|
||||||
hint when `unread > 0`. Claude drives any further `recv`/`send` itself.
|
|
||||||
|
|
||||||
**Tool envelope** (`mcp::run_tool_envelope`): every MCP tool handler logs
|
**Tool envelope** (`mcp::run_tool_envelope`): every MCP tool handler logs
|
||||||
the request, runs the body, logs the result. Pre-/post-log only; the old
|
the request, runs the body, appends a status line (e.g.
|
||||||
`[status] N unread message(s)` appendage was removed once unread moved
|
`[status] 3 unread message(s) in inbox` from a non-mutating `Status` peek),
|
||||||
into the wake prompt + UI header. New tools call this helper.
|
logs the result. New tools call this helper.
|
||||||
|
|
||||||
**Tool whitelist** (`mcp::ALLOWED_BUILTIN_TOOLS`):
|
**Tool whitelist** (`mcp::ALLOWED_BUILTIN_TOOLS`):
|
||||||
- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `TodoWrite`,
|
- Allowed built-ins: `Bash`, `Edit`, `Glob`, `Grep`, `Read`, `TodoWrite`,
|
||||||
|
|
@ -226,27 +207,9 @@ into the wake prompt + UI header. New tools call this helper.
|
||||||
|
|
||||||
**Live view.** Each agent runs an `events::Bus` (a
|
**Live view.** Each agent runs an `events::Bus` (a
|
||||||
`tokio::sync::broadcast<LiveEvent>` wrapper). The harness emits
|
`tokio::sync::broadcast<LiveEvent>` wrapper). The harness emits
|
||||||
`TurnStart { from, body, unread }`, `Stream(value)` (one per parsed
|
`TurnStart`, `Stream(value)` (one per parsed stream-json line), `Note`,
|
||||||
stream-json line), `Note`, `TurnEnd { ok, note }`. The web UI subscribes
|
`TurnEnd`. The web UI subscribes via `/events/stream` (SSE) and a JS panel
|
||||||
via `/events/stream` (SSE) and a JS panel (terminal-themed: Crust bg, inset
|
appends rows. No full-page reload — operator input stays put.
|
||||||
shadow, monospace) renders rows:
|
|
||||||
|
|
||||||
- `TurnStart` → `◆ TURN ← <from> · N unread` header + indented body.
|
|
||||||
- `Stream` `tool_use` → `→ Read /path` / `→ Bash $ cmd` /
|
|
||||||
`→ send → operator: "..."` etc., per-tool pretty rather than raw JSON.
|
|
||||||
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
|
||||||
`<details>` `▸ ← Nl · headline` (click to expand full body).
|
|
||||||
- `Stream` `thinking` → shows the thinking text if claude provided one,
|
|
||||||
otherwise the bare `· thinking …` indicator.
|
|
||||||
- `Stream` `system init`, `result`, `rate_limit_event` are dropped — too
|
|
||||||
noisy and `TurnEnd` already says the turn finished.
|
|
||||||
- `Note` → `· text`.
|
|
||||||
- `TurnEnd` → `✓ turn ok` / `✗ turn fail — note` and triggers a
|
|
||||||
`refreshState()` so the page form view reflects state transitions
|
|
||||||
(e.g. login just landed).
|
|
||||||
|
|
||||||
The operator send form sits below the live panel, so the tail is what
|
|
||||||
you read first.
|
|
||||||
|
|
||||||
## Manager (hm1nd) is hive-c0re-managed
|
## Manager (hm1nd) is hive-c0re-managed
|
||||||
|
|
||||||
|
|
@ -274,43 +237,6 @@ Differences from sub-agents:
|
||||||
block from your host NixOS config. hyperhive creates and updates the
|
block from your host NixOS config. hyperhive creates and updates the
|
||||||
manager itself now.
|
manager itself now.
|
||||||
|
|
||||||
**Manager policy** (from `prompts/manager.md`): the manager does NOT
|
|
||||||
rubber-stamp sub-agent config requests. It verifies (role match, package
|
|
||||||
legitimacy, cheaper alternative, blast radius) before committing +
|
|
||||||
calling `request_apply_commit`. For ambiguous cases or anything that
|
|
||||||
needs human signal, the manager calls `ask_operator(question, options?)`
|
|
||||||
which queues the question on the dashboard and returns the id
|
|
||||||
immediately; the operator's answer arrives later as
|
|
||||||
`HelperEvent::OperatorAnswered` in the manager inbox. Store at
|
|
||||||
`hive-c0re::operator_questions` (sqlite); answer flow:
|
|
||||||
`POST /answer-question/{id}` → `OperatorQuestions::answer` →
|
|
||||||
`notify_manager(OperatorAnswered { ... })`.
|
|
||||||
|
|
||||||
## Helper events to the manager
|
|
||||||
|
|
||||||
`Coordinator::notify_manager(&HelperEvent)` enqueues an inbox message
|
|
||||||
from sender `system` with the event JSON in the body. The manager
|
|
||||||
harness no longer short-circuits these — they drive a regular claude
|
|
||||||
turn so the manager can react. Variants
|
|
||||||
(`hive_sh4re::HelperEvent`):
|
|
||||||
|
|
||||||
- `ApprovalResolved { id, agent, commit_ref, status, note }` — fired by
|
|
||||||
`actions::approve` + `actions::deny` whenever an approval transitions
|
|
||||||
to its terminal state.
|
|
||||||
- `Spawned { agent, ok, note }` — `actions::approve` (Spawn-kind) +
|
|
||||||
admin `HostRequest::Spawn`.
|
|
||||||
- `Rebuilt { agent, ok, note }` — `auto_update::rebuild_agent` (covers
|
|
||||||
startup scan + manual `/rebuild` from dashboard) + `actions::approve`
|
|
||||||
(ApplyCommit).
|
|
||||||
- `Killed { agent }` — admin `HostRequest::Kill` + dashboard `/kill`.
|
|
||||||
- `Destroyed { agent }` — `actions::destroy`.
|
|
||||||
- `OperatorAnswered { id, question, answer }` — `dashboard::post_answer_question`
|
|
||||||
fires this after the operator submits the answer form for a question
|
|
||||||
the manager queued via `ask_operator`.
|
|
||||||
|
|
||||||
To add a new event: new `HelperEvent` variant + call sites + update
|
|
||||||
`prompts/manager.md` so the manager knows the new shape.
|
|
||||||
|
|
||||||
## Auto-update on startup
|
## Auto-update on startup
|
||||||
|
|
||||||
`hive-c0re serve` runs `auto_update::run` in a background task right after
|
`hive-c0re serve` runs `auto_update::run` in a background task right after
|
||||||
|
|
@ -326,23 +252,7 @@ auto-update is a no-op — rebuild manually.
|
||||||
The dashboard surfaces pending updates per agent: a clickable "needs update
|
The dashboard surfaces pending updates per agent: a clickable "needs update
|
||||||
↻" badge appears whenever the marker differs from current rev. The badge
|
↻" badge appears whenever the marker differs from current rev. The badge
|
||||||
POSTs `/rebuild/<name>`, calling the same `auto_update::rebuild_agent`
|
POSTs `/rebuild/<name>`, calling the same `auto_update::rebuild_agent`
|
||||||
path so manual triggers and the startup scan can't drift. When at least
|
path so manual triggers and the startup scan can't drift.
|
||||||
one container is stale, a top-level `↻ UPD4TE 4LL` button appears that
|
|
||||||
loops over every stale container.
|
|
||||||
|
|
||||||
## Dashboard action surface
|
|
||||||
|
|
||||||
Container row buttons (rendered per-state by `assets/app.js`):
|
|
||||||
|
|
||||||
- Always: `↻ R3BU1LD` (calls `lifecycle::rebuild`), and for sub-agents
|
|
||||||
`DESTR0Y` (container removed, state + creds kept).
|
|
||||||
- Running: `↺ R3ST4RT` + (sub-agents only) `■ ST0P`.
|
|
||||||
- Stopped: `▶ ST4RT`.
|
|
||||||
- Stale marker: clickable `needs update ↻` badge (same target as rebuild
|
|
||||||
but only shown when out of date).
|
|
||||||
|
|
||||||
Top of the containers list: `↻ UPD4TE 4LL` (when any stale) + the
|
|
||||||
"R3QU3ST SP4WN" form for queuing a new agent through the approval flow.
|
|
||||||
|
|
||||||
## Approval flow
|
## Approval flow
|
||||||
|
|
||||||
|
|
|
||||||
46
TODO.md
46
TODO.md
|
|
@ -25,6 +25,9 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## UI / UX
|
## UI / UX
|
||||||
|
|
||||||
|
- **Operator inbox view.** Drain replies addressed to `operator` and show
|
||||||
|
them on the dashboard. Today they accumulate in sqlite unread; you can
|
||||||
|
only see them by watching the live panel of the agent that sent them.
|
||||||
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
|
- **Per-agent UI substance.** Show last N inbox messages, last turn timing,
|
||||||
link back to dashboard.
|
link back to dashboard.
|
||||||
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
- **xterm.js terminal** embedded per-agent, attached to a PTY exposed by
|
||||||
|
|
@ -33,12 +36,43 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## Manager → operator question channel
|
## Manager → operator question channel
|
||||||
|
|
||||||
- **TTL / cancel on `ask_operator`.** Questions today block forever; the
|
- **`mcp__hyperhive__ask_operator(question, options?)` tool** on the manager
|
||||||
manager turn stays alive until the operator answers. Add a per-question
|
MCP surface. The manager turn pauses; the question gets surfaced as a
|
||||||
`ttl_seconds` (or a dashboard "cancel" button that resolves the question
|
prominent prompt on the dashboard (its own section, or interleaved with
|
||||||
with a sentinel answer) so a long-idle question can time out and let the
|
the operator inbox); the operator's typed answer comes back as the tool
|
||||||
manager fall back. Wire the timeout into `OperatorQuestions::wait_answered`
|
result. Modelled after Claude Code's `AskUserQuestion` tool.
|
||||||
and surface remaining-time on the dashboard.
|
|
||||||
|
Design open questions:
|
||||||
|
|
||||||
|
- **Storage.** New sqlite table `operator_questions(id, asker, question,
|
||||||
|
options_json, asked_at, answered_at, answer)` — or piggyback on the
|
||||||
|
existing message broker with a new envelope kind. Probably a new
|
||||||
|
table because the lifecycle (pending → answered) is different from
|
||||||
|
fire-and-forget messages.
|
||||||
|
|
||||||
|
- **Waiting semantics.** The MCP tool call needs to block until
|
||||||
|
answered. Two options:
|
||||||
|
1. Long-poll from inside the tool handler (broker-style — broadcast
|
||||||
|
on insert, await via `tokio::sync::broadcast`). Simple but the
|
||||||
|
claude turn stays alive for the whole wait, eating context-window
|
||||||
|
budget.
|
||||||
|
2. Tool returns a `question_id` immediately; manager re-enters its
|
||||||
|
inbox loop and a `HelperEvent::OperatorAnswered { id, answer }`
|
||||||
|
wakes it. Cheaper context-wise but two-step.
|
||||||
|
|
||||||
|
- **Dashboard UX.** New "◆ M1ND H4S QU3STI0NS ◆" section at the top
|
||||||
|
when any question is pending. Inline `<form>` with a textarea (or
|
||||||
|
select if `options` were provided), POST `/api/answer-question`.
|
||||||
|
State refresh + the live SSE stream notify the manager harness.
|
||||||
|
|
||||||
|
- **Sub-agent path.** Sub-agents don't get the tool — they message the
|
||||||
|
manager and the manager decides whether to relay the question to the
|
||||||
|
operator. The manager's system prompt already covers this.
|
||||||
|
|
||||||
|
- **Timeout / cancel.** Questions that sit pending too long: do they
|
||||||
|
expire? Manager probably wants to know if the operator hasn't
|
||||||
|
answered after some interval so it can fall back. Maybe a per-
|
||||||
|
question `ttl_seconds`.
|
||||||
|
|
||||||
## Loop substance
|
## Loop substance
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,6 @@
|
||||||
}
|
}
|
||||||
// Clear text inputs the operator typed into (the form value was sent).
|
// Clear text inputs the operator typed into (the form value was sent).
|
||||||
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
|
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();
|
refreshState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('action failed: ' + err);
|
alert('action failed: ' + err);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ Tools (hyperhive surface):
|
||||||
- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name).
|
- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name).
|
||||||
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent.
|
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent.
|
||||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
|
- `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`.
|
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`.
|
||||||
|
|
||||||
|
|
@ -20,9 +19,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.
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
Durable knowledge:
|
Durable knowledge:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,9 +176,7 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
||||||
let pending = if unread == 0 {
|
let pending = if unread == 0 {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||||
"\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}")
|
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,11 +170,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
turn::emit_turn_end(&bus, &outcome);
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Empty) => {}
|
Ok(ManagerResponse::Empty) => {}
|
||||||
Ok(
|
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
||||||
ManagerResponse::Ok
|
|
||||||
| ManagerResponse::Status { .. }
|
|
||||||
| ManagerResponse::QuestionQueued { .. },
|
|
||||||
) => {
|
|
||||||
tracing::warn!("recv produced unexpected response kind");
|
tracing::warn!("recv produced unexpected response kind");
|
||||||
}
|
}
|
||||||
Ok(ManagerResponse::Err { message }) => {
|
Ok(ManagerResponse::Err { message }) => {
|
||||||
|
|
@ -188,6 +184,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
|
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
|
||||||
/// (`prompts/manager.md` → `claude --system-prompt-file`); this is just
|
/// (`prompts/manager.md` → `claude --system-prompt-file`); this is just
|
||||||
/// the wake signal. `unread` is the inbox depth after this message was
|
/// the wake signal. `unread` is the inbox depth after this message was
|
||||||
|
|
@ -196,9 +193,7 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
||||||
let pending = if unread == 0 {
|
let pending = if unread == 0 {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||||
"\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}")
|
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ pub enum SocketReply {
|
||||||
Message { from: String, body: String },
|
Message { from: String, body: String },
|
||||||
Empty,
|
Empty,
|
||||||
Status(u64),
|
Status(u64),
|
||||||
QuestionQueued(i64),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<hive_sh4re::AgentResponse> for SocketReply {
|
impl From<hive_sh4re::AgentResponse> for SocketReply {
|
||||||
|
|
@ -60,7 +59,6 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
|
||||||
hive_sh4re::ManagerResponse::Message { from, body } => Self::Message { from, body },
|
hive_sh4re::ManagerResponse::Message { from, body } => Self::Message { from, body },
|
||||||
hive_sh4re::ManagerResponse::Empty => Self::Empty,
|
hive_sh4re::ManagerResponse::Empty => Self::Empty,
|
||||||
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
|
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
|
||||||
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,16 +209,6 @@ pub struct KillArgs {
|
||||||
pub name: String,
|
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)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct RequestApplyCommitArgs {
|
pub struct RequestApplyCommitArgs {
|
||||||
/// Agent whose config repo the commit lives in (use `"hm1nd"` for the
|
/// Agent whose config repo the commit lives in (use `"hm1nd"` for the
|
||||||
|
|
@ -322,36 +310,6 @@ impl ManagerServer {
|
||||||
.await
|
.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(
|
#[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 \
|
||||||
|
|
@ -364,19 +322,23 @@ impl ManagerServer {
|
||||||
let log = format!("{args:?}");
|
let log = format!("{args:?}");
|
||||||
let agent = args.agent.clone();
|
let agent = args.agent.clone();
|
||||||
let commit_ref = args.commit_ref.clone();
|
let commit_ref = args.commit_ref.clone();
|
||||||
run_tool_envelope("request_apply_commit", log, async move {
|
run_tool_envelope(
|
||||||
let resp = self
|
"request_apply_commit",
|
||||||
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
|
log,
|
||||||
agent: args.agent,
|
async move {
|
||||||
commit_ref: args.commit_ref,
|
let resp = self
|
||||||
})
|
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
|
||||||
.await;
|
agent: args.agent,
|
||||||
format_ack(
|
commit_ref: args.commit_ref,
|
||||||
resp,
|
})
|
||||||
"request_apply_commit",
|
.await;
|
||||||
format!("apply approval queued for {agent} @ {commit_ref}"),
|
format_ack(
|
||||||
)
|
resp,
|
||||||
})
|
"request_apply_commit",
|
||||||
|
format!("apply approval queued for {agent} @ {commit_ref}"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -386,8 +348,7 @@ 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). The manager's own config lives at \
|
||||||
dashboard). The manager's own config lives at \
|
|
||||||
`/agents/hm1nd/config/agent.nix`."
|
`/agents/hm1nd/config/agent.nix`."
|
||||||
)]
|
)]
|
||||||
impl ServerHandler for ManagerServer {}
|
impl ServerHandler for ManagerServer {}
|
||||||
|
|
@ -427,7 +388,6 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
||||||
"request_spawn",
|
"request_spawn",
|
||||||
"kill",
|
"kill",
|
||||||
"request_apply_commit",
|
"request_apply_commit",
|
||||||
"ask_operator",
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
names
|
names
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,6 @@
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Re-enable the button — refreshState() rebuilds most lists but
|
|
||||||
// skips forms that didn't change (e.g. the spawn form), so without
|
|
||||||
// this the spinner sticks and the button can't be clicked again.
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
|
||||||
// Clear text inputs whose value was just submitted.
|
|
||||||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
|
||||||
refreshState();
|
refreshState();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('action failed: ' + err);
|
alert('action failed: ' + err);
|
||||||
|
|
@ -176,49 +170,6 @@
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQuestions(s) {
|
|
||||||
const root = $('questions-section');
|
|
||||||
root.innerHTML = '';
|
|
||||||
if (!s.questions || !s.questions.length) {
|
|
||||||
root.append(el('p', { class: 'empty' }, '▓ no pending questions ▓'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
||||||
const ul = el('ul', { class: 'questions' });
|
|
||||||
for (const q of s.questions) {
|
|
||||||
const li = el('li', { class: 'question' });
|
|
||||||
li.append(
|
|
||||||
el('div', { class: 'q-head' },
|
|
||||||
el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ',
|
|
||||||
el('span', { class: 'msg-from' }, q.asker), ' ',
|
|
||||||
el('span', { class: 'msg-sep' }, 'asks:'),
|
|
||||||
),
|
|
||||||
el('div', { class: 'q-body' }, q.question),
|
|
||||||
);
|
|
||||||
const f = el('form', {
|
|
||||||
method: 'POST', action: '/answer-question/' + q.id,
|
|
||||||
class: 'qform', 'data-async': '',
|
|
||||||
});
|
|
||||||
let input;
|
|
||||||
if (q.options && q.options.length) {
|
|
||||||
input = el('select', { name: 'answer', required: '' });
|
|
||||||
input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…'));
|
|
||||||
for (const opt of q.options) {
|
|
||||||
input.append(el('option', { value: opt }, opt));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input = el('input', {
|
|
||||||
name: 'answer', type: 'text', required: '',
|
|
||||||
placeholder: 'your answer', autocomplete: 'off',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R'));
|
|
||||||
li.append(f);
|
|
||||||
ul.append(li);
|
|
||||||
}
|
|
||||||
root.append(ul);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderInbox(s) {
|
function renderInbox(s) {
|
||||||
const root = $('inbox-section');
|
const root = $('inbox-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
@ -299,7 +250,6 @@
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderQuestions(s);
|
|
||||||
renderInbox(s);
|
renderInbox(s);
|
||||||
renderApprovals(s);
|
renderApprovals(s);
|
||||||
// Auto-refresh while a spawn is in flight; otherwise back off.
|
// Auto-refresh while a spawn is in flight; otherwise back off.
|
||||||
|
|
|
||||||
|
|
@ -193,36 +193,6 @@ summary:hover { color: var(--purple); }
|
||||||
.diff .diff-hunk { color: var(--cyan); }
|
.diff .diff-hunk { color: var(--cyan); }
|
||||||
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
||||||
.diff .diff-ctx { color: var(--fg); }
|
.diff .diff-ctx { color: var(--fg); }
|
||||||
.questions {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--amber);
|
|
||||||
box-shadow: 0 0 12px -4px var(--amber);
|
|
||||||
padding: 0.6em 0.9em;
|
|
||||||
}
|
|
||||||
.questions li.question {
|
|
||||||
padding: 0.4em 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.questions li.question:last-child { border-bottom: 0; }
|
|
||||||
.questions .q-head { font-size: 0.9em; }
|
|
||||||
.questions .q-body {
|
|
||||||
color: var(--fg);
|
|
||||||
margin: 0.3em 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; }
|
|
||||||
.qform input, .qform select {
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1em;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--fg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 0.4em 0.6em;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.qform input::placeholder { color: var(--muted); }
|
|
||||||
.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); }
|
|
||||||
.inbox {
|
.inbox {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,6 @@
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
||||||
<div id="questions-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
|
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="inbox-section">
|
<div id="inbox-section">
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,8 @@ impl Broker {
|
||||||
/// inbox view on the dashboard. Caller decides what to show.
|
/// inbox view on the dashboard. Caller decides what to show.
|
||||||
pub fn recent_for(&self, recipient: &str, limit: u64) -> Result<Vec<InboxRow>> {
|
pub fn recent_for(&self, recipient: &str, limit: u64) -> Result<Vec<InboxRow>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
let limit_i =
|
||||||
|
i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, sender, body, sent_at
|
"SELECT id, sender, body, sent_at
|
||||||
FROM messages
|
FROM messages
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ use anyhow::{Context, Result};
|
||||||
use crate::agent_server::{self, AgentSocket};
|
use crate::agent_server::{self, AgentSocket};
|
||||||
use crate::approvals::Approvals;
|
use crate::approvals::Approvals;
|
||||||
use crate::broker::Broker;
|
use crate::broker::Broker;
|
||||||
use crate::operator_questions::OperatorQuestions;
|
|
||||||
|
|
||||||
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
||||||
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
const MANAGER_RUNTIME_ROOT: &str = "/run/hyperhive/manager";
|
||||||
|
|
@ -27,7 +26,6 @@ const APPLIED_STATE_ROOT: &str = "/var/lib/hyperhive/applied";
|
||||||
pub struct Coordinator {
|
pub struct Coordinator {
|
||||||
pub broker: Arc<Broker>,
|
pub broker: Arc<Broker>,
|
||||||
pub approvals: Arc<Approvals>,
|
pub approvals: Arc<Approvals>,
|
||||||
pub questions: Arc<OperatorQuestions>,
|
|
||||||
/// URL of the hyperhive flake (no fragment). Inlined into per-agent
|
/// URL of the hyperhive flake (no fragment). Inlined into per-agent
|
||||||
/// `flake.nix` files as `inputs.hyperhive.url`.
|
/// `flake.nix` files as `inputs.hyperhive.url`.
|
||||||
pub hyperhive_flake: String,
|
pub hyperhive_flake: String,
|
||||||
|
|
@ -60,11 +58,9 @@ impl Coordinator {
|
||||||
pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result<Self> {
|
pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result<Self> {
|
||||||
let broker = Broker::open(db_path).context("open broker")?;
|
let broker = Broker::open(db_path).context("open broker")?;
|
||||||
let approvals = Approvals::open(db_path).context("open approvals")?;
|
let approvals = Approvals::open(db_path).context("open approvals")?;
|
||||||
let questions = OperatorQuestions::open(db_path).context("open operator_questions")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
broker: Arc::new(broker),
|
broker: Arc::new(broker),
|
||||||
approvals: Arc::new(approvals),
|
approvals: Arc::new(approvals),
|
||||||
questions: Arc::new(questions),
|
|
||||||
hyperhive_flake,
|
hyperhive_flake,
|
||||||
dashboard_port,
|
dashboard_port,
|
||||||
agents: Mutex::new(HashMap::new()),
|
agents: Mutex::new(HashMap::new()),
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/start/{name}", post(post_start))
|
.route("/start/{name}", post(post_start))
|
||||||
.route("/rebuild/{name}", post(post_rebuild))
|
.route("/rebuild/{name}", post(post_rebuild))
|
||||||
.route("/update-all", post(post_update_all))
|
.route("/update-all", post(post_update_all))
|
||||||
.route("/answer-question/{id}", post(post_answer_question))
|
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
.with_state(AppState { coord });
|
.with_state(AppState { coord });
|
||||||
|
|
@ -76,10 +75,7 @@ async fn serve_index() -> impl IntoResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_css() -> impl IntoResponse {
|
async fn serve_css() -> impl IntoResponse {
|
||||||
(
|
([("content-type", "text/css")], include_str!("../assets/dashboard.css"))
|
||||||
[("content-type", "text/css")],
|
|
||||||
include_str!("../assets/dashboard.css"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_app_js() -> impl IntoResponse {
|
async fn serve_app_js() -> impl IntoResponse {
|
||||||
|
|
@ -101,11 +97,6 @@ struct StateSnapshot {
|
||||||
/// asynchronously so the operator can see them without watching the
|
/// asynchronously so the operator can see them without watching the
|
||||||
/// live panel during a turn.
|
/// live panel during a turn.
|
||||||
operator_inbox: Vec<crate::broker::InboxRow>,
|
operator_inbox: Vec<crate::broker::InboxRow>,
|
||||||
/// Pending operator questions (currently only from the manager).
|
|
||||||
/// `ask_operator` returns immediately with the id; on `/answer-question`
|
|
||||||
/// we mark the row answered and fire `HelperEvent::OperatorAnswered`
|
|
||||||
/// into the manager's inbox.
|
|
||||||
questions: Vec<crate::operator_questions::OpQuestion>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -140,7 +131,10 @@ struct ApprovalView {
|
||||||
diff_html: Option<String>,
|
diff_html: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(
|
||||||
|
headers: HeaderMap,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> axum::Json<StateSnapshot> {
|
||||||
let host = headers
|
let host = headers
|
||||||
.get("host")
|
.get("host")
|
||||||
.and_then(|h| h.to_str().ok())
|
.and_then(|h| h.to_str().ok())
|
||||||
|
|
@ -233,7 +227,6 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
.broker
|
.broker
|
||||||
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
|
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let questions = state.coord.questions.pending().unwrap_or_default();
|
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
hostname,
|
hostname,
|
||||||
|
|
@ -243,7 +236,6 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
transients,
|
transients,
|
||||||
approvals: approval_views,
|
approvals: approval_views,
|
||||||
operator_inbox,
|
operator_inbox,
|
||||||
questions,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -279,36 +271,6 @@ struct RequestSpawnForm {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct AnswerForm {
|
|
||||||
answer: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_answer_question(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
AxumPath(id): AxumPath<i64>,
|
|
||||||
Form(form): Form<AnswerForm>,
|
|
||||||
) -> Response {
|
|
||||||
let answer = form.answer.trim();
|
|
||||||
if answer.is_empty() {
|
|
||||||
return error_response("answer: required");
|
|
||||||
}
|
|
||||||
match state.coord.questions.answer(id, answer) {
|
|
||||||
Ok(question) => {
|
|
||||||
tracing::info!(%id, "operator answered question");
|
|
||||||
state
|
|
||||||
.coord
|
|
||||||
.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
|
|
||||||
id,
|
|
||||||
question,
|
|
||||||
answer: answer.to_owned(),
|
|
||||||
});
|
|
||||||
Redirect::to("/").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_request_spawn(
|
async fn post_request_spawn(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Form(form): Form<RequestSpawnForm>,
|
Form(form): Form<RequestSpawnForm>,
|
||||||
|
|
@ -363,10 +325,7 @@ async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<Strin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_restart(
|
async fn post_restart(State(_state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
State(_state): State<AppState>,
|
|
||||||
AxumPath(name): AxumPath<String>,
|
|
||||||
) -> Response {
|
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
match lifecycle::restart(&logical).await {
|
match lifecycle::restart(&logical).await {
|
||||||
Ok(()) => Redirect::to("/").into_response(),
|
Ok(()) => Redirect::to("/").into_response(),
|
||||||
|
|
@ -409,10 +368,7 @@ async fn post_update_all(State(state): State<AppState>) -> Response {
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
Redirect::to("/").into_response()
|
Redirect::to("/").into_response()
|
||||||
} else {
|
} else {
|
||||||
error_response(&format!(
|
error_response(&format!("update-all partial failure:\n{}", errors.join("\n")))
|
||||||
"update-all partial failure:\n{}",
|
|
||||||
errors.join("\n")
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -437,6 +393,8 @@ fn error_response(message: &str) -> Response {
|
||||||
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
|
(StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// Filter out approvals whose agent state dir was wiped out from under us
|
/// Filter out approvals whose agent state dir was wiped out from under us
|
||||||
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
|
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
|
||||||
/// `pending` on next render.
|
/// `pending` on next render.
|
||||||
|
|
@ -550,3 +508,4 @@ fn html_escape(s: &str) -> String {
|
||||||
.replace('<', "<")
|
.replace('<', "<")
|
||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ mod coordinator;
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
mod lifecycle;
|
mod lifecycle;
|
||||||
mod manager_server;
|
mod manager_server;
|
||||||
mod operator_questions;
|
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
use coordinator::Coordinator;
|
use coordinator::Coordinator;
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ async fn serve(stream: UnixStream, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
|
|
||||||
const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30);
|
const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30);
|
||||||
|
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse {
|
async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse {
|
||||||
match req {
|
match req {
|
||||||
ManagerRequest::Send { to, body } => match coord.broker.send(&Message {
|
ManagerRequest::Send { to, body } => match coord.broker.send(&Message {
|
||||||
|
|
@ -144,18 +143,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ManagerRequest::AskOperator { question, options } => {
|
|
||||||
tracing::info!(%question, ?options, "manager: ask_operator");
|
|
||||||
match coord.questions.submit(MANAGER_AGENT, question, options) {
|
|
||||||
Ok(id) => {
|
|
||||||
tracing::info!(%id, "operator question queued");
|
|
||||||
ManagerResponse::QuestionQueued { id }
|
|
||||||
}
|
|
||||||
Err(e) => ManagerResponse::Err {
|
|
||||||
message: format!("{e:#}"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ManagerRequest::RequestApplyCommit { agent, commit_ref } => {
|
ManagerRequest::RequestApplyCommit { agent, commit_ref } => {
|
||||||
tracing::info!(%agent, %commit_ref, "manager: request_apply_commit");
|
tracing::info!(%agent, %commit_ref, "manager: request_apply_commit");
|
||||||
match coord.approvals.submit(agent, commit_ref) {
|
match coord.approvals.submit(agent, commit_ref) {
|
||||||
|
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
//! Operator question queue. Manager submits via `AskOperator`; the
|
|
||||||
//! operator answers via the dashboard. The manager-socket handler long-polls
|
|
||||||
//! the store until the answer lands, so claude's `ask_operator` tool call
|
|
||||||
//! returns the answer directly as its result.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use rusqlite::{Connection, OptionalExtension, params};
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
const SCHEMA: &str = r"
|
|
||||||
CREATE TABLE IF NOT EXISTS operator_questions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
asker TEXT NOT NULL,
|
|
||||||
question TEXT NOT NULL,
|
|
||||||
options_json TEXT NOT NULL,
|
|
||||||
asked_at INTEGER NOT NULL,
|
|
||||||
answered_at INTEGER,
|
|
||||||
answer TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
|
||||||
ON operator_questions (id) WHERE answered_at IS NULL;
|
|
||||||
";
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct OpQuestion {
|
|
||||||
pub id: i64,
|
|
||||||
pub asker: String,
|
|
||||||
pub question: String,
|
|
||||||
pub options: Vec<String>,
|
|
||||||
pub asked_at: i64,
|
|
||||||
pub answered_at: Option<i64>,
|
|
||||||
pub answer: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct OperatorQuestions {
|
|
||||||
conn: Mutex<Connection>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OperatorQuestions {
|
|
||||||
pub fn open(path: &Path) -> Result<Self> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent).with_context(|| {
|
|
||||||
format!("create operator_questions db parent {}", parent.display())
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
let conn = Connection::open(path)
|
|
||||||
.with_context(|| format!("open operator_questions db {}", path.display()))?;
|
|
||||||
conn.execute_batch(SCHEMA)
|
|
||||||
.context("apply operator_questions schema")?;
|
|
||||||
Ok(Self {
|
|
||||||
conn: Mutex::new(conn),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn submit(&self, asker: &str, question: &str, options: &[String]) -> Result<i64> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO operator_questions (asker, question, options_json, asked_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
|
||||||
params![asker, question, options_json, now_unix()],
|
|
||||||
)?;
|
|
||||||
Ok(conn.last_insert_rowid())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mark the question answered. Returns the original question text so the
|
|
||||||
/// caller can include it in any helper event it fires off.
|
|
||||||
pub fn answer(&self, id: i64, answer: &str) -> Result<String> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let question: Option<(String, Option<i64>)> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT question, answered_at FROM operator_questions WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
||||||
)
|
|
||||||
.optional()?;
|
|
||||||
let Some((question, answered_at)) = question else {
|
|
||||||
bail!("question {id} not found");
|
|
||||||
};
|
|
||||||
if answered_at.is_some() {
|
|
||||||
bail!("question {id} already answered");
|
|
||||||
}
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
|
||||||
params![answer, now_unix(), id],
|
|
||||||
)?;
|
|
||||||
Ok(question)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
|
|
||||||
FROM operator_questions WHERE id = ?1",
|
|
||||||
params![id],
|
|
||||||
row_to_question,
|
|
||||||
)
|
|
||||||
.optional()
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
|
||||||
let conn = self.conn.lock().unwrap();
|
|
||||||
let mut stmt = conn.prepare(
|
|
||||||
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
|
|
||||||
FROM operator_questions
|
|
||||||
WHERE answered_at IS NULL
|
|
||||||
ORDER BY id ASC",
|
|
||||||
)?;
|
|
||||||
let rows = stmt.query_map([], row_to_question)?;
|
|
||||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
|
||||||
.map_err(Into::into)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
|
||||||
let options_json: String = row.get(3)?;
|
|
||||||
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
|
|
||||||
Ok(OpQuestion {
|
|
||||||
id: row.get(0)?,
|
|
||||||
asker: row.get(1)?,
|
|
||||||
question: row.get(2)?,
|
|
||||||
options,
|
|
||||||
asked_at: row.get(4)?,
|
|
||||||
answered_at: row.get(5)?,
|
|
||||||
answer: row.get(6)?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_unix() -> i64 {
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.ok()
|
|
||||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
@ -228,15 +228,6 @@ pub enum HelperEvent {
|
||||||
/// A sub-agent's container was torn down (container removed; state
|
/// A sub-agent's container was torn down (container removed; state
|
||||||
/// dirs preserved per `destroy` semantics).
|
/// dirs preserved per `destroy` semantics).
|
||||||
Destroyed { agent: String },
|
Destroyed { agent: String },
|
||||||
/// The operator answered a question that was queued via
|
|
||||||
/// `AskOperator`. `id` matches the `QuestionQueued.id` returned to the
|
|
||||||
/// asker; `question` echoes the original prompt so the manager can
|
|
||||||
/// stitch the answer back to context across compactions.
|
|
||||||
OperatorAnswered {
|
|
||||||
id: i64,
|
|
||||||
question: String,
|
|
||||||
answer: String,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests on the manager socket. Manager has the agent surface (send/recv)
|
/// Requests on the manager socket. Manager has the agent surface (send/recv)
|
||||||
|
|
@ -274,35 +265,14 @@ pub enum ManagerRequest {
|
||||||
agent: String,
|
agent: String,
|
||||||
commit_ref: String,
|
commit_ref: String,
|
||||||
},
|
},
|
||||||
/// Ask the operator a question. The host-side handler blocks until the
|
|
||||||
/// operator answers via the dashboard; the answer is then returned as the
|
|
||||||
/// response. `options` is advisory: an empty list means free-text.
|
|
||||||
AskOperator {
|
|
||||||
question: String,
|
|
||||||
#[serde(default)]
|
|
||||||
options: Vec<String>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||||
pub enum ManagerResponse {
|
pub enum ManagerResponse {
|
||||||
Ok,
|
Ok,
|
||||||
Err {
|
Err { message: String },
|
||||||
message: String,
|
Message { from: String, body: String },
|
||||||
},
|
|
||||||
Message {
|
|
||||||
from: String,
|
|
||||||
body: String,
|
|
||||||
},
|
|
||||||
Empty,
|
Empty,
|
||||||
Status {
|
Status { unread: u64 },
|
||||||
unread: u64,
|
|
||||||
},
|
|
||||||
/// Result of `AskOperator`: the queued question id. The actual answer
|
|
||||||
/// arrives later as a `HelperEvent::OperatorAnswered` in the manager
|
|
||||||
/// inbox, so this returns immediately rather than blocking the turn.
|
|
||||||
QuestionQueued {
|
|
||||||
id: i64,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue