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