diff --git a/CLAUDE.md b/CLAUDE.md index 1a8b0df0..9b52b3f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,10 @@ hand-maintained per-file tree drifts out of sync with the code. operator dashboard (`dashboard.rs`). Largest crate. - **`hive-ag3nt/`** — in-container harness; one `hive` binary for every agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude` - driver, embedded MCP server (`mcp.rs`), per-agent web UI (`web_ui/` - module dir), event + turn-stats sqlite sinks, login flow, system-prompt - renderer, forge-notify subscriber. + driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer + (`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config` + render), per-agent web UI (`web_ui/` module dir), event + turn-stats + sqlite sinks, login flow, system-prompt renderer, forge-notify subscriber. - **`hive-claude/`** — reusable, app-agnostic driver for headless `claude --print`: spawns the CLI, streams + classifies stream-json, parses per-turn `Telemetry`, and drives a durable self-compacting @@ -85,7 +86,10 @@ read them à la carte. - **"How does the per-agent terminal classify + colour events?"** → [`docs/terminal-rendering.md`](docs/terminal-rendering.md). - **"How does claude get its prompt and what tools does it have?"** → - [`docs/turn-loop.md`](docs/turn-loop.md). + [`docs/turn-loop.md`](docs/turn-loop.md) (index: the loop, binary shape, + turn outcomes; sub-pages: + [`claude-invocation`](docs/turn-loop/claude-invocation.md), + [`config`](docs/turn-loop/config.md), [`mcp`](docs/turn-loop/mcp.md)). - **"How do config changes flow from manager to operator to container?"** → [`docs/approvals.md`](docs/approvals.md). - **"What state survives destroy / purge / restart?"** → diff --git a/docs/conventions.md b/docs/conventions.md index a927ec52..2c43c4af 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -360,7 +360,8 @@ the var is absent or empty. **Updating the surface** — when a new `#[tool]` fn is added to `HiveServer` in `hive-ag3nt/src/mcp.rs`, add its name to the matching `ToolGroup::tools()` slice in `hive-sh4re/src/lib.rs`. That's the single source of truth; -`allowed_mcp_tools` reads it at session start. +`mcp_config::allowed_mcp_tools` (in `hive-ag3nt/src/mcp_config.rs`) reads it at +session start. ## Capabilities diff --git a/docs/forge.md b/docs/forge.md index 0ea3f431..093b5860 100644 --- a/docs/forge.md +++ b/docs/forge.md @@ -213,7 +213,7 @@ Five shapes, distinguished by the notification's classification: | Trigger | Wrapper | | ----------------------------------- | --------------------------------------------------------------------------------- | | Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | -| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: body\nassignee: ...` | +| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` | | New issue / PR | `[new PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | | Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\n\n\nassignee: ...` | | State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` | @@ -224,6 +224,10 @@ Review labels come from the Forgejo `state` field: `APPROVED` → submitted yet — no peer-visible event). Unknown states fall back to the generic comment wrapper. +A review submitted with **no body** renders `reviewed by: ` in +place of the `: ` line — deliberately worded to not collide +with the meta-suffix `reviewer:` line (requested reviewers, below). + ### "new" vs "activity on" A review submitted with **no body** carries no `latest_comment_url`, diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 54814b42..0b70e486 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -18,42 +18,37 @@ Each agent harness (`hive serve` — one binary for all agents) runs: over stdin. 5. Stream stdout (JSON lines) into the bus as `LiveEvent::Stream(value)`. Pump stderr as `Note`. -6. Wait for claude to exit. Compaction is two-pronged — *reactive* - on `Prompt is too long` and *proactive* on a context watermark - (see [Compaction](#compaction) below). **Rate-limit detection**: - on stderr the harness does a raw-line match for `429` / - `rate_limit` markers; on stdout it only fires on parsed - `{"type":"error"}` JSON events (avoiding false positives when - agents discuss `rate_limit_error` in conversation text). On - detection the harness sets the `rate_limited` sentinel - (`Bus::emit_status("rate_limited")`), sleeps - `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), then retries. - The dashboard and per-agent page show a `⊘ rate limited` badge - while the harness is parked. **Auth-failed detection**: both - stdout and stderr pumps also match - `AUTH_FAIL_MARKERS` (`"authentication_failed"`, `401`, etc.). - On the first 401, `drive_turn` retries the same prompt once - immediately (transient token-refresh races and brief API hiccups - can cause a 401 that clears on retry). Only if the retry also - returns `AuthFailed` does `drive_turn` bubble it up to the serve - loop, which then writes `{state_dir}/hyperhive-needs-login`, - emits `needs_login_idle` status, requeues the inflight message - (so it replays after re-auth), and parks in `wait_for_login` — - the same path used at boot. The operator re-authenticates via - the per-agent web UI login flow; on success the sentinel is - cleared and the queued message drives the next turn normally. - **Mtime-snapshot resumption**: `wait_for_login` - snapshots the `~/.claude/` dir (newest file mtime + file count) - at entry and only resumes when that snapshot advances — not - just when credentials exist on disk. This prevents a silent - infinite-401 loop: stale credentials already on disk at the - time of the 401 no longer cause an immediate false-resume. The - `DirSnapshot` struct tracks both axes; either a mtime advance - OR a file-count change triggers resume (the count axis handles - filesystems where `modified()` errors on every file). +6. Wait for claude to exit and classify the turn's outcome from the + stream + exit — success, compaction, rate-limit, auth-failure, or + hard failure. The outcome drives the post-turn action (see + [Turn outcomes](#turn-outcomes)); compaction is handled inside the + session (see + [Compaction](turn-loop/claude-invocation.md#compaction)). Rate-limit + and auth-failure detection is described [below](#failure-detection-and-login). 7. Emit `LiveEvent::TurnEnd { ok, note }`. Sleep `poll_ms` to avoid tight loops on transient failures. +### Failure detection and login + +- **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` resumes only when that set changes (a new file or a + newer mtime), so stale credentials on disk at the 401 don't trigger an + instant false-resume, and leftover session-history files don't read as a + live session after a logout + container recreate. + ## Harness binary shape One `hive` binary for all agents. The earlier split into @@ -95,7 +90,8 @@ loop (`serve_loop` / `handle_turn` / `wake`) has no per-role branches. `HIVE_LABEL` (default `"hive"` for standalone runs; the meta flake sets it unconditionally for any container-deployed agent; see `docs/conventions.md::Hive identity` for the env stack), -opens turn-stats sqlite, prepares the on-boot files (see below), +opens turn-stats sqlite, prepares the on-boot files (see +[claude-invocation](turn-loop/claude-invocation.md#on-boot-files)), installs claude plugins, spawns `forge_notify::run` + `web_ui::serve`, and either drops into `serve_loop` directly (`Online`) or parks on the login flow first (`NeedsLogin`). @@ -108,14 +104,16 @@ and the manager fall through to operator). ### Turn outcomes -`turn::TurnOutcome` drives the post-claude branch: +`turn::TurnOutcome` (`Result` — `Ok(compacted)` on success, +else a `TurnError`) drives the post-claude branch: | Outcome | Action | | --- | --- | -| `Ok` / `Compacted` | `ack_turn` | -| `RateLimited` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` | -| `AuthFailed` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` | -| `Failed(err)` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | +| `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(Failed(err))` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | After the outcome handler, the stats sink records a row and the `hyperhive-continue` sentinel (dropped by the `request_next_turn` @@ -133,632 +131,21 @@ isn't needed (this is the `request_next_turn` contract — "no effect if a new inbox message arrives before this turn ends"). The `should_self_continue` predicate encodes exactly that decision. -## The claude invocation - -``` -claude --print --verbose --output-format stream-json --model \ - --effort --resume # or --name <title> on first use \ - --system-prompt-file /run/hive/claude-system-prompt.md \ - --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ - --tools <builtins> --allowedTools <builtins+mcp> -# wake prompt piped over stdin -``` - -**Crate split.** The generic subprocess mechanics — spawning -`claude --print`, streaming + classifying stream-json, session -lookup/archive, and the durable-session compaction loop — live in the -reusable **`hive-claude`** crate (`hive_claude::{Claude, InfiniteSession, -Attach, CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}`; -see `hive-claude/README.md`). `hive_ag3nt::turn` is the hyperhive **policy -layer** on top: it builds the per-turn config from the bus, bridges the -output stream onto the event bus (`BusSink`), and owns the compaction / -auto-reset / retry decisions in `drive_turn`. The lib returns everything it -parsed from a turn (usage, cost, context window, resolved model) as -`Telemetry`, which the policy layer applies to the bus. - -Hive-enforced settings ship at `/etc/claude-code/managed-settings.json` -(claude-code's canonical managed-settings path — precedence #1, -read-only, un-overridable), wired in `nix/templates/harness-base.nix` -from the `prompts/claude-settings.json` asset. `effortLevel` is -deliberately not in that file — effort is controlled live via the -`--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which -managed scope would otherwise lock. - -`<name>` is read from `Bus::model()` on each turn. The initial -default is set by `hyperhive.model` in the agent's `agent.nix` -(NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls -back to `"haiku"` if unset). The operator can flip it at runtime -with `/model <name>` in the web terminal — the next turn picks it -up. The choice is persisted to `/harness/hyperhive-model` so it -survives restart; override path: `HYPERHIVE_MODEL_FILE` env var -for tests. - -Context-window size is looked up per-model via -`events::context_window_tokens(model)`. Resolution order (first -match wins): - -1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` env var, where `KEY` - (lowercased) is a substring of the active model name. Injected - by the meta flake from `services.hyperhive.c0re.contextWindowTokens` - (host-level NixOS option, defaults: haiku=200k, sonnet=1M, - opus=1M). Override these for all agents at once without a - per-agent config change. -2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any - model (useful in dev / test). -3. Hard fallback: `200_000` (conservative; only reached outside - NixOS where the env vars aren't set). - -The effective window drives watermarks and is exposed at runtime -via `/api/state.context_window_tokens` so the UI can show a -percentage-of-window ctx badge. - -**Session identity — a constant title.** Every turn keys on one fixed, -harness-owned session title (`turn::session_title()`, default -`hive-session`, override `HIVE_SESSION_TITLE`). The durable -`hive_claude::InfiniteSession` (built once by the serve loop via -`turn::make_session`, then reused) `--resume <title>`s it; the *first* use -(bootstrap, post-archive, post-purge) misses and the session re-runs the -same prompt once with `--name <title>` to mint it. That single self-heal -rule is the whole identity system — there is **no** scraped session-id -file. Because the -title is constant, `/compact` and its post-compact retry provably target -the same session (killing the old "compact ran on a different/empty -session" bug), and a `choom` invocation in the same cwd can't hijack the -context (it won't carry our title). claude stores sessions in -`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); -`--name` writes the title into the file as a `custom-title` event, which -is what `--resume <title>` resolves against. We never pass bare -`--continue` (it resumes the *latest* session in the cwd — the hijack -vector). Auto-compact and auto-memory are disabled via the managed -settings at `/etc/claude-code/managed-settings.json` because hyperhive -owns compaction — see [Compaction](#compaction) below. - -**Session reset** is available via `POST /api/new-session` (or -`/new-session` slash command). It does *not* touch the session inline — -that would race a mid-write claude process. Instead `Bus::request_session_reset()` -sets a one-shot flag consumed at the next turn boundary by `drive_turn`, -which **archives** the current session: the backing `<uuid>.jsonl` is -renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` -resolution glob, history preserved on disk, only the file carrying *our* -title — any `choom` session sharing the cwd is left alone). The next -turn's `--resume <title>` then misses and self-heals into a fresh session. - -### Compaction - -claude's own in-session auto-compact is off (via the managed settings -at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The -`hive_claude::InfiniteSession` keeps the session alive across the context -window with two triggers baked into its `run`: - -- **Reactive** — claude-code prints `Prompt is too long`. The session is - *already* past the window, so no turn can run on it — the session - `/compact`s straight away and retries the same wake-up prompt once. No - notes-checkpoint turn is possible here: the detail is gone. -- **Proactive** — a turn finishes cleanly but the last inference's context - size 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` — by default re-invokes the running binary - as `mcp` stdio child (so the same binary serves as harness + MCP - server per turn). When `hyperhive.mcp.httpPort` is set in the - agent's NixOS config, the config instead points claude at the - persistent `hive-mcp-http` daemon (`http://127.0.0.1:{port}/mcp`) - — no stdio child per turn; trades the per-turn re-registration race - for a hard dependency on the daemon's uptime (`Restart=always`). -- `claude-system-prompt.md` — rendered from - `hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`: - HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`, - same for `role:manager`) gate the role-specific blocks; everything - else is shared. Five placeholders are then - substituted: `{label}` (short agent name), `{qualified_label}` - (hive-qualified `name@domain` form), `{operator_pronouns}`, - `{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when - `hyperhive.hiveName` is unset), and `{swarm_identity}` (same - shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS` - env (set by the meta flake from - `services.hyperhive.c0re.operatorPronouns`, default `she/her`). - When `hyperhive.docs.enable` is set, `HIVE_DOCS_DIR` is present - in the environment and `render()` appends a one-sentence pointer - telling the agent the docs are mounted at that path (in lieu of - the old CLAUDE.md-in-docs-dir approach, which was dropped in - favour of this direct injection). - Passed via `--system-prompt-file`. - - **Marker grammar.** `<!-- role:X -->` opens a block; matching - `<!-- /role:X -->` closes it. The renderer always uses role `agent`. - Blocks with other role tags are elided. Nesting is NOT supported — - a stray opener overrides until its closing tag (or end of file). A - mismatched closer is elided from the output but does NOT pop the - active role. Whitespace inside markers is tolerated - (`<!--role:foo-->` parses the same as `<!-- role:foo -->`). - Content outside any marker is always included. - - **`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`. - -### Reference docs (`hyperhive.docs.enable`) - -```nix -hyperhive.docs.enable = true; # default: false (true for the manager agent) -``` - -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. The tree is served by `claude --add-dir` so the full -markdown is readable during every turn. - -Enabled by default only for the root/manager agent (`manager.nix`). Any -agent can opt in by adding the line above to its `agent.nix`. - -The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked -separately from the main `hyperhive` flake so editing docs re-locks only -that input — not every agent's container gets rebuilt on a doc-only change. - -### Agent icon - -```nix -hyperhive.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 -hyperhive.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 same privilege surface the previous root-user shape -had, now elevated explicitly rather than implicitly. - -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. - -`hyperhive.user.uid`, `hyperhive.user.gid`, and -`hyperhive.user.name` are the companion options; see -`docs/agent-hierarchy.md` — "Harness systemd unit shape" for the full -`user.*` surface. - -### Dashboard links - -```nix -hyperhive.dashboardLinks = [ - { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } - { label = "Scratchpad"; url = "http://localhost:8080"; } -]; -``` - -Declares extra navigation links that appear on the agent's dashboard -card and 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. | - -The list is written to `<state>/hyperhive-dashboard-links.json` by a -one-shot systemd unit at container boot. `hive-c0re` reads the file on -each container-view snapshot and attaches the links to the agent card -(`kind = External`) without any code change. Omitting the option -(default empty) produces no extra links. - -### Custom static files - -```nix -hyperhive.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, `_`, `.`, `/`, `-`. `..` segments are -rejected by a config assertion. The merge step refuses to overwrite -files already present in the default dist — pick a target name that -does not collide with existing paths (`static/`, `index.html`, etc.). - -The default dist ships at `hyperhive.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 `hyperhive.forge.*` / `hyperhive.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 -hyperhive.forge.url = "http://localhost:3000"; # default -hyperhive.matrix.url = "http://localhost:8008"; # default -``` - -**`hyperhive.forge.url`** — base URL of the Forgejo instance. Used by -a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml` -directly from the agent's `forge-token`, so `tea` and `hive-forge` -work without an interactive auth step. The unit is a no-op when -`forge-token` is absent. Override when the agent should connect to a -Forgejo on a different host or port (e.g. a swarm peer's forge). -Validated: must be an `http://` or `https://` URL or the empty string. - -**`hyperhive.matrix.url`** — homeserver URL used by -`hive-matrix-daemon` when connecting via the matrix-sdk. Default -(`localhost:8008`) is overridden by hive-c0re at deploy time to the -gateway-routed `matrix.<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. - -### Claude Code plugins - -The harness installs Claude Code plugins before the serve loop opens. -Two per-agent `agent.nix` options control this: - -```nix -hyperhive.claudeMarketplaces = [ "anthropics/claude-plugins-official" ]; # default -hyperhive.claudePlugins = [ "formatter@my-marketplace" ]; # default: [] -hyperhive.claudePluginsAutoUpdate = false; # default -``` - -- **`claudeMarketplaces`** — list of marketplace sources passed to - `claude plugin marketplace add <source>`. The official Anthropic - marketplace is pre-configured by default; 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>`. Empty by default. Each spec is - installed on every boot (`install` is expected to be idempotent); - failures log a warning but do not abort boot. -- **`claudePluginsAutoUpdate`** — when `true`, runs - `claude plugin marketplace update` before installing plugins to pull - the latest index. Disabled by default to keep boot times short and - plugin versions pinned. - -### `cargo.shortMessages` - -```nix -hyperhive.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. -Non-compile subcommands (`new`, `add`, third-party `cargo-*`) are -left untouched. - -Set to `false` for agents that parse cargo's JSON output -programmatically and do not pass `--message-format json` themselves. - -## MCP surface - -The harness ships an embedded MCP server (rmcp 1.7). Claude launches -it as a stdio child via `--mcp-config`. The hyperhive socket name is -`hyperhive`, so the tools land in claude as `mcp__hyperhive__<tool>`. - -Tool access is gated by tool groups (`HIVE_TOOL_GROUPS`). The default -preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and -`execution`. Privileged groups (`lifecycle`, `approvals`, `scheduling`, -`diagnostics`) are opt-in via the P3RM1SS10NS tab. - -### Core tools (always available) - -**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`, -`recv(wait_seconds?, max?)`, `ask(question, options?, multi?, -ttl_seconds?, to?)`, `answer(id, answer)`, `ack_until(up_to)`. - -- `send` — message a peer (logical name) or the operator - (`to: "operator"`). Use `to: "<parent>"` to address the topology - parent without hardcoding the label; the broker resolves the - sentinel at delivery time. Optional `in_reply_to: i64` links the - message to a prior id for thread rendering. Per-agent - `hyperhive.allowedRecipients` (default: empty = unrestricted) limits - which names `send` accepts — useful for sandboxing: set - `[ "operator" ]` to restrict a sub-agent to operator messages only - (the topology parent is always reachable regardless of this list — - that carve-out is structural, keyed on parent relationship, not name). -- `recv` — drain inbox. Without `wait_seconds` (or `0`) returns - immediately. Positive value parks the turn up to that many seconds - (cap 180) — incoming messages wake instantly. `max` (default 1, cap - 5) drains up to N rows; `wait_seconds` applies to the first, then - drains up to `max` total. Each returned row is prefixed 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 huge transcript. -- `ask` — surface a structured question to the operator (default) or - a peer agent (`to: "<agent>"`). Non-blocking — returns a question - id; the answer arrives as a `question_answered` system event in the - asker's inbox. `options` is advisory; `multi=true` renders as - checkboxes; `ttl_seconds` auto-cancels with answer `[expired]`. -- `answer` — respond to a `question_asked` event routed to this - agent. Strict authorisation: only the declared target can answer. -- `ack_until(up_to)` — bulk-mark inbox rows handled: every row with - broker id `<= up_to` is stamped 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`): lifecycle and Q&A events -delivered as regular inbox messages (same `recv` path; body is a JSON -object with an `event` discriminant field). The **submitting agent** -(the root agent for top-level containers; an agent with the `approvals` -tool group for its own subtree) receives lifecycle events (`spawned`, -`rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, -`logged_in`, `config_ready`, `needs_update`, `approval_resolved`). Any -agent receives Q&A events when it is the declared target -(`question_asked`) or the asker (`question_answered`). Full payload -shapes and routing logic in -[`docs/approvals.md` § Helper events](../approvals.md#helper-events-to-the-submitting-agent). - -**Inbox** (`inbox` group): `get_loose_ends(agent?)`, -`cancel_loose_end(kind, id)`, `remind(message, delay_seconds? | -at_unix_timestamp?)`, `request_next_turn()`. - -- `get_loose_ends(agent?)` — list pending questions (asked/owed), - scheduled reminders, and active local tasks published by external MCP - daemons (e.g. running bash tasks from `hive-bash-mcp`). Each row - carries an id + kind for `cancel_loose_end`. Omit `agent` to list - your own threads. Pass `agent: "<name>"` to inspect a direct child - agent (always accessible per topology enforcement); non-children - require the `query_agent_state` capability. The `"*"` hive-wide - query is not available on the agent socket. -- `cancel_loose_end` — withdraw a `question` (posts `[cancelled by - <self>]`), hard-delete a `reminder`, or cancel a pending `approval` - row. Agents may only cancel rows they own; the `approval` kind is - further restricted to the root agent (`ruth`) server-side. -- `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`). -- `request_next_turn` — ask the harness to start another turn - immediately after this one ends, even if the inbox is empty. - Next turn fires with `from: "self"` and `body: "continue"`. - -**Meta** (`meta` group): `set_status(text)`, `get_agent_meta(name?)`. - -- `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. -- `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. - -### Privileged tools (by tool group) - -- **Bash execution** (`execution`) — background shell tasks. See - [`docs/tools/bash.md`](tools/bash.md). -- **Lifecycle + config** (`lifecycle`, `approvals`) — manage child - agents, spawn new ones, apply config commits. See - [`docs/tools/lifecycle.md`](tools/lifecycle.md). -- **Scheduling + diagnostics** (`scheduling`, `diagnostics`) — - scheduled prompts, `get_logs`. See - [`docs/tools/scheduling.md`](tools/scheduling.md). -- **Forge repos** (`forge`) — `create_repo` — the only agent path to - create a repo under the `agents/` org (direct forge token creation is - disabled for agents). The repo is created in the c0re-owned `agents` - org; the calling agent gets write collaborator access; the default - branch is branch-protected (operator-team must approve merges, so the - agent cannot self-merge). Opt-in; not in any default preset. - See [`docs/tools/forge.md — Repo management`](tools/forge.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; add the group in the - P3RM1SS10NS tab and rebuild to enable. -- **Capability-gated** — `get_host_journal` (requires - `read_host_journal` capability set via the P3RM1SS10NS tab; - orthogonal to tool groups). Full list of capabilities and their - effects in [`docs/conventions.md#capabilities`](../docs/conventions.md). - Also documented in [`docs/tools/scheduling.md`](tools/scheduling.md). -- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and - per-agent extra MCP config. See - [`docs/tools/matrix.md`](tools/matrix.md). - -### Waking the agent from inside the container - -External MCP servers (and any other in-container process) can -inject a wake-up event into the agent's inbox via the per-agent -socket at `/run/hive/mcp.sock`. Two equivalent paths: - -- **Shell out to `hive wake --from <label> --body <text>`** - (use `--body -` to read body from stdin). Already on the - container's `PATH` since the harness binary is in - `systemPackages`. Convenient for shell-script integrations and - co-process daemons (matrix bridge, webhook listeners, scrapers). - -- **Speak the wire protocol directly** — JSON-line over the - unix socket: `{"cmd":"wake","from":"matrix","body":"new dm - from @alice"}\n`. Same shape as any other `AgentRequest`; - see `hive-sh4re::AgentRequest::Wake`. - -The wake event lands in the broker as `{from:<label>, -to:<agent>, body}`, waking whatever `recv` call the harness -is currently blocked on. The next turn fires with the wake -prompt formed from that message. - -Identity = socket: anything that can connect to -`/run/hive/mcp.sock` is implicitly trusted to inject these — -the bind-mount is the agent's own container only. - -### Authoritative state - -`hive_ag3nt::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 moved to the wake prompt + UI header. - -### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`) - -- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`. -- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the - `web_tools` tool group is enabled — see P3RM1SS10NS tab). -- Denied by omission or the managed-settings deny list - (`/etc/claude-code/managed-settings.json`): `Bash`, `Task`, - `NotebookEdit`, `TodoWrite`. -- Allowed MCP tools: as listed above (by tool group). - -`Bash` is disallowed — shell execution goes through -`mcp__bash__run` (background tasks with structured output + -task-id tracking) instead of an interactive shell. The bash MCP server -(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all -`mcp__bash__*` tools are always available regardless of tool groups. - -`WebFetch` / `WebSearch` are off by default; enable the `web_tools` -tool group in the P3RM1SS10NS tab and rebuild the agent to enable them. - +## Sub-pages + +The rest lives in three topic pages under [`turn-loop/`](turn-loop/): + +- **[claude-invocation.md](turn-loop/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](turn-loop/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](turn-loop/mcp.md)** — the MCP tool surface claude sees: core tools, + privileged tool groups, self-wake, authoritative state, the tool envelope, + and the built-in tool whitelist. + +Per-subsystem impl detail lives in each module's `//!` doc-comment; these pages +describe present-state behaviour + wiring, not line-level mechanics. diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md new file mode 100644 index 00000000..e44d050e --- /dev/null +++ b/docs/turn-loop/claude-invocation.md @@ -0,0 +1,241 @@ +# The claude invocation + +``` +claude --print --verbose --output-format stream-json --model <name> \ + --effort <level> --resume <title> # or --name <title> on first use \ + --system-prompt-file /run/hive/claude-system-prompt.md \ + --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ + --tools <builtins> --allowedTools <builtins+mcp> +# wake prompt piped over stdin +``` + +**Crate split.** The generic subprocess mechanics — spawning +`claude --print`, streaming + classifying stream-json, session +lookup/archive, and the durable-session compaction loop — live in the +reusable **`hive-claude`** crate (`hive_claude::{Claude, InfiniteSession, +Attach, CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}`; +see `hive-claude/README.md`). `hive_ag3nt::turn` is the hyperhive **policy +layer** on top: it builds the per-turn config from the bus, bridges the +output stream onto the event bus (`BusSink`), and owns the compaction / +auto-reset / retry decisions in `drive_turn`. The lib returns everything it +parsed from a turn (usage, cost, context window, resolved model) as +`Telemetry`, which the policy layer applies to the bus. + +Hive-enforced settings ship at `/etc/claude-code/managed-settings.json` +(claude-code's canonical managed-settings path — precedence #1, +read-only, un-overridable), wired in `nix/templates/harness-base.nix` +from the `prompts/claude-settings.json` asset. `effortLevel` is +deliberately not in that file — effort is controlled live via the +`--effort` flag (`HIVE_DEFAULT_EFFORT` / the per-agent UI slider), which +managed scope would otherwise lock. + +`<name>` is read from `Bus::model()` on each turn. The initial +default is set by `hyperhive.model` in the agent's `agent.nix` +(NixOS option; propagates via `HIVE_DEFAULT_MODEL` env var; falls +back to `"haiku"` if unset). The operator can flip it at runtime +with `/model <name>` in the web terminal — the next turn picks it +up. The choice is persisted to `/harness/hyperhive-model` so it +survives restart; override path: `HYPERHIVE_MODEL_FILE` env var +for tests. + +Context-window size is looked up per-model via +`events::context_window_tokens(model)`. Resolution order (first +match wins): + +1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` env var, where `KEY` + (lowercased) is a substring of the active model name. Injected + by the meta flake from `services.hyperhive.c0re.contextWindowTokens` + (host-level NixOS option, defaults: haiku=200k, sonnet=1M, + opus=1M). Override these for all agents at once without a + per-agent config change. +2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override for any + model (useful in dev / test). +3. Hard fallback: `200_000` (conservative; only reached outside + NixOS where the env vars aren't set). + +The effective window drives watermarks and is exposed at runtime +via `/api/state.context_window_tokens` so the UI can show a +percentage-of-window ctx badge. + +**Session identity — a constant title.** Every turn keys on one fixed, +harness-owned session title (`turn::session_title()`, default +`hive-session`, override `HIVE_SESSION_TITLE`). The durable +`hive_claude::InfiniteSession` (built once by the serve loop via +`turn::make_session`, then reused) `--resume <title>`s it; the *first* use +(bootstrap, post-archive, post-purge) misses and the session re-runs the +same prompt once with `--name <title>` to mint it. That single self-heal +rule is the whole identity system — there is **no** scraped session-id +file. Because the +title is constant, `/compact` and its post-compact retry provably target +the same session (killing the old "compact ran on a different/empty +session" bug), and a `choom` invocation in the same cwd can't hijack the +context (it won't carry our title). claude stores sessions in +`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); +`--name` writes the title into the file as a `custom-title` event, which +is what `--resume <title>` resolves against. We never pass bare +`--continue` (it resumes the *latest* session in the cwd — the hijack +vector). Auto-compact and auto-memory are disabled via the managed +settings at `/etc/claude-code/managed-settings.json` because hyperhive +owns compaction — see [Compaction](#compaction) below. + +**Session reset** is available via `POST /api/new-session` (or +`/new-session` slash command). It does *not* touch the session inline — +that would race a mid-write claude process. Instead `Bus::request_session_reset()` +sets a one-shot flag consumed at the next turn boundary by `drive_turn`, +which **archives** the current session: the backing `<uuid>.jsonl` is +renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` +resolution glob, history preserved on disk, only the file carrying *our* +title — any `choom` session sharing the cwd is left alone). The next +turn's `--resume <title>` then misses and self-heals into a fresh session. + +## Compaction + +claude's own in-session auto-compact is off (via the managed settings +at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The +`hive_claude::InfiniteSession` keeps the session alive across the context +window with two triggers baked into its `run`: + +- **Reactive** — claude-code prints `Prompt is too long`. The session is + *already* past the window, so no turn can run on it — the session + `/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` — by default re-invokes the running binary + as `mcp` stdio child (so the same binary serves as harness + MCP + server per turn). When `hyperhive.mcp.httpPort` is set in the + agent's NixOS config, the config instead points claude at the + persistent `hive-mcp-http` daemon (`http://127.0.0.1:{port}/mcp`) + — no stdio child per turn; trades the per-turn re-registration race + for a hard dependency on the daemon's uptime (`Restart=always`). +- `claude-system-prompt.md` — rendered from + `hive-ag3nt/prompts/system.md` by `hive_ag3nt::prompt::render`: + HTML-comment markers (`<!-- role:agent -->...<!-- /role:agent -->`, + same for `role:manager`) gate the role-specific blocks; everything + else is shared. Five placeholders are then + substituted: `{label}` (short agent name), `{qualified_label}` + (hive-qualified `name@domain` form), `{operator_pronouns}`, + `{hive_identity}` (e.g. `` on hive `pr1ma` ``; empty when + `hyperhive.hiveName` is unset), and `{swarm_identity}` (same + shape for the swarm). Pronouns come from `HIVE_OPERATOR_PRONOUNS` + env (set by the meta flake from + `services.hyperhive.c0re.operatorPronouns`, default `she/her`). + When `hyperhive.docs.enable` is set, `HIVE_DOCS_DIR` is present + in the environment and `render()` appends a one-sentence pointer + telling the agent the docs are mounted at that path (in lieu of + the old CLAUDE.md-in-docs-dir approach, which was dropped in + favour of this direct injection). + Passed via `--system-prompt-file`. + + **Marker grammar.** `<!-- role:X -->` opens a block; any + `<!-- /role:X -->` closes the current block. The renderer always uses + role `agent`, so blocks with other role tags are elided. Nesting is NOT + supported — a stray opener with no closer runs until end of file. + Whitespace inside markers is tolerated (`<!--role:foo-->` parses the + same as `<!-- role:foo -->`). Content outside any marker is always + included. Today's `system.md` carries no markers (single agent role) — + the grammar stays wired for a future manager / multi-role prompt. + + **`hive_identity` / `swarm_identity` shape.** Each carries a + leading space + backticked name (` on hive \`pr1ma\``, + ` in swarm \`constellat1on\``) when the corresponding env var + is set, otherwise empty string. The independence lets the + template drop one or both into the opener prose without + breaking single-hive deployments that never set the option; + the renderer also treats `Some("")` from a caller 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`. + diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md new file mode 100644 index 00000000..ad74b988 --- /dev/null +++ b/docs/turn-loop/config.md @@ -0,0 +1,199 @@ +# Agent config knobs + +Optional per-agent knobs the meta flake wires into the container from +`services.hyperhive.agents.<name>`, 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 (`hyperhive.docs.enable`) + +```nix +hyperhive.docs.enable = true; # default: false (true for the manager agent) +``` + +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. The tree is served by `claude --add-dir` so the full +markdown is readable during every turn. + +Enabled by default only for the root/manager agent (`manager.nix`). Any +agent can opt in by adding the line above to its `agent.nix`. + +The `docs/` source is a narrow flake input (`hyperhive-docs`) tracked +separately from the main `hyperhive` flake so editing docs re-locks only +that input — not every agent's container gets rebuilt on a doc-only change. + +## Agent icon + +```nix +hyperhive.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 +hyperhive.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 same privilege surface the previous root-user shape +had, now elevated explicitly rather than implicitly. + +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. + +`hyperhive.user.uid`, `hyperhive.user.gid`, and +`hyperhive.user.name` are the companion options; see +`docs/agent-hierarchy.md` — "Harness systemd unit shape" for the full +`user.*` surface. + +## Dashboard links + +```nix +hyperhive.dashboardLinks = [ + { label = "Stats"; icon = "📊"; url = "http://localhost:9001/stats"; } + { label = "Scratchpad"; url = "http://localhost:8080"; } +]; +``` + +Declares extra navigation links that appear on the agent's dashboard +card and 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. | + +The list is written to `<state>/hyperhive-dashboard-links.json` by a +one-shot systemd unit at container boot. `hive-c0re` reads the file on +each container-view snapshot and attaches the links to the agent card +(`kind = External`) without any code change. Omitting the option +(default empty) produces no extra links. + +## Custom static files + +```nix +hyperhive.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, `_`, `.`, `/`, `-`. `..` segments are +rejected by a config assertion. The merge step refuses to overwrite +files already present in the default dist — pick a target name that +does not collide with existing paths (`static/`, `index.html`, etc.). + +The default dist ships at `hyperhive.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 `hyperhive.forge.*` / `hyperhive.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 +hyperhive.forge.url = "http://localhost:3000"; # default +hyperhive.matrix.url = "http://localhost:8008"; # default +``` + +**`hyperhive.forge.url`** — base URL of the Forgejo instance. Used by +a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml` +directly from the agent's `forge-token`, so `tea` and `hive-forge` +work without an interactive auth step. The unit is a no-op when +`forge-token` is absent. Override when the agent should connect to a +Forgejo on a different host or port (e.g. a swarm peer's forge). +Validated: must be an `http://` or `https://` URL or the empty string. + +**`hyperhive.matrix.url`** — homeserver URL used by +`hive-matrix-daemon` when connecting via the matrix-sdk. Default +(`localhost:8008`) is overridden by hive-c0re at deploy time to the +gateway-routed `matrix.<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. + +## Claude Code plugins + +The harness installs Claude Code plugins before the serve loop opens. +Two per-agent `agent.nix` options control this: + +```nix +hyperhive.claudeMarketplaces = [ "anthropics/claude-plugins-official" ]; # default +hyperhive.claudePlugins = [ "formatter@my-marketplace" ]; # default: [] +hyperhive.claudePluginsAutoUpdate = false; # default +``` + +- **`claudeMarketplaces`** — list of marketplace sources passed to + `claude plugin marketplace add <source>`. The official Anthropic + marketplace is pre-configured by default; 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>`. Empty by default. Each spec is + installed on every boot (`install` is expected to be idempotent); + failures log a warning but do not abort boot. +- **`claudePluginsAutoUpdate`** — when `true`, runs + `claude plugin marketplace update` before installing plugins to pull + the latest index. Disabled by default to keep boot times short and + plugin versions pinned. + +## `cargo.shortMessages` + +```nix +hyperhive.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. +Non-compile subcommands (`new`, `add`, third-party `cargo-*`) are +left untouched. + +Set to `false` for agents that parse cargo's JSON output +programmatically and do not pass `--message-format json` themselves. + diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md new file mode 100644 index 00000000..02d707ed --- /dev/null +++ b/docs/turn-loop/mcp.md @@ -0,0 +1,201 @@ +# MCP surface + +The harness ships an embedded MCP server (rmcp 1.7). Claude launches +it as a stdio child via `--mcp-config`. The hyperhive socket name is +`hyperhive`, so the tools land in claude as `mcp__hyperhive__<tool>`. + +Tool access is gated by tool groups (`HIVE_TOOL_GROUPS`). The default +preset (`AGENT_DEFAULT`) includes `messaging`, `meta`, `inbox`, and +`execution`. Privileged groups (`lifecycle`, `approvals`, `scheduling`, +`diagnostics`) are opt-in via the P3RM1SS10NS tab. + +## Core tools (always available) + +**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`, +`recv(wait_seconds?, max?)`, `ask(question, options?, multi?, +ttl_seconds?, to?)`, `answer(id, answer)`, `ack_until(up_to)`. + +- `send` — message a peer (logical name) or the operator + (`to: "operator"`). Use `to: "<parent>"` to address the topology + parent without hardcoding the label; the broker resolves the + sentinel at delivery time. Optional `in_reply_to: i64` links the + message to a prior id for thread rendering. Per-agent + `hyperhive.allowedRecipients` (default: empty = unrestricted) limits + which names `send` accepts — useful for sandboxing: set + `[ "operator" ]` to restrict a sub-agent to operator messages only + (the topology parent is always reachable regardless of this list — + that carve-out is structural, keyed on parent relationship, not name). +- `recv` — drain inbox. Without `wait_seconds` (or `0`) returns + immediately. Positive value parks the turn up to that many seconds + (cap 180) — incoming messages wake instantly. `max` (default 1, cap + 5) drains up to N rows; `wait_seconds` applies to the first, then + drains up to `max` total. Each returned row is prefixed 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 huge transcript. +- `ask` — surface a structured question to the operator (default) or + a peer agent (`to: "<agent>"`). Non-blocking — returns a question + id; the answer arrives as a `question_answered` system event in the + asker's inbox. `options` is advisory; `multi=true` renders as + checkboxes; `ttl_seconds` auto-cancels with answer `[expired]`. +- `answer` — respond to a `question_asked` event routed to this + agent. Strict authorisation: only the declared target can answer. +- `ack_until(up_to)` — bulk-mark inbox rows handled: every row with + broker id `<= up_to` is stamped 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`): lifecycle and Q&A events +delivered as regular inbox messages (same `recv` path; body is a JSON +object with an `event` discriminant field). The **submitting agent** +(the root agent for top-level containers; an agent with the `approvals` +tool group for its own subtree) receives lifecycle events (`spawned`, +`rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, +`logged_in`, `config_ready`, `needs_update`, `approval_resolved`). Any +agent receives Q&A events when it is the declared target +(`question_asked`) or the asker (`question_answered`). Full payload +shapes and routing logic in +[`docs/approvals.md` § Helper events](../approvals.md#helper-events-to-the-submitting-agent). + +**Inbox** (`inbox` group): `get_loose_ends(agent?)`, +`cancel_loose_end(kind, id)`, `remind(message, delay_seconds? | +at_unix_timestamp?)`, `request_next_turn()`. + +- `get_loose_ends(agent?)` — list pending questions (asked/owed), + scheduled reminders, and active local tasks published by external MCP + daemons (e.g. running bash tasks from `hive-bash-mcp`). Each row + carries an id + kind for `cancel_loose_end`. Omit `agent` to list + your own threads. Pass `agent: "<name>"` to inspect a direct child + agent (always accessible per topology enforcement); non-children + require the `query_agent_state` capability. The `"*"` hive-wide + query is not available on the agent socket. +- `cancel_loose_end` — withdraw a `question` (posts `[cancelled by + <self>]`), hard-delete a `reminder`, or cancel a pending `approval` + row. Agents may only cancel rows they own; the `approval` kind is + further restricted to the root agent (`ruth`) server-side. +- `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`). +- `request_next_turn` — ask the harness to start another turn + immediately after this one ends, even if the inbox is empty. + Next turn fires with `from: "self"` and `body: "continue"`. + +**Meta** (`meta` group): `set_status(text)`, `get_agent_meta(name?)`. + +- `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. +- `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. + +## Privileged tools (by tool group) + +- **Bash execution** (`execution`) — background shell tasks. See + [`docs/tools/bash.md`](../tools/bash.md). +- **Lifecycle + config** (`lifecycle`, `approvals`) — manage child + agents, spawn new ones, apply config commits. See + [`docs/tools/lifecycle.md`](../tools/lifecycle.md). +- **Scheduling + diagnostics** (`scheduling`, `diagnostics`) — + scheduled prompts, `get_logs`. See + [`docs/tools/scheduling.md`](../tools/scheduling.md). +- **Forge repos** (`forge`) — `create_repo` — the only agent path to + create a repo under the `agents/` org (direct forge token creation is + disabled for agents). The repo is created in the c0re-owned `agents` + org; the calling agent gets write collaborator access; the default + branch is branch-protected (operator-team must approve merges, so the + agent cannot self-merge). Opt-in; not in any default preset. + See [`docs/tools/forge.md — Repo management`](../tools/forge.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; add the group in the + P3RM1SS10NS tab and rebuild to enable. +- **Capability-gated** — `get_host_journal` (requires + `read_host_journal` capability set via the P3RM1SS10NS tab; + orthogonal to tool groups). Full list of capabilities and their + effects in [`docs/conventions.md#capabilities`](../conventions.md). + Also documented in [`docs/tools/scheduling.md`](../tools/scheduling.md). +- **Matrix MCP + extra servers** — `mcp__matrix__*` tools and + per-agent extra MCP config. See + [`docs/tools/matrix.md`](../tools/matrix.md). + +## Waking the agent from inside the container + +External MCP servers (and any other in-container process) can +inject a wake-up event into the agent's inbox via the per-agent +socket at `/run/hive/mcp.sock`. Two equivalent paths: + +- **Shell out to `hive wake --from <label> --body <text>`** + (use `--body -` to read body from stdin). Already on the + container's `PATH` since the harness binary is in + `systemPackages`. Convenient for shell-script integrations and + co-process daemons (matrix bridge, webhook listeners, scrapers). + +- **Speak the wire protocol directly** — JSON-line over the + unix socket: `{"cmd":"wake","from":"matrix","body":"new dm + from @alice"}\n`. Same shape as any other `AgentRequest`; + see `hive-sh4re::AgentRequest::Wake`. + +The wake event lands in the broker as `{from:<label>, +to:<agent>, body}`, waking whatever `recv` call the harness +is currently blocked on. The next turn fires with the wake +prompt formed from that message. + +Identity = socket: anything that can connect to +`/run/hive/mcp.sock` is implicitly trusted to inject these — +the bind-mount is the agent's own container only. + +## Authoritative state + +`hive_ag3nt::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 moved to the wake prompt + UI header. + +## Tool whitelist (`mcp_config::ALLOWED_BUILTIN_TOOLS`) + +- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`. +- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the + `web_tools` tool group is enabled — see P3RM1SS10NS tab). +- Denied by omission or the managed-settings deny list + (`/etc/claude-code/managed-settings.json`): `Bash`, `Task`, + `NotebookEdit`, `TodoWrite`. +- Allowed MCP tools: as listed above (by tool group). + +`Bash` is disallowed — shell execution goes through +`mcp__bash__run` (background tasks with structured output + +task-id tracking) instead of an interactive shell. The bash MCP server +(`run` / `status` / `kill`) uses `allowedTools = ["*"]` so all +`mcp__bash__*` tools are always available regardless of tool groups. + +`WebFetch` / `WebSearch` are off by default; enable the `web_tools` +tool group in the P3RM1SS10NS tab and rebuild the agent to enable them. + diff --git a/hive-ag3nt/src/bin/hive.rs b/hive-ag3nt/src/bin/hive.rs index 37cf3864..6e710138 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; - use anyhow::Result; use clap::{Parser, Subcommand}; use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; @@ -108,7 +107,7 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { } /// Body string for the turn-failure notification we route to -/// `<parent>` on `TurnOutcome::Failed`. Reads the hive-qualified +/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified /// identity so the receiver sees `agent@hive` rather than relying on /// the caller threading a `label` through every turn-handling layer. /// Falls back to `<unknown>` when `HIVE_LABEL` is missing so a @@ -277,39 +276,37 @@ trait Surface { /// Talks `AgentRequest` / `AgentResponse`. struct AgentSurface; +/// Issue an `Ok`-expecting fire-and-forget broker request, logging any +/// rejection / unexpected response / transport error under `label`. Shared by +/// the `Surface` methods that don't need the reply (`ack_turn`, +/// `requeue_inflight`, `graceful_stop_complete`). +async fn fire_and_forget(socket: &Path, req: AgentRequest, label: &str) { + match client::request::<_, AgentResponse>(socket, &req).await { + Ok(AgentResponse::Ok) => {} + Ok(AgentResponse::Err { message }) => { + tracing::warn!(%message, "{label} rejected by broker"); + } + Ok(other) => tracing::warn!(?other, "{label} unexpected response"), + Err(e) => tracing::warn!(error = ?e, "{label} transport error"), + } +} + impl Surface for AgentSurface { async fn ack_turn(socket: &Path) { - match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await { - Ok(AgentResponse::Ok) => {} - Ok(AgentResponse::Err { message }) => { - tracing::warn!(%message, "ack_turn rejected by broker"); - } - Ok(other) => tracing::warn!(?other, "ack_turn unexpected response"), - Err(e) => tracing::warn!(error = ?e, "ack_turn transport error"), - } + fire_and_forget(socket, AgentRequest::AckTurn, "ack_turn").await; } async fn requeue_inflight(socket: &Path) { - match client::request::<_, AgentResponse>(socket, &AgentRequest::RequeueInflight).await { - Ok(AgentResponse::Ok) => {} - Ok(AgentResponse::Err { message }) => { - tracing::warn!(%message, "requeue_inflight rejected by broker"); - } - Ok(other) => tracing::warn!(?other, "requeue_inflight unexpected response"), - Err(e) => tracing::warn!(error = ?e, "requeue_inflight transport error"), - } + fire_and_forget(socket, AgentRequest::RequeueInflight, "requeue_inflight").await; } async fn graceful_stop_complete(socket: &Path) { - match client::request::<_, AgentResponse>(socket, &AgentRequest::GracefulStopComplete).await - { - Ok(AgentResponse::Ok) => {} - Ok(AgentResponse::Err { message }) => { - tracing::warn!(%message, "graceful_stop_complete rejected by broker"); - } - Ok(other) => tracing::warn!(?other, "graceful_stop_complete unexpected response"), - Err(e) => tracing::warn!(error = ?e, "graceful_stop_complete transport error"), - } + fire_and_forget( + socket, + AgentRequest::GracefulStopComplete, + "graceful_stop_complete", + ) + .await; } async fn inbox_unread(socket: &Path) -> u64 { @@ -432,7 +429,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> { // through `<parent>` via the `send_to_parent` failure-notify path. // The broker resolves `<parent>` per `topology::parent_of`; // root agents fall through to operator. - for failure in plugins::install_configured(socket).await { + for failure in plugins::install_configured().await { S::send_to_parent(socket, failure).await; } tokio::spawn(hive_ag3nt::forge_notify::run(socket.to_path_buf())); @@ -549,8 +546,7 @@ async fn serve_loop<S: Surface>( } }, }; - let ctrl = - handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await; + let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await; if ctrl.auth_failed { *login_state.lock().unwrap() = LoginState::NeedsLogin; login::wait_for_login( @@ -601,13 +597,10 @@ async fn handle_turn<S: Surface>( let outcome = turn::drive_turn(&prompt, files, bus, session).await; turn::emit_turn_end(bus, &outcome); bus.set_state(TurnState::Idle); - if matches!( - outcome, - turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted - ) { + if outcome.is_ok() { S::ack_turn(socket).await; } - if matches!(outcome, turn::TurnOutcome::RateLimited) { + if matches!(outcome, Err(turn::TurnError::RateLimited)) { let secs = turn::rate_limit_sleep_secs(); bus.emit_status("rate_limited"); bus.emit(LiveEvent::Note { @@ -618,7 +611,7 @@ async fn handle_turn<S: Surface>( S::requeue_inflight(socket).await; bus.emit_status("online"); } - if matches!(outcome, turn::TurnOutcome::AuthFailed) { + if matches!(outcome, Err(turn::TurnError::AuthFailed)) { bus.emit_status("needs_login_idle"); bus.emit(LiveEvent::Note { text: "API 401 — waiting for re-login via web UI".into(), @@ -626,7 +619,15 @@ async fn handle_turn<S: Surface>( tracing::warn!("auth-failed; parking until re-login"); S::requeue_inflight(socket).await; } - if let turn::TurnOutcome::Failed(e) = &outcome { + if matches!(outcome, Err(turn::TurnError::PromptTooLong)) { + // `drive_turn` already archived the session; requeue the message so it + // redelivers into the fresh session (which fits — the wake prompt is + // tiny, the overflow was the now-cleared context). No status park: the + // agent is healthy, it just needs one more delivery. + tracing::warn!("prompt-too-long; session archived, requeueing message for a fresh turn"); + S::requeue_inflight(socket).await; + } + if let Err(turn::TurnError::Failed(e)) = &outcome { S::send_to_parent(socket, format_turn_failure(e)).await; } if let Some(stats) = stats { @@ -659,7 +660,7 @@ async fn handle_turn<S: Surface>( tracing::info!(%pending, "pending messages after turn; fetching next"); } TurnControl { - auth_failed: matches!(outcome, turn::TurnOutcome::AuthFailed), + auth_failed: matches!(outcome, Err(turn::TurnError::AuthFailed)), continue_requested: consume_continue_sentinel(), pending, } diff --git a/hive-ag3nt/src/client.rs b/hive-ag3nt/src/client.rs index 33e9fa80..0cfd36f9 100644 --- a/hive-ag3nt/src/client.rs +++ b/hive-ag3nt/src/client.rs @@ -76,7 +76,9 @@ where } } } - Err(last_err.unwrap_or_else(|| anyhow!("hive socket: retries exhausted"))) + // Reaching here means the final attempt returned `Transient`, which always + // sets `last_err` — so this is infallible. + Err(last_err.expect("a transient failure on the final attempt set last_err")) } /// Transient = connect / IO error worth a retry (server restart, broken diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index d9721b34..2aa32d7f 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -433,7 +433,6 @@ impl EventStore { } } - /// Authoritative turn-loop state. The harness owns it; the web UI /// reads via `/api/state` and renders. Lives alongside the bus /// because everyone who has a `Bus` already has the right handle to @@ -467,12 +466,6 @@ pub fn configured_model() -> Option<&'static str> { .map(|s| &*Box::leak(s.into_boxed_str())) } -/// Return the model to use when no config and no persisted override exist. -#[must_use] -pub fn default_model() -> &'static str { - configured_model().unwrap_or(DEFAULT_MODEL) -} - /// Compiled-in fallback effort level — matches the `effortLevel` baked /// into `prompts/claude-settings.json`. pub const DEFAULT_EFFORT: &str = "medium"; @@ -908,6 +901,16 @@ impl Bus { *self.api_context_window.lock().unwrap() } + /// The effective context window for `model`: the API-reported window if a + /// turn has completed ([`api_context_window`]), else the per-model default + /// ([`context_window_tokens`]). Single accessor so the state + dashboard + /// endpoints agree by construction. + #[must_use] + pub fn effective_context_window(&self, model: &str) -> u64 { + self.api_context_window() + .unwrap_or_else(|| context_window_tokens(model)) + } + /// Walk a stream-json value for `tool_use` blocks and bump the /// per-turn counter for each one we find. Called by the stdout /// pump on every parsed line. Cheap when the line isn't an diff --git a/hive-ag3nt/src/forge_notify.rs b/hive-ag3nt/src/forge_notify.rs index 855c3f87..99a1f225 100644 --- a/hive-ag3nt/src/forge_notify.rs +++ b/hive-ag3nt/src/forge_notify.rs @@ -6,8 +6,8 @@ //! off forge's own unread-state, and the agent reading the thread via the //! CLI is what marks it read. A delivery-dedupe cursor (thread id → //! last-delivered `updated_at`) stops the still-unread notification from -//! re-firing a wake every poll; self-echo and drop-listed notifications -//! are still marked read directly. The cursor is persisted as the +//! re-firing a wake every poll; self-echo notifications (the agent's own +//! writes) are still marked read directly. The cursor is persisted as the //! `forge_cursor` field of the harness's consolidated `hyperhive-harness.json` //! (via [`crate::events`]) and reloaded on boot so a container //! rebuild/restart doesn't re-deliver the whole currently-unread backlog — @@ -247,7 +247,7 @@ fn truncate(s: &str, max: usize) -> String { let end = s .char_indices() .map(|(i, _)| i) - .take_while(|&i| i <= max - 3) + .take_while(|&i| i <= max.saturating_sub(3)) .last() .unwrap_or(0); format!("{}…", &s[..end]) @@ -314,6 +314,20 @@ fn render_truncated_mentions(lines: &[&str]) -> String { out } +/// The escaped body excerpt + overflowed-mention suffix for one issue / PR / +/// comment body, computed in the one required order: truncate → diff the +/// overflowed `@mention` lines against the *unescaped* excerpt → heading-escape +/// the excerpt. Both notification formatters build their body block from this +/// pair so the ordering contract lives in exactly one place. Returns +/// `(escaped_excerpt, mentions_suffix)`; `mentions_suffix` is empty when the +/// body fit (nothing was truncated away). +fn render_body_excerpt(raw: &str) -> (String, String) { + let raw_excerpt = truncate(raw, BODY_TRUNCATE); + let mentions = render_truncated_mentions(&extract_truncated_mention_lines(raw, &raw_excerpt)); + let excerpt = escape_md_headings(&raw_excerpt); + (excerpt, mentions) +} + /// Map a Forgejo review state to a readable action label. /// Returns `None` for non-review states (regular comments have no `state` field; /// `PENDING` means the review was saved but not submitted yet). @@ -378,7 +392,9 @@ async fn format_notification( fetch_json(client, subject_api_url, token).await }; - let is_pr = matches!(notif_type, "Pull Request" | "Pull"); + // Forgejo's notification `subject.type` is "Pull" / "Issue" (never + // "Pull Request") — see the comment in `build_meta_suffix` below. + let is_pr = notif_type == "Pull"; let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); // Determine whether this notification was triggered by a comment/review or @@ -513,23 +529,17 @@ async fn format_comment_notification( .. } = meta; - // Truncate → mention-overflow → escape, in that order. See - // `docs/forge.md::Body excerpt + truncation + heading escape` for - // why truncate comes before escape (mention diff compares against - // unescaped raw body). - let raw_excerpt = truncate(body_text, BODY_TRUNCATE); - let truncated_mentions = if body_text.len() > BODY_TRUNCATE { - render_truncated_mentions(&extract_truncated_mention_lines(body_text, &raw_excerpt)) - } else { - String::new() - }; - let body_for_embed = escape_md_headings(&raw_excerpt); + // Truncate → mention-overflow → escape (see `render_body_excerpt`). + let (body_for_embed, truncated_mentions) = render_body_excerpt(body_text); if let Some(review_label) = review_state { // Review submission on a PR. let kind = format!("PR {review_label}{num}{repo}"); let mut out = format!("[{kind}] {title}\nurl: {url}"); if body_text.is_empty() { - write!(out, "\n\nreviewer: {author}").ok(); + // Bodiless review: name who reviewed. `reviewed by:` (not + // `reviewer:`) to avoid colliding with the `reviewer:` line the + // meta suffix carries for a PR's *requested* reviewers. + write!(out, "\n\nreviewed by: {author}").ok(); } else { write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok(); } @@ -541,9 +551,6 @@ async fn format_comment_notification( let mut out = format!( "[{kind}] {title}\nurl: {url}\n\n{author}: {body_for_embed}{truncated_mentions}" ); - if out.ends_with('\n') { - out.pop(); - } out.push_str(meta_suffix); Some(out) } @@ -633,20 +640,15 @@ fn format_state_change_notification( kind }; - // Include the start of the issue/PR description so the agent - // gets context without a follow-up fetch. Same truncate → - // mention-overflow → escape pipeline as comment bodies (see - // `docs/forge.md::Body excerpt + truncation + heading escape`). + // Include the start of the issue/PR description so the agent gets context + // without a follow-up fetch. Same body pipeline as comment bodies. let body_block = subject .as_ref() .and_then(|s| s["body"].as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(|raw| { - let raw_excerpt = truncate(raw, BODY_TRUNCATE); - let truncated = extract_truncated_mention_lines(raw, &raw_excerpt); - let mentions = render_truncated_mentions(&truncated); - let excerpt = escape_md_headings(&raw_excerpt); + let (excerpt, mentions) = render_body_excerpt(raw); format!("\n\n{excerpt}{mentions}") }) .unwrap_or_default(); @@ -712,8 +714,15 @@ fn parse_rfc3339_secs(s: &str) -> Option<i64> { if !(rest.is_empty() || rest.starts_with('Z')) { let sign = rest.as_bytes()[0]; let off = &rest[1..]; - let oh: i64 = off.get(0..2)?.parse().ok()?; - let om: i64 = off.get(3..5).unwrap_or("00").parse().ok()?; + // Accept `HH:MM` (Forgejo's form) and bare `HHMM`; minutes optional. + // Both fields fail the same way — a present-but-unparseable component + // returns `None` rather than one silently defaulting. + let (hh, mm) = match off.split_once(':') { + Some((h, m)) => (h, m), + None => (off.get(0..2)?, off.get(2..4).unwrap_or("00")), + }; + let oh: i64 = hh.parse().ok()?; + let om: i64 = if mm.is_empty() { 0 } else { mm.parse().ok()? }; let offset = oh * 3_600 + om * 60; match sign { b'+' => epoch -= offset, @@ -885,11 +894,11 @@ fn should_deliver(delivered: &HashMap<u64, String>, id: u64, updated_at: &str) - } /// Mark a notification thread as read. Best-effort — logs on failure but -/// does not abort the poll loop. Called only on the self-echo and -/// drop-listed paths (the agent's own writes / explicitly-suppressed -/// reasons) — delivered threads are deliberately left unread for the -/// read-before-comment guard, and a failed delivery is left unread + out -/// of the dedupe cursor so it resurfaces on the next poll tick. +/// does not abort the poll loop. Called only on the self-echo path (the +/// agent's own comment/review/creation writes) — delivered threads are +/// deliberately left unread for the read-before-comment guard, and a failed +/// delivery is left unread + out of the dedupe cursor so it resurfaces on the +/// next poll tick. async fn mark_read(client: &reqwest::Client, forge_url: &str, token: &str, id: u64) { let mark_url = format!("{forge_url}/api/v1/notifications/threads/{id}"); match client diff --git a/hive-ag3nt/src/identity.rs b/hive-ag3nt/src/identity.rs index 56759aee..4cafa13b 100644 --- a/hive-ag3nt/src/identity.rs +++ b/hive-ag3nt/src/identity.rs @@ -14,14 +14,18 @@ pub fn label() -> String { env::var("HIVE_LABEL").unwrap_or_default() } +/// `env::var(key)` reduced to `Some(value)` only when the var is set and +/// non-empty — the shared shape of the hive/swarm display-name lookups below. +fn non_empty_env(key: &str) -> Option<String> { + env::var(key).ok().filter(|s| !s.is_empty()) +} + /// The hive's canonical DNS domain when set, otherwise None. Single-hive /// deployments where `HYPERHIVE_HIVE_DOMAIN` is unset return None — callers /// then degrade gracefully to the short label. #[must_use] pub fn hive_domain() -> Option<String> { - env::var("HYPERHIVE_HIVE_DOMAIN") - .ok() - .filter(|s| !s.is_empty()) + non_empty_env("HYPERHIVE_HIVE_DOMAIN") } /// Human display name of this hive (e.g. `pr1ma`). Distinct from @@ -32,9 +36,7 @@ pub fn hive_domain() -> Option<String> { /// at their discretion. #[must_use] pub fn hive_name() -> Option<String> { - env::var("HYPERHIVE_HIVE_NAME") - .ok() - .filter(|s| !s.is_empty()) + non_empty_env("HYPERHIVE_HIVE_NAME") } /// Human display name of the wider swarm this hive belongs to (e.g. @@ -43,9 +45,7 @@ pub fn hive_name() -> Option<String> { /// `services.hyperhive.swarmName` option is unset. #[must_use] pub fn swarm_name() -> Option<String> { - env::var("HYPERHIVE_SWARM_NAME") - .ok() - .filter(|s| !s.is_empty()) + non_empty_env("HYPERHIVE_SWARM_NAME") } /// One peer hive in the same swarm. Parsed from `HYPERHIVE_PEERS`. diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 56a62a29..4b861eed 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -9,6 +9,7 @@ pub mod identity; pub mod login; pub mod login_session; pub mod mcp; +pub mod mcp_config; pub mod mcp_loose_ends; pub mod paths; pub mod plugins; diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs index 33a50a43..18468e85 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -2,11 +2,12 @@ //! provided by hive-c0re and persists across container destroy/recreate so //! OAuth tokens survive. //! -//! "Has session" today means "the dir contains at least one regular file." -//! That's a heuristic: a fresh bind-mount starts empty, and `claude auth login` -//! writes credentials into the dir. We may refine later (probe for the -//! specific credentials filename, or run a no-op `claude` call) once the -//! exact layout is locked in. +//! "Has session" means the dir contains at least one of the credential files +//! in [`CRED_FILE_NAMES`] — the same set `/logout` (`web_ui::auth`) deletes to +//! force re-login. Keying both off one constant keeps boot detection and +//! logout in agreement: logout deliberately preserves session-history files, +//! so a "contains any regular file" check would wrongly report `Online` after +//! a logout + container recreate and burn a turn 401-ing before it reroutes. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -24,20 +25,61 @@ pub fn default_dir() -> PathBuf { crate::paths::claude_dir() } -/// Returns `true` if `dir` exists and contains any regular file. Used at -/// startup to decide whether to enter the turn loop (logged in) or stay in -/// the partial-run "needs login" state. +/// The credential files that constitute a logged-in claude session inside +/// [`default_dir`]. A session exists iff at least one is present; a login +/// "refresh" is a change to one of them. `/logout` (`web_ui::auth`) deletes +/// exactly these to force re-login while preserving session-history files — +/// so boot detection ([`has_session`]) and logout agree by construction. +/// Rationale + the previous wholesale-wipe shape we replaced live in +/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../docs/web-ui/agent.md) +/// (the `/api/logout` bullet). +pub const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; + +/// Is `entry` a regular file whose name is one of [`CRED_FILE_NAMES`]? +fn is_cred_file(entry: &std::fs::DirEntry) -> bool { + entry.file_type().is_ok_and(|t| t.is_file()) + && entry + .file_name() + .to_str() + .is_some_and(|n| CRED_FILE_NAMES.contains(&n)) +} + +/// Returns `true` if `dir` exists and holds at least one credential file +/// (see [`CRED_FILE_NAMES`]). Used at startup to decide whether to enter the +/// turn loop (logged in) or stay in the partial-run "needs login" state. #[must_use] pub fn has_session(dir: &Path) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|t| t.is_file()) { - return true; + entries.flatten().any(|e| is_cred_file(&e)) +} + +/// Outcome of [`clear_session`]: which credential files were removed and any +/// non-fatal per-file errors (e.g. permission denied). A file that was already +/// absent is not reported — deletion is idempotent. +#[derive(Debug, Default)] +pub struct ClearedSession { + pub wiped: Vec<&'static str>, + pub warnings: Vec<String>, +} + +/// Delete the credential files (see [`CRED_FILE_NAMES`]) from `dir`, forcing a +/// re-login on the next turn, while preserving the session-history files +/// alongside them so `claude --continue` keeps working after a fresh login. +/// Idempotent: an already-absent file is skipped, not reported. This is the +/// write-side counterpart to [`has_session`]; `/logout` (`web_ui::auth`) drives +/// it. +pub async fn clear_session(dir: &Path) -> ClearedSession { + let mut cleared = ClearedSession::default(); + for name in CRED_FILE_NAMES { + match tokio::fs::remove_file(dir.join(name)).await { + Ok(()) => cleared.wiped.push(name), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => cleared.warnings.push(format!("{name}: {e}")), } } - false + cleared } /// Login state the harness reports to its web UI. @@ -105,16 +147,16 @@ pub async fn wait_for_login( } } -/// Snapshot of the credentials dir at a point in time: number of -/// regular files + newest `mtime` across them. The two axes are both -/// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): -/// mtime catches the common case (re-login overwrites an existing -/// credentials file in-place), `file_count` catches the pathological case -/// where `meta.modified()` errors on every file (exotic fs, NFS quirks) -/// so the mtime axis stays `None` forever but new files still trigger a -/// resume. Defaults to `{0, None}` on `read_dir` failure (missing or -/// unreadable dir) — `wait_for_login` then resumes when files first -/// appear. +/// Snapshot of the credential files (see [`CRED_FILE_NAMES`]) in the dir at a +/// point in time: how many are present + newest `mtime` across them. The two +/// axes are both load-bearing for `wait_for_login`'s refresh check +/// (`session_refreshed`): mtime catches the common case (re-login overwrites +/// an existing credentials file in-place), `file_count` catches the +/// pathological case where `meta.modified()` errors on every file (exotic fs, +/// NFS quirks) so the mtime axis stays `None` forever but a new credential +/// file still triggers a resume. Defaults to `{0, None}` on `read_dir` failure +/// (missing or unreadable dir) — `wait_for_login` then resumes when a +/// credential file first appears. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct DirSnapshot { file_count: usize, @@ -127,7 +169,7 @@ fn snapshot_dir(dir: &Path) -> DirSnapshot { }; let mut snap = DirSnapshot::default(); for entry in entries.flatten() { - if !entry.file_type().is_ok_and(|t| t.is_file()) { + if !is_cred_file(&entry) { continue; } snap.file_count += 1; @@ -165,7 +207,31 @@ mod tests { use std::fs; use std::time::{Duration, SystemTime}; - use super::{DirSnapshot, session_refreshed, snapshot_dir}; + use super::{DirSnapshot, has_session, session_refreshed, snapshot_dir}; + + #[test] + fn has_session_only_counts_credential_files() { + let dir = tempfile::tempdir().unwrap(); + // Session-history files (what `/logout` preserves) must NOT read as a + // logged-in session — this is the logout+recreate 401 bug. + fs::write(dir.path().join("history.jsonl"), b"{}").unwrap(); + fs::write(dir.path().join("some-project-uuid.json"), b"{}").unwrap(); + assert!(!has_session(dir.path())); + // A real credential file does. + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); + assert!(has_session(dir.path())); + } + + #[test] + fn snapshot_dir_ignores_non_credential_files() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("history.jsonl"), b"{}").unwrap(); + let snap = snapshot_dir(dir.path()); + assert_eq!( + snap.file_count, 0, + "history files must not count as session" + ); + } #[test] fn snapshot_dir_empty_dir_is_default() { @@ -191,11 +257,11 @@ mod tests { #[test] fn snapshot_dir_picks_latest_mtime_and_counts_files() { let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("old.json"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); // Sleep so the second file's mtime is strictly greater than // the first on filesystems with low timestamp resolution. std::thread::sleep(Duration::from_millis(20)); - let newer_path = dir.path().join("newer.json"); + let newer_path = dir.path().join("mcp-needs-auth-cache.json"); fs::write(&newer_path, b"{}").unwrap(); let snap = snapshot_dir(dir.path()); assert_eq!(snap.file_count, 2); @@ -204,13 +270,13 @@ mod tests { } #[test] - fn session_refreshed_first_login_flips_on_any_file() { - // Empty-dir snapshot → any file appearing means a fresh + fn session_refreshed_first_login_flips_on_cred_file() { + // Empty-dir snapshot → a credential file appearing means a fresh // login landed. First-time login semantics. let dir = tempfile::tempdir().unwrap(); let snapshot = snapshot_dir(dir.path()); assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); - fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); assert!(session_refreshed(snapshot, snapshot_dir(dir.path()))); } @@ -220,7 +286,7 @@ mod tests { // must NOT immediately return — it would loop straight into // another 401-failing turn. let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = snapshot_dir(dir.path()); assert_eq!(snapshot.file_count, 1); // No change to the file → loop must NOT exit. @@ -233,10 +299,10 @@ mod tests { // flow lands a refreshed credentials file — its mtime bumps // strictly past the snapshot and wait_for_login resumes. let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = snapshot_dir(dir.path()); std::thread::sleep(Duration::from_millis(20)); - fs::write(dir.path().join("credentials.json"), b"{\"v\":2}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap(); assert!(session_refreshed(snapshot, snapshot_dir(dir.path()))); } @@ -246,7 +312,7 @@ mod tests { // skew between snapshot and probe) must keep waiting until a // file's mtime actually exceeds it, not return on first poll. let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let snapshot = DirSnapshot { file_count: 1, newest_mtime: Some(SystemTime::now() + Duration::from_hours(1)), @@ -262,12 +328,12 @@ mod tests { // here by forging a snapshot with file_count=1 + no mtime, then // writing a second file. let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join("a"), b"{}").unwrap(); + fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); let forged = DirSnapshot { file_count: 1, newest_mtime: None, }; - fs::write(dir.path().join("b"), b"{}").unwrap(); + fs::write(dir.path().join("mcp-needs-auth-cache.json"), b"{}").unwrap(); // Real snapshot has file_count=2, so refresh fires even // though the mtime axis would be inconclusive. assert!(session_refreshed(forged, snapshot_dir(dir.path()))); diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index f9afd86f..2f7fd95b 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -24,117 +24,6 @@ use rmcp::{ use crate::client; -/// Wire-protocol-agnostic view of a hyperhive socket response. Both flavors -/// of `AgentServer` convert into this so the tool formatters can be shared. -#[derive(Debug)] -pub enum SocketReply { - Ok, - Err(String), - /// Unified `recv` result: zero or more messages popped in one - /// round-trip. Empty vec = "(empty)" path; single-message = the - /// standard wake body; multi = batch render with per-message - /// separators. Per-row `id` is rendered as a `[msg #<id>]` marker - /// so claude can bulk-triage via `ack_until` (turn-level ack still - /// rides `AckTurn`); `redelivered` triggers the "may already be - /// handled" banner in `format_recv` for that specific row. - Messages(Vec<hive_sh4re::DeliveredMessage>), - Status(u64), - /// `ack_until` result: rows newly marked handled. - Acked(u64), - QuestionQueued(i64), - Recent(Vec<hive_sh4re::InboxRow>), - Logs(String), - HostJournal(String), - /// `list_schedules` result — returned by `list_schedules` (scheduling tool group). - Schedules(Vec<hive_sh4re::WireSchedule>), - /// `list_containers` result — descendant containers with running status. - Containers(Vec<hive_sh4re::ContainerInfo>), - LooseEnds(Vec<hive_sh4re::LooseEnd>), - PendingRemindersCount(u64), - ReminderRollup(hive_sh4re::ReminderStats), - AgentMeta { - name: String, - running: bool, - hyperhive_rev: Option<String>, - status_text: Option<String>, - status_set_at: Option<i64>, - hive_name: Option<String>, - swarm_name: Option<String>, - matrix_accounts: Vec<hive_sh4re::MatrixIdentity>, - }, - /// `create_repo` result — the new repo's full name + clone URL. - RepoCreated { - full_name: String, - clone_url: String, - }, -} - -impl From<hive_sh4re::Response> for SocketReply { - fn from(r: hive_sh4re::Response) -> Self { - match r { - hive_sh4re::Response::Ok => Self::Ok, - hive_sh4re::Response::Err { message } => Self::Err(message), - hive_sh4re::Response::Messages { messages } => Self::Messages(messages), - hive_sh4re::Response::Status { unread } => Self::Status(unread), - hive_sh4re::Response::Acked { count } => Self::Acked(count), - hive_sh4re::Response::Recent { rows } => Self::Recent(rows), - hive_sh4re::Response::QuestionQueued { id } => Self::QuestionQueued(id), - hive_sh4re::Response::LooseEnds { loose_ends } => Self::LooseEnds(loose_ends), - hive_sh4re::Response::PendingRemindersCount { count } => { - Self::PendingRemindersCount(count) - } - hive_sh4re::Response::ReminderRollup(stats) => Self::ReminderRollup(stats), - hive_sh4re::Response::Logs { content } => Self::Logs(content), - hive_sh4re::Response::HostJournal { content } => Self::HostJournal(content), - hive_sh4re::Response::Schedules { schedules } => Self::Schedules(schedules), - hive_sh4re::Response::Containers { containers } => Self::Containers(containers), - // A graceful stop is pending — the inbox is fenced. Returning an - // *empty* inbox here is ambiguous: claude's "park on recv" habit - // makes it long-poll again instead of ending the turn, so the - // stop-checkpoint turn never finishes and the drain wait times out - // into a hard stop. Return a single explicit directive instead, so - // every recv during the stop unmissably tells claude to flush + end. - hive_sh4re::Response::GracefulStop => Self::Messages(vec![hive_sh4re::DeliveredMessage { - from: "graceful-stop".into(), - body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \ - ends. Flush anything worth keeping to your durable /state files, then END \ - YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \ - only keep returning this same notice." - .into(), - id: 0, - redelivered: false, - in_reply_to: None, - }]), - hive_sh4re::Response::AgentMeta { - name, - running, - hyperhive_rev, - status_text, - status_set_at, - hive_name, - swarm_name, - matrix_accounts, - } => Self::AgentMeta { - name, - running, - hyperhive_rev, - status_text, - status_set_at, - hive_name, - swarm_name, - matrix_accounts, - }, - hive_sh4re::Response::RepoCreated { - full_name, - clone_url, - } => Self::RepoCreated { - full_name, - clone_url, - }, - } - } -} - /// Write (or remove) the status file in the agent's own `state/` directory. /// Called by `AgentServer::set_status` for both agent and manager flavors /// before dispatching the wire `SetStatus` request (which only triggers a @@ -180,16 +69,32 @@ fn write_status_file(text: &str) -> Result<(), String> { result.map_err(|e| format!("set_status write failed: {e}")) } +/// Render the three identical failure arms every data-returning tool handler +/// repeats: a broker `Err` → `"{tool} failed: {m}"`, an unexpected `Ok` variant +/// → `"{tool} unexpected response: …"`, and a transport error → `"{tool} +/// transport error: …"`. Handlers match their own happy-path variant and route +/// everything else here via a catch-all arm (`other => reply_err(other, tool)`), +/// so the triplet lives in exactly one place. +fn reply_err(resp: Result<hive_sh4re::Response, anyhow::Error>, tool: &str) -> String { + match resp { + Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"), + Ok(other) => format!("{tool} unexpected response: {other:?}"), + Err(e) => format!("{tool} transport error: {e:#}"), + } +} + /// Format helper for "send-like" tools (anything that expects an `Ok`). /// `tool` and `ok_msg` only appear in the result string; they don't change /// behavior. #[must_use] -pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg: String) -> String { +pub fn format_ack( + resp: Result<hive_sh4re::Response, anyhow::Error>, + tool: &str, + ok_msg: String, +) -> String { match resp { - Ok(SocketReply::Ok) => ok_msg, - Ok(SocketReply::Err(m)) => format!("{tool} failed: {m}"), - Ok(other) => format!("{tool} unexpected response: {other:?}"), - Err(e) => format!("{tool} transport error: {e:#}"), + Ok(hive_sh4re::Response::Ok) => ok_msg, + other => reply_err(other, tool), } } @@ -204,14 +109,41 @@ pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg: /// so the model can tell where one ends and the next begins; /// per-message redelivery banners included. #[must_use] -pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, waited: bool) -> String { +pub fn format_recv(resp: Result<hive_sh4re::Response, anyhow::Error>, waited: bool) -> String { + match resp { + Ok(hive_sh4re::Response::Messages { messages }) => render_recv_messages(&messages, waited), + // A graceful stop is pending — the inbox is fenced. Render a single + // explicit directive (not an empty inbox, which claude's "park on recv" + // habit would long-poll again, stalling the stop-checkpoint turn until + // the drain wait times out into a hard stop) so every recv during the + // stop unmissably tells claude to flush + end. + Ok(hive_sh4re::Response::GracefulStop) => { + render_recv_messages(&[graceful_stop_message()], waited) + } + other => reply_err(other, "recv"), + } +} + +/// The synthetic single-message directive rendered for a fenced (graceful-stop) +/// inbox — see the `GracefulStop` arm of [`format_recv`]. +fn graceful_stop_message() -> hive_sh4re::DeliveredMessage { + hive_sh4re::DeliveredMessage { + from: "graceful-stop".into(), + body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \ + ends. Flush anything worth keeping to your durable /state files, then END \ + YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \ + only keep returning this same notice." + .into(), + id: 0, + redelivered: false, + in_reply_to: None, + } +} + +/// Render the popped-message payload of a successful `recv` (see `format_recv` +/// for the empty/single/batch shapes). +fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String { use std::fmt::Write as _; - let messages = match resp { - Ok(SocketReply::Messages(m)) => m, - Ok(SocketReply::Err(m)) => return format!("recv failed: {m}"), - Ok(other) => return format!("recv unexpected response: {other:?}"), - Err(e) => return format!("recv transport error: {e:#}"), - }; if messages.is_empty() { return if waited { format!("(empty){IDLE_WAIT_HINT}") @@ -269,10 +201,9 @@ pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \ notes to update), do that now rather than immediately parking on recv again."; -/// Inner renderer for a `Vec<LooseEnd>` already extracted from the -/// socket reply. Called by both `format_loose_ends` (which handles the -/// `Result<SocketReply>` wrapper) and the augmented `get_loose_ends` -/// handler (which injects the `UnreadMatrix` entry before formatting). +/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket +/// reply. Called by the `get_loose_ends` handler, which injects the +/// `UnreadMatrix` entry before formatting. fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { use std::fmt::Write as _; if loose_ends.is_empty() { @@ -351,21 +282,6 @@ fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { out } -/// Format helper for `get_loose_ends`: renders a short bulleted list -/// of pending approvals + questions + reminders. Empty list collapses -/// to a clear marker so claude doesn't go hunting for a payload that -/// isn't there. -#[must_use] -pub fn format_loose_ends(resp: Result<SocketReply, anyhow::Error>) -> String { - let loose_ends = match resp { - Ok(SocketReply::LooseEnds(t)) => t, - Ok(SocketReply::Err(m)) => return format!("get_loose_ends failed: {m}"), - Ok(other) => return format!("get_loose_ends unexpected response: {other:?}"), - Err(e) => return format!("get_loose_ends transport error: {e:#}"), - }; - render_loose_ends(&loose_ends) -} - /// Per-room unread entry returned by `matrix_unread_summary`. Mirrors /// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a /// cross-crate dep on the matrix-sdk crate tree. @@ -464,11 +380,11 @@ fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { /// (it would be a stale snapshot from before the stop) so the status /// line is implicitly `<none>` in that case — but the explicit /// `running: no` line tells the caller WHY. See -/// `docs/turn-loop.md::Sub-agent tools` (`get_agent_meta`). +/// `docs/turn-loop/mcp.md::Core tools` (`get_agent_meta`). #[must_use] -pub fn format_agent_meta(resp: Result<SocketReply, anyhow::Error>) -> String { +pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String { match resp { - Ok(SocketReply::AgentMeta { + Ok(hive_sh4re::Response::AgentMeta { name, running, hyperhive_rev, @@ -536,9 +452,7 @@ pub fn format_agent_meta(resp: Result<SocketReply, anyhow::Error>) -> String { } out } - Ok(SocketReply::Err(m)) => format!("get_agent_meta failed: {m}"), - Ok(other) => format!("get_agent_meta unexpected response: {other:?}"), - Err(e) => format!("get_agent_meta transport error: {e:#}"), + other => reply_err(other, "get_agent_meta"), } } @@ -680,18 +594,18 @@ impl AgentServer { Self { socket } } - /// Issue any `Request` through the retry-aware client and pull - /// the reply through `SocketReply`. Returns the retry count so tool - /// handlers can annotate their result (see `annotate_retries`). + /// Issue any `Request` through the retry-aware client. Returns the raw + /// `Response` plus the retry count so tool handlers can annotate their + /// result (see `annotate_retries`). /// /// `AgentRequest` / `ManagerRequest` / `Request` are all the same type /// (hive-sh4re type aliases), so this single method covers both sockets. async fn dispatch( &self, req: hive_sh4re::Request, - ) -> (Result<SocketReply, anyhow::Error>, u32) { + ) -> (Result<hive_sh4re::Response, anyhow::Error>, u32) { match client::request_retried::<_, hive_sh4re::Response>(&self.socket, &req).await { - Ok((r, n)) => (Ok(SocketReply::from(r)), n), + Ok((r, n)) => (Ok(r), n), Err(e) => (Err(e), 0), } } @@ -712,7 +626,7 @@ impl AgentServer { let to = args.to.clone(); // Check per-agent allow-list (hyperhive.allowedRecipients). When no // policy file is present (e.g. manager containers) the check is a no-op. - if let Err(refusal) = check_send_allowed(&to) { + if let Err(refusal) = crate::mcp_config::check_send_allowed(&to) { return run_tool_envelope("send", log, async move { refusal }).await; } run_tool_envelope("send", log, async move { @@ -757,13 +671,11 @@ impl AgentServer { }) .await; let s = match resp { - Ok(SocketReply::QuestionQueued(id)) => format!( + Ok(hive_sh4re::Response::QuestionQueued { id }) => format!( "question queued (id={id}); answer will arrive as a system \ `question_answered` event in your inbox" ), - Ok(SocketReply::Err(m)) => format!("ask failed: {m}"), - Ok(other) => format!("ask unexpected response: {other:?}"), - Err(e) => format!("ask transport error: {e:#}"), + other => reply_err(other, "ask"), }; annotate_retries(s, retries) }) @@ -847,12 +759,10 @@ impl AgentServer { .dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to }) .await; let rendered = match resp { - Ok(SocketReply::Acked(count)) => { + Ok(hive_sh4re::Response::Acked { count }) => { format!("acked {count} message(s) up to id {}", args.up_to) } - Ok(SocketReply::Err(m)) => format!("ack_until failed: {m}"), - Ok(other) => format!("ack_until unexpected response: {other:?}"), - Err(e) => format!("ack_until transport error: {e:#}"), + other => reply_err(other, "ack_until"), }; annotate_retries(rendered, retries) }) @@ -882,22 +792,8 @@ impl AgentServer { .await; // Extract the vec so we can augment before rendering. let mut loose_ends = match resp { - Ok(SocketReply::LooseEnds(t)) => t, - Ok(SocketReply::Err(m)) => { - return annotate_retries(format!("get_loose_ends failed: {m}"), retries); - } - Ok(other) => { - return annotate_retries( - format!("get_loose_ends unexpected response: {other:?}"), - retries, - ); - } - Err(e) => { - return annotate_retries( - format!("get_loose_ends transport error: {e:#}"), - retries, - ); - } + Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => loose_ends, + other => return annotate_retries(reply_err(other, "get_loose_ends"), retries), }; // Prepend matrix unread entry for self-queries only (can't // reach another agent's matrix daemon from here). @@ -1035,13 +931,11 @@ impl AgentServer { .dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo }) .await; let s = match resp { - Ok(SocketReply::RepoCreated { + Ok(hive_sh4re::Response::RepoCreated { full_name, clone_url, }) => format!("created repo {full_name} — clone: {clone_url}"), - Ok(SocketReply::Err(m)) => format!("create_repo failed: {m}"), - Ok(other) => format!("create_repo unexpected response: {other:?}"), - Err(e) => format!("create_repo transport error: {e:#}"), + other => reply_err(other, "create_repo"), }; annotate_retries(s, retries) }) @@ -1198,7 +1092,7 @@ impl AgentServer { run_tool_envelope("list_containers", String::new(), async move { let (resp, retries) = self.dispatch(hive_sh4re::Request::ListDescendants).await; let body = match resp { - Ok(SocketReply::Containers(containers)) => { + Ok(hive_sh4re::Response::Containers { containers }) => { if containers.is_empty() { "no descendant containers".to_owned() } else { @@ -1212,9 +1106,7 @@ impl AgentServer { .join("\n") } } - Ok(SocketReply::Err(m)) => format!("list_containers failed: {m}"), - Ok(other) => format!("list_containers unexpected response: {other:?}"), - Err(e) => format!("list_containers transport error: {e:#}"), + other => reply_err(other, "list_containers"), }; annotate_retries(body, retries) }) @@ -1254,10 +1146,8 @@ impl AgentServer { }) .await; let result = match resp { - Ok(SocketReply::HostJournal(content)) => content, - Ok(SocketReply::Err(m)) => format!("get_host_journal failed: {m}"), - Ok(other) => format!("get_host_journal unexpected response: {other:?}"), - Err(e) => format!("get_host_journal transport error: {e:#}"), + Ok(hive_sh4re::Response::HostJournal { content }) => content, + other => reply_err(other, "get_host_journal"), }; annotate_retries(result, retries) }) @@ -1381,16 +1271,14 @@ impl AgentServer { }) .await; let s = match resp { - Ok(SocketReply::Logs(content)) => { + Ok(hive_sh4re::Response::Logs { content }) => { if content.is_empty() { format!("(no journal output for {agent})") } else { content } } - Ok(SocketReply::Err(m)) => format!("get_logs failed: {m}"), - Ok(other) => format!("get_logs unexpected response: {other:?}"), - Err(e) => format!("get_logs transport error: {e:#}"), + other => reply_err(other, "get_logs"), }; annotate_retries(s, retries) }) @@ -1576,11 +1464,11 @@ impl AgentServer { run_tool_envelope("list_schedules", String::new(), async move { let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await; let body = match resp { - Ok(SocketReply::Schedules(schedules)) => serde_json::to_string(&schedules) - .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")), - Ok(SocketReply::Err(m)) => format!("list_schedules: {m}"), - Ok(other) => format!("list_schedules unexpected response: {other:?}"), - Err(e) => format!("list_schedules transport error: {e:#}"), + Ok(hive_sh4re::Response::Schedules { schedules }) => { + serde_json::to_string(&schedules) + .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")) + } + other => reply_err(other, "list_schedules"), }; annotate_retries(body, retries) }) @@ -1595,27 +1483,23 @@ impl AgentServer { )] impl ServerHandler for AgentServer {} -/// Run an MCP server over stdio for the given flavor. Returns when the client disconnects. +/// Run the MCP server over stdio. Used by all roles. Returns when the client +/// disconnects. /// /// # Errors /// /// Returns an error if the MCP server fails to initialize or the transport /// encounters a fatal error. -pub async fn serve_stdio(socket: PathBuf) -> Result<()> { +pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> { let server = AgentServer::new(socket); let service = server.serve(stdio()).await?; service.waiting().await?; Ok(()) } -/// Run the MCP server over stdio. Used by all roles. -pub async fn serve_agent_stdio(socket: PathBuf) -> Result<()> { - serve_stdio(socket).await -} - /// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`. /// -/// Unlike [`serve_stdio`] — a fresh stdio child claude respawns every turn — +/// Unlike [`serve_agent_stdio`] — a fresh stdio child claude respawns every turn — /// this is meant to run as a long-lived in-container daemon. claude reconnects /// to the stable URL each turn instead of respawning and re-registering a stdio /// subprocess, which removes the per-turn MCP registration race that can strand @@ -1765,17 +1649,6 @@ pub struct CancelLooseEndArgs { pub id: i64, } -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct GetLooseEndsArgs { - /// Whose loose ends to list. Omit (or `null`) for your own: approvals - /// you submitted + questions where you are asker/target + your own - /// pending reminders. Pass `"*"` for a hive-wide view of EVERY pending - /// approval, unanswered question, and reminder across the swarm. Pass a - /// specific agent name to inspect just that agent's threads. - #[serde(default)] - pub agent: Option<String>, -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AgentGetLooseEndsArgs { /// Whose loose ends to list. Omit (or `null`) for your own. You may @@ -1937,448 +1810,26 @@ pub struct GetHostJournalArgs { #[serde(default)] pub until: Option<String>, } - -/// Name of the hyperhive MCP server inside claude's view. Claude prefixes -/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`). -pub const SERVER_NAME: &str = "hyperhive"; - -/// Built-in claude tools always present in every session. Anything not -/// in this list (or added by `extra_builtin_tools`) literally doesn't -/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are -/// tool-group-gated (`web_tools`) — off by default. Nested agents -/// (`Task`) are intentionally omitted. `Bash` is disallowed — shell -/// execution goes through `mcp__bash__run` (background tasks -/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite` -/// is omitted because the todo list lives in claude's in-process session -/// state and silently evaporates on /compact or session reset — agents -/// should plan in /state notes instead. -pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"]; - -/// Env var written by the meta renderer with a comma-separated list of -/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). -/// When present, the harness expands the groups into per-tool allow entries -/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. -const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; - -/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the -/// operator grants capabilities to this agent. Comma-separated -/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. -const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; - -/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are -/// unlocked by the agent's current capability set. These are added to the -/// `--allowedTools` list so claude can call them without prompting, and -/// hive-c0re performs a second server-side capability check before executing. -fn allowed_capability_tools() -> Vec<String> { - let raw = match std::env::var(CAPABILITIES_ENV) { - Ok(v) if !v.trim().is_empty() => v, - _ => return vec![], - }; - let mut tools = Vec::new(); - for token in raw.split(',') { - let t = token.trim().to_ascii_lowercase(); - match t.as_str() { - "read_host_journal" => tools.push("get_host_journal".to_owned()), - // infra_admin lets an agent restart hive infrastructure - // containers (hive-ci / hive-gateway / hive-forge) through the - // existing `restart` tool. Unlock it here so agents that hold - // the capability without the full `lifecycle` group can still - // call it; c0re re-checks the capability server-side and only - // honours infra-container names via this path. - "infra_admin" => tools.push("restart".to_owned()), - // manage_root_agent / query_agent_state don't expose new MCP - // tools: manage_root_agent gates existing lifecycle tools via - // topology enforcement; query_agent_state unlocks the `agent` - // field in get_loose_ends / count_pending_reminders / - // reminder_rollup (c0re enforces the cap server-side). - "manage_root_agent" | "query_agent_state" => {} - unknown => { - tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); - } - } - } - tools -} - -/// Resolve the active tool groups for a harness session. -/// -/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated -/// token is matched (case-insensitive) against the `ToolGroup` serde names -/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, -/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. -/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty. -fn effective_tool_groups() -> Vec<hive_sh4re::ToolGroup> { - let raw = match std::env::var(TOOL_GROUPS_ENV) { - Ok(v) if !v.trim().is_empty() => v, - _ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), - }; - let mut groups = Vec::new(); - for token in raw.split(',') { - let t = token.trim().to_ascii_lowercase(); - // Parse via serde_json (the canonical deserialization path). - if let Ok(g) = - serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone())) - { - groups.push(g); - } else { - tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); - } - } - if groups.is_empty() { - tracing::warn!( - "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ - falling back to AGENT_DEFAULT" - ); - return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(); - } - groups -} - -/// Tool group an extra (out-of-process) MCP server is gated behind, if any. -/// -/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever -/// the operator declares them. The `bash` server is the exception: raw shell -/// execution is a privilege, so it is only exposed when the agent holds the -/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at -/// dispatch) and the capability tools (re-checked server-side by hive-c0re), -/// an out-of-process server has **no** later enforcement point — once it is -/// in the claude MCP config the agent can call it. So this gate, applied at -/// config-render time, is the security boundary for those servers. -fn extra_server_required_group(server: &str) -> Option<hive_sh4re::ToolGroup> { - match server { - "bash" => Some(hive_sh4re::ToolGroup::Execution), - _ => None, - } -} - -/// Whether an extra MCP server should be exposed to claude given the active -/// tool `groups`. A gated server (see [`extra_server_required_group`]) is -/// suppressed when the agent lacks its required group. -fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool { - extra_server_required_group(server).is_none_or(|required| groups.contains(&required)) -} - -#[cfg(test)] -mod extra_server_gate_tests { - use super::{extra_server_enabled, extra_server_required_group}; - use hive_sh4re::ToolGroup; - - #[test] - fn bash_is_gated_behind_execution() { - assert_eq!( - extra_server_required_group("bash"), - Some(ToolGroup::Execution) - ); - // Suppressed without Execution, even if other groups are present. - assert!(!extra_server_enabled( - "bash", - &[ToolGroup::Messaging, ToolGroup::Inbox] - )); - // Available once Execution is granted. - assert!(extra_server_enabled("bash", &[ToolGroup::Execution])); - } - - #[test] - fn other_servers_are_ungated() { - assert_eq!(extra_server_required_group("matrix"), None); - assert_eq!(extra_server_required_group("scraper"), None); - // An ungated server is available regardless of (even empty) groups. - assert!(extra_server_enabled("matrix", &[])); - assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging])); - } -} - -/// MCP tools claude is allowed to call without prompting, derived from -/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl -/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re -/// (single source of truth). See `docs/conventions.md::Tool groups`. -#[must_use] -pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> { - // Collect all tool names, deduplicating while preserving order. - // Always-on tools (e.g. `set_status`) come first so they're present - // regardless of which groups the agent is granted — a misconfigured - // agent still has to be able to report its dashboard status. - let mut seen = std::collections::HashSet::new(); - let mut out: Vec<String> = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS - .iter() - .copied() - .chain(groups.iter().flat_map(|g| g.tools().iter().copied())) - .filter(|t| seen.insert(*t)) - .map(|t| format!("mcp__{SERVER_NAME}__{t}")) - .collect(); - // Extra MCP servers declared via `hyperhive.extraMcpServers` in - // the agent's NixOS config. Each entry maps its `allowedTools` - // pattern list to `mcp__<server>__<pattern>` so claude can call - // them without per-tool operator approval. `["*"]` (the default) - // expands to `mcp__<server>__*` — every tool from that server. - for (server, spec) in load_extra_mcp() { - if server == SERVER_NAME || !extra_server_enabled(&server, groups) { - continue; - } - for pat in spec.allowed_tools { - out.push(format!("mcp__{server}__{pat}")); - } - } - out -} - -/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers -/// both the built-ins and the MCP surface. -#[must_use] -pub fn allowed_tools_arg() -> String { - let groups = effective_tool_groups(); - // Base built-ins always present. - let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS - .iter() - .map(|s| (*s).to_owned()) - .collect(); - // Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools). - for group in &groups { - for tool in group.builtin_tools() { - if !all.iter().any(|t| t == *tool) { - all.push((*tool).to_owned()); - } - } - } - all.extend(allowed_mcp_tools(&groups)); - // Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES - // includes the corresponding capability. hive-c0re performs a second - // server-side check, so this is a usability gate (no annoying prompts), - // not the security boundary. - for tool in allowed_capability_tools() { - all.push(format!("mcp__{SERVER_NAME}__{tool}")); - } - all.join(",") -} - -/// Built-in tools list for `--tools` (which built-ins exist in this -/// session). Base set plus any group-gated built-ins (e.g. -/// `WebFetch`/`WebSearch` when the `web_tools` group is active). -#[must_use] -pub fn builtin_tools_arg() -> String { - let groups = effective_tool_groups(); - let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec(); - for group in &groups { - for t in group.builtin_tools() { - if !tools.contains(t) { - tools.push(t); - } - } - } - tools.join(",") -} - -/// Where the NixOS module writes the per-agent extra-MCP spec (see -/// `nix/templates/harness-base.nix`). Each entry becomes an additional -/// `mcpServers.<key>` block in the rendered claude config + a -/// `mcp__<key>__<tool>` pattern in `--allowedTools`. -const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; - -/// Where the NixOS module writes the per-agent send allow-list (see -/// `nix/templates/harness-base.nix`). Empty list = unrestricted (the -/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` -/// field; the manager is always implicitly permitted regardless of -/// the list contents. -const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; - -/// Enforce the per-agent send allow-list. Returns `Ok` when the -/// recipient is permitted (no list configured, `<parent>` sentinel -/// always allowed, or `to` is in the list); returns `Err(refusal)` -/// with a claude-readable string when blocked ��� the harness surfaces -/// the refusal as the tool result so claude knows the message didn't -/// land and can react (e.g. route via `<parent>` instead). -fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::PARENT_RECIPIENT { - // Always allow `<parent>` — the allow-list constrains peer - // chatter, not the structural reporting line; the operator - // can rewire who the parent IS via `set_parent` without - // having to remember to update the per-agent allow-list. - // The broker resolves the sentinel to the real parent label - // on the host side per topology.json (falls back to `operator` - // for root agents). - return Ok(()); - } - let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { - return Ok(()); // file missing → no policy configured → unrestricted - }; - let allow: Vec<String> = match serde_json::from_str(&raw) { - Ok(v) => v, - Err(e) => { - tracing::warn!( - path = SEND_ALLOW_PATH, - error = ?e, - "send allow-list parse failed; falling back to unrestricted", - ); - return Ok(()); - } - }; - if allow.is_empty() { - return Ok(()); // empty list = unrestricted (back-compat) - } - if allow.iter().any(|n| n == to) { - return Ok(()); - } - Err(format!( - "send refused: recipient '{to}' not in hyperhive.allowedRecipients \ - (configured in agent.nix). Allowed: {allow:?}. Your structural \ - parent is always reachable — route through `send(to: \"{}\", …)` \ - if you need to reach someone outside the allow-list.", - hive_sh4re::PARENT_RECIPIENT - )) -} - -#[derive(Debug, serde::Deserialize)] -struct ExtraMcpServer { - command: String, - #[serde(default)] - args: Vec<String>, - #[serde(default)] - env: std::collections::BTreeMap<String, String>, - #[serde(default = "default_allowed_tools")] - #[serde(rename = "allowedTools")] - allowed_tools: Vec<String>, -} - -fn default_allowed_tools() -> Vec<String> { - vec!["*".to_owned()] -} - -/// Read + parse the extra-MCP spec. Returns an empty map when -/// the file is missing or unparsable (the agent has none configured, -/// or the file is malformed — both cases degrade to "no extra servers"). -fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> { - let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { - return std::collections::BTreeMap::new(); - }; - serde_json::from_str(&raw).unwrap_or_else(|e| { - tracing::warn!( - path = EXTRA_MCP_PATH, - error = ?e, - "extra-mcp spec parse failed; ignoring", - ); - std::collections::BTreeMap::new() - }) -} - -/// Render the MCP config blob claude reads from `--mcp-config <path>`. -/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` -/// executable; `socket` is the hyperhive per-agent socket bind-mounted into -/// the container (forwarded to the child as `--socket <path>`). Merges in -/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the -/// agent's NixOS config. -#[must_use] -pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { - let mut servers = serde_json::Map::new(); - // When the harness is configured to run the built-in server as a - // persistent streamable-http daemon (loopback port in - // `HYPERHIVE_MCP_HTTP_PORT`), point claude at the stable URL instead of - // respawning a fresh stdio child each turn. The URL survives the per-turn - // claude re-spawn, so there is no per-turn re-registration race for the - // hyperhive surface. Extra servers (matrix/bash) stay stdio bridges. - let hyperhive_entry = match std::env::var("HYPERHIVE_MCP_HTTP_PORT") - .ok() - .and_then(|p| p.trim().parse::<u16>().ok()) - { - Some(port) => serde_json::json!({ - "type": "http", - "url": format!("http://127.0.0.1:{port}/mcp"), - }), - None => serde_json::json!({ - "command": agent_binary, - "args": ["--socket", socket.display().to_string(), "mcp"], - "env": {} - }), - }; - servers.insert(SERVER_NAME.to_owned(), hyperhive_entry); - // Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the - // agent's durable state dir without the agent author hard-coding it. - // User-supplied env takes precedence — we only fill in the missing key. - let state_dir = crate::paths::state_dir(); - // Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`). - // This is the security boundary for them: an out-of-process server the - // agent isn't entitled to must not even appear in the MCP config, or the - // agent could call it directly (there is no later enforcement point). - let groups = effective_tool_groups(); - for (name, mut spec) in load_extra_mcp() { - if name == SERVER_NAME { - tracing::warn!( - "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", - ); - continue; - } - if !extra_server_enabled(&name, &groups) { - tracing::info!( - server = %name, - "extra MCP server suppressed: agent lacks the required tool group" - ); - continue; - } - spec.env - .entry("HYPERHIVE_STATE_DIR".to_owned()) - .or_insert_with(|| state_dir.display().to_string()); - servers.insert( - name, - serde_json::json!({ - "command": spec.command, - "args": spec.args, - "env": spec.env, - }), - ); - } - let config = serde_json::json!({ "mcpServers": servers }); - serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) -} - #[cfg(test)] mod tests { - use super::{IDLE_WAIT_HINT, SocketReply, format_recv}; - use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::ToolGroup; + use super::{IDLE_WAIT_HINT, format_recv}; #[test] fn empty_recv_after_wait_appends_idle_hint() { - let out = format_recv(Ok(SocketReply::Messages(vec![])), true); + let out = format_recv( + Ok(hive_sh4re::Response::Messages { messages: vec![] }), + true, + ); assert!(out.starts_with("(empty)")); assert!(out.contains(IDLE_WAIT_HINT)); } #[test] fn empty_recv_without_wait_has_no_hint() { - let out = format_recv(Ok(SocketReply::Messages(vec![])), false); + let out = format_recv( + Ok(hive_sh4re::Response::Messages { messages: vec![] }), + false, + ); assert_eq!(out, "(empty)"); } - - fn qualified(tool: &str) -> String { - format!("mcp__{SERVER_NAME}__{tool}") - } - - #[test] - fn set_status_present_with_no_groups() { - // An agent with zero tool groups (or any group set that omits - // `meta`) must still be able to report its dashboard status. - let tools = allowed_mcp_tools(&[]); - assert!( - tools.contains(&qualified("set_status")), - "set_status missing from empty-group allow-list: {tools:?}" - ); - } - - #[test] - fn set_status_present_without_meta_group() { - let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]); - assert!(tools.contains(&qualified("set_status"))); - // get_agent_meta stays gated behind `meta` — only set_status is always-on. - assert!(!tools.contains(&qualified("get_agent_meta"))); - } - - #[test] - fn no_duplicate_set_status_when_meta_granted() { - let tools = allowed_mcp_tools(&[ToolGroup::Meta]); - let count = tools - .iter() - .filter(|t| **t == qualified("set_status")) - .count(); - assert_eq!(count, 1, "set_status duplicated: {tools:?}"); - assert!(tools.contains(&qualified("get_agent_meta"))); - } } diff --git a/hive-ag3nt/src/mcp_config.rs b/hive-ag3nt/src/mcp_config.rs new file mode 100644 index 00000000..8bd13af0 --- /dev/null +++ b/hive-ag3nt/src/mcp_config.rs @@ -0,0 +1,439 @@ +//! Claude launch-config layer: resolves the agent's tool-group / capability +//! set into the `--allowedTools` / `--tools` argument strings and renders the +//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server + +//! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by +//! [`crate::turn`] when it builds the claude command — it never touches the +//! running MCP server ([`crate::mcp`]). The `send` allow-list check +//! ([`check_send_allowed`]) lives here too since it's driven by the same +//! `/etc/hyperhive/*.json` operator config. + +/// Name of the hyperhive MCP server inside claude's view. Claude prefixes +/// tools as `mcp__<this>__<tool>` (e.g. `mcp__hyperhive__send`). +pub const SERVER_NAME: &str = "hyperhive"; + +/// Built-in claude tools always present in every session. Anything not +/// in this list (or added by `extra_builtin_tools`) literally doesn't +/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are +/// tool-group-gated (`web_tools`) — off by default. Nested agents +/// (`Task`) are intentionally omitted. `Bash` is disallowed — shell +/// execution goes through `mcp__bash__run` (background tasks +/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite` +/// is omitted because the todo list lives in claude's in-process session +/// state and silently evaporates on /compact or session reset — agents +/// should plan in /state notes instead. +pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Write"]; + +/// Env var written by the meta renderer with a comma-separated list of +/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). +/// When present, the harness expands the groups into per-tool allow entries +/// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. +const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; + +/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the +/// operator grants capabilities to this agent. Comma-separated +/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. +const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; + +/// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are +/// unlocked by the agent's current capability set. These are added to the +/// `--allowedTools` list so claude can call them without prompting, and +/// hive-c0re performs a second server-side capability check before executing. +fn allowed_capability_tools() -> Vec<String> { + let raw = match std::env::var(CAPABILITIES_ENV) { + Ok(v) if !v.trim().is_empty() => v, + _ => return vec![], + }; + let mut tools = Vec::new(); + for token in raw.split(',') { + let t = token.trim().to_ascii_lowercase(); + match t.as_str() { + "read_host_journal" => tools.push("get_host_journal".to_owned()), + // infra_admin lets an agent restart hive infrastructure + // containers (hive-ci / hive-gateway / hive-forge) through the + // existing `restart` tool. Unlock it here so agents that hold + // the capability without the full `lifecycle` group can still + // call it; c0re re-checks the capability server-side and only + // honours infra-container names via this path. + "infra_admin" => tools.push("restart".to_owned()), + // manage_root_agent / query_agent_state don't expose new MCP + // tools: manage_root_agent gates existing lifecycle tools via + // topology enforcement; query_agent_state unlocks the `agent` + // field in get_loose_ends / count_pending_reminders / + // reminder_rollup (c0re enforces the cap server-side). + "manage_root_agent" | "query_agent_state" => {} + unknown => { + tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); + } + } + } + tools +} + +/// Resolve the active tool groups for a harness session. +/// +/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated +/// token is matched (case-insensitive) against the `ToolGroup` serde names +/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, +/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. +/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty. +fn effective_tool_groups() -> Vec<hive_sh4re::ToolGroup> { + let raw = match std::env::var(TOOL_GROUPS_ENV) { + Ok(v) if !v.trim().is_empty() => v, + _ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), + }; + let mut groups = Vec::new(); + for token in raw.split(',') { + let t = token.trim().to_ascii_lowercase(); + // Parse via serde_json (the canonical deserialization path). + if let Ok(g) = + serde_json::from_value::<hive_sh4re::ToolGroup>(serde_json::Value::String(t.clone())) + { + groups.push(g); + } else { + tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); + } + } + if groups.is_empty() { + tracing::warn!( + "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ + falling back to AGENT_DEFAULT" + ); + return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(); + } + groups +} + +/// Tool group an extra (out-of-process) MCP server is gated behind, if any. +/// +/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever +/// the operator declares them. The `bash` server is the exception: raw shell +/// execution is a privilege, so it is only exposed when the agent holds the +/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at +/// dispatch) and the capability tools (re-checked server-side by hive-c0re), +/// an out-of-process server has **no** later enforcement point — once it is +/// in the claude MCP config the agent can call it. So this gate, applied at +/// config-render time, is the security boundary for those servers. +fn extra_server_required_group(server: &str) -> Option<hive_sh4re::ToolGroup> { + match server { + "bash" => Some(hive_sh4re::ToolGroup::Execution), + _ => None, + } +} + +/// Whether an extra MCP server should be exposed to claude given the active +/// tool `groups`. A gated server (see [`extra_server_required_group`]) is +/// suppressed when the agent lacks its required group. +fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool { + extra_server_required_group(server).is_none_or(|required| groups.contains(&required)) +} + +#[cfg(test)] +mod extra_server_gate_tests { + use super::{extra_server_enabled, extra_server_required_group}; + use hive_sh4re::ToolGroup; + + #[test] + fn bash_is_gated_behind_execution() { + assert_eq!( + extra_server_required_group("bash"), + Some(ToolGroup::Execution) + ); + // Suppressed without Execution, even if other groups are present. + assert!(!extra_server_enabled( + "bash", + &[ToolGroup::Messaging, ToolGroup::Inbox] + )); + // Available once Execution is granted. + assert!(extra_server_enabled("bash", &[ToolGroup::Execution])); + } + + #[test] + fn other_servers_are_ungated() { + assert_eq!(extra_server_required_group("matrix"), None); + assert_eq!(extra_server_required_group("scraper"), None); + // An ungated server is available regardless of (even empty) groups. + assert!(extra_server_enabled("matrix", &[])); + assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging])); + } +} + +/// MCP tools claude is allowed to call without prompting, derived from +/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl +/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re +/// (single source of truth). See `docs/conventions.md::Tool groups`. +#[must_use] +pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec<String> { + // Collect all tool names, deduplicating while preserving order. + // Always-on tools (e.g. `set_status`) come first so they're present + // regardless of which groups the agent is granted — a misconfigured + // agent still has to be able to report its dashboard status. + let mut seen = std::collections::HashSet::new(); + let mut out: Vec<String> = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS + .iter() + .copied() + .chain(groups.iter().flat_map(|g| g.tools().iter().copied())) + .filter(|t| seen.insert(*t)) + .map(|t| format!("mcp__{SERVER_NAME}__{t}")) + .collect(); + // Extra MCP servers declared via `hyperhive.extraMcpServers` in + // the agent's NixOS config. Each entry maps its `allowedTools` + // pattern list to `mcp__<server>__<pattern>` so claude can call + // them without per-tool operator approval. `["*"]` (the default) + // expands to `mcp__<server>__*` — every tool from that server. + for (server, spec) in load_extra_mcp() { + if server == SERVER_NAME || !extra_server_enabled(&server, groups) { + continue; + } + for pat in spec.allowed_tools { + out.push(format!("mcp__{server}__{pat}")); + } + } + out +} + +/// Combined allow-list passed to `--allowedTools` (auto-approve) — covers +/// both the built-ins and the MCP surface. +#[must_use] +pub fn allowed_tools_arg() -> String { + let groups = effective_tool_groups(); + // Base built-ins always present. + let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS + .iter() + .map(|s| (*s).to_owned()) + .collect(); + // Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools). + for group in &groups { + for tool in group.builtin_tools() { + if !all.iter().any(|t| t == *tool) { + all.push((*tool).to_owned()); + } + } + } + all.extend(allowed_mcp_tools(&groups)); + // Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES + // includes the corresponding capability. hive-c0re performs a second + // server-side check, so this is a usability gate (no annoying prompts), + // not the security boundary. + for tool in allowed_capability_tools() { + all.push(format!("mcp__{SERVER_NAME}__{tool}")); + } + all.join(",") +} + +/// Built-in tools list for `--tools` (which built-ins exist in this +/// session). Base set plus any group-gated built-ins (e.g. +/// `WebFetch`/`WebSearch` when the `web_tools` group is active). +#[must_use] +pub fn builtin_tools_arg() -> String { + let groups = effective_tool_groups(); + let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec(); + for group in &groups { + for t in group.builtin_tools() { + if !tools.contains(t) { + tools.push(t); + } + } + } + tools.join(",") +} + +/// Where the NixOS module writes the per-agent extra-MCP spec (see +/// `nix/templates/harness-base.nix`). Each entry becomes an additional +/// `mcpServers.<key>` block in the rendered claude config + a +/// `mcp__<key>__<tool>` pattern in `--allowedTools`. +const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; + +/// Where the NixOS module writes the per-agent send allow-list (see +/// `nix/templates/harness-base.nix`). Empty list = unrestricted (the +/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` +/// field; the manager is always implicitly permitted regardless of +/// the list contents. +const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; + +/// Enforce the per-agent send allow-list. Returns `Ok` when the +/// recipient is permitted (no list configured, `<parent>` sentinel +/// always allowed, or `to` is in the list); returns `Err(refusal)` +/// with a claude-readable string when blocked ��� the harness surfaces +/// the refusal as the tool result so claude knows the message didn't +/// land and can react (e.g. route via `<parent>` instead). +pub fn check_send_allowed(to: &str) -> Result<(), String> { + if to == hive_sh4re::PARENT_RECIPIENT { + // Always allow `<parent>` — the allow-list constrains peer + // chatter, not the structural reporting line; the operator + // can rewire who the parent IS via `set_parent` without + // having to remember to update the per-agent allow-list. + // The broker resolves the sentinel to the real parent label + // on the host side per topology.json (falls back to `operator` + // for root agents). + return Ok(()); + } + let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { + return Ok(()); // file missing → no policy configured → unrestricted + }; + let allow: Vec<String> = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + path = SEND_ALLOW_PATH, + error = ?e, + "send allow-list parse failed; falling back to unrestricted", + ); + return Ok(()); + } + }; + if allow.is_empty() { + return Ok(()); // empty list = unrestricted (back-compat) + } + if allow.iter().any(|n| n == to) { + return Ok(()); + } + Err(format!( + "send refused: recipient '{to}' not in hyperhive.allowedRecipients \ + (configured in agent.nix). Allowed: {allow:?}. Your structural \ + parent is always reachable — route through `send(to: \"{}\", …)` \ + if you need to reach someone outside the allow-list.", + hive_sh4re::PARENT_RECIPIENT + )) +} + +#[derive(Debug, serde::Deserialize)] +struct ExtraMcpServer { + command: String, + #[serde(default)] + args: Vec<String>, + #[serde(default)] + env: std::collections::BTreeMap<String, String>, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec<String>, +} + +fn default_allowed_tools() -> Vec<String> { + vec!["*".to_owned()] +} + +/// Read + parse the extra-MCP spec. Returns an empty map when +/// the file is missing or unparsable (the agent has none configured, +/// or the file is malformed — both cases degrade to "no extra servers"). +fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> { + let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { + return std::collections::BTreeMap::new(); + }; + serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!( + path = EXTRA_MCP_PATH, + error = ?e, + "extra-mcp spec parse failed; ignoring", + ); + std::collections::BTreeMap::new() + }) +} + +/// Render the MCP config blob claude reads from `--mcp-config <path>`. +/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` +/// executable; `socket` is the hyperhive per-agent socket bind-mounted into +/// the container (forwarded to the child as `--socket <path>`). Merges in +/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the +/// agent's NixOS config. +#[must_use] +pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { + let mut servers = serde_json::Map::new(); + // When the harness is configured to run the built-in server as a + // persistent streamable-http daemon (loopback port in + // `HYPERHIVE_MCP_HTTP_PORT`), point claude at the stable URL instead of + // respawning a fresh stdio child each turn. The URL survives the per-turn + // claude re-spawn, so there is no per-turn re-registration race for the + // hyperhive surface. Extra servers (matrix/bash) stay stdio bridges. + let hyperhive_entry = match std::env::var("HYPERHIVE_MCP_HTTP_PORT") + .ok() + .and_then(|p| p.trim().parse::<u16>().ok()) + { + Some(port) => serde_json::json!({ + "type": "http", + "url": format!("http://127.0.0.1:{port}/mcp"), + }), + None => serde_json::json!({ + "command": agent_binary, + "args": ["--socket", socket.display().to_string(), "mcp"], + "env": {} + }), + }; + servers.insert(SERVER_NAME.to_owned(), hyperhive_entry); + // Auto-inject HYPERHIVE_STATE_DIR so extra MCP servers can resolve the + // agent's durable state dir without the agent author hard-coding it. + // User-supplied env takes precedence — we only fill in the missing key. + let state_dir = crate::paths::state_dir(); + // Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`). + // This is the security boundary for them: an out-of-process server the + // agent isn't entitled to must not even appear in the MCP config, or the + // agent could call it directly (there is no later enforcement point). + let groups = effective_tool_groups(); + for (name, mut spec) in load_extra_mcp() { + if name == SERVER_NAME { + tracing::warn!( + "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", + ); + continue; + } + if !extra_server_enabled(&name, &groups) { + tracing::info!( + server = %name, + "extra MCP server suppressed: agent lacks the required tool group" + ); + continue; + } + spec.env + .entry("HYPERHIVE_STATE_DIR".to_owned()) + .or_insert_with(|| state_dir.display().to_string()); + servers.insert( + name, + serde_json::json!({ + "command": spec.command, + "args": spec.args, + "env": spec.env, + }), + ); + } + let config = serde_json::json!({ "mcpServers": servers }); + serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) +} + +#[cfg(test)] +mod tests { + use super::{SERVER_NAME, allowed_mcp_tools}; + use hive_sh4re::ToolGroup; + + fn qualified(tool: &str) -> String { + format!("mcp__{SERVER_NAME}__{tool}") + } + + #[test] + fn set_status_present_with_no_groups() { + // An agent with zero tool groups (or any group set that omits + // `meta`) must still be able to report its dashboard status. + let tools = allowed_mcp_tools(&[]); + assert!( + tools.contains(&qualified("set_status")), + "set_status missing from empty-group allow-list: {tools:?}" + ); + } + + #[test] + fn set_status_present_without_meta_group() { + let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]); + assert!(tools.contains(&qualified("set_status"))); + // get_agent_meta stays gated behind `meta` — only set_status is always-on. + assert!(!tools.contains(&qualified("get_agent_meta"))); + } + + #[test] + fn no_duplicate_set_status_when_meta_granted() { + let tools = allowed_mcp_tools(&[ToolGroup::Meta]); + let count = tools + .iter() + .filter(|t| **t == qualified("set_status")) + .count(); + assert_eq!(count, 1, "set_status duplicated: {tools:?}"); + assert!(tools.contains(&qualified("get_agent_meta"))); + } +} diff --git a/hive-ag3nt/src/plugins.rs b/hive-ag3nt/src/plugins.rs index f5b21765..e3bc3cdc 100644 --- a/hive-ag3nt/src/plugins.rs +++ b/hive-ag3nt/src/plugins.rs @@ -11,8 +11,6 @@ //! plugin specs resolve against current index data. Marketplace update //! failures are non-fatal — stale index is better than no install attempt. -use std::path::Path; - use tokio::process::Command; const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json"; @@ -103,8 +101,7 @@ async fn update_marketplaces() { /// notification, see `Surface::send_to_parent`). Wire-agnostic: the /// caller picks the recipient via the same `<parent>` sentinel that /// failure-notify uses everywhere else. -pub async fn install_configured(socket: &Path) -> Vec<String> { - let _ = socket; // Reserved for future telemetry; currently unused. +pub async fn install_configured() -> Vec<String> { let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else { return Vec::new(); }; diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index aa584747..4e37ce05 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -2,7 +2,7 @@ //! HTML-comment markers gating role-specific blocks; this module //! assembles the final prompt (always "agent" role — there is only one //! role). Marker grammar + placeholder substitution rules in -//! `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). +//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). use std::path::{Path, PathBuf}; @@ -16,7 +16,7 @@ use anyhow::{Context, Result}; /// [`hive_sh4re::assets::prompt_template`] /// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders + /// marker grammar documented in -/// `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). +/// `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). #[must_use] pub fn render( template: &str, @@ -55,14 +55,12 @@ pub fn render( } } -/// Walk `template` line-by-line. Inside a `<!-- role:X -->` block, -/// suppress all lines unless `X == target`. Marker lines themselves are -/// always elided from the output. Unbalanced openers (no matching -/// closer) hold the suppression state until end-of-file. A mismatched -/// closer (`<!-- /role:manager -->` inside a `role:agent` block) is -/// elided from the output but does NOT reset the active role — keeps -/// the suppression conservative so a typo can't dump wrong-flavor -/// content. +/// Walk `template` line-by-line, stripping `<!-- role:X -->` / +/// `<!-- /role:X -->` marker lines and suppressing the lines inside a block +/// unless `X == target`. A close marker ends the current block. Production +/// `system.md` carries no markers today (single agent role — see the module +/// doc), so this is effectively a passthrough; the marker grammar stays wired +/// for a future manager / multi-role prompt. fn filter_role_blocks(template: &str, target: &str) -> String { let mut out = String::with_capacity(template.len()); // None = outside any block; Some(role) = inside role-tagged block. @@ -73,19 +71,11 @@ fn filter_role_blocks(template: &str, target: &str) -> String { active_role = Some(role); continue; } - if let Some(close_role) = parse_close_marker(trimmed) { - if active_role == Some(close_role) { - active_role = None; - } - // Mismatched close: elide the marker line but keep the - // active role intact so wrong-flavor content stays gated. + if parse_close_marker(trimmed).is_some() { + active_role = None; continue; } - let include = match active_role { - None => true, - Some(role) => role == target, - }; - if include { + if active_role.is_none_or(|role| role == target) { out.push_str(line); out.push('\n'); } @@ -245,46 +235,6 @@ shared closer assert_eq!(parse_close_marker("just text"), None); } - #[test] - fn mismatched_close_keeps_active_role() { - // `<!-- role:agent -->` block with a stray `<!-- /role:manager -->` - // closer inside: the manager-tagged close must NOT pop the agent - // gate, else manager-target output would leak the agent block's - // text (or vice-versa). Stray marker line itself is still elided. - let template = "shared\n\ - <!-- role:agent -->\n\ - agent line 1\n\ - <!-- /role:manager -->\n\ - agent line 2\n\ - <!-- /role:agent -->\n\ - shared end\n"; - let manager = filter_role_blocks(template, "manager"); - // Both agent lines stay gated out for the manager target; the - // stray close didn't accidentally pop the role. Stray marker - // itself elided from the output. - assert!(!manager.contains("agent line 1")); - assert!(!manager.contains("agent line 2")); - assert!(!manager.contains("<!--")); - assert!(manager.contains("shared")); - assert!(manager.contains("shared end")); - // Agent target still sees both lines (matched closer pops at the end). - let agent = filter_role_blocks(template, "agent"); - assert!(agent.contains("agent line 1")); - assert!(agent.contains("agent line 2")); - assert!(agent.contains("shared end")); - } - - #[test] - fn unbalanced_opener_suppresses_until_eof() { - // Stray opener with no closer — content stays suppressed for - // the wrong-role target right through to end-of-file. Real- - // file safety net: a typo in a closer doesn't accidentally - // dump wrong-flavor content into the active prompt. - let template = "shared\n<!-- role:manager -->\nm-only\nstill m-only\n"; - let agent = filter_role_blocks(template, "agent"); - assert_eq!(agent, "shared\n"); - } - #[test] fn render_substitutes_label_and_pronouns() { // Real template's first agent line — keeps the renderer diff --git a/hive-ag3nt/src/serve_common.rs b/hive-ag3nt/src/serve_common.rs index 3a3b00cc..e23bd4c0 100644 --- a/hive-ag3nt/src/serve_common.rs +++ b/hive-ag3nt/src/serve_common.rs @@ -5,7 +5,7 @@ use crate::events::Bus; use crate::mcp::REDELIVERY_HINT; -use crate::turn::TurnOutcome; +use crate::turn::{TurnError, TurnOutcome}; use crate::turn_stats::TurnStatRow; /// Assemble the per-turn wake prompt string. The role/tools/etc. live in the @@ -100,12 +100,12 @@ pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow { serde_json::to_string(&tool_calls).ok() }; let (result_kind, note) = match outcome { - TurnOutcome::Ok => ("ok", None), - TurnOutcome::Compacted => ("compacted", None), - TurnOutcome::PromptTooLong => ("prompt_too_long", None), - TurnOutcome::RateLimited => ("rate_limited", None), - TurnOutcome::AuthFailed => ("auth_failed", None), - TurnOutcome::Failed(e) => ("failed", Some(format!("{e:#}"))), + Ok(false) => ("ok", None), + Ok(true) => ("compacted", None), + Err(TurnError::PromptTooLong) => ("prompt_too_long", None), + Err(TurnError::RateLimited) => ("rate_limited", None), + Err(TurnError::AuthFailed) => ("auth_failed", None), + Err(TurnError::Failed(e)) => ("failed", Some(format!("{e:#}"))), }; let wake_from = if wake_from.starts_with("bash-task-") { "bash-task".to_owned() diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs index 80298c04..eb3f6152 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -42,6 +42,7 @@ impl Window { "7d" => Self::Week, "30d" => Self::Month, "all" => Self::All, + // Default (incl. `label()`'s own canonical `"24h"`/`"1d"`). _ => Self::Day, } } @@ -229,11 +230,15 @@ fn empty_snapshot(window: Window) -> Snapshot { } fn snapshot(path: &Path, window: Window) -> Result<Snapshot> { - // Read-only open so an in-flight writer (the harness's own - // turn_stats sink) never blocks us and we can't corrupt the db - // via a query bug. + // Read-only open so we can't corrupt the db via a query bug. let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) .with_context(|| format!("open {} read-only", path.display()))?; + // turn_stats is rollback-journal (not WAL): a read landing while the + // harness's own sink is mid-INSERT gets SQLITE_BUSY, which propagates up + // and blanks the whole stats page. Wait out the brief write instead — + // matches hive-c0re's host-side reader (`hive_stats::read_agent`). + conn.busy_timeout(std::time::Duration::from_millis(500)) + .with_context(|| format!("set busy_timeout on {}", path.display()))?; let now = now_secs(); // Fixed windows look back a constant span; `all` starts at the earliest // recorded turn (`MIN(started_at)`, falling back to `now` on an empty diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 62661fbe..78d6a329 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -12,7 +12,7 @@ use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink}; use serde_json::Value; use crate::events::{Bus, LiveEvent}; -use crate::mcp; +use crate::mcp_config; // Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json` // (wired in `nix/templates/harness-base.nix` from the `prompts/claude-settings.json` @@ -92,8 +92,8 @@ pub struct TurnFiles { } impl TurnFiles { - /// Write all three files into the per-agent runtime dir alongside - /// `socket`. Idempotent — overwrites whatever was there. + /// Write the two per-turn files (MCP config + system prompt) into the + /// agent's config dir. Idempotent — overwrites whatever was there. /// /// # Errors /// @@ -121,7 +121,7 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> { let exe = std::env::current_exe() .ok() .map_or_else(|| "hive".into(), |p| p.display().to_string()); - let body = mcp::render_claude_config(&exe, socket); + let body = mcp_config::render_claude_config(&exe, socket); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude MCP config"); Ok(path) @@ -139,29 +139,39 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf> crate::prompt::write_system_prompt(socket, label).await } -/// One claude turn's outcome. The harness uses this to decide whether to -/// transparently kick off a compaction and retry. +/// One claude turn's outcome: `Ok(compacted)` on success, or a [`TurnError`] +/// the serve loop must act on. The `compacted` bool is `true` when a +/// compaction ran this turn (reactively on overflow, or proactively per the +/// policy — or an operator `/compact` at turn end); it's recorded as +/// `result_kind = "compacted"` in turn stats so the stats page can distinguish +/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases +/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the +/// `hive` serve loop). +pub type TurnOutcome = std::result::Result<bool, TurnError>; + +/// The ways a turn can end without a usable result. Each is deliberately *not* +/// a generic failure — the serve loop reacts to each differently (requeue, +/// park, escalate). #[derive(Debug)] -pub enum TurnOutcome { - Ok, - /// Turn completed and proactive context-size compaction fired afterwards. - /// Treated like `Ok` for ack and failure-notification purposes; recorded - /// as `result_kind = "compacted"` in turn stats so the stats page can - /// distinguish normal turns from turns that triggered a compaction. - Compacted, +pub enum TurnError { /// claude saw "Prompt is too long" and even a reactive compact + retry /// (inside [`InfiniteSession::run`]) couldn't bring it back under the - /// window. Rare; the serve loop treats it like `Ok` (acks the turn). + /// window. Rare. [`drive_turn`] archives the session (so the next turn + /// starts fresh) and the serve loop requeues the in-flight message, which + /// redelivers into that fresh session — the wake prompt itself is tiny, so + /// the overflow was the accumulated context, which the archive clears. PromptTooLong, /// The Anthropic API refused the request due to a rate limit, per-account /// usage cap, or exhausted credit balance. The serve loop should park for - /// `rate_limit_sleep_secs()` and retry — NOT bubble up as a crash. + /// `rate_limit_sleep_secs()` and requeue — NOT bubble up as a crash. RateLimited, /// The Anthropic API rejected the request with 401 (OAuth session /// expired or revoked). The serve loop should flip the container /// into `needs_login_idle` and stop driving turns until the /// operator re-auths via the per-agent web UI. AuthFailed, + /// A hard failure with no recovery — the serve loop escalates it to the + /// parent (`send_to_parent`). Failed(anyhow::Error), } @@ -226,8 +236,10 @@ fn compact_percent() -> u8 { if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) { return 0; } - let pct = env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); - u8::try_from(pct.min(100)).unwrap_or(DEFAULT_COMPACT_PERCENT) + let pct = + env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); + // `min(100)` is ≤ 100, so this `try_from` is infallible. + u8::try_from(pct.min(100)).expect("value clamped to <= 100 fits in u8") } /// The agent's durable session type: the constant-title [`InfiniteSession`] @@ -309,24 +321,36 @@ pub async fn drive_turn( text: format!("created fresh session titled \"{}\"", session_title()), }); } - if progress.compacted { - TurnOutcome::Compacted - } else { - TurnOutcome::Ok - } + Ok(progress.compacted) } Err(e) => error_to_turn(e), }; + if matches!(outcome, Err(TurnError::PromptTooLong)) { + // The lib already compacted + retried and the session is still over the + // window. Archive it here (session lifecycle stays hive-side) so the + // requeued message — handled by the serve loop — redelivers into a + // fresh session that fits. + bus.emit(LiveEvent::Note { + text: "context still over the window after compaction — archiving session so the \ + retried message starts fresh" + .into(), + }); + archive_session(bus); + return Err(TurnError::PromptTooLong); + } // Operator `/compact` (`POST /api/compact`) deferred to the turn boundary: // run it now that the turn is done, so it works mid-turn rather than only // when the agent is idle. Only on a healthy turn — no point spawning a // compaction after a rate-limited / auth-failed / crashed one. - if bus.take_compact() && matches!(outcome, TurnOutcome::Ok | TurnOutcome::Compacted) { + if bus.take_compact() && outcome.is_ok() { bus.emit(LiveEvent::Note { text: "operator: /compact — running at turn end".into(), }); + // Reflect `Compacting` in the UI like the idle path (`run_pending_compact`) + // does; the serve loop resets to `Idle` once this turn returns. + bus.set_state(crate::events::TurnState::Compacting); let _ = session.compact(&config, &sink).await; - return TurnOutcome::Compacted; + return Ok(true); } outcome } @@ -374,28 +398,35 @@ fn maybe_auto_reset(bus: &Bus) { /// semantics stay consistent across every agent role. pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) { match outcome { - TurnOutcome::Ok | TurnOutcome::Compacted | TurnOutcome::PromptTooLong => { + Ok(_) => { bus.emit(LiveEvent::TurnEnd { ok: true, note: None, }); tracing::info!("turn finished"); } - TurnOutcome::RateLimited => { + Err(TurnError::PromptTooLong) => { + bus.emit(LiveEvent::TurnEnd { + ok: false, + note: Some("context too long after compaction — session archived, retrying".into()), + }); + tracing::warn!("turn prompt-too-long; archived session and requeueing"); + } + Err(TurnError::RateLimited) => { bus.emit(LiveEvent::TurnEnd { ok: false, note: Some("rate limited — parking until quota resets".into()), }); tracing::warn!("turn rate-limited"); } - TurnOutcome::AuthFailed => { + Err(TurnError::AuthFailed) => { bus.emit(LiveEvent::TurnEnd { ok: false, note: Some("authentication failed (401) — waiting for re-login".into()), }); tracing::warn!("turn auth-failed (401)"); } - TurnOutcome::Failed(e) => { + Err(TurnError::Failed(e)) => { let note = format!("{e:#}"); bus.emit(LiveEvent::TurnEnd { ok: false, @@ -489,8 +520,8 @@ fn claude_config(bus: &Bus, files: &TurnFiles) -> Config { system_prompt_file: Some(files.system_prompt.clone()), mcp_config: Some(files.mcp_config.clone()), strict_mcp_config: true, - tools: Some(mcp::builtin_tools_arg()), - allowed_tools: Some(mcp::allowed_tools_arg()), + tools: Some(mcp_config::builtin_tools_arg()), + allowed_tools: Some(mcp_config::allowed_tools_arg()), add_dirs, ..Config::default() } @@ -503,11 +534,13 @@ fn claude_config(bus: &Bus, files: &TurnFiles) -> Config { fn error_to_turn(err: hive_claude::Error) -> TurnOutcome { use hive_claude::Error; match err { - Error::PromptTooLong => TurnOutcome::PromptTooLong, - Error::RateLimited => TurnOutcome::RateLimited, - Error::AuthFailed => TurnOutcome::AuthFailed, - Error::SessionNotFound => TurnOutcome::Ok, - other => TurnOutcome::Failed(other.into()), + Error::PromptTooLong => Err(TurnError::PromptTooLong), + Error::RateLimited => Err(TurnError::RateLimited), + Error::AuthFailed => Err(TurnError::AuthFailed), + // A resume-miss the lib couldn't self-heal is benign — treat it as a + // clean (non-compacted) turn; the next turn creates the session fresh. + Error::SessionNotFound => Ok(false), + other => Err(TurnError::Failed(other.into())), } } @@ -603,4 +636,3 @@ fn archive_session(bus: &Bus) { } } } - diff --git a/hive-ag3nt/src/turn_stats.rs b/hive-ag3nt/src/turn_stats.rs index e314923e..0734d581 100644 --- a/hive-ag3nt/src/turn_stats.rs +++ b/hive-ag3nt/src/turn_stats.rs @@ -1,8 +1,14 @@ //! Per-turn analytics sink. One sqlite row per claude turn captures: //! identity (`model`, `wake_from`, `result_kind`), timing (`started_at`, -//! `ended_at`, `duration_ms`), cost (token counts), behaviour (tool-call -//! count + per-tool breakdown), and post-turn snapshot metrics -//! (`open_threads_count`, `open_reminders_count`). +//! `ended_at`, `duration_ms`), cost (token counts), and behaviour (tool-call +//! count + per-tool breakdown). +//! +//! **Captured but not yet read** (written every turn, no reader today — +//! kept for a future chart / backfill, not consumed by `stats::snapshot` +//! or the host rollup): `tool_call_count` (the snapshot recomputes tool +//! totals from `tool_call_breakdown_json` instead), `open_threads_count` + +//! `open_reminders_count` (planned: a loose-ends-over-time trend), and +//! `note` (failure detail for `result_kind = "failed"`). //! //! Lives next to `hyperhive-events.sqlite` in the agent's state dir //! so the host-side state vacuum sweep can reach both. Schema is @@ -103,14 +109,19 @@ pub struct TurnStatRow { pub last_output_tokens: u64, pub last_cache_read_input_tokens: u64, pub last_cache_creation_input_tokens: u64, + /// Captured, not yet read — the snapshot recomputes tool totals from + /// `tool_call_breakdown_json` (see the module doc). pub tool_call_count: u64, /// Per-tool breakdown as JSON: `{"Read":12,"Bash":3,...}`. None /// when no tools were called (saves a sqlite write of `"{}"`). pub tool_call_breakdown_json: Option<String>, + /// Post-turn loose-ends snapshot. Captured, not yet read — planned to + /// feed a loose-ends-over-time trend on the stats page. pub open_threads_count: Option<u64>, pub open_reminders_count: Option<u64>, /// `"ok" | "failed" | "prompt_too_long"`. pub result_kind: &'static str, + /// Failure detail for `result_kind = "failed"`. Captured, not yet read. pub note: Option<String>, /// FK to `sessions.id` for the fresh claude session this turn belongs /// to. `None` on pre-capture rows (and when the stats db couldn't mint @@ -278,7 +289,10 @@ impl TurnStats { last_input_tokens, last_output_tokens, last_cache_read_input_tokens, last_cache_creation_input_tokens FROM turn_stats - ORDER BY started_at DESC + -- `id` (AUTOINCREMENT) is monotonic with insertion, so this is + -- the most-recently-inserted row even among same-second turns + -- (which `started_at DESC` would order arbitrarily). + ORDER BY id DESC LIMIT 1", [], |row| { diff --git a/hive-ag3nt/src/web_ui/actions.rs b/hive-ag3nt/src/web_ui/actions.rs index 49fce257..1132b9e6 100644 --- a/hive-ag3nt/src/web_ui/actions.rs +++ b/hive-ag3nt/src/web_ui/actions.rs @@ -8,9 +8,7 @@ use axum::{ }; use serde::Deserialize; -use crate::client; - -use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; +use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct SendForm { @@ -25,41 +23,28 @@ pub(super) async fn post_send( if body.is_empty() { return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); } - let result = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::OperatorMsg { body }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Ok)) => Ok(()), - Ok(Ok(hive_sh4re::Response::Err { message })) => Err(message), - Ok(Ok(other)) => Err(format!("unexpected response: {other:?}")), - Ok(Err(e)) => Err(format!("transport: {e:#}")), - Err(_) => Err("timed out — hive-c0re busy, retry".to_owned()), - }; - match result { + match super::broker_request(&state.socket, &hive_sh4re::Request::OperatorMsg { body }).await { // 200 instead of 303 → the client doesn't refetch /api/state. // The operator message becomes a broker `Sent` (already shown // server-side in the dashboard); on the agent side, the // resulting `TurnStart` SSE event drives the terminal + the // inbox row gets consumed by the time `TurnEnd` fires the // existing turn-end refresh. - Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), - Err(e) => error_response( + Ok(hive_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(), + Ok(hive_sh4re::Response::Err { message }) => error_response( StatusCode::INTERNAL_SERVER_ERROR, - &format!("send failed: {e}"), + &format!("send failed: {message}"), ), + Ok(other) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("send failed: unexpected response: {other:?}"), + ), + Err(e) => super::broker_error_response(&e, "send"), } } pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response { - let out = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; + let out = super::sigint_claude().await; let note = match out { Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(), Ok(o) if o.status.code() == Some(1) => { diff --git a/hive-ag3nt/src/web_ui/auth.rs b/hive-ag3nt/src/web_ui/auth.rs index f3e175e0..9c1cceb5 100644 --- a/hive-ag3nt/src/web_ui/auth.rs +++ b/hive-ag3nt/src/web_ui/auth.rs @@ -74,17 +74,9 @@ pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response (axum::http::StatusCode::OK, "ok").into_response() } -/// OAuth credential filenames inside `paths::claude_dir()`. Wiping -/// only these (and not the rest of `~/.claude/`) preserves session -/// history so `claude --continue` keeps working after a fresh login. -/// Rationale + the previous wholesale-wipe shape we replaced live in -/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) -/// (the `/api/logout` bullet). -const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; - /// Operator-driven `/logout`: SIGINT claude, delete the credential -/// files in `CRED_FILE_NAMES`, flip `LoginState::NeedsLogin`. The -/// turn loop's next iteration parks into `wait_for_login` which +/// files (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`. +/// The turn loop's next iteration parks into `wait_for_login` which /// resumes when a fresh credentials file appears via `/login/code`. /// Always returns 200 with a body describing what happened. See /// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) @@ -92,36 +84,20 @@ const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.js /// preservation invariants. pub(super) async fn post_logout(State(state): State<AppState>) -> Response { // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). - let _ = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; - // Step 2: delete OAuth credential files only — preserve session - // history files alongside them. + let _ = super::sigint_claude().await; + // Step 2: delete OAuth credential files only — login::clear_session owns + // the file set and preserves session-history files alongside them. let dir = crate::paths::claude_dir(); - let mut warnings: Vec<String> = Vec::new(); - let mut wiped: Vec<&str> = Vec::new(); - for name in CRED_FILE_NAMES { - let path = dir.join(name); - match tokio::fs::remove_file(&path).await { - Ok(()) => wiped.push(name), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Already gone — operator clicked /logout while - // already logged out, or the file simply didn't exist - // for this agent. Idempotent. - } - Err(e) => warnings.push(format!("{name}: {e}")), - } - } - let wipe_summary = if wiped.is_empty() { + let cleared = crate::login::clear_session(&dir).await; + let wipe_summary = if cleared.wiped.is_empty() { "no credential files present (already logged out)".to_owned() } else { - format!("wiped {}", wiped.join(", ")) + format!("wiped {}", cleared.wiped.join(", ")) }; - let warn_suffix = if warnings.is_empty() { + let warn_suffix = if cleared.warnings.is_empty() { String::new() } else { - format!(" (warnings: {})", warnings.join("; ")) + format!(" (warnings: {})", cleared.warnings.join("; ")) }; // Step 3: flip LoginState + emit Note. Turn loop sees the flip on // its next iteration and parks into wait_for_login. diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs index 1827e3da..ec99fccb 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -259,6 +259,17 @@ fn read_gui_vnc_port() -> Option<u16> { std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() } +/// SIGINT any running `claude` process in this container (best-effort). Shared +/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers +/// can inspect the exit status (0 = signalled, 1 = no process matched) or +/// ignore it. +async fn sigint_claude() -> std::io::Result<std::process::Output> { + tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await +} + fn error_response(status: StatusCode, message: &str) -> Response { // Plain text — JS app surfaces in `alert()`, HTML wrapping would just // be noise. Status is per-caller: 400 for bad input, 409 for a @@ -267,3 +278,52 @@ fn error_response(status: StatusCode, message: &str) -> Response { // in its alert, so a benign "busy, retry" must not read as a 500. (status, message.to_owned()).into_response() } + +/// Why a deadline-bounded broker request via the per-agent socket didn't +/// yield a response. Kept distinct so action handlers pick the right status +/// code (see [`broker_error_response`]) while decorative fetches `.ok()` both. +enum BrokerError { + /// Outran [`SOCKET_FETCH_TIMEOUT`] — hive-c0re is busy or stalled. A + /// retryable state conflict (→ 409), not a server fault. + Timeout, + /// The socket transport itself failed (connect / encode / decode). + Transport(anyhow::Error), +} + +/// Issue a broker request over the per-agent socket, bounded by +/// [`SOCKET_FETCH_TIMEOUT`] so a busy or stalled hive-c0re degrades the +/// response instead of hanging it. Callers match the returned [`Response`] +/// variant themselves; the error side distinguishes a retryable timeout from +/// a transport failure. This is the one shared broker-call scaffold — every +/// web-UI handler that talks to the broker goes through it. +async fn broker_request( + socket: &Path, + req: &hive_sh4re::Request, +) -> std::result::Result<hive_sh4re::Response, BrokerError> { + match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + crate::client::request::<_, hive_sh4re::Response>(socket, req), + ) + .await + { + Ok(Ok(resp)) => Ok(resp), + Ok(Err(e)) => Err(BrokerError::Transport(e)), + Err(_) => Err(BrokerError::Timeout), + } +} + +/// Map a [`BrokerError`] to an operator-facing error response: a timeout is a +/// retryable "busy" conflict (409), a transport failure is a 500. `action` +/// prefixes the message (e.g. `"send"`, `"get_loose_ends"`). +fn broker_error_response(err: &BrokerError, action: &str) -> Response { + match err { + BrokerError::Timeout => error_response( + StatusCode::CONFLICT, + &format!("{action}: timed out — hive-c0re busy, retry"), + ), + BrokerError::Transport(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("{action}: transport: {e:#}"), + ), + } +} diff --git a/hive-ag3nt/src/web_ui/state.rs b/hive-ag3nt/src/web_ui/state.rs index f67ea0e2..78b7c378 100644 --- a/hive-ag3nt/src/web_ui/state.rs +++ b/hive-ag3nt/src/web_ui/state.rs @@ -3,11 +3,10 @@ use axum::extract::State; use serde::Serialize; -use crate::client; use crate::login::LoginState; use crate::login_session::drop_if_finished; -use super::{AppState, SOCKET_FETCH_TIMEOUT}; +use super::AppState; pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> { // Capture seq *before* any reads so the dedupe contract is @@ -37,10 +36,7 @@ pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<State let inbox = recent_inbox(&state.socket).await; let (turn_state, turn_state_since) = state.bus.state_snapshot(); let model = state.bus.model(); - let context_window_tokens = state - .bus - .api_context_window() - .unwrap_or_else(|| crate::events::context_window_tokens(&model)); + let context_window_tokens = state.bus.effective_context_window(&model); let ctx_usage = state.bus.last_ctx_usage(); let cost_usage = state.bus.last_cost_usage(); let effort = state.bus.effort(); @@ -88,10 +84,7 @@ pub(super) async fn api_dashboard_state( let (status_text, status_set_at) = read_own_status(); let rate_limited = state.bus.is_rate_limited(); let model = state.bus.model(); - let context_window_tokens = state - .bus - .api_context_window() - .unwrap_or_else(|| crate::events::context_window_tokens(&model)); + let context_window_tokens = state.bus.effective_context_window(&model); // Full context-window size = input + cache-read + cache-creation. Using // raw `input_tokens` here reported only the *uncached* sliver, which is // ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match @@ -371,57 +364,24 @@ struct ExtraLink { /// failure — the inbox section is decorative, not authoritative. async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> { const LIMIT: u64 = 30; - // Deadline-bounded: `/api/state` must render even when hive-c0re is - // busy — an empty inbox section beats a hung snapshot. - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::Recent { limit: LIMIT }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Recent { rows })) => rows, + // Deadline-bounded (via `broker_request`): `/api/state` must render even + // when hive-c0re is busy — an empty inbox section beats a hung snapshot. + match super::broker_request(socket, &hive_sh4re::Request::Recent { limit: LIMIT }).await { + Ok(hive_sh4re::Response::Recent { rows }) => rows, _ => Vec::new(), } } -/// Fetch reminder activity stats from the broker via the per-agent / -/// manager socket. Returns None on any transport / decode failure — the -/// stats are decorative, not authoritative. -pub(super) async fn fetch_reminder_stats( - socket: &std::path::Path, - window_secs: u64, -) -> Option<hive_sh4re::ReminderStats> { - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::ReminderRollup { - since_secs: window_secs, - agent: None, - }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::ReminderRollup(stats))) => Some(stats), - _ => None, - } -} - /// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by /// `services.hyperhive.availableModels`) and return the parsed list. /// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent /// or resolves to an empty list after trimming. fn available_models() -> Vec<String> { const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; - let raw = match std::env::var("HIVE_AVAILABLE_MODELS") { - Ok(v) if !v.trim().is_empty() => v, - _ => return DEFAULT.iter().map(ToString::to_string).collect(), - }; - let models: Vec<String> = raw + // Absent / empty / all-whitespace env all funnel to the single + // emptiness check below — no separate up-front guard needed. + let models: Vec<String> = std::env::var("HIVE_AVAILABLE_MODELS") + .unwrap_or_default() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) diff --git a/hive-ag3nt/src/web_ui/stats.rs b/hive-ag3nt/src/web_ui/stats.rs index 9c0ace5e..bd5982e4 100644 --- a/hive-ag3nt/src/web_ui/stats.rs +++ b/hive-ag3nt/src/web_ui/stats.rs @@ -5,10 +5,7 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; -use crate::client; - -use super::state::fetch_reminder_stats; -use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; +use super::{AppState, error_response}; #[derive(Deserialize)] pub(super) struct StatsQuery { @@ -29,6 +26,27 @@ pub(super) async fn api_stats( axum::Json(snapshot) } +/// Fetch reminder activity stats from the broker via the per-agent / manager +/// socket. Returns None on any transport / decode failure — the stats are +/// decorative, not authoritative. +async fn fetch_reminder_stats( + socket: &std::path::Path, + window_secs: u64, +) -> Option<hive_sh4re::ReminderStats> { + match super::broker_request( + socket, + &hive_sh4re::Request::ReminderRollup { + since_secs: window_secs, + agent: None, + }, + ) + .await + { + Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats), + _ => None, + } +} + /// Proxy this agent's loose-ends list via the per-agent socket. The /// web UI surfaces the result as a collapsible section in the page /// so the operator can see at a glance what's pending against the @@ -37,42 +55,25 @@ pub(super) async fn api_stats( /// the `mcp__hyperhive__get_loose_ends` tool sees from inside the /// container. pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response { - let loose_ends: Vec<hive_sh4re::LooseEnd> = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::GetLooseEnds { agent: None }, - ), + match super::broker_request( + &state.socket, + &hive_sh4re::Request::GetLooseEnds { agent: None }, ) .await { - Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends, - Ok(Ok(hive_sh4re::Response::Err { message })) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("get_loose_ends: {message}"), - ); + Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => { + axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() } - Ok(Ok(other)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("unexpected response: {other:?}"), - ); - } - Ok(Err(e)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("transport: {e:#}"), - ); - } - Err(_) => { - return error_response( - StatusCode::CONFLICT, - "get_loose_ends: timed out — hive-c0re busy, retry", - ); - } - }; - axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() + Ok(hive_sh4re::Response::Err { message }) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: {message}"), + ), + Ok(other) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: unexpected response: {other:?}"), + ), + Err(e) => super::broker_error_response(&e, "get_loose_ends"), + } } /// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks. diff --git a/hive-ag3nt/src/web_ui/stream.rs b/hive-ag3nt/src/web_ui/stream.rs index dabbcc45..e94a520a 100644 --- a/hive-ag3nt/src/web_ui/stream.rs +++ b/hive-ag3nt/src/web_ui/stream.rs @@ -56,15 +56,22 @@ pub(super) async fn events_stream( ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { tracing::info!("sse: client subscribed"); let rx = state.bus.subscribe(); - // Drop a "hello" note into the bus so every new subscriber sees at - // least one event immediately and can clear the connecting placeholder. - state.bus.emit(crate::events::LiveEvent::Note { - text: "live stream attached".into(), - }); - let stream = BroadcastStream::new(rx).filter_map(|res| { + // Prime THIS connection with a one-off "hello" so it can clear the + // connecting placeholder immediately. Injected into this subscriber's own + // stream rather than emitted to the bus — a bus emit would spam every + // already-connected client with a spurious note each time anyone opens + // the stream. + let hello = Event::default().data( + serde_json::to_string(&crate::events::LiveEvent::Note { + text: "live stream attached".into(), + }) + .unwrap_or_default(), + ); + let live = BroadcastStream::new(rx).filter_map(|res| { let ev = res.ok()?; let json = serde_json::to_string(&ev).ok()?; Some(Ok(Event::default().data(json))) }); + let stream = tokio_stream::once(Ok(hello)).chain(live); Sse::new(stream).keep_alive(KeepAlive::default()) } diff --git a/hive-claude/src/driver.rs b/hive-claude/src/driver.rs index 76c3d267..b790522f 100644 --- a/hive-claude/src/driver.rs +++ b/hive-claude/src/driver.rs @@ -58,7 +58,10 @@ impl Claude { })?; if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(prompt.as_bytes()).await.map_err(Error::Stdin)?; + stdin + .write_all(prompt.as_bytes()) + .await + .map_err(Error::Stdin)?; // Best-effort flush/close; claude sees EOF and starts the turn. stdin.shutdown().await.ok(); } diff --git a/hive-claude/src/policy.rs b/hive-claude/src/policy.rs index 998b0968..63218078 100644 --- a/hive-claude/src/policy.rs +++ b/hive-claude/src/policy.rs @@ -41,7 +41,11 @@ impl CompactionPolicy for PercentPolicy { if self.percent == 0 { return false; } - let Some(window) = usage.context_window.or(self.default_window).filter(|&w| w > 0) else { + let Some(window) = usage + .context_window + .or(self.default_window) + .filter(|&w| w > 0) + else { return false; }; usage.context_tokens.saturating_mul(100) >= u64::from(self.percent) * window diff --git a/hive-claude/src/session.rs b/hive-claude/src/session.rs index 4fded3b5..074da067 100644 --- a/hive-claude/src/session.rs +++ b/hive-claude/src/session.rs @@ -4,7 +4,9 @@ use std::sync::Mutex; use serde_json::Value; -use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Telemetry}; +use crate::{ + Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Telemetry, +}; /// A named claude session that outlives the model's context window by /// compacting itself. Bundles the three things a durable session needs: