hyperhive/docs/turn-loop/claude-invocation.md

13 KiB

The claude invocation

claude --print --verbose --output-format stream-json --model <name> \
  --effort <level> --resume <title>   # or --name <title> on first use \
  --system-prompt-file /run/hive/claude-system-prompt.md \
  --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \
  --tools <builtins> --allowedTools <builtins+mcp>
# wake prompt piped over stdin

Crate split. The generic subprocess mechanics — spawning claude --print, streaming + classifying stream-json, session lookup/archive, and the durable-session compaction loop — live in the reusable hive-claude crate (hive_claude::{Claude, InfiniteSession, Attach, CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}; see hive-claude/README.md). hive_ag3nt::turn is the hyperhive policy layer on top: it builds the per-turn config from the bus, bridges the output stream onto the event bus (BusSink), and owns the compaction / auto-reset / retry decisions in drive_turn. The lib returns everything it parsed from a turn (usage, cost, context window, resolved model) as Telemetry, which the policy layer applies to the bus.

Hive-enforced settings ship at /etc/claude-code/managed-settings.json (claude-code's canonical managed-settings path — precedence #1, read-only, un-overridable), wired in nix/templates/harness-base.nix from the prompts/claude-settings.json asset. effortLevel is deliberately not in that file — effort is controlled live via the --effort flag (HIVE_DEFAULT_EFFORT / the per-agent UI slider), which managed scope would otherwise lock.

<name> 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 <name> 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_<KEY> 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.

Session identity — a constant title. Every turn keys on one fixed, harness-owned session title (turn::session_title(), default hive-session, override HIVE_SESSION_TITLE). The durable hive_claude::InfiniteSession (built once by the serve loop via turn::make_session, then reused) --resume <title>s it; the first use (bootstrap, post-archive, post-purge) misses and the session re-runs the same prompt once with --name <title> to mint it. That single self-heal rule is the whole identity system — there is no scraped session-id file. Because the title is constant, /compact and its post-compact retry provably target the same session (killing the old "compact ran on a different/empty session" bug), and a choom invocation in the same cwd can't hijack the context (it won't carry our title). claude stores sessions in ~/.claude/projects/<cwd-slug>/<uuid>.jsonl (bind-mounted persistently); --name writes the title into the file as a custom-title event, which is what --resume <title> resolves against. We never pass bare --continue (it resumes the latest session in the cwd — the hijack vector). Auto-compact and auto-memory are disabled via the managed settings at /etc/claude-code/managed-settings.json because hyperhive owns compaction — see Compaction below.

Session reset is available via POST /api/new-session (or /new-session slash command). It does not touch the session inline — that would race a mid-write claude process. Instead Bus::request_session_reset() sets a one-shot flag consumed at the next turn boundary by drive_turn, which archives the current session: the backing <uuid>.jsonl is renamed to <uuid>.jsonl.archived (dropped out of claude's *.jsonl resolution glob, history preserved on disk, only the file carrying our title — any choom session sharing the cwd is left alone). The next turn's --resume <title> then misses and self-heals into a fresh session.

Compaction

claude's own in-session auto-compact is off (via the managed settings at /etc/claude-code/managed-settings.json); hyperhive owns it. The hive_claude::InfiniteSession keeps the session alive across the context window with two triggers baked into its run:

  • Reactive — claude-code prints Prompt is too long. The session is already past the window, so no turn can run on it — the session /compacts straight away and retries the same wake-up prompt once. No notes-checkpoint turn is possible here: the detail is gone. If the retry still overflows, run surfaces Error::PromptTooLong; drive_turn then archives the session (session lifecycle stays hive-side) and the serve loop requeues the message so it redelivers into a fresh session (see Turn outcomes — the wake prompt itself is tiny, so the overflow was the accumulated context the archive clears).
  • Proactive — a turn finishes cleanly but the last inference's context size crossed the policy watermark. While the session is still healthy it runs one synthetic notes-checkpoint turn (CHECKPOINT_PROMPT — "context is filling up, flush durable state into /state now") and then /compacts, so the agent can persist in-flight state before the detail collapses into a summary.

The when is a hive_claude::CompactionPolicy injected by the harness: turn::make_session builds a PercentPolicy that compacts once the model-reported context fill reaches HIVE_COMPACT_WATERMARK_PERCENT (default 75%), falling back to events::context_window_tokens(model) for the window on turns the model didn't report one. 0 disables proactive compaction (the reactive path always applies). The proactive path is best-effort — a failed checkpoint or /compact never fails the turn that already succeeded.

The operator can force a compaction any time via POST /api/compact. It's deferred: the handler sets Bus::request_compact() and returns immediately; the harness runs the /compact at the next turn boundary (end of the in-flight turn in drive_turn, or — when the agent is idle — in turn::run_pending_compact on the serve loop's next empty poll). This lets /api/compact work mid-turn instead of only when idle, without racing a live claude process.

