Agents run whatever `claude-code` the meta flake's `nixpkgs` resolves to, and that is normally a release channel. This one package moves fast enough that stable trails unstable by weeks — 26.05 is on 2.1.187 while unstable carries 2.1.220 — and an agent cannot fix it for itself: it only ever sees the single nixpkgs hive-c0re injects, so an `agent.nix` has no other tree to reach for. New host option `services.hyperhive.c0re.claudeCodePackage` takes the package directly and rides the existing `hyperhiveDocs` threading path — serveConfigJson -> HiveEnv -> render_flake — to reach each agent as `hyperhive.claudeCodePath`. Null (the default) is today's behaviour. What travels is the store *path*, as a plain string literal, not a flake input: containers share the host's `/nix/store`, so the build is already reachable inside them with its whole closure and has nothing to travel. An input would be worse than useless — a `path:/nix/store/<pkg>` input is re-copied as a reference-less `-source`, which strips exactly the closure the binary needs. The catch is that a path written into a generated flake is text, so nothing in the container's closure keeps the binary alive. The host does that instead, and gets it for free: the package is interpolated into `/etc/hyperhive/serve.json`, `builtins.toJSON` preserves string context, so the /etc entry references it and the system closure gc-roots it for as long as that generation is the one the agents were rendered from. An assertion pins that property, because losing the context is invisible at eval and at deploy — it would surface only as every agent failing to spawn `claude` whenever the next gc ran. Container side wraps the path in a symlink farm rather than putting it on PATH directly: `systemd.services.<name>.path` and `environment.systemPackages` both coerce a store-path *string* through `lib.toDerivation`, i.e. `builtins.storePath`, which pure evaluation rejects. Interpolating the path into a builder is just text and evaluates anywhere. `claude-code` drops out of systemPackages when a pin is set, so there is exactly one claude in the container. Refs #2693
255 lines
14 KiB
Markdown
255 lines
14 KiB
Markdown
# 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.
|
|
|
|
**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 usually a release channel and
|
|
this package moves fast, the operator can pin one hive-wide with
|
|
`services.hyperhive.c0re.claudeCodePackage`: its store path is written
|
|
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/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 — 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, auto-memory, and dynamic workflows (the `/workflows`
|
|
feature) are disabled via the managed settings at
|
|
`/etc/claude-code/managed-settings.json`: hyperhive owns compaction
|
|
(see [Compaction](#compaction) below), and `disableWorkflows` keeps the
|
|
`/workflows` machinery from spawning sub-runs that burn usage on the
|
|
harness's autonomous turns.
|
|
|
|
**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
|
|
`/compact`s 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](../turn-loop.md#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* `/compact`s, 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:
|
|
|
|
```nix
|
|
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` — points claude at the persistent
|
|
`hive-mcp-http` daemon (`http://127.0.0.1:{port}/mcp`, port from
|
|
`hyperhive.mcp.httpPort`, default 8790) for the built-in `hyperhive`
|
|
surface. 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 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 as `None` 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`.
|
|
|