diff --git a/CLAUDE.md b/CLAUDE.md index 9b52b3f4..1a8b0df0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,9 @@ 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`) + 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. + 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. - **`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 @@ -86,10 +85,7 @@ 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) (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)). + [`docs/turn-loop.md`](docs/turn-loop.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 2c43c4af..a927ec52 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -360,8 +360,7 @@ 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; -`mcp_config::allowed_mcp_tools` (in `hive-ag3nt/src/mcp_config.rs`) reads it at -session start. +`allowed_mcp_tools` reads it at session start. ## Capabilities diff --git a/docs/forge.md b/docs/forge.md index 093b5860..0ea3f431 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\nauthor: body\nassignee: ...` | +| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nreviewer: 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,10 +224,6 @@ 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 0b70e486..54814b42 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -18,37 +18,42 @@ 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 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). +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). 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 @@ -90,8 +95,7 @@ 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 -[claude-invocation](turn-loop/claude-invocation.md#on-boot-files)), +opens turn-stats sqlite, prepares the on-boot files (see below), 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`). @@ -104,16 +108,14 @@ and the manager fall through to operator). ### Turn outcomes -`turn::TurnOutcome` (`Result` — `Ok(compacted)` on success, -else a `TurnError`) drives the post-claude branch: +`turn::TurnOutcome` drives the post-claude branch: | Outcome | Action | | --- | --- | -| `Ok(_)` (`false` normal / `true` compacted) | `ack_turn` | -| `Err(PromptTooLong)` | `drive_turn` archived the session (the lib already compacted + retried and it still overflowed); requeue inflight so the message redelivers into a fresh session that fits — no status park | -| `Err(RateLimited)` | sleep `HIVE_RATE_LIMIT_SLEEP_SECS` (default 300), requeue inflight, status back to `online` | -| `Err(AuthFailed)` | emit `needs_login_idle` sentinel, requeue inflight, park in `wait_for_login` | -| `Err(Failed(err))` | route `[system] \`\` claude turn failed:\n` to `` via `send_to_parent` | +| `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` | After the outcome handler, the stats sink records a row and the `hyperhive-continue` sentinel (dropped by the `request_next_turn` @@ -131,21 +133,632 @@ 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. -## Sub-pages +## The claude invocation -The rest lives in three topic pages under [`turn-loop/`](turn-loop/): +``` +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 +``` -- **[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. +**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. -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 deleted file mode 100644 index e44d050e..00000000 --- a/docs/turn-loop/claude-invocation.md +++ /dev/null @@ -1,241 +0,0 @@ -# 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 deleted file mode 100644 index ad74b988..00000000 --- a/docs/turn-loop/config.md +++ /dev/null @@ -1,199 +0,0 @@ -# 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 deleted file mode 100644 index 02d707ed..00000000 --- a/docs/turn-loop/mcp.md +++ /dev/null @@ -1,201 +0,0 @@ -# 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 6e710138..37cf3864 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -8,6 +8,7 @@ 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}; @@ -107,7 +108,7 @@ fn log_system_event(bus: &Bus, from: &str, body: &str) { } /// Body string for the turn-failure notification we route to -/// `<parent>` on `TurnError::Failed`. Reads the hive-qualified +/// `<parent>` on `TurnOutcome::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 @@ -276,37 +277,39 @@ 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) { - fire_and_forget(socket, AgentRequest::AckTurn, "ack_turn").await; + 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"), + } } async fn requeue_inflight(socket: &Path) { - fire_and_forget(socket, AgentRequest::RequeueInflight, "requeue_inflight").await; + 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"), + } } async fn graceful_stop_complete(socket: &Path) { - fire_and_forget( - socket, - AgentRequest::GracefulStopComplete, - "graceful_stop_complete", - ) - .await; + 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"), + } } async fn inbox_unread(socket: &Path) -> u64 { @@ -429,7 +432,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().await { + for failure in plugins::install_configured(socket).await { S::send_to_parent(socket, failure).await; } tokio::spawn(hive_ag3nt::forge_notify::run(socket.to_path_buf())); @@ -546,7 +549,8 @@ 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( @@ -597,10 +601,13 @@ 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 outcome.is_ok() { + if matches!( + outcome, + turn::TurnOutcome::Ok | turn::TurnOutcome::Compacted + ) { S::ack_turn(socket).await; } - if matches!(outcome, Err(turn::TurnError::RateLimited)) { + if matches!(outcome, turn::TurnOutcome::RateLimited) { let secs = turn::rate_limit_sleep_secs(); bus.emit_status("rate_limited"); bus.emit(LiveEvent::Note { @@ -611,7 +618,7 @@ async fn handle_turn<S: Surface>( S::requeue_inflight(socket).await; bus.emit_status("online"); } - if matches!(outcome, Err(turn::TurnError::AuthFailed)) { + if matches!(outcome, turn::TurnOutcome::AuthFailed) { bus.emit_status("needs_login_idle"); bus.emit(LiveEvent::Note { text: "API 401 — waiting for re-login via web UI".into(), @@ -619,15 +626,7 @@ async fn handle_turn<S: Surface>( tracing::warn!("auth-failed; parking until re-login"); S::requeue_inflight(socket).await; } - 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 { + if let turn::TurnOutcome::Failed(e) = &outcome { S::send_to_parent(socket, format_turn_failure(e)).await; } if let Some(stats) = stats { @@ -660,7 +659,7 @@ async fn handle_turn<S: Surface>( tracing::info!(%pending, "pending messages after turn; fetching next"); } TurnControl { - auth_failed: matches!(outcome, Err(turn::TurnError::AuthFailed)), + auth_failed: matches!(outcome, turn::TurnOutcome::AuthFailed), continue_requested: consume_continue_sentinel(), pending, } diff --git a/hive-ag3nt/src/client.rs b/hive-ag3nt/src/client.rs index 0cfd36f9..33e9fa80 100644 --- a/hive-ag3nt/src/client.rs +++ b/hive-ag3nt/src/client.rs @@ -76,9 +76,7 @@ where } } } - // 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")) + Err(last_err.unwrap_or_else(|| anyhow!("hive socket: retries exhausted"))) } /// 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 2aa32d7f..d9721b34 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -433,6 +433,7 @@ 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 @@ -466,6 +467,12 @@ 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"; @@ -901,16 +908,6 @@ 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 99a1f225..855c3f87 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 notifications (the agent's own -//! writes) are still marked read directly. The cursor is persisted as the +//! re-firing a wake every poll; self-echo and drop-listed notifications +//! 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.saturating_sub(3)) + .take_while(|&i| i <= max - 3) .last() .unwrap_or(0); format!("{}…", &s[..end]) @@ -314,20 +314,6 @@ 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). @@ -392,9 +378,7 @@ async fn format_notification( fetch_json(client, subject_api_url, token).await }; - // 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 is_pr = matches!(notif_type, "Pull Request" | "Pull"); let meta_suffix = build_meta_suffix(subject.as_ref(), is_pr); // Determine whether this notification was triggered by a comment/review or @@ -529,17 +513,23 @@ async fn format_comment_notification( .. } = meta; - // Truncate → mention-overflow → escape (see `render_body_excerpt`). - let (body_for_embed, truncated_mentions) = render_body_excerpt(body_text); + // 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); 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() { - // 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(); + write!(out, "\n\nreviewer: {author}").ok(); } else { write!(out, "\n\n{author}: {body_for_embed}{truncated_mentions}").ok(); } @@ -551,6 +541,9 @@ 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) } @@ -640,15 +633,20 @@ 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 body pipeline as comment bodies. + // 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`). let body_block = subject .as_ref() .and_then(|s| s["body"].as_str()) .map(str::trim) .filter(|s| !s.is_empty()) .map(|raw| { - let (excerpt, mentions) = render_body_excerpt(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); format!("\n\n{excerpt}{mentions}") }) .unwrap_or_default(); @@ -714,15 +712,8 @@ 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..]; - // 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 oh: i64 = off.get(0..2)?.parse().ok()?; + let om: i64 = off.get(3..5).unwrap_or("00").parse().ok()?; let offset = oh * 3_600 + om * 60; match sign { b'+' => epoch -= offset, @@ -894,11 +885,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 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. +/// 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. 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 4cafa13b..56759aee 100644 --- a/hive-ag3nt/src/identity.rs +++ b/hive-ag3nt/src/identity.rs @@ -14,18 +14,14 @@ 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> { - non_empty_env("HYPERHIVE_HIVE_DOMAIN") + env::var("HYPERHIVE_HIVE_DOMAIN") + .ok() + .filter(|s| !s.is_empty()) } /// Human display name of this hive (e.g. `pr1ma`). Distinct from @@ -36,7 +32,9 @@ pub fn hive_domain() -> Option<String> { /// at their discretion. #[must_use] pub fn hive_name() -> Option<String> { - non_empty_env("HYPERHIVE_HIVE_NAME") + env::var("HYPERHIVE_HIVE_NAME") + .ok() + .filter(|s| !s.is_empty()) } /// Human display name of the wider swarm this hive belongs to (e.g. @@ -45,7 +43,9 @@ pub fn hive_name() -> Option<String> { /// `services.hyperhive.swarmName` option is unset. #[must_use] pub fn swarm_name() -> Option<String> { - non_empty_env("HYPERHIVE_SWARM_NAME") + env::var("HYPERHIVE_SWARM_NAME") + .ok() + .filter(|s| !s.is_empty()) } /// 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 4b861eed..56a62a29 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -9,7 +9,6 @@ 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 18468e85..33a50a43 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -2,12 +2,11 @@ //! provided by hive-c0re and persists across container destroy/recreate so //! OAuth tokens survive. //! -//! "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. +//! "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. use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -25,61 +24,20 @@ pub fn default_dir() -> PathBuf { crate::paths::claude_dir() } -/// 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. +/// 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. #[must_use] pub fn has_session(dir: &Path) -> bool { let Ok(entries) = std::fs::read_dir(dir) else { return false; }; - 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}")), + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|t| t.is_file()) { + return true; } } - cleared + false } /// Login state the harness reports to its web UI. @@ -147,16 +105,16 @@ pub async fn wait_for_login( } } -/// 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. +/// 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. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct DirSnapshot { file_count: usize, @@ -169,7 +127,7 @@ fn snapshot_dir(dir: &Path) -> DirSnapshot { }; let mut snap = DirSnapshot::default(); for entry in entries.flatten() { - if !is_cred_file(&entry) { + if !entry.file_type().is_ok_and(|t| t.is_file()) { continue; } snap.file_count += 1; @@ -207,31 +165,7 @@ mod tests { use std::fs; use std::time::{Duration, SystemTime}; - 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" - ); - } + use super::{DirSnapshot, session_refreshed, snapshot_dir}; #[test] fn snapshot_dir_empty_dir_is_default() { @@ -257,11 +191,11 @@ mod tests { #[test] fn snapshot_dir_picks_latest_mtime_and_counts_files() { let dir = tempfile::tempdir().unwrap(); - fs::write(dir.path().join(".credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join("old.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("mcp-needs-auth-cache.json"); + let newer_path = dir.path().join("newer.json"); fs::write(&newer_path, b"{}").unwrap(); let snap = snapshot_dir(dir.path()); assert_eq!(snap.file_count, 2); @@ -270,13 +204,13 @@ mod tests { } #[test] - fn session_refreshed_first_login_flips_on_cred_file() { - // Empty-dir snapshot → a credential file appearing means a fresh + fn session_refreshed_first_login_flips_on_any_file() { + // Empty-dir snapshot → any 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()))); } @@ -286,7 +220,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. @@ -299,10 +233,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()))); } @@ -312,7 +246,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)), @@ -328,12 +262,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(".credentials.json"), b"{}").unwrap(); + fs::write(dir.path().join("a"), b"{}").unwrap(); let forged = DirSnapshot { file_count: 1, newest_mtime: None, }; - fs::write(dir.path().join("mcp-needs-auth-cache.json"), b"{}").unwrap(); + fs::write(dir.path().join("b"), 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 2f7fd95b..f9afd86f 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -24,6 +24,117 @@ 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 @@ -69,32 +180,16 @@ 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<hive_sh4re::Response, anyhow::Error>, - tool: &str, - ok_msg: String, -) -> String { +pub fn format_ack(resp: Result<SocketReply, anyhow::Error>, tool: &str, ok_msg: String) -> String { match resp { - Ok(hive_sh4re::Response::Ok) => ok_msg, - other => reply_err(other, tool), + 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:#}"), } } @@ -109,41 +204,14 @@ pub fn format_ack( /// 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<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 { +pub fn format_recv(resp: Result<SocketReply, anyhow::Error>, 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}") @@ -201,9 +269,10 @@ 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 the `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 both `format_loose_ends` (which handles the +/// `Result<SocketReply>` wrapper) and the augmented `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() { @@ -282,6 +351,21 @@ 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. @@ -380,11 +464,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/mcp.md::Core tools` (`get_agent_meta`). +/// `docs/turn-loop.md::Sub-agent tools` (`get_agent_meta`). #[must_use] -pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String { +pub fn format_agent_meta(resp: Result<SocketReply, anyhow::Error>) -> String { match resp { - Ok(hive_sh4re::Response::AgentMeta { + Ok(SocketReply::AgentMeta { name, running, hyperhive_rev, @@ -452,7 +536,9 @@ pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> S } out } - other => reply_err(other, "get_agent_meta"), + 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:#}"), } } @@ -594,18 +680,18 @@ impl AgentServer { Self { socket } } - /// 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`). + /// 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`). /// /// `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<hive_sh4re::Response, anyhow::Error>, u32) { + ) -> (Result<SocketReply, anyhow::Error>, u32) { match client::request_retried::<_, hive_sh4re::Response>(&self.socket, &req).await { - Ok((r, n)) => (Ok(r), n), + Ok((r, n)) => (Ok(SocketReply::from(r)), n), Err(e) => (Err(e), 0), } } @@ -626,7 +712,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) = crate::mcp_config::check_send_allowed(&to) { + if let Err(refusal) = check_send_allowed(&to) { return run_tool_envelope("send", log, async move { refusal }).await; } run_tool_envelope("send", log, async move { @@ -671,11 +757,13 @@ impl AgentServer { }) .await; let s = match resp { - Ok(hive_sh4re::Response::QuestionQueued { id }) => format!( + Ok(SocketReply::QuestionQueued(id)) => format!( "question queued (id={id}); answer will arrive as a system \ `question_answered` event in your inbox" ), - other => reply_err(other, "ask"), + Ok(SocketReply::Err(m)) => format!("ask failed: {m}"), + Ok(other) => format!("ask unexpected response: {other:?}"), + Err(e) => format!("ask transport error: {e:#}"), }; annotate_retries(s, retries) }) @@ -759,10 +847,12 @@ impl AgentServer { .dispatch(hive_sh4re::Request::AckUntil { up_to: args.up_to }) .await; let rendered = match resp { - Ok(hive_sh4re::Response::Acked { count }) => { + Ok(SocketReply::Acked(count)) => { format!("acked {count} message(s) up to id {}", args.up_to) } - other => reply_err(other, "ack_until"), + 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:#}"), }; annotate_retries(rendered, retries) }) @@ -792,8 +882,22 @@ impl AgentServer { .await; // Extract the vec so we can augment before rendering. let mut loose_ends = match resp { - Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => loose_ends, - other => return annotate_retries(reply_err(other, "get_loose_ends"), retries), + 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, + ); + } }; // Prepend matrix unread entry for self-queries only (can't // reach another agent's matrix daemon from here). @@ -931,11 +1035,13 @@ impl AgentServer { .dispatch(hive_sh4re::Request::CreateRepo { repo: args.repo }) .await; let s = match resp { - Ok(hive_sh4re::Response::RepoCreated { + Ok(SocketReply::RepoCreated { full_name, clone_url, }) => format!("created repo {full_name} — clone: {clone_url}"), - other => reply_err(other, "create_repo"), + 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:#}"), }; annotate_retries(s, retries) }) @@ -1092,7 +1198,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(hive_sh4re::Response::Containers { containers }) => { + Ok(SocketReply::Containers(containers)) => { if containers.is_empty() { "no descendant containers".to_owned() } else { @@ -1106,7 +1212,9 @@ impl AgentServer { .join("\n") } } - other => reply_err(other, "list_containers"), + 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:#}"), }; annotate_retries(body, retries) }) @@ -1146,8 +1254,10 @@ impl AgentServer { }) .await; let result = match resp { - Ok(hive_sh4re::Response::HostJournal { content }) => content, - other => reply_err(other, "get_host_journal"), + 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:#}"), }; annotate_retries(result, retries) }) @@ -1271,14 +1381,16 @@ impl AgentServer { }) .await; let s = match resp { - Ok(hive_sh4re::Response::Logs { content }) => { + Ok(SocketReply::Logs(content)) => { if content.is_empty() { format!("(no journal output for {agent})") } else { content } } - other => reply_err(other, "get_logs"), + 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:#}"), }; annotate_retries(s, retries) }) @@ -1464,11 +1576,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(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"), + 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:#}"), }; annotate_retries(body, retries) }) @@ -1483,23 +1595,27 @@ impl AgentServer { )] impl ServerHandler for AgentServer {} -/// Run the MCP server over stdio. Used by all roles. Returns when the client -/// disconnects. +/// Run an MCP server over stdio for the given flavor. 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_agent_stdio(socket: PathBuf) -> Result<()> { +pub async fn serve_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_agent_stdio`] — a fresh stdio child claude respawns every turn — +/// Unlike [`serve_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 @@ -1649,6 +1765,17 @@ 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 @@ -1810,26 +1937,448 @@ 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, format_recv}; + use super::{IDLE_WAIT_HINT, SocketReply, format_recv}; + use super::{SERVER_NAME, allowed_mcp_tools}; + use hive_sh4re::ToolGroup; #[test] fn empty_recv_after_wait_appends_idle_hint() { - let out = format_recv( - Ok(hive_sh4re::Response::Messages { messages: vec![] }), - true, - ); + let out = format_recv(Ok(SocketReply::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(hive_sh4re::Response::Messages { messages: vec![] }), - false, - ); + let out = format_recv(Ok(SocketReply::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 deleted file mode 100644 index 8bd13af0..00000000 --- a/hive-ag3nt/src/mcp_config.rs +++ /dev/null @@ -1,439 +0,0 @@ -//! 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 e3bc3cdc..f5b21765 100644 --- a/hive-ag3nt/src/plugins.rs +++ b/hive-ag3nt/src/plugins.rs @@ -11,6 +11,8 @@ //! 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"; @@ -101,7 +103,8 @@ 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() -> Vec<String> { +pub async fn install_configured(socket: &Path) -> Vec<String> { + let _ = socket; // Reserved for future telemetry; currently unused. 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 4e37ce05..aa584747 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/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). +//! `docs/turn-loop.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/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). +/// `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). #[must_use] pub fn render( template: &str, @@ -55,12 +55,14 @@ pub fn render( } } -/// 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. +/// 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. 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. @@ -71,11 +73,19 @@ fn filter_role_blocks(template: &str, target: &str) -> String { active_role = Some(role); continue; } - if parse_close_marker(trimmed).is_some() { - active_role = None; + 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. continue; } - if active_role.is_none_or(|role| role == target) { + let include = match active_role { + None => true, + Some(role) => role == target, + }; + if include { out.push_str(line); out.push('\n'); } @@ -235,6 +245,46 @@ 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 e23bd4c0..3a3b00cc 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::{TurnError, TurnOutcome}; +use crate::turn::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 { - 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:#}"))), + 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:#}"))), }; 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 eb3f6152..80298c04 100644 --- a/hive-ag3nt/src/stats.rs +++ b/hive-ag3nt/src/stats.rs @@ -42,7 +42,6 @@ impl Window { "7d" => Self::Week, "30d" => Self::Month, "all" => Self::All, - // Default (incl. `label()`'s own canonical `"24h"`/`"1d"`). _ => Self::Day, } } @@ -230,15 +229,11 @@ fn empty_snapshot(window: Window) -> Snapshot { } fn snapshot(path: &Path, window: Window) -> Result<Snapshot> { - // Read-only open so we can't corrupt the db via a query bug. + // 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. 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 78d6a329..62661fbe 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_config; +use crate::mcp; // 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 the two per-turn files (MCP config + system prompt) into the - /// agent's config dir. Idempotent — overwrites whatever was there. + /// Write all three files into the per-agent runtime dir alongside + /// `socket`. 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_config::render_claude_config(&exe, socket); + let body = mcp::render_claude_config(&exe, socket); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude MCP config"); Ok(path) @@ -139,39 +139,29 @@ 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: `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). +/// One claude turn's outcome. The harness uses this to decide whether to +/// transparently kick off a compaction and retry. #[derive(Debug)] -pub enum TurnError { +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, /// claude saw "Prompt is too long" and even a reactive compact + retry /// (inside [`InfiniteSession::run`]) couldn't bring it back under the - /// 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. + /// window. Rare; the serve loop treats it like `Ok` (acks the turn). 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 requeue — NOT bubble up as a crash. + /// `rate_limit_sleep_secs()` and retry — 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), } @@ -236,10 +226,8 @@ 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)); - // `min(100)` is ≤ 100, so this `try_from` is infallible. - u8::try_from(pct.min(100)).expect("value clamped to <= 100 fits in u8") + 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) } /// The agent's durable session type: the constant-title [`InfiniteSession`] @@ -321,36 +309,24 @@ pub async fn drive_turn( text: format!("created fresh session titled \"{}\"", session_title()), }); } - Ok(progress.compacted) + if progress.compacted { + TurnOutcome::Compacted + } else { + TurnOutcome::Ok + } } 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() && outcome.is_ok() { + if bus.take_compact() && matches!(outcome, TurnOutcome::Ok | TurnOutcome::Compacted) { 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 Ok(true); + return TurnOutcome::Compacted; } outcome } @@ -398,35 +374,28 @@ fn maybe_auto_reset(bus: &Bus) { /// semantics stay consistent across every agent role. pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) { match outcome { - Ok(_) => { + TurnOutcome::Ok | TurnOutcome::Compacted | TurnOutcome::PromptTooLong => { bus.emit(LiveEvent::TurnEnd { ok: true, note: None, }); tracing::info!("turn finished"); } - 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) => { + TurnOutcome::RateLimited => { bus.emit(LiveEvent::TurnEnd { ok: false, note: Some("rate limited — parking until quota resets".into()), }); tracing::warn!("turn rate-limited"); } - Err(TurnError::AuthFailed) => { + TurnOutcome::AuthFailed => { bus.emit(LiveEvent::TurnEnd { ok: false, note: Some("authentication failed (401) — waiting for re-login".into()), }); tracing::warn!("turn auth-failed (401)"); } - Err(TurnError::Failed(e)) => { + TurnOutcome::Failed(e) => { let note = format!("{e:#}"); bus.emit(LiveEvent::TurnEnd { ok: false, @@ -520,8 +489,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_config::builtin_tools_arg()), - allowed_tools: Some(mcp_config::allowed_tools_arg()), + tools: Some(mcp::builtin_tools_arg()), + allowed_tools: Some(mcp::allowed_tools_arg()), add_dirs, ..Config::default() } @@ -534,13 +503,11 @@ 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 => 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())), + Error::PromptTooLong => TurnOutcome::PromptTooLong, + Error::RateLimited => TurnOutcome::RateLimited, + Error::AuthFailed => TurnOutcome::AuthFailed, + Error::SessionNotFound => TurnOutcome::Ok, + other => TurnOutcome::Failed(other.into()), } } @@ -636,3 +603,4 @@ fn archive_session(bus: &Bus) { } } } + diff --git a/hive-ag3nt/src/turn_stats.rs b/hive-ag3nt/src/turn_stats.rs index 0734d581..e314923e 100644 --- a/hive-ag3nt/src/turn_stats.rs +++ b/hive-ag3nt/src/turn_stats.rs @@ -1,14 +1,8 @@ //! 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), 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"`). +//! `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`). //! //! Lives next to `hyperhive-events.sqlite` in the agent's state dir //! so the host-side state vacuum sweep can reach both. Schema is @@ -109,19 +103,14 @@ 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 @@ -289,10 +278,7 @@ impl TurnStats { last_input_tokens, last_output_tokens, last_cache_read_input_tokens, last_cache_creation_input_tokens FROM turn_stats - -- `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 + ORDER BY started_at DESC LIMIT 1", [], |row| { diff --git a/hive-ag3nt/src/web_ui/actions.rs b/hive-ag3nt/src/web_ui/actions.rs index 1132b9e6..49fce257 100644 --- a/hive-ag3nt/src/web_ui/actions.rs +++ b/hive-ag3nt/src/web_ui/actions.rs @@ -8,7 +8,9 @@ use axum::{ }; use serde::Deserialize; -use super::{AppState, error_response}; +use crate::client; + +use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; #[derive(Deserialize)] pub(super) struct SendForm { @@ -23,28 +25,41 @@ pub(super) async fn post_send( if body.is_empty() { return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); } - match super::broker_request(&state.socket, &hive_sh4re::Request::OperatorMsg { body }).await { + 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 { // 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(hive_sh4re::Response::Ok) => (axum::http::StatusCode::OK, "ok").into_response(), - Ok(hive_sh4re::Response::Err { message }) => error_response( + Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), + Err(e) => error_response( StatusCode::INTERNAL_SERVER_ERROR, - &format!("send failed: {message}"), + &format!("send failed: {e}"), ), - 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 = super::sigint_claude().await; + let out = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .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 9c1cceb5..f3e175e0 100644 --- a/hive-ag3nt/src/web_ui/auth.rs +++ b/hive-ag3nt/src/web_ui/auth.rs @@ -74,9 +74,17 @@ 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 (via [`crate::login::clear_session`]), flip `LoginState::NeedsLogin`. -/// The turn loop's next iteration parks into `wait_for_login` which +/// files in `CRED_FILE_NAMES`, 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) @@ -84,20 +92,36 @@ pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response /// preservation invariants. pub(super) async fn post_logout(State(state): State<AppState>) -> Response { // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). - 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 _ = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await; + // Step 2: delete OAuth credential files only — preserve session + // history files alongside them. let dir = crate::paths::claude_dir(); - let cleared = crate::login::clear_session(&dir).await; - let wipe_summary = if cleared.wiped.is_empty() { + 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() { "no credential files present (already logged out)".to_owned() } else { - format!("wiped {}", cleared.wiped.join(", ")) + format!("wiped {}", wiped.join(", ")) }; - let warn_suffix = if cleared.warnings.is_empty() { + let warn_suffix = if warnings.is_empty() { String::new() } else { - format!(" (warnings: {})", cleared.warnings.join("; ")) + format!(" (warnings: {})", 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 ec99fccb..1827e3da 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -259,17 +259,6 @@ 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 @@ -278,52 +267,3 @@ 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 78b7c378..f67ea0e2 100644 --- a/hive-ag3nt/src/web_ui/state.rs +++ b/hive-ag3nt/src/web_ui/state.rs @@ -3,10 +3,11 @@ use axum::extract::State; use serde::Serialize; +use crate::client; use crate::login::LoginState; use crate::login_session::drop_if_finished; -use super::AppState; +use super::{AppState, SOCKET_FETCH_TIMEOUT}; pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> { // Capture seq *before* any reads so the dedupe contract is @@ -36,7 +37,10 @@ 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.effective_context_window(&model); + let context_window_tokens = state + .bus + .api_context_window() + .unwrap_or_else(|| crate::events::context_window_tokens(&model)); let ctx_usage = state.bus.last_ctx_usage(); let cost_usage = state.bus.last_cost_usage(); let effort = state.bus.effort(); @@ -84,7 +88,10 @@ 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.effective_context_window(&model); + let context_window_tokens = state + .bus + .api_context_window() + .unwrap_or_else(|| crate::events::context_window_tokens(&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 @@ -364,24 +371,57 @@ 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 (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, + // 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, _ => 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"]; - // 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() + 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 .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 bd5982e4..9c0ace5e 100644 --- a/hive-ag3nt/src/web_ui/stats.rs +++ b/hive-ag3nt/src/web_ui/stats.rs @@ -5,7 +5,10 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; -use super::{AppState, error_response}; +use crate::client; + +use super::state::fetch_reminder_stats; +use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; #[derive(Deserialize)] pub(super) struct StatsQuery { @@ -26,27 +29,6 @@ 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 @@ -55,25 +37,42 @@ async fn fetch_reminder_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 { - match super::broker_request( - &state.socket, - &hive_sh4re::Request::GetLooseEnds { agent: None }, + 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 }, + ), ) .await { - Ok(hive_sh4re::Response::LooseEnds { loose_ends }) => { - axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() + 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::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"), - } + 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() } /// `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 e94a520a..dabbcc45 100644 --- a/hive-ag3nt/src/web_ui/stream.rs +++ b/hive-ag3nt/src/web_ui/stream.rs @@ -56,22 +56,15 @@ pub(super) async fn events_stream( ) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { tracing::info!("sse: client subscribed"); let rx = state.bus.subscribe(); - // 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| { + // 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| { 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 b790522f..76c3d267 100644 --- a/hive-claude/src/driver.rs +++ b/hive-claude/src/driver.rs @@ -58,10 +58,7 @@ 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 63218078..998b0968 100644 --- a/hive-claude/src/policy.rs +++ b/hive-claude/src/policy.rs @@ -41,11 +41,7 @@ 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 074da067..4fded3b5 100644 --- a/hive-claude/src/session.rs +++ b/hive-claude/src/session.rs @@ -4,9 +4,7 @@ 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: