Watch
0
0
Fork
You've already forked hyperhive
0

docs: sort the top-level directories into five sections by what the reader wants

Every docs directory moves under 1-getting-started, 2-handbook,
4-implementation or 5-project; the options reference (section 3) comes from
the website and has no directory here. trust-boundary stays whole under the
handbook for now. web-ui, swarm and tools split between handbook and
implementation, and the command-line references move to
2-handbook/reference. Links, path references in code and nix, and the docs
index follow the moves; no page content changes.
This commit is contained in:
iris 2026-11-06 18:30:12 +01:00 • committed by mara
commit 6739bb903d
4 changed files with 736 additions and 701 deletions

View file

@ -0,0 +1,259 @@
# Turn loop + MCP
How the harness wakes up, what it asks the agent's runtime to do, and what
tools the agent has access to in return.
## Runtimes
Every turn runs through `hive-runtime`, on the runtime
`services.hyperhive.agent.runtime` selects:
- **`claude`** (default) — one `claude --print` process per turn.
→ [claude-invocation.md](claude-invocation.md)
- **`acp`** — any Agent Client Protocol agent, run as one long-lived child
of the harness. → [`hive-runtime/README.md`](../../../hive-runtime/README.md)
Both report the turn in claude's `stream-json` shape, so the loop below is
the same for either.
## The loop
Each agent harness (`hive-agent` — one serve-loop binary for all
agents) runs:
0. Check the pause marker (`<harness>/paused`). While it exists the
loop does nothing but re-stat it every 5 s — no broker poll, no
claude process. Because step 1 is never reached, messages stay
queued and unacked, so a resume drains the backlog instead of
losing it; reminders and todo wakes buffer in their channels. Set
it with `hivectl agent <name> pause` or the dashboard toggle; see
[persistence](../../2-handbook/agent-lifecycle/persistence.md#-harnesspaused-per-agent).
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. After a failed `Recv` (a socket error, or
a broker error, which comes back at once) the loop waits before the
next one: 250 ms, doubling up to 30 s, and no wait again once a
`Recv` succeeds. Todo wakes and local messages don't wait for it.
2. Pop one message. Peek the remaining inbox depth with `Status`.
3. Emit `LiveEvent::TurnStart { from, body, unread }` onto the SSE
bus.
4. Hand the wake prompt to the runtime: on `claude`, spawn
`claude --print` and pipe it over stdin; on `acp`, send it as a
prompt on the agent's session.
5. Stream the turn's `stream-json` lines into the bus as
`LiveEvent::Stream(value)`. Pump stderr as `Note`.
6. Wait for the turn to end and classify its outcome from the
stream + result — success, compaction, rate-limit, auth-failure, stall,
or hard failure. The outcome drives the post-turn action (see
[Turn outcomes](#turn-outcomes)); the session handles compaction
internally (see
[Compaction](claude-invocation.md#compaction)). This page describes
rate-limit and auth-failure detection [below](#failure-detection-and-login-claude).
7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid
tight loops on transient failures.
### Failure detection and login (claude)
- **Rate limit** — a `429` / `rate_limit` marker on stderr, or a parsed
`{"type":"error"}` rate-limit event on stdout (conversation-text
mentions don't count), sets the `rate_limited` sentinel, parks for
`HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries. The UI shows
a `⊘ rate limited` badge while parked.
- **Auth failure (401)** — `drive_turn` retries once (transient
token-refresh races clear on retry); a second `AuthFailed` writes
`{state_dir}/hyperhive-needs-login`, requeues the message, and parks in
`wait_for_login` — the same path as a cold boot with no session. The
operator re-auths via the per-agent web UI; the queued message then
drives the next turn.
- **Login detection** — both boot (`login::has_session`, Online vs
NeedsLogin) and `wait_for_login`'s resume check key off the credential
files in `login::CRED_FILE_NAMES` (the set `/logout` deletes).
`wait_for_login` takes a `since: SystemTime` baseline (the instant of
the 401 that parked it, or `login::NO_PRIOR_FAILURE` at cold boot) and
resumes only once a credential file's mtime postdates it — so stale
credentials already on disk at the 401 don't trigger an instant
false-resume, and a login that lands *before* `wait_for_login` even
starts polling still resumes correctly (baselining on a fixed instant
rather than an entry-time directory snapshot is what closes that race).
Leftover session-history files still don't read as a live session after
a logout + container recreate (`has_session`/`is_cred_file` scope to
`CRED_FILE_NAMES` either way).
## Harness binary shape
Two sibling binaries over one runtime library, all role-agnostic (there
is one role: agent — the privilege boundary lives server-side at the
broker socket (`/run/hive/mcp.sock`), which refuses privileged `Request`
variants regardless of who sends them):
- `hive-agent` — long-running harness loop (the inbox poll +
runtime turn + ack/requeue cycle described above).
- `hive-runtime` — the library every turn runs through, one `Runtime`
impl per runtime (`ClaudeRuntime`, `AcpRuntime`); `hive-subagent-mcp`
drives its nested sessions through it too.
- `hive-agent-mcp` — MCP server for the built-in `hyperhive` surface.
Run with `--http <addr>` as a persistent streamable-HTTP daemon (the
`hive-mcp-http` systemd unit, on `services.hyperhive.agent.mcp.httpPort`, default
8790); claude connects to its URL via `--mcp-config`. HTTP is the sole
transport — no per-turn stdio child (eliminates the re-registration
race).
A small `Surface` trait, with one zero-sized impl, factors
`hive-agent`'s wire types (`hive_core_agent_sock::{Request, Response}` —
one unified enum shared by every agent socket) and its turn
loop, so the loop itself has no per-role branches. See
`hive-agent/src/main.rs`'s module
doc for the trait shape.
### 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/5-project/process/conventions.md::Hive identity` for the env stack),
opens turn-stats sqlite, prepares the on-boot files (see
[claude-invocation](claude-invocation.md#on-boot-files)),
installs claude plugins, spawns `web_ui::serve` + `vacuum::run`,
and either drops into `serve_loop` directly (`Online`) or parks on
the login flow first (`NeedsLogin`). `hive-forge-notify` polls forge
notifications on its own, not this loop — see
[`forge.md`](../../2-handbook/integrations/forge.md).
Boot also opens the todos store and the socket in-container producers
dial. Matrix / bash / forge-notify daemons and the in-process
`disk_watch` todo producer (low state-disk space) are the built-in
producers, but the socket accepts any `subsystem` marker — a
user-configured MCP server can push its own todos the same way. See
[`docs/2-handbook/agent-lifecycle/persistence.md`](../../2-handbook/agent-lifecycle/persistence.md#state-dirs-per-agent) for
what each built-in todo producer watches and how the store + `get_loose_ends`
merge work.
Plugin install failures aren't fatal: the harness logs each at `warn` and skips it.
### Turn outcomes
`turn::TurnOutcome` (`Result<bool, TurnError>` — `Ok(compacted)` on success,
else a `TurnError`) drives the post-claude branch:
| Outcome | Action |
| --- | --- |
| `Ok(_)` (`false` normal / `true` compacted) | `ack_turn` |
| `Err(PromptTooLong)` | `drive_turn` archived the session (the lib already compacted + retried and it still overflowed); requeue inflight so the message redelivers into a fresh session that fits — no status park |
| `Err(RateLimited)` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` |
| `Err(AuthFailed)` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` |
| `Err(SessionNotFound)` | resume + create self-heal both missed ("shouldn't happen"); requeue inflight so the next turn creates fresh — no status park, message not dropped |
| `Err(ApiStall)` | idle watchdog sent claude SIGINT after `HIVE_TURN_IDLE_SECS` (default 600) of output silence and claude exited; sleep `HIVE_STALL_SLEEP_SECS` (default 60), requeue inflight, status back to `online` |
| `Err(AgentStall(note))` | the same, for an ACP agent: after `HIVE_TURN_IDLE_SECS` with no `session/update` the harness sent the agent `session/cancel`, and SIGINT if it ignored that; `note` says which |
| `Err(Failed(err))` | the harness logs it at `warn` as `turn failed`, the line the Grafana alert rule `hh-agent-turn-failed` (`nix/host-modules/swarm-grafana.nix`) fires on |
`ApiStall` catches an Anthropic API stall — a multi-retry connection storm where
the stream goes silent for minutes. On `claude` the idle watchdog lives in
`hive-claude`'s driver (`Config::idle_timeout`, enforced around `child.wait()`): the timer
resets on every stdout line, so a large but still-streaming turn is never cut —
only complete output silence for the window trips it. The harness sets the
window from `HIVE_TURN_IDLE_SECS` (`0` disables) and maps the driver's
`Error::IdleTimeout` onto `TurnError::ApiStall`. The watchdog sends claude's
process group SIGINT, so claude stops the commands it started, and then
waits for claude to exit. Nothing escalates to SIGKILL: a claude that
ignores the SIGINT keeps the turn open until someone kills it or the unit
stops.
`hive-runtime` applies the same window to an ACP agent's turns. That's
also how a provider HTTP 429 ends an ACP turn when the agent retries it without
reporting it: the turn goes silent until the watchdog stops it.
After the outcome handler, the stats sink records a row. `handle_turn`
reports the result to `serve_loop` via `TurnControl { auth_failed }` —
on auth failure the loop parks in `wait_for_login`; otherwise it loops
straight back to the idle wait (step 1). No same-turn
self-continue mechanism exists: every multi-step continuation rides an
external wake instead — a new inbox message, a `remind`, or an
in-container todo wake (bash-task completion, forge notification,
matrix activity). Ending the turn and letting one of
those drive the next one is strictly better than parking in-process:
it checkpoints the session and observes wakes that only reach the
harness between turns.
## Harness systemd unit shape
One harness serve binary (`hive-agent`, with its `hive-agent-mcp`
sibling), one shared `nix/agent-modules/` tree, one service unit
(`systemd.services.hive-agent`) for all agents. Privilege
differences live in the agent's swarm permissions and the broker socket (which tools
and manager-surface calls each agent receives).
`nix/templates/agent.nix` is the one entry point every agent builds from, and
it imports the shared `nix/agent-modules/`.
`services.hyperhive.agent.docs.enable` defaults to `false` for every agent,
ruth included; an agent opts in from its own `agent.nix` (see
[config.md](config.md), "Reference docs").
### Environment variables set on the unit
- `HOME = /home/<userName>` — systemd defaults `HOME` to `/` for
services without `User=` set; with the per-agent user the harness
needs the right home so claude finds its bind-mounted `~/.claude/`
session dir.
- `HIVE_STATIC_DIR = <mergedDist>` — `tower_http::ServeDir` root for
the per-agent web UI; merged dist = agent default + every
`services.hyperhive.agent.frontend.extraFiles` overlay.
- `HIVE_ASSETS_DIR = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive"`
(`nix/agent-modules/agent-service.nix:480`; `packages.assets` resolves
to the `hyperhive-assets` derivation) — set directly on the unit,
**not** via `environment.variables`, because the latter only
populates `/etc/profile` which systemd services don't inherit.
### `PATH` setup (the wrapper-dir trick)
```nix
path = [ "/run/wrappers" "/run/current-system/sw" ];
```
<!-- vale write-good.Passive = NO -->
`/run/wrappers` (not `/run/wrappers/bin`) comes first so setuid
wrappers — notably `sudo` — resolve before bare nix-store binaries; see
[`docs/5-project/process/gotchas.md`](../../5-project/process/gotchas.md) ("`systemd.services.*.path` appends
`/bin` to every entry") for why the trailing `/bin` matters in
general. It's load-bearing here because the harness runs as the
per-agent user: without the wrapper dir on `PATH`, `sudo` resolves to
the non-setuid nix-store binary and every
`services.hyperhive.agent.user.passwordlessSudo` grant fails with "must be owned by
uid 0 and have the setuid bit set."
<!-- vale write-good.Passive = YES -->
### `serviceConfig` highlights
- `ExecStart = "${config.services.hyperhive.agent.packages.hive-agent}/bin/${binary}"`
(`nix/agent-modules/agent-service.nix:535`); `packages.hive-agent` is
wired to the flake's `hyperhive.packages.<system>.hive-agent` output
(`nix/agent-modules/packages.nix:8-22`) — same binary for every agent.
- `RuntimeDirectory = "hive-config"` → `/run/hive-config/` owned by
`User=`, autocleared on stop. The harness writes regenerated
`claude-{mcp-config,settings,system-prompt}` files there
(`paths::config_dir`). Deliberately separate from `/run/hive`, which
the host bind-mounts in root-owned and which holds hive-c0re's
`mcp.sock`.
- `User = Group = userName` — drops root inside the container; sudo is
the explicit escalation surface (`services.hyperhive.agent.user.passwordlessSudo`).
## Sub-pages
The rest lives alongside this page, in three topic files:
- **[claude-invocation.md](claude-invocation.md)** — how the harness
spawns `claude --print` each turn, the two-pronged compaction (reactive +
proactive), and the on-boot files it materialises (`--mcp-config`,
`--system-prompt-file`).
- **[config.md](config.md)** — the optional per-agent knobs the meta
flake wires in (reference docs, icon, passwordless sudo, dashboard links,
custom static files, connectivity overrides, claude plugins, cargo message
filtering).
- **[mcp.md](mcp.md)** — the MCP tool surface claude sees: core tools,
permission-gated tools, self-wake, authoritative state, the tool envelope,
and the built-in tool allowlist.
Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages
describe present-state behaviour + wiring, not line-level mechanics.

View file

@ -0,0 +1,317 @@
# The claude invocation
How a turn runs on the `claude` runtime, the default
(`services.hyperhive.agent.runtime`). An `acp` agent's turns:
→ [`hive-runtime/README.md`](../../../hive-runtime/README.md).
```
claude --print --verbose --output-format stream-json --model <name> \
--effort <level> --resume <title> # or --name <title> on first use \
--system-prompt-file /run/hive-config/claude-system-prompt.md \
--mcp-config /run/hive-config/claude-mcp-config.json --strict-mcp-config \
--tools <builtins> --allowedTools <builtins+mcp>
# wake prompt piped over stdin
```
**Crate split.** `hive-agent` runs every turn through `hive-runtime`,
whose `ClaudeRuntime` is a pass-through to the external **`hive-claude`**
crate (a crates.io dependency, not in this repo). `hive-claude` holds the
generic subprocess mechanics — spawning `claude --print`, streaming +
classifying stream-json, session lookup/archive, and the durable-session
compaction loop (`hive_claude::{Claude, InfiniteSession, Attach,
CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}`).
`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 per hive with
`services.hyperhive.deploy.hive-controller.claudeCodePackage`. `hive-priv`
writes its store path into the container's `/etc/hyperhive-hive-facts` as
`HYPERHIVE_CLAUDE_CODE_PATH` before every start. `claude` on PATH is a wrapper
that runs that build when the path exists, and the container's own
`claude-code` when the variable is unset or the path no longer exists, saying
so on stderr in the second case. A bump reaches an agent at its next container
start, not at a rebuild. See docs/5-project/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 swarm's effort setting) controls effort
live, which managed scope would otherwise lock.
The harness reads `<name>` from `Bus::model()` on each turn.
`services.hyperhive.agent.model` in the agent's `agent.nix` sets the initial
default (NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls
back to an existing `/harness/hyperhive-model` file, then `"haiku"`, if
unset). The swarm changes the model at runtime, not the web
terminal: a model or effort the swarm sets for the agent replaces the
`Bus` value from the next turn, and the harness doesn't persist it;
clearing it goes back to the local value →
[`swarm/inference.md`](../swarm/inference.md).
`harness_state::context_window_tokens(model)` looks up
context-window size per-model. Resolution order (first
match wins):
1. The harness's per-family defaults, matched when the family name
is a case-insensitive substring of the active model name:
haiku=200k, sonnet=1M, opus=1M.
2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any
other model (useful in dev / test).
3. Hard fallback: `200_000`.
Once a turn completes, the window the model reports
(`modelUsage.*.contextWindow`) replaces this value.
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 that 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. The harness never passes
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](#compaction) below for why (it's a safety net, not the
primary mechanism; hyperhive still owns that). `outputStyle` is fleet-wide
**Concise** — output tokens are real spend on an always-on agent, and the
preamble/recap Concise drops carries no information a turn-loop consumer
needs.
**Session reset** is available via `POST /api/new-session` (the state
badge's `new session` menu row). 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`:
<!-- vale write-good.Passive = NO -->
- **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](README.md#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 `/state` now") and
_then_ `/compact`s, so the agent can persist in-flight state before the
detail collapses into a summary.
<!-- vale write-good.Passive = YES -->
**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 (a known, root-caused gap).
**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 `services.hyperhive.agent.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. An ACP agent gets the same policy; how it compacts is in
[`hive-runtime/README.md`](../../../hive-runtime/README.md#acp-backend-compaction).
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
services.hyperhive.agent.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. `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, `drive_turn` drops 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.
Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to 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. AGENTS.md autoload walks
upward from `/state` — drop a per-agent AGENTS.md there if you
want long-term hints that survive destroy/recreate. A `CLAUDE.md`
in `/state` or above it shadows `AGENTS.md`: claude then loads only
the `CLAUDE.md` (see `docs/2-handbook/agent-lifecycle/persistence.md` for the
boot-time rename).
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.
`hive-agent` self-announces once on every boot:
`boot_announce::announce` pushes a one-line "you were just
(re)started, check /state/ for your notes, your session is intact"
todo the moment the in-agent todo store opens, before the serve loop
starts. This replaced `hive-c0re`'s old `Coordinator::kick_agent`
calls at the tail of the rebuild and start DAG nodes — those fired a
broker message per DAG node, so one rebuild (always a real
`StopForUpdate` → `Swap` → `RebuildBookkeeping` → `Reconcile` →
`Start` cycle) queued two separate restart messages for a single
actual boot. The harness itself is the one side that knows for
certain whether a boot happened, so it dedups for free: exactly one
todo per real process start, surfaced the normal todo-wake way
rather than as an 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 persistent
`hive-mcp-http` daemon (`http://127.0.0.1:{port}/mcp`, port from
`services.hyperhive.agent.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-agent/prompts/system.md` by `hive-agent`'s `prompt::render`:
HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`,
same for `role:manager`) gate the role-specific blocks; both flavors
share everything else. `<!-- config:repo -->` and
`<!-- config:archetype -->` blocks gate the config-repo paragraphs: an
agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built
from an [archetype](../../2-handbook/swarm/archetypes.md)) gets the archetype block and
every other agent the repo block, with `{archetype}` filled in with the
archetype's name. Five placeholders are then
substituted: `{label}` (short agent name), `{qualified_label}`
(hive-qualified `name@domain` form),
`{hive_identity}` (for example `` on hive `pr1ma` ``; empty when
`HYPERHIVE_HIVE_NAME` is unset), `{swarm_identity}` (same
shape for the swarm), and `{operators}`: a paragraph listing the
swarm's operators with the pronouns from each one's forge profile,
for example `Operators: mara (she/her), sol.`. The harness reads
them with one direct get on the one key of the `operator-profiles`
KV bucket, which `swarm-controller` writes. The harness waits up to five seconds for
them. When the bucket is empty or doesn't answer in time,
`{operators}` is empty and the prompt names no operator. Either way
the template tells agents to say "the operator" in third person. A
pronoun change reaches an agent at its next harness start, since
the prompt renders once per start.
When you enable `services.hyperhive.agent.docs.enable`, `HIVE_DOCS_DIR` is present
in the environment and `render()` appends a one-sentence pointer
telling the agent where the docs live.
Passed via `--system-prompt-file`.
**Marker grammar.** `<!-- KIND:X -->` opens a block; any
`<!-- /KIND:X -->` closes the current block of that kind. The renderer
filters the two kinds one after the other. The renderer always uses
role `agent`, so it elides blocks with other role tags, and config `repo`
or `archetype` as above. Nesting **isn't**
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's `system.md` carries no role markers (single agent
role), and the role grammar stays wired for a future manager / multi-role
prompt; it carries one pair of config blocks.
**`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 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, reached
through `hive-runtime`'s `ClaudeRuntime`.
Login-wait lives in `hive-agent`'s `login` module.

View file

@ -0,0 +1,348 @@
# Agent config knobs
Optional per-agent knobs, set under `services.hyperhive.agent.*` in the
agent's own `agent.nix` and read at boot or per turn by the harness.
Absent means the default. (The claude spawn + compaction themselves live in
[claude-invocation](claude-invocation.md).)
## Reference docs (`services.hyperhive.agent.docs.enable`)
```nix
services.hyperhive.agent.docs.enable = true; # default: false
```
Makes the hyperhive `docs/` tree available inside the container at a nix
store path read from `$HIVE_DOCS_DIR`, and injects a single pointer
sentence into the agent's system prompt so it knows the docs exist and
where to find them. `claude --add-dir` serves the tree so the full
markdown is readable during every turn.
Off by default for every agent, ruth included. An agent opts in by adding the
line above to its `agent.nix`.
The docs come from the pinned `hyperhive` input (the `reference-docs` package
built from its `docs/` tree), so they match the hyperhive rev the host runs.
## Agent icon
```nix
services.hyperhive.agent.icon = ./icon.svg; # default: null (falls back to shared hyperhive logo)
```
Path to an SVG file used as this agent's visual identity — shown in
the per-agent page header, as the page favicon, and uploaded to the
agent's Forgejo profile avatar (via the `forge-avatar-sync` boot
unit) and Matrix profile avatar (set by `hive-matrix-daemon` over its
live Client). Commit the SVG next to `agent.nix` in the config repo
and reference it as a relative path.
When `null` (the default), the agent falls back to the shared
hyperhive branding mark. The harness serves whichever icon is active
at `GET /icon` on the per-agent web port.
## `user.passwordlessSudo`
```nix
services.hyperhive.agent.user.passwordlessSudo = true; # default
```
Grants the per-agent unix user passwordless `sudo` (`NOPASSWD: ALL`).
Enabled by default so claude's shell tools work for operations that
need root inside the container (`systemctl`, package managers in dev
shells, etc.) — the agent user gets root explicitly via `sudo` rather
than running as root itself.
Set to `false` for agents that should be strictly unprivileged.
Any tool invocation that needs root then fails loudly with the standard
sudo rejection rather than silently succeeding — easier to audit.
`services.hyperhive.agent.user.name` is the unix user the harness and
its co-process daemons run as inside the container (default `"agent"`
for a standalone evaluation; the meta flake rebinds it to the agent's
own name). `services.hyperhive.agent.user.uid` / `.gid` are optional
fixed UID/GID (`null` default lets NixOS assign one automatically from
the normal-user range) — set them only when something outside the
container needs a stable numeric id across a full destroy + recreate.
## Dashboard links
```nix
services.hyperhive.agent.dashboardLinks = [
{ label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; }
{ label = "Scratchpad"; url = "http://localhost:8080"; }
];
```
Declares extra navigation links that appear in the per-agent page
header alongside the built-in forge / config / container links. Each
entry has:
| Field | Required | Description |
| ------- | -------- | ----------------------------------------------------------------------------------------- |
| `label` | yes | Display text shown in the icon strip tooltip and meta-nav. |
| `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). |
| `icon` | no | Emoji or short glyph prefix. Defaults to empty string. |
A one-shot systemd unit writes the list to
`<state>/hyperhive-dashboard-links.json` at container boot. The harness's own web UI
(`agent_links` in `hive-agent/src/web_ui/state.rs`) reads the file on
each `/api/state` snapshot and appends the entries to the per-agent
nav as `kind = External` links — no `hive-c0re` / operator-dashboard
involvement, and no code change needed to pick up a new entry.
Omitting the option (default empty) produces no extra links.
## Custom static files
```nix
services.hyperhive.agent.frontend.extraFiles = {
"games/bitburner" = {
source = ./bitburner-dist; # path relative to agent.nix
# target defaults to attribute name: "games/bitburner"
};
"my-page" = {
source = ./my-page.html;
target = "my-page.html"; # explicit override
};
};
```
Layers additional files over the default per-agent web UI dist. Each
attribute defines one overlay entry:
- **`source`** — a Nix path (file or directory) copied into the merged
static tree. Evaluated at nix build time; the resulting derivation is
pointed at by `HIVE_STATIC_DIR`.
- **`target`** — destination path within the merged tree, used as both
the served URL prefix (`/<target>/…`) and the on-disk layout.
Defaults to the attribute name. Forward slashes create nested layouts
(`"games/bitburner"` serves at `/games/bitburner/…`).
Constraints: `target` must start with an alphanumeric or `_` and
contain only alphanumerics, `_`, `.`, `/`, `-`. A config assertion
rejects `..` segments. The merge step refuses to overwrite
files already present in the default dist — pick a target name that
doesn't collide with existing paths (`static/`, `index.html`, etc.).
The default dist ships at `services.hyperhive.agent.frontend.dist` (the
`hyperhive-frontend` package output, read-only). To replace the
entire UI rather than layer on top, override `frontend.dist` directly.
## Connectivity overrides
Two `services.hyperhive.agent.forge.*` / `services.hyperhive.agent.matrix.*` options override where
the per-agent daemons connect. Both rarely need changing on a standard
single-host deploy, but are useful for multi-hive or custom-network
setups.
```nix
services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: null
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
```
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
the git credential helper to this forge and is where `forge-avatar-sync`
uploads the agent's icon. Override when the agent should connect to a
Forgejo on a different host or port (for example a swarm peer's forge).
Validated: must be an `http://` or `https://` URL, or `null`.
**Defaults to `null`, meaning "no forge" — not a guessed address.** A
loopback default would only ever be correct when the forge shares the
agent's network namespace, and inside a container `localhost` is the
agent itself, so the default was a value that built fine and then talked
to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
units aren't generated at all: an absent integration rather than a
misdirected one. You don't normally set this — hive-c0re renders the
host's real forge URL into every agent, and refuses to write a meta
flake without one, so `null` only survives where nix evaluates the
agent modules outside a hive.
**`services.hyperhive.agent.matrix.url`** — homeserver URL used by
`hive-matrix-daemon` when connecting via the matrix-sdk. hive-c0re
writes it into every agent at deploy time as the gateway-routed
`chat.<swarm-domain>` URL, so isolated agents can reach the homeserver.
Override per-agent when an agent should talk to a different homeserver
— for example a remote hive's tuwunel reached over a VPN, or an
external Matrix server for a federation-only agent.
**Defaults to `null`, meaning "no matrix" — same reasoning as
`forge.url` above** (a loopback default would resolve inside the
agent's own netns to the agent itself, not the homeserver). With
`null` the daemon has no homeserver and no-ops exactly as it does
without a token. The hive only forwards `HIVE_MATRIX_URL` when it
actually has a matrix vhost to name, so `null` survives where a hive
runs no homeserver, or where nix evaluates the agent modules outside a
hive.
## Claude Code plugins
The harness installs Claude Code plugins before the serve loop opens.
Three per-agent `agent.nix` options control this:
```nix
services.hyperhive.agent.claudeMarketplaces = [ # default
"anthropics/claude-plugins-official"
"${services.hyperhive.agent.packages.claude-plugins}" # hive's own local marketplace, registered as `hyperhive`
];
services.hyperhive.agent.claudePlugins = [ # default
"skill-creator@claude-plugins-official"
"base@hyperhive"
];
services.hyperhive.agent.claudePluginsAutoUpdate = false; # default
```
- **`claudeMarketplaces`** — list of marketplace sources passed to
`claude plugin marketplace add <source>`. Defaults to Anthropic's
official marketplace plus hyperhive's own `claude-plugins` nix
package (see `nix/packages/claude-plugins.nix`) — a local-path
marketplace registered under the name `hyperhive`, so a hive-authored
skill needs no forge repo or git remote to ship. Override or extend
to add custom marketplaces. Idempotent — re-adding an existing
source is a no-op.
- **`claudePlugins`** — list of plugin specs passed to
`claude plugin install <spec>`. The harness installs each spec on
every boot, assuming `install` is idempotent; failures log a warning
but don't abort boot. Boot waits for the marketplace adds, the optional
update and the installs (serial `claude plugin` calls). The harness kills
and logs any call that hasn't exited within 2 minutes, and caps the whole
sequence at 4 minutes: after that the remaining calls fail at once with
the same warning and the web UI starts. Defaults to Anthropic's
`skill-creator` (so every
agent can author, refine, and evaluate its own skills) plus
hyperhive's own `base` plugin — skills that apply to every agent
regardless of role (currently just `state-hygiene`) — all without
any per-agent wiring.
> Both plugin lists follow ordinary NixOS list-option semantics: a
> per-agent definition **replaces** the default, it doesn't extend it.
> An agent that sets `claudePlugins` and still wants the defaults has
> to list `skill-creator@claude-plugins-official` and `base@hyperhive`
> explicitly alongside its own entries — likewise for the two default
> entries in `claudeMarketplaces`.
- **`claudePluginsAutoUpdate`** — when `true`, runs
`claude plugin marketplace update` before installing plugins to pull
the latest index. Off by default to keep boot times short and
plugin versions pinned.
## `cargo.shortMessages`
```nix
services.hyperhive.agent.cargo.shortMessages = true; # default
```
When enabled (the default), the harness injects a `cargo` shell
function into `/etc/hyperhive/bash-env.sh` that transparently appends
`--message-format short` to compile subcommands (`build`, `check`,
`clippy`, `test`, `run`, `doc`, `bench`, `install`, `rustc`, `fix`).
This suppresses the per-crate progress lines that flood the response
window, leaving only warnings and errors.
The function handles `+toolchain` selectors (`cargo +nightly build`)
and passes through cleanly when `--message-format` is already present.
It leaves non-compile subcommands (`new`, `add`, third-party
`cargo-*`) untouched.
Set to `false` for agents that parse cargo's JSON output
programmatically and don't pass `--message-format json` themselves.
## API-key provider (`useApiKey` / `backendEnvironmentFile`)
```nix
services.hyperhive.agent.useApiKey = true; # default: false
services.hyperhive.agent.backendEnvironmentFile =
"/agents/myagent/harness/openrouter.env"; # default: null
services.hyperhive.agent.model = "anthropic/claude-3.5-sonnet"; # provider-specific model string
```
Runs this agent's `claude` against an API-key provider (for example OpenRouter)
instead of a Claude subscription via OAuth. Two options, paired — each
is a no-op without the other:
- **`useApiKey`** tells the harness itself not to wait for a Claude OAuth
session: at boot, `LoginState::from_dir` reports `Online` without
checking `~/.claude/` (`hive_agent::login::using_api_key`, reads
`HIVE_USE_API_KEY`), and the harness stamps the fact into the
consolidated harness state file so the operator dashboard also stops reading this
agent's empty `~/.claude/` as "needs login." An api-key agent that hits
a real 401 (the key itself is bad) still surfaces `needs_login` — the
harness skips only the boot-time check for an earlier login, not
the auth-failure path.
- **`backendEnvironmentFile`** points at an operator-managed file
(outside the nix store, one `KEY=value` per line, systemd
`EnvironmentFile` syntax) supplying the credentials `claude` itself
reads from the environment — typically `ANTHROPIC_API_KEY` and
`ANTHROPIC_BASE_URL`. Loaded as an _optional_ `EnvironmentFile`
(leading `-`), so setting the option before the file exists doesn't
strand the harness at boot. With `useApiKey` set, the subagent daemon
(`hive-subagent-daemon`) loads the same file, so the agent's claude
subagents run on the same key.
On an agent with a secret store (`services.hyperhive.agent.bao.addr`) that uses
`useApiKey` or the `acp` runtime, `hive-agent-backend-env.service` also keeps the
file's text in the store at `swarm/agents/<agent>/backend-env`, field `env`, and
writes it to `/run/hive-agent-backend-env/env` every 2 minutes. When the store
holds nothing there, the unit creates the leaf from `backendEnvironmentFile`,
byte for byte and once; it never rotates a value and never rewrites or deletes
the file. The harness and the subagent daemon load `backendEnvironmentFile`
first and the `/run` file after it, so a variable the store holds wins, and with
the store down at a start the file alone carries the agent. Both read it at
their own start, so a changed value reaches a running agent at its next
restart.
To set or replace a key, use **set the API-key environment** on the agent in the swarm UI:
it sends `PUT /api/hives/{hive}/agents/{agent}/backend-env` with the file text,
which must be blank lines, `#` or `;` comments, and `NAME=VALUE` lines of at
most 16 KiB. A save overwrites the stored text, including text the agent wrote
into an empty leaf, and the store's value wins over the file. The box never
shows the stored text. **Remove** (`DELETE` on the same path) deletes the stored
text. An agent whose file has content creates the leaf from it again within 2
minutes; one without a file keeps its copy in `/run` until the container
restarts.
A save also keeps the host of `ANTHROPIC_BASE_URL` (and of every `*_BASE_URL`
variable) as an automatic allowed-domain entry for the agent, so a restricted
agent reaches its provider through the hive's proxy; **Remove** drops it. The
controller derives only from an operator's save, never from text it finds in the
store, so an environment saved before this existed needs one more save. An
agent that has just the file gets no entry, and an operator adds its
provider's host in the swarm UI (see
[allowed domains](../../2-handbook/swarm/ui.md#allowed-domains)).
Provision the file once, out of band (never through nix — an API key in
the store is world-readable and travels with the flake closure):
```sh
# on the host, once per agent that should use an api-key backend
sudo install -m 0600 -o root /dev/stdin \
/var/lib/hyperhive/agents/<name>/harness/openrouter.env <<KEYS
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
ANTHROPIC_API_KEY=sk-or-...
KEYS
```
The file lives in the agent's bind-mounted **harness** dir, not `state/`
— `harness/` survives container rebuilds exactly like `state/` does, but
(unlike `state/`) is never bind-mounted into a parent agent's container
(see `docs/2-handbook/agent-lifecycle/persistence.md`'s "Parent access to child state"), so an API
key placed here is reachable by nothing but this agent and the host. The
file doesn't stay root-owned, though: the container's activation chowns the
whole harness dir to the agent, so after the next boot the file is agent-owned
whatever owner you installed it with, and the agent can rewrite it. That holds
until the swarm secret store replaces the file.
<!-- vale write-good.Passive = NO -->
⚠️ Verified end-to-end against OpenRouter hasn't happened as of this
writing — `ANTHROPIC_BASE_URL` support in the shipped Claude CLI is
documented behavior, not something this hive has run a live turn
against yet. Tool use, streaming, and MCP all need to keep working
through a non-Anthropic base URL; treat the first real agent on this
path as the actual verification, not this doc.
<!-- vale write-good.Passive = YES -->
Switching an already-provisioned OAuth agent to `useApiKey` leaves
`~/.claude/credentials.json` in place but unused — harmless, not
cleaned up automatically. Cost shape also changes: subscription pricing
→ per-request billing with no built-in monthly cap, worth knowing before
pointing a busy agent at a metered provider.

View file

@ -0,0 +1,240 @@
# MCP surface
The harness ships an embedded MCP server (rmcp 2). A persistent
`hive-mcp-http` daemon serves the built-in `hyperhive` surface over
streamable HTTP (loopback, `127.0.0.1:<services.hyperhive.agent.mcp.httpPort>`,
per-container private netns). Claude connects to its stable URL via
`--mcp-config` rather than respawning a stdio child each turn, so the
URL survives the per-turn claude re-spawn (and a host-side hive-c0re
restart) — there is no per-turn MCP re-registration race for the
built-in surface. HTTP is the sole transport for it (no stdio fallback).
Extra servers (`services.hyperhive.agent.extraMcpServers`) pick their own transport per
entry (`type = "stdio" | "http"`, default `"stdio"`): `matrix` and
`subagent` each run their own persistent streamable-http listener
(`hive-matrix-daemon`, `hive-subagent-daemon`), same reasoning as the
built-in surface and `bash`'s `hive-bash-daemon`. The GUI bridge
(`screen`, gated on `services.hyperhive.agent.gui.enable`) is the one
entry on the schema's stdio default — it spawns
`hive-screen-mcp` fresh each turn. `rea` (gated on
`services.hyperhive.agent.rea.enable`) is a second stdio entry, spawning `rea mcp`. The server name is `hyperhive`, so
the tools land in claude as
`mcp__hyperhive__<tool>`. Each entry also has an `availableToSubagents`
toggle (default `false`) — see
[`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md#mcp-servers-available-to-a-subagent)
for what reaches a subagent's own `--mcp-config` and what doesn't.
An `"http"` entry that needs request headers (a bearer-authenticated remote
server) sets `headersFile` to an absolute path: a file holding a JSON object
of header name to value. It renders as claude's `headersHelper` (`cat` of the
file), so the secret never enters the nix store, and claude re-reads a rotated token
on its next reconnect. ACP agents don't read it.
The agent's permissions gate tool access
([`docs/5-project/process/conventions.md#permissions`](../../5-project/process/conventions.md)).
The default (`Permission::DEFAULT`) is `messaging`, `meta`, `inbox` and
`execution`; `swarmctl agent permissions set` changes it, and the next turn
runs on the change.
## Core tools (always available)
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
`recv(max?)`, `ack_until(up_to)`.
- `send` — hive-local broker message to a peer on this hive (logical name). With
the matrix MCP tools, talk to peers over matrix instead (a DM to the `user_id` in
`get_agent_meta`'s `matrix_accounts`); `send` is for peers without a matrix
account and agents without matrix tools. Optional
`in_reply_to: i64` links the message to a prior id for thread
rendering. Per-agent `services.hyperhive.agent.allowedRecipients`
(default: empty = unrestricted) limits which names `send` accepts —
useful for sandboxing. The broker refuses `to: "operator"`. The error
points an agent that has the matrix MCP tools at matrix
([matrix](../../2-handbook/tools/matrix.md)), and tells one without them that the
operator reads its `set_status` line, its turn output on the dashboard,
and its logs.
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
it to `ack_until` to bulk-triage the batch). **Graceful shutdown**: when the harness
receives a stop signal, the inbox becomes fenced and `recv` returns an
explicit `from: "graceful-stop"` message instead of an empty inbox.
This unmissably directs the agent to flush durable state (`/state`
files) and end the turn — the container exits when the turn completes.
The graceful-stop turn takes the same post-turn compaction path as any
other turn: if the context crossed the watermark the harness runs a
notes-checkpoint turn and then `/compact`. Compacting before shutdown
keeps a later cold start cheap instead of re-uploading a large transcript.
- `ack_until(up_to)` — bulk-mark inbox rows handled: it stamps every row
with broker id `<= up_to` as acked in a single UPDATE.
Recipient-scoped (agents can only ack their own rows). Use when a
restart redelivers a large backlog of already-handled messages: read
the highest `[msg #N]` from the set you've actually processed, then
`ack_until(N)` to prevent re-pop. Acked rows never redeliver.
Transient pings (sentinel id 0) have nothing to ack and show no marker.
**System messages** (from sender `system`): the broker delivers them as regular inbox
messages (same `recv` path). A container crash never reaches an
agent — it only produces a log line + swarm-level grafana
alert, no agent turn at all (`docs/4-implementation/scheduler/observability.md`). Lifecycle
transitions the job-queue scheduler or crash watcher drive directly —
stop/kill, destroy, a flake-rev login or logout state change — reach
no individual agent: they publish onto a swarm-wide NATS
JetStream stream (`swarm_notices::notify`,
`hive-c0re/src/swarm_notices.rs`); every hive is swarm-controlled.
**Inbox** (`inbox` group): `get_loose_ends()`,
`cancel_loose_end(kind, id)`, `remind(message, delay_seconds? |
at_unix_timestamp?)`.
- `get_loose_ends()` — list this agent's own open threads: pending
reminders, open todos pushed by an in-container subsystem (matrix,
bash, forge, or any user-configured MCP server), an undelivered-inbox
count, and unread matrix notifications. Always the caller's own rows. Full per-variant wire shape in
[`docs/5-project/process/conventions.md` § Loose-ends wire
shape](../../5-project/process/conventions.md#loose-ends-wire-shape).
- `cancel_loose_end` — hard-delete a `reminder` or clear a `todo` row
(loose-ends-v2). Agents may only cancel rows they own.
- `remind` — schedule a reminder in this agent's own inbox. Large
payloads spill to `/agents/<self>/state/reminders/`. Pending count
capped at 50 per agent (`HIVE_REMIND_MAX_PENDING_PER_AGENT`).
No same-turn self-continue tool exists — see
[Turn outcomes](README.md#turn-outcomes) for why. Multi-step work rides
`remind` for a durable self-wake, or an in-container todo wake
(bash-task completion, forge notification, matrix activity) for work
already in flight.
**Meta** (`meta` group): `get_agent_meta(name?)`.
- `get_agent_meta` — fetch identity + status metadata for an agent:
`{ name, hyperhive_rev, running, status_text, status_set_at,
hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
list of matrix identities the agent can act as (`name`, `user_id?`,
`homeserver`); omitted for agents with no matrix provisioning. Omit
`name` to query self.
**Always-on, no permission needed**: `set_status(text)`, `compact()`,
`mark_todos_done(ids)`.
- `set_status` — set a free-text status string visible on the
dashboard. Single line, ≤ 200 chars. Persisted to
`{state_dir}/hyperhive-status`. Pass `""` to clear. Not gated by the
`meta` group — every agent needs to keep its dashboard status chip
current regardless of which optional groups it holds.
- `compact` — agent self-service equivalent of the operator dashboard's
`/compact` button. No args. Gated server-side on the last completed
turn's context usage: refused (with an explanation, no side effect)
unless usage is above 66% of the effective context window. On a
pass, queues the same deferred `compact_pending` flag the dashboard
button sets — consumed at the end of the current turn, so it never
races a live claude process, and the usual pre-compaction
notes-checkpoint turn still fires first. Dispatched through the
in-agent socket (`hive-agent-sock::Request::Compact`), not the
broker — see `hive-agent/src/todo_server.rs`.
- `mark_todos_done(ids)` — bulk-clear specific loose-ends-v2 todo rows
by id in one call, instead of `cancel_loose_end`ing each one.
List-based: pass the ids `get_loose_ends` actually showed you reviewed;
unknown or already-done ids are silently skipped. Dials the same
in-agent todo socket as `cancel_loose_end`'s `todo` kind.
## Privileged tools (by permission)
- **Bash execution** (`execution`) — background shell tasks. See
[`docs/2-handbook/tools/bash.md`](../../2-handbook/tools/bash.md).
- **Subagent spawning** — nested sessions on the agent's own runtime
(claude or ACP, via `hive-runtime`) as background tasks, shipped
default-on like bash execution; no permission gates it. See
[`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md).
- **Config changes** — no permission: they go through a forge PR on
`agent-configs/<name>` — see
[`docs/2-handbook/agent-lifecycle/config-changes.md`](../../2-handbook/agent-lifecycle/config-changes.md).
- **Web egress** (`web_tools`) — enables Claude's built-in `WebFetch`
and `WebSearch` tools (not MCP tools; added directly to the
`--allowedTools` list). Off by default.
- **Recovery mounts** (`manage_root_agent`) — no tool: every agent's state
and config mounted into this agent's container. See
[`docs/5-project/process/conventions.md#permissions`](../../5-project/process/conventions.md).
- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and
per-agent extra MCP config. See
[`docs/2-handbook/tools/matrix.md`](../../2-handbook/tools/matrix.md).
## Waking the agent from inside the container
The built-in producers (matrix, bash, forge) upsert a todo on the
harness's in-agent socket (`HIVE_AGENT_SOCKET`, `UpsertTodo` — see
[`docs/2-handbook/tools/bash.md` § Completion as a todo
(loose-ends v2)](../../2-handbook/tools/bash.md#completion-as-a-todo-loose-ends-v2)
for the full upsert/signal/clear mechanism): a new or changed summary
makes the harness signal its own turn loop, with no hive-c0re
round-trip. An external MCP server (or any other in-container process)
can push its own todo the same way — `subsystem` is any string.
The host-served per-agent socket at `/run/hive/mcp.sock` carries a
lower-level `Wake` request (`hive_core_agent_sock::Request::Wake {
from, body }` — JSON-line, `{"cmd":"wake","from":"matrix","body":"new dm
from @alice"}\n`) that drops the body straight into the broker inbox as
`{from:<label>, to:<agent>, body}`, bypassing the todo layer entirely.
`hive-c0re` handles it; matrix, bash and forge don't call it. Identity = socket: anything that can connect to
`/run/hive/mcp.sock` is implicitly trusted to inject one — the
bind-mount is the agent's own container only.
## Authoritative state
`hive-agent`'s `events::Bus` carries the current turn-loop state in
addition to the broadcast channel and the events history. Variants:
- `Idle` — sitting on `Recv` waiting for mail.
- `Thinking` — `claude --print` is running for a turn.
- `Compacting` — operator-triggered `/compact` is in flight.
The harness flips state at the relevant transitions
(`set_state(Thinking)` before `drive_turn`, `set_state(Idle)`
after; `set_state(Compacting)` around an idle operator compact in
`turn::run_pending_compact`). Exposed via `/api/state.turn_state` +
`turn_state_since` (unix seconds); the agent page renders this rather
than deriving from SSE events.
## Tool envelope
`mcp::run_tool_envelope`: every MCP tool handler logs the request,
runs the body, logs the result. Pre-/post-log only — the inbox
status hint lives in the wake prompt + UI header, not here.
## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`)
The built-in list and the `--tools` value it resolves to live in
`hive-sh4re` (the `Permission` enum itself is in `hive-types`), not in the
harness, because the
subagent daemon spawns its own `claude` and must resolve the same set —
see [`docs/2-handbook/tools/subagent.md`](../../2-handbook/tools/subagent.md).
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`.
`Skill` is what makes an installed plugin's `SKILL.md` invokable —
without it, a skill's `description` frontmatter never gets seen by
the model no matter how well it matches the task.
- Permission-gated built-ins: `WebFetch`, `WebSearch` (added once the
agent holds `web_tools`).
- Denied by omission (absent from the harness `--tools` /
`--allowedTools`, so they "literally don't exist" in a harness turn):
`Bash`, `Task`, `NotebookEdit`, `TodoWrite`.
- Additionally in the managed-settings deny list
(`/etc/claude-code/managed-settings.json`, un-overridable): `Task`,
`TodoWrite`. `Bash` **isn't** in the managed deny — see below.
- Allowed MCP tools: as listed above (by permission).
The autonomous harness disallows `Bash` — shell execution goes
through `mcp__bash__run` (background tasks with structured output +
task-id tracking) instead of an interactive shell. The harness gate is
`--tools` / `--allowedTools` (Bash absent from `ALLOWED_BUILTIN_TOOLS`),
so Bash never exists in a harness turn regardless of managed settings.
`Bash` is deliberately **not** in the managed-settings deny so that the
operator-driven `hivectl agent <name> choom` session — which passes neither `--tools`
nor `--allowedTools` — gets claude's built-in synchronous `Bash` tool
(inline, human-approved). That sidesteps the async `mcp__bash__run`
completion wake landing in the wrong session (the harness inbox) for a
choom-started task; `choom` is an operator action (root or `hive-admin`),
so built-in shell there stays within the existing trust boundary. The bash MCP server
(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all
`mcp__bash__*` tools are available whenever the agent holds `execution`.
`WebFetch` / `WebSearch` are off by default; grant `web_tools`
(`swarmctl agent permissions set`) to enable them from the next turn.