Fifth batch of the ongoing write-good.Passive pass (hyperhive#4042):
read all 42 hits across the four docs/turn-loop files in context and
rewrote the 36 with a clearly nameable actor, usually one already
sitting in the same sentence or established a sentence or two earlier
(the broker, the harness, drive_turn, the renderer, hive-c0re, or a
specific fn/type named right there). Left 6 alone: predicate-adjective
copulas that only look passive ("is gone", "Bash is disallowed", "is
documented behavior" — nothing actually acts on the subject), a
config-conditional idiom with no in-file inconsistency to fix ("when
hyperhive.docs.enable is set"), and a paraphrase of what a rendered
message tells the agent ("the docs are mounted at that path").
Verified via vale before/after: 42 -> 6 write-good.Passive hits in
docs/turn-loop, exactly the 6 left alone; error count and other
warning categories unchanged. Re-read every changed line in its full
surrounding context after editing (not just the vale count) per the
lesson from the docs/process batch.
15 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-agent's turn module 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 / autoreset / 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.
Which claude binary. The bare name claude, resolved off the
harness unit's PATH. By default that's the claude-code in the agent's
own nixpkgs (the meta flake's nixpkgs input) via
environment.systemPackages. Since that's typically a release channel and
this package moves fast, the operator can pin one hive-wide with
services.hyperhive.c0re.claudeCodePackage: hive-c0re writes its
store path into each agent's flake, and claude on PATH becomes a symlink to it
instead of the container's own claude-code — so there's only ever one
claude in the container. Agents pick up a new build on their
next rebuild, not live. See docs/process/gotchas.md::claude-code is unfree.
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/agent-modules/claude-settings.nix
from the prompts/claude-settings.json asset. effortLevel is
deliberately not in that file — the --effort flag
(HIVE_DEFAULT_EFFORT / the per-agent UI slider) controls effort
live, which managed scope would otherwise lock.
The harness reads <name> from Bus::model() on each turn.
hyperhive.model in the agent's agent.nix sets the initial
default (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 harness persists the choice to /harness/hyperhive-model so it
survives restart; override path: HYPERHIVE_MODEL_FILE env var
for tests.
harness_state::context_window_tokens(model) looks up
context-window size per-model. Resolution order (first
match wins):
HIVE_CONTEXT_WINDOW_TOKENS_<KEY>env var, whereKEY(lowercased) is a substring of the active model name. Injected by the meta flake fromservices.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.HIVE_CONTEXT_WINDOW_TOKENS— single global override for any model (useful in dev / test).- Hard fallback:
200_000(conservative; only reached outside NixOS where the env vars aren't set).
The effective window drives watermarks; the harness exposes it 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). The managed settings at /etc/claude-code/managed-settings.json
disable automemory and dynamic workflows (the /workflows feature):
disableWorkflows keeps the /workflows machinery from spawning sub-runs
that burn usage on the harness's autonomous turns. Claude's own native
autocompact is on, at the widest window claude-code allows — see
Compaction below for why (it's a safety net, not the
primary mechanism; hyperhive still owns that).
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: it renames the backing
<uuid>.jsonl to <uuid>.jsonl.archived (dropped out of claude's *.jsonl
resolution glob, history preserved on disk, only the file carrying our
title — it leaves any choom session sharing the cwd alone). The next
turn's --resume <title> then misses and self-heals into a fresh session.
Compaction
hyperhive owns compaction as policy: 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,runsurfacesError::PromptTooLong;drive_turnthen 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 small, 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/statenow") and then/compacts, so the agent can persist in-flight state before the detail collapses into a summary.
hive-agent checks both triggers above between its own turns —
PercentPolicy::should_compact reads the percent watermark from the
last completed turn's telemetry (checked after attempt() returns), and
the reactive path only fires once claude-code has already refused a whole
turn as too long. Neither can see context growth happening inside a
single long turn's own tool-calling before it returns — a turn whose tool
calls alone blow past the window hits the reactive path with no preceding
checkpoint, losing whatever wasn't already persisted (#2707, root-caused in
#3727).
Safety net for that gap: claude-code's own native autocompact is on,
via the managed settings ("autoCompactEnabled": true,
"autoCompactWindow": 1000000 — the CLI's documented max, so it fires only
right before the real hard limit, not as an earlier watermark). This is
deliberately not the primary mechanism — it runs with no checkpoint and
none of the CHECKPOINT_PROMPT notes-flush machinery above, so a session
saved by it still loses whatever wasn't already durable — but it can act
mid-turn, underneath InfiniteSession::run, which the two triggers above
structurally can't. Don't confuse this with the hyperhive.autoCompact
nix option below: that one is a per-agent on/off for hyperhive's own
proactive watermark and has no effect on claude's native mechanism, which
is fleet-wide and set once in hive-agent/prompts/claude-settings.json.
The when for hyperhive's own policy 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 harness_state::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% ofcontext_window_tokens(model)) AND the time since the last turn exceeds the assumed prompt-cache TTL (HIVE_CACHE_TTL_SECS, default3600). 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.drive_turnarchives 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,drive_turndrops the session entirely rather than compacting it — and no preceding checkpoint turn runs, because any turn before the reset would just re-warm the cache and defeat the purpose. SetHIVE_AUTO_RESET_WATERMARK_TOKENS=0to disable. Autoreset 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 autoload 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-agent's turn module writes two files into its own per-service
runtime dir, /run/hive-config/ (RuntimeDirectory = "hive-config",
deliberately separate from /run/hive/, the host-owned dir holding the
per-agent socket — so the harness owns this write surface and nothing
needs to chown a bind mount), once at startup:
-
claude-mcp-config.json— points claude at the persistenthive-mcp-httpdaemon (http://127.0.0.1:{port}/mcp, port fromhyperhive.mcp.httpPort, default 8790) for the built-inhyperhivesurface. HTTP is the sole transport for it — no per-turn stdio child, so the URL survives the per-turn claude re-spawn (no re-registration race), trading that for a hard dependency on the daemon's uptime (Restart=always, no stdio fallback). Extra servers stay stdio. -
claude-system-prompt.md— rendered fromhive-agent/prompts/system.mdbyhive-agent'sprompt::render: HTML-comment markers (<!-- role:agent -->...<!-- /role:agent -->, same forrole:manager) gate the role-specific blocks; everything else is shared. Five placeholders are then substituted:{label}(short agent name),{qualified_label}(hive-qualifiedname@domainform),{operator_pronouns},{hive_identity}(for exampleon hive `pr1ma`; empty whenhyperhive.hiveNameis unset), and{swarm_identity}(same shape for the swarm). Pronouns come fromHIVE_OPERATOR_PRONOUNSenv (set by the meta flake fromservices.hyperhive.c0re.operatorPronouns, defaultshe/her). Whenhyperhive.docs.enableis set,HIVE_DOCS_DIRis present in the environment andrender()appends a one-sentence pointer telling the agent the docs are mounted at that path. Passed via--system-prompt-file.Marker grammar.
<!-- role:X -->opens a block; any<!-- /role:X -->closes the current block. The renderer always uses roleagent, so it elides blocks with other role tags. Nesting is NOT supported — a stray opener with no closer runs until end of file. The renderer tolerates whitespace inside markers (<!--role:foo-->parses the same as<!-- role:foo -->). Content outside any marker is always included. Today'ssystem.mdcarries no markers (single agent role) — the grammar stays wired for a future manager / multi-role prompt.hive_identity/swarm_identityshape. 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 treatsSome("")from a caller asNone` so empty-string env vars and missing env vars round-trip the same way.
The per-turn plumbing described on this page — on-boot files, session
identity, the reset/autoreset/retry state machine, and the
telemetry-to-bus bridge — lives in hive-agent's turn module; see its
//! doc comment (hive-agent/src/turn.rs) for the exact call shape.
The actual claude spawn, stream classification, and the
reactive/proactive compaction loop are in the hive-claude crate.
Login-wait lives in hive-agent's login module.