# Turn loop + MCP How the harness wakes up, what it asks claude to do, and what tools claude has access to in return. ## The loop Each agent harness (`hive serve`, with role picked from `$HIVE_ROLE` — `"agent"` for sub-agents, `"manager"` for the manager — one binary, not two) runs: 1. Long-poll `Recv` on its socket. The host-side broker (`broker.rs::recv_blocking_batch`) returns immediately if there's a pending message, otherwise waits up to 30 s for a broker `Sent` event for this recipient. 2. Pop one message. Peek the remaining inbox depth with `Status`. 3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE bus. 4. Spawn claude (one process per turn) and pipe the wake prompt over stdin. 5. Stream stdout (JSON lines) into the bus as `LiveEvent::Stream(value)`. Pump stderr as `Note`. 6. Wait for claude to exit. Compaction is two-pronged — *reactive* on `Prompt is too long` and *proactive* on a context watermark (see [Compaction](#compaction) below). **Rate-limit detection**: on stderr the harness does a raw-line match for `429` / `rate_limit` markers; on stdout it only fires on parsed `{"type":"error"}` JSON events (avoiding false positives when agents discuss `rate_limit_error` in conversation text). On detection the harness sets the `rate_limited` sentinel (`Bus::emit_status("rate_limited")`), sleeps `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries. The dashboard and per-agent page show a `⊘ rate limited` badge while the harness is parked. **Auth-failed detection**: both stdout and stderr pumps also match `AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.). On the first 401, `drive_turn` retries the same prompt once immediately (transient token-refresh races and brief API hiccups can cause a 401 that clears on retry). Only if the retry also returns `AuthFailed` does `drive_turn` bubble it up to the serve loop, which then writes `{state_dir}/hyperhive-needs-login`, emits `needs_login_idle` status, requeues the inflight message (so it replays after re-auth), and parks in `wait_for_login` — the same path used at boot. The operator re-authenticates via the per-agent web UI login flow; on success the sentinel is cleared and the queued message drives the next turn normally. **Mtime-snapshot resumption**: `wait_for_login` snapshots the `~/.claude/` dir (newest file mtime + file count) at entry and only resumes when that snapshot advances — not just when credentials exist on disk. This prevents a silent infinite-401 loop: stale credentials already on disk at the time of the 401 no longer cause an immediate false-resume. The `DirSnapshot` struct tracks both axes; either a mtime advance OR a file-count change triggers resume (the count axis handles filesystems where `modified()` errors on every file). 7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid tight loops on transient failures. ## Harness binary shape One `hive` binary serves both roles. The split into `hive-ag3nt` + `hive-m1nd` was collapsed because the privilege boundary lives server-side at the broker socket (`/run/hive/mcp.sock`): an agent-flavor socket refuses `ManagerRequest` calls regardless of who sends them, so there's no escalation risk in shipping the same code to both. `main()` reads `$HIVE_ROLE` (set by `harness-base.nix` from `hyperhive.role`; defaults to `"agent"` for standalone `nix run` invocations) and dispatches. Three subcommands: - `serve` — long-running harness loop (the inbox poll + claude-pump + ack/requeue cycle described above). - `mcp` — stdio MCP server claude spawns via `--mcp-config` per turn. Same binary, different mode. - `wake --from --body ` — push a message into our own inbox so the next turn fires with the given body. Used by co-process daemons (matrix bridge, scraper, webhook listeners) to nudge claude on external events. `--body -` reads from stdin. ### `Surface` trait + zero-sized type tags `AgentRequest` / `AgentResponse` and `ManagerRequest` / `ManagerResponse` are wire-disjoint, but the turn loop itself (boot → recv → drive → ack/requeue → stats → continue-sentinel) is identical regardless of role. `bin/hive.rs` factors that sameness through a `Surface` trait with two zero-sized impls (`AgentSurface`, `ManagerSurface`) wrapping: - Per-role MCP `Flavor` constant (picks which system-prompt block + tool registration goes into the spawned claude). - Per-role `forge_notify::run` flag (picks `AgentRequest::Wake` vs `ManagerRequest::Wake` so the broker socket accepts the push). - One async method per wire op: `ack_turn`, `requeue_inflight`, `inbox_unread`, `post_turn_counts`, `send_to_parent`, `self_wake`, `recv_next`, `wake_external`. `main()`'s dispatch picks `serve_main::` vs `serve_main::` and the turn logic stays in lockstep by construction — there's no separate per-role copy of `serve_loop` / `handle_turn` / `wake`. ### Boot wiring `serve_main` reads `HIVE_PORT` (default `DEFAULT_WEB_PORT`) + `HIVE_LABEL` (default `"hive"` for standalone runs; the meta flake sets it unconditionally for any container-deployed agent; see `docs/conventions.md::Hive identity` for the env stack), opens turn-stats sqlite, prepares the on-boot files (see below), installs claude plugins, spawns `forge_notify::run` + `web_ui::serve`, and either drops into `serve_loop` directly (`Online`) or parks on the login flow first (`NeedsLogin`). Plugin install failures are not fatal: each entry comes back as a human-readable failure string that gets routed via `Surface::send_to_parent` to the agent's topology parent (the broker resolves `` per `topology::parent_of`; root agents and the manager fall through to operator). ### Turn outcomes `turn::TurnOutcome` drives the post-claude branch: | Outcome | Action | | --- | --- | | `Ok` / `Compacted` | `ack_turn` | | `RateLimited` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` | | `AuthFailed` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` | | `Failed(err)` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | After the outcome handler, the stats sink records a row and the `hyperhive-continue` sentinel (dropped by the `request_next_turn` MCP tool) is consumed if present, firing `self_wake` so the next turn starts with `{ from: "self", body: "continue" }` even if the inbox is empty. ## The claude invocation ``` claude --print --verbose --output-format stream-json --model \ --continue --settings /run/hive/claude-settings.json \ --system-prompt-file /run/hive/claude-system-prompt.md \ --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ --tools --allowedTools # wake prompt piped over stdin ``` `` is read from `Bus::model()` on each turn. The initial default is set by `hyperhive.model` in the agent's `agent.nix` (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls back to `"haiku"` if unset). The operator can flip it at runtime with `/model ` in the web terminal — the next turn picks it up. The choice is persisted to `/harness/hyperhive-model` so it survives restart; override path: `HYPERHIVE_MODEL_FILE` env var for tests. Context-window size is looked up per-model via `events::context_window_tokens(model)`. Resolution order (first match wins): 1. `HIVE_CONTEXT_WINDOW_TOKENS_` env var, where `KEY` (lowercased) is a substring of the active model name. Injected by the meta flake from `services.hyperhive.c0re.contextWindowTokens` (host-level NixOS option, defaults: haiku=200k, sonnet=1M, opus=1M). Override these for all agents at once without a per-agent config change. 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any model (useful in dev / test). 3. Hard fallback: `200_000` (conservative; only reached outside NixOS where the env vars aren't set). The effective window drives watermarks and is exposed at runtime via `/api/state.context_window_tokens` so the UI can show a percentage-of-window ctx badge. `--continue` keeps a persistent session per agent (claude stores sessions in `~/.claude/projects/`, which is bind-mounted persistently). Auto-compact and auto-memory are disabled via `--settings` because hyperhive owns compaction — see [Compaction](#compaction) below. A one-shot `--continue` suppression is available via `POST /api/new-session` (or `/new-session` slash command in the per-agent terminal) — `Bus::take_skip_continue()` flips an `AtomicBool` once per turn, the next claude invocation drops `--continue`, every subsequent turn resumes normal behaviour. ### Compaction claude's own in-session auto-compact is off (`--settings`); hyperhive owns it explicitly in `turn::drive_turn`. There are two triggers: - **Reactive** — claude-code prints `Prompt is too long` (the `PROMPT_TOO_LONG_MARKER`). The session is *already* past the context window, so no turn can run on it — `drive_turn` runs `/compact` straight away and retries the same wake-up prompt once. No notes-checkpoint turn is possible here: the detail is gone. - **Proactive** — a turn finishes cleanly but the last inference's context size (`Bus::last_ctx_usage().context_tokens()`) is at or above a watermark. While the session is still healthy, `drive_turn` injects one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` — "context is filling up, flush durable state into `/state` now") and *then* runs `/compact`. This gives the agent a chance to persist in-flight task state, decisions, and file paths before the conversation detail collapses into a summary. The compact watermark defaults to **75% of `context_window_tokens(model)`** (dynamically derived — 150k for haiku, 750k for sonnet/opus). Override with `HIVE_COMPACT_WATERMARK_TOKENS` (absolute token count); set to `0` to disable proactive compaction entirely (the reactive path always applies). The proactive path is best-effort — a failed checkpoint turn or `/compact` is surfaced as a `Note` but never fails the turn that already succeeded. The operator can also force a compaction any time via `/api/compact`. - **Auto session-reset** — a third path that fires when both conditions hold: context is ≥ a watermark (`HIVE_AUTO_RESET_WATERMARK_TOKENS`, default **50% of `context_window_tokens(model)`**) AND the time since the last turn exceeds the assumed prompt-cache TTL (`HIVE_CACHE_TTL_SECS`, default `3600`). Claude's prompt cache lives ~5 minutes; if the cache is already cold, resuming with `--continue` pays the full re-upload cost of the current context with no benefit over starting fresh. So: `drive_turn` injects one `AUTO_RESET_CHECKPOINT_PROMPT` notes turn ("flush state to files, cache is cold") then arms `Bus::take_skip_continue()` for the real turn — the next turn runs without `--continue`, starting a fresh session. Unlike proactive compaction the session is dropped entirely, not compacted. Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. The child runs with `cwd = /state` (when the bind exists; falls back to the parent's cwd in dev), so any relative path in a tool call (`Read foo.md`, `Bash ls`, `Write notes.md`) lands in the agent's durable bind-mounted dir. CLAUDE.md auto-load walks upward from `/state` — drop a per-agent CLAUDE.md there if you want long-term hints that survive destroy/recreate. The wake prompt is intentionally minimal: just the popped message's `from`/`body`, plus an inline `({unread} more pending — drain via …)` hint when `unread > 0`. Claude drives any further `recv`/`send` itself via the embedded MCP server. Whenever hive-c0re starts / restarts / rebuilds a container, it also drops a `system` message into the agent's inbox via `Coordinator::kick_agent` — a one-line "you were just (re)started, check /state/ for your notes, --continue session is intact". The next turn picks it up like any other inbox message. ### On-boot files `hive_ag3nt::turn::write_*` writes three files next to the per-agent socket at `/run/hive/` once at startup: - `claude-mcp-config.json` — re-invokes the running binary as `mcp` child (so the same binary serves as harness + as claude's MCP child process). - `claude-settings.json` — the `--settings` blob (auto-compact and auto-memory off, effortLevel medium). - `claude-system-prompt.md` — rendered from `hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`: HTML-comment markers (`...`, same for `role:manager`) gate the role-specific blocks; everything else is shared. Five placeholders are then substituted: `{label}` (short agent name), `{qualified_label}` (hive-qualified `name@domain` form), `{operator_pronouns}`, `{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when `hyperhive.hiveName` is unset), and `{swarm_identity}` (same shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS` env (set by the meta flake from `services.hyperhive.c0re.operatorPronouns`, default `she/her`). Passed via `--system-prompt-file`. **Marker grammar.** `` opens a block; matching `` closes it. Nesting is NOT supported — a stray opener overrides until its closing tag (or end of file). A mismatched closer (`` inside a `role:agent` block) is elided from the output but does NOT pop the active role: suppression stays conservative so a typo can't dump wrong-flavor content. Whitespace inside markers is tolerated (`` parses the same as ``). Content outside any marker is always shared. **`hive_identity` / `swarm_identity` shape.** Each carries a leading space + backticked name (` on hive \`pr1ma\``, ` in swarm \`constellat1on\``) when the corresponding env var is set, otherwise empty string. The independence lets the template drop one or both into the opener prose without breaking single-hive deployments that never set the option; the renderer also treats `Some("")` from a caller as `None` so empty-string env vars and missing env vars round-trip the same way. 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, wait_for_login, compact_session}` so the two binaries can't drift. ## MCP surface The harness ships an embedded MCP server (rmcp 1.7). Claude launches it as a stdio child via `--mcp-config`. The hyperhive socket name is `hyperhive`, so the tools land in claude as `mcp__hyperhive__`. ### Sub-agent tools - `send(to, body, in_reply_to?)` — message a peer (logical agent name), another agent, or the operator (recipient `operator`, surfaces in the dashboard inbox). Use `to: ""` to address the agent's topology parent without knowing its label; the broker resolves the sentinel at delivery time (falls back to `operator` for root agents). Optional `in_reply_to: i64` links this message to a prior message id for thread rendering in the dashboard message flow and the per-agent inbox. - `recv(wait_seconds?, max?)` — drain inbox messages. Without `wait_seconds` (or with `0`) returns immediately, a cheap "anything pending?" peek. Positive value parks the turn up to that many seconds (cap 180) — incoming messages wake instantly, otherwise returns empty at the timeout. `max` (default 1, server-side cap 32) drains up to N popped rows in one round-trip; `wait_seconds` applies to the *first* message, then the call drains up to `max` total. - `ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question. Same shape as the manager's; recipient defaults to the operator (dashboard) but can be set to a peer agent name via `to: ""`. 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. - `get_loose_ends()` — list everything still pending against this agent: unanswered questions it asked / was asked, plus reminders it scheduled. Each row carries an id + kind for `cancel_loose_end`. - `cancel_loose_end(kind, id)` — withdraw a `question` (posts `[cancelled by ]` to unblock the asker), a `reminder` (hard-delete before fire), or (manager only) an `approval` (transitions to `Cancelled`; sub-agents refused with a clear error). Sub-agents may only cancel rows they own. - `remind(message, due)` — schedule a reminder that lands in this agent's own inbox at a future time (sender shows as `reminder`). Large payloads spill to `/agents//state/reminders/` with the inbox message a short pointer. Each agent's pending-reminder count is capped (default 50, override via `HIVE_REMIND_MAX_PENDING_PER_AGENT`); scheduling a new one fails if the cap is already hit. - `set_status(text)` — set a free-text status string visible on the operator dashboard. Persisted to `{state_dir}/hyperhive-status`; survives harness restarts. Pass an empty string to clear. Validated: must be a single line and ≤ 200 Unicode characters; the server rejects multi-line or over-length text with a clear error. - `get_agent_meta(name?)` — fetch identity + status metadata for an agent: `{ name, role, hyperhive_rev, running, status_text, status_set_at, hive_name?, swarm_name? }`. Pass `name` to query a peer (e.g. check whether a sub-agent is idle before sending it work). Omit `name` to get your own identity stamp — replaces the previous `whoami` tool. `running` is `true` when the container is up. When `running` is `false` the host clears `status_text` / `status_set_at` (they would be stale snapshots from before the container stopped) before serving the response. Status fields are also `None` when the target has never called `set_status` or has cleared it. `hive_name` and `swarm_name` are present when `services.hyperhive.hiveName` / `services.hyperhive.swarmName` are configured on the host; omitted in single-hive deployments. - `request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends. No args. - `bash_run(cmd, timeout_secs?)` — submit a shell command for background execution (`sh -c `). Returns a task ID immediately; the command runs asynchronously in a harness-managed tokio task. Stdout and stderr stream to `harness/bash-tasks/.{out,err}`. When the task completes (or times out, or the process errors), the harness wakes the agent with a summary body — handle on a future turn. Default timeout 180s; pass `timeout_secs` to override. Requires the `execution` tool group. - `bash_status(id)` — poll the status of a task submitted with `bash_run`. Returns status (`pending`/`running`/`done`/`timed_out`/ `interrupted`), exit code, run duration, and the last 4 KiB of stdout and stderr (full output in the `.out`/`.err` files). Tasks marked `interrupted` had their process killed by a harness restart; a best- effort wake was still sent so the agent is not silently blocked. ### Waking the agent from inside the container External MCP servers (and any other in-container process) can inject a wake-up event into the agent's inbox via the per-agent socket at `/run/hive/mcp.sock`. Two equivalent paths: - **Shell out to `hive wake --from