To disable proactive compaction for a specific agent, use the nix option:

hyperhive.autoCompact = false;  # default true

Setting autoCompact = false sets HIVE_COMPACT_WATERMARK_TOKENS=0, which the percent resolver still honours as a disable. Useful for large-context models (sonnet/opus) where the 75% heuristic fires before the session is actually full — the reactive path (compact-on-overflow at the hard limit) still applies.

  • Auto session-reset — a third path (turn::maybe_auto_reset, pre-turn) 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 goes cold after a while; once it's cold, --resume-ing a large session pays the full re-upload cost with no benefit over starting fresh. So drive_turn archives the current session (same mechanism as the operator reset — rename <uuid>.jsonl.archived) so the next turn's --resume <title> misses and starts fresh. Unlike proactive compaction the session is dropped entirely, not compacted — and no preceding checkpoint turn runs, because any turn before the reset would just re-warm the cache and defeat the purpose. Set HIVE_AUTO_RESET_WATERMARK_TOKENS=0 to disable. Auto-reset and the operator reset are mutually exclusive per turn (both archive → fresh turn), so an explicit operator reset short-circuits the heuristic.

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: the popped message's from/body, prefixed with a [msg #<id>] broker-row-id marker (so the agent knows what id to pass to ack_until for bulk triage; transient pings show no marker because their sentinel id 0 has nothing to ack), 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, your session is intact". The next turn picks it up like any other inbox message.

On-boot files

hive_ag3nt::turn::write_* writes two files next to the per-agent socket at /run/hive/ once at startup:

  • claude-mcp-config.json — by default re-invokes the running binary as mcp stdio child (so the same binary serves as harness + MCP server per turn). When hyperhive.mcp.httpPort is set in the agent's NixOS config, the config instead points claude at the persistent hive-mcp-http daemon (http://127.0.0.1:{port}/mcp) — no stdio child per turn; trades the per-turn re-registration race for a hard dependency on the daemon's uptime (Restart=always).

  • claude-system-prompt.md — rendered from hive-ag3nt/prompts/system.md by hive_ag3nt::prompt::render: HTML-comment markers (<!-- role:agent -->...<!-- /role:agent -->, 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). When hyperhive.docs.enable is set, HIVE_DOCS_DIR is present in the environment and render() appends a one-sentence pointer telling the agent the docs are mounted at that path (in lieu of the old CLAUDE.md-in-docs-dir approach, which was dropped in favour of this direct injection). Passed via --system-prompt-file.

    Marker grammar. <!-- role:X --> opens a block; any <!-- /role:X --> closes the current block. The renderer always uses role agent, so blocks with other role tags are elided. Nesting is NOT supported — a stray opener with no closer runs until end of file. Whitespace inside markers is tolerated (<!--role:foo--> parses the same as <!-- role:foo -->). Content outside any marker is always included. Today's system.md carries no markers (single agent role) — the grammar stays wired for a future manager / multi-role prompt.

    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 asNone` so empty-string env vars and missing env vars round-trip the same way.

The per-turn plumbing lives in hive_ag3nt::turn: write_mcp_config / write_system_prompt (on-boot files), make_session (builds the durable InfiniteSession, once), drive_turn (the policy state machine — reset/auto-reset, the turn, 401-retry, deferred-compact-at-turn-end), run_pending_compact (idle operator compact), BusSink (stream → bus + Telemetry applied via apply_telemetry), emit_turn_end, session_title / session_store / archive_session (identity + turn-boundary reset). The actual claude spawn, stream classification, and the reactive/proactive compaction loop are in the hive-claude crate. Login-wait (wait_for_login) lives in hive_ag3nt::login.