Watch
0
0
Fork
You've already forked hyperhive
0

refactor(#2464): rename hive-ag3nt crate to hive-agent, collapse lib into main

This commit is contained in:
damocles 2026-07-15 01:18:22 +02:00 • committed by mara
commit 3f1643c594
57 changed files with 101 additions and 130 deletions

2
Cargo.lock generated
View file

@ -1460,7 +1460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "hive-ag3nt" name = "hive-agent"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",

View file

@ -1,7 +1,7 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = [
"hive-ag3nt", "hive-agent",
"hive-agent-mcp", "hive-agent-mcp",
"hive-agent-wake", "hive-agent-wake",
"hive-bash-mcp", "hive-bash-mcp",

View file

@ -1,28 +0,0 @@
//! Shared in-container harness code for the sibling `hive-agent` /
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
//! agent role (the manager is just an agent role, not a separate set
//! of binaries).
pub mod client;
pub mod events;
pub mod forge_notify;
pub mod harness_state;
pub mod identity;
pub mod login;
pub mod login_session;
pub mod mcp_config;
pub mod paths;
pub mod plugins;
pub mod prompt;
pub mod serve_common;
pub mod stats;
pub mod turn;
pub mod turn_stats;
pub mod vacuum;
pub mod web_ui;
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
pub const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
/// Default web UI port — used when `HIVE_PORT` env is unset.
pub const DEFAULT_WEB_PORT: u16 = 8042;

View file

@ -9,7 +9,7 @@
//! //!
//! Standalone bin crate: the MCP surface (`mcp/`) plus its small support //! Standalone bin crate: the MCP surface (`mcp/`) plus its small support
//! modules (socket client, send allow-list, loose-end scanner, path //! modules (socket client, send allow-list, loose-end scanner, path
//! resolver) live here rather than in the `hive-ag3nt` harness lib, so the //! resolver) live here rather than in the `hive-agent` harness lib, so the
//! server binary doesn't link the whole turn loop. //! server binary doesn't link the whole turn loop.
use std::path::PathBuf; use std::path::PathBuf;

View file

@ -6,7 +6,7 @@
//! //!
//! Standalone bin crate: it dials the per-agent MCP socket directly and //! Standalone bin crate: it dials the per-agent MCP socket directly and
//! carries its own copy of the retrying request client (below), so it //! carries its own copy of the retrying request client (below), so it
//! does not link the whole `hive-ag3nt` harness lib — a helper author //! does not link the whole `hive-agent` harness lib — a helper author
//! wiring up an `extraMcpServers` binary only needs this one small crate. //! wiring up an `extraMcpServers` binary only needs this one small crate.
use std::path::PathBuf; use std::path::PathBuf;
@ -66,7 +66,7 @@ async fn main() -> Result<()> {
} }
/// Self-contained retrying unix-socket JSON request client. A trimmed /// Self-contained retrying unix-socket JSON request client. A trimmed
/// copy of `hive_ag3nt::client` (no `request_retried` variant — the wake /// copy of `hive_agent::client` (no `request_retried` variant — the wake
/// CLI never needs the retry-count) so this bin does not link the harness /// CLI never needs the retry-count) so this bin does not link the harness
/// lib. Keeping the retry matters: the socket can be briefly absent while /// lib. Keeping the retry matters: the socket can be briefly absent while
/// hive-c0re restarts under an operator redeploy, and a helper firing a /// hive-c0re restarts under an operator redeploy, and a helper firing a

41
hive-agent/Cargo.toml Normal file
View file

@ -0,0 +1,41 @@
[package]
name = "hive-agent"
edition.workspace = true
version.workspace = true
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
reqwest.workspace = true
hyper.workspace = true
hyper-util.workspace = true
http-body-util.workspace = true
forgejo-api.workspace = true
url.workspace = true
time.workspace = true
futures-util = "0.3"
clap.workspace = true
hive-claude.workspace = true
hive-sh4re.workspace = true
rmcp.workspace = true
rusqlite.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
tempfile = "3"
# Single harness serve-loop binary: `hive-agent` (from `src/main.rs`).
# The sibling MCP server + external wake CLI are their own bin crates
# now (`hive-agent-mcp`, `hive-agent-wake`). Privilege boundary is
# enforced server-side at the socket (tool groups / manager surface).
# See `docs/turn-loop.md::Harness binary shape`.

View file

@ -0,0 +1,8 @@
{
"autoCompactEnabled": false,
"autoMemoryEnabled": false,
"disableWorkflows": true,
"permissions": {
"deny": ["Task", "TodoWrite"]
}
}

View file

@ -0,0 +1,68 @@
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + peers can disambiguate; within your own hive the short form is fine.
Tools (hyperhive surface):
- `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 5) drains several queued messages in one call — the wake prompt tells you the pending count.
- `mcp__hyperhive__ack_until(up_to)` — bulk-mark inbox messages handled: every message with broker id `<= up_to` (ids show as `[msg #<id>]` in wake prompts and recv output) is acked in one call, pending and delivered alike. Use it to clear a backlog you've already triaged — e.g. a redelivered flood after a container restart — instead of draining it one recv at a time: note the highest `[msg #N]` you've seen, then `ack_until(up_to: N)`. Acked messages never redeliver; messages newer than `up_to` stay queued. Only affects your own inbox.
- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: "<parent>"` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Use `to: "<children>"` to fan-out to every direct child of yours per `topology.json` (no-op for leaf agents). Both sentinels let the operator reparent at runtime with zero change on your side. Optional `in_reply_to: <message-id>` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through a peer agent or contact the operator directly.
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). Returns immediately with a question id — do NOT wait inline. When the recipient answers, a system message with event `question_answered { id, question, answer, answerer }` lands in your inbox; handle it on a future turn. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the operator calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
- `mcp__hyperhive__get_loose_ends(agent?)` — list your loose ends: unanswered questions where you're asker (waiting on someone) or target (owing a reply), plus reminders you've scheduled that haven't fired. No args to list your own threads — cheap server-side sweep useful at turn start. Pass `agent: "<name>"` to inspect a peer agent's threads. Direct child agents are always accessible. For non-children, the `query_agent_state` capability is required — without it the request is rejected with an error.
- `mcp__hyperhive__cancel_loose_end(kind, id)` — cancel one of your own open threads. `kind` is `"question"` (the asker — you, in this case — gets a `[cancelled by <you>]` answer so the waiter unblocks), `"reminder"` (hard-deleted before it fires), or `"approval"` (withdraws a pending approval you submitted that got superseded — root agent only; the server rejects this kind for all other callers). `id` from the matching `get_loose_ends` row or the original submission reply.
- `mcp__hyperhive__remind(message, delay_seconds? | at_unix_timestamp?, file_path?)` — schedule a message to land in your _own_ inbox at a future time (sender shows as `reminder`). Set exactly one of `delay_seconds` (relative) or `at_unix_timestamp` (absolute). Use for self-paced follow-ups instead of blocking a whole turn on a long `recv` wait. A large `message` auto-spills to a file under `/agents/{label}/state/reminders/`; pass `file_path` to point at one yourself. Each agent's pending-reminder count is capped (default 50) — the tool will error if the cap is already reached.
- `mcp__hyperhive__set_status(text)` — set a free-text status visible on the operator dashboard. **Call this at the start of every task** to say what you're working on (e.g. `"processing matrix messages"`, `"fixing #319 model priority"`, `"idle"`). Single line, ≤200 chars — the dashboard renders this as a short chip, so longer multi-line text is rejected. Pass an empty string to clear. Persists across harness restarts.
- `mcp__hyperhive__get_agent_meta(name?)` — fetch identity + status metadata for an agent: canonical `name`, current `hyperhive_rev`, plus self-reported `status` text (set via `set_status`) and how long ago it was set. Also returns `running: bool` (whether the container is up — when `false`, `status_text`/`status_set_at` are stale pre-stop values) and the hive + swarm display names (`hive_name`, `swarm_name`) when the operator has configured `services.hyperhive.{hiveName, swarmName}`; both lines omitted when unset. Pass `name` to query a peer (e.g. check whether iris is idle before pinging them). Omit `name` to get your own trustworthy identity stamp — useful for state files, commit messages, cross-agent attribution that won't drift across renames or session-continue boundaries where the system-prompt label could be stale.
- `mcp__hyperhive__request_next_turn()` — ask the harness to start another turn immediately after this one ends, even if the inbox is empty. Use for multi-turn tasks (long builds, sequential steps) where you want to continue without waiting for an external message. The next turn starts with `from: "self"` and `body: "continue"`. No-op if new inbox messages arrive before this turn ends (the harness already loops immediately on pending messages). No args.
- `mcp__hyperhive__restart(name)` — _(requires `lifecycle` tool group)_ restart a direct child sub-agent (stop + start). The server enforces topology: the call is rejected unless `name` is a direct child of yours per `topology.json`. No approval required.
- `mcp__hyperhive__kill(name)` — _(requires `lifecycle` tool group)_ stop a direct child sub-agent (graceful). Direct children only — server enforces topology. State dir kept; recreating reuses prior config + credentials. No approval required.
- `mcp__hyperhive__start(name)` — _(requires `lifecycle` tool group)_ start a stopped direct child sub-agent. Direct children only — server enforces topology. No approval required.
- `mcp__hyperhive__update(name)` — _(requires `lifecycle` tool group)_ rebuild a direct child sub-agent: re-applies the current hyperhive flake + agent.nix and restarts it. Direct children only — server enforces topology. No approval required. Idempotent.
- `mcp__hyperhive__list_containers()` — _(requires `lifecycle` tool group)_ list all containers that are topological descendants of this agent (children + their subtrees). Returns each name with running/stopped status, ordered parents-first. Useful before kill/update/restart to check what's under you.
- `mcp__hyperhive__request_init_config(name, description?)` — _(requires `approvals` tool group)_ initialise a brand-new direct child agent's proposed config repo. Queues an `InitConfig` approval; on approval hive-c0re seeds `/agents/<name>/config/agent.nix`. `name` must be a direct child in the topology tree — server enforces. Fails if the config repo already exists (use `request_apply_commit` instead).
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — _(requires `approvals` tool group)_ submit a config commit for a direct child agent, queued for operator approval. `agent` must be a direct child in the topology tree — server enforces. `commit_ref` must be a 7-40 char hex sha (not a branch/tag name). On approval hive-c0re rebuilds the container with the pinned commit.
- `mcp__hyperhive__request_update_meta_inputs(inputs?, description?)` — _(requires `approvals` tool group)_ queue an approval for the operator to run `nix flake update [inputs...]` on the meta flake. Pass specific input names (e.g. `["bitburner-agent"]`) or omit / pass `[]` for all inputs. Returns immediately; lock update runs on operator approval. Does NOT trigger rebuilds — call `update(name)` on affected agents after approval resolves.
- `mcp__hyperhive__request_schedule_prompt(targets, body, first_fire_at_unix, interval_seconds?, description?)` — _(requires `scheduling` tool group)_ queue an approval for the operator to add a scheduled prompt. On approve hive-c0re inserts a schedule row and the worker fans `body` out to each agent in `targets` at `first_fire_at_unix` (recurring every `interval_seconds` if set, one-shot when absent). Catch-up clamp: long downtime fires ONCE per recurring row on resume.
- `mcp__hyperhive__cancel_schedule(id, targets?)` — _(requires `scheduling` tool group)_ cancel a schedule. Omit `targets` / pass empty to cancel the whole schedule; pass a list to cancel just those recipients. Authorization: you can cancel schedules you own OR any owned by a sub-agent in your subtree.
- `mcp__hyperhive__fire_schedule_now(id)` — _(requires `scheduling` tool group)_ fire a scheduled prompt out of band immediately. Recurring schedules keep their cadence intact; one-shot schedules are consumed. Same authorization as `cancel_schedule`.
- `mcp__hyperhive__edit_schedule(id, body?, description?, interval_seconds?, next_fire_at_unix?, targets_add?, targets_remove?)` — _(requires `scheduling` tool group)_ partial-update a schedule's mutable fields. Pass only the fields you want to change. `targets_add` / `targets_remove` mutate the recipient list in the same transaction. Refuses cancelled rows. Same authorization as `cancel_schedule`.
- `mcp__hyperhive__list_schedules()` — _(requires `scheduling` tool group)_ snapshot every schedule in the queue. Returns id, owner, body, target set with per-target `last_fired_at` + `last_result`, `next_fire_at_unix`, recurring `interval_seconds`.
- `mcp__hyperhive__get_logs(agent, lines?)` — _(requires `diagnostics` tool group)_ fetch recent journal lines for a sub-agent container. Pass the plain logical agent name; `lines` defaults to 50 (capped at 500).
- `mcp__hyperhive__get_host_journal(unit?, container?, lines?, priority?, grep?, since?, until?)` — _(requires `read_host_journal` capability)_ fetch recent lines from the host journal. All filters optional — omit to get the last N host journal lines. `unit`: filter to a systemd unit (e.g. `hive-c0re.service`). `container`: nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure containers use their full name (e.g. `hive-ci`, `hive-forge`, `hive-matrix`, `hive-gateway`). `lines`: how many lines (default 30, max 100). `priority`: minimum syslog level enum. `grep`: regex matched against log message fields (journalctl --grep). `since`: show entries on or newer than this (e.g. `-1h`, `2024-01-01 12:00:00`). `until`: show entries on or older than this.
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — ask a peer agent with the `approvals` tool group, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
Approval boundary: lifecycle ops on _existing_ direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent (two-step: `request_init_config` + `request_apply_commit`) and _changing_ any agent's config (`request_apply_commit`) both go through the approval queue (requires `approvals` tool group). The operator only signs off on changes; you run the day-to-day.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `config_ready`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes:
- `config_ready` — the proposed config repo for a new agent was just seeded (post-`InitConfig` approval). Review and edit `/agents/<agent>/config/agent.nix`, commit your changes, then call `request_apply_commit` with the commit sha.
- `needs_login` — agent has no claude session yet. Flag the operator if it's been long.
- `logged_in` — agent just completed login; first useful turn is imminent.
- `needs_update` — agent's flake rev is stale. Call `update(name)` to rebuild — it's idempotent and doesn't need approval.
- `container_crash` — restart with `start(name)`. If it crashes again, ask the operator.
- otherwise greet freshly-spawned agents, retry failed rebuilds, pick up answers to questions you asked.
Durable knowledge:
- write to `/agents/{label}/state/notes.md` (free-form) or any other path under `/agents/{label}/state/`. That directory is bind-mounted from the host and persists across container destroy/recreate — claude's `--continue` session only carries short-term context, but `/agents/{label}/state/` is forever. Read it back at the start of relevant turns to remember things across resets.
Claude session (OAuth credentials) lives at `/root/.claude/` and persists across restarts.
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
**Hive knowledge**: `/knowledge` is a read-only bind-mount (inside your container, at the literal path `/knowledge`) of the `internal/knowledge` repo on the forge — hive-wide reference docs shared across all agents: conventions, runbooks, shared notes. **Start at `/knowledge/README.md`**, its table of contents, then read whatever's relevant for context. The README also covers how to contribute a doc.
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
The `hive-forge` CLI is the supported interface to the Forgejo — issues, PRs, comments, labels, reviews, CI status, attachments, triage (`lint`). **Discover the verb list and each verb's full signature with `hive-forge --help` and `hive-forge <verb> --help`** rather than memorising them. Default repo comes from `HIVE_FORGE_REPO`; pass `-r <repo>` (global flag, works before or after the verb) to target a different repo. A few conventions `--help` won't surface: **never `curl` the forge** — the CLI handles auth and is the only supported path; to check a PR's CI + mergeability use `hive-forge pr-status --pr <n>` (or `--sha <commit>` for a CI-only fast path; exit code is a merge-readiness verdict), not curl. `--body-file -` reads the body from stdin, so a HEREDOC works for multi-line comments/issues: `hive-forge comment <num> --body-file - <<EOF ... EOF`. `hive-forge pr-create --title "..." --head <branch> [--push]` opens a PR and prints its URL; `--push` `git push`es the head branch first (default remote `forge`). Forge notifications are delivered via the internal message daemon (sender `forge`), not polling. A `forge` notification stays unread **on the forge** until you actually read its thread — viewing the referenced issue/PR with `hive-forge comments <n>` or `view <n>` marks that notification read (it's the forge's own read-state, not a local mirror). So when a `forge` message points you at a thread, read the thread to clear the notification instead of letting the same one linger and re-surface. (`hive-forge comment` does the opposite — it *refuses* to post to a thread with unread activity until you've read it, so read first, then comment.)
Keep messages short — a few sentences each. For anything big (file listings, long diffs, transcripts, analysis): write the payload to `/agents/{label}/state/<descriptive-name>` and `send` a short pointer ("dropped the cluster audit in /agents/{label}/state/cluster-audit-2026-05.md, headline: 3 nodes over 80% mem"). The operator can read your state from the host as `/agents/{label}/state/`. Sub-agent peers can't read each other's state directly — coordinate through shared space or a common parent.
When your inbox has a message, handle it and stop. Don't narrate intent — act.
**Turns are your checkpoint.** The harness runs one claude turn per inbox message; when you stop, it acknowledges that message and your `--continue` session is saved to disk. Ending the turn is how you commit progress — both the session and the inbox acknowledgement. If the container restarts _while a turn is still running_, the message that drove it was never acknowledged, so it gets redelivered on the next boot, prefixed `[redelivered after harness restart — may already be handled]`. A long single turn that does step after step widens the window where a restart loses work and forces that redelivery, so prefer short turns: do a unit of work, write anything durable under `/agents/{label}/state/`, and end.
**To keep working without waiting for a new message, call `request_next_turn()`** before you stop. The harness immediately starts a fresh turn with `from: "self"`, `body: "continue"` — the supported way to run multi-step work (long builds, sequential edits) as a series of checkpointed turns rather than one monolithic turn. Don't busy-wait inside a turn for a condition to resolve: end the turn and let the next wake drive the continuation — a `remind` you scheduled, an external event, a backgrounded bash task's completion, or `request_next_turn()`. (A long-poll `recv(wait_seconds: …)` blocks _within_ the current turn — it parks for new inbox messages but does not end the turn or checkpoint, so it isn't a substitute for ending the turn.)

167
hive-agent/src/client.rs Normal file
View file

@ -0,0 +1,167 @@
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
/// Backoff schedule between attempts. Five entries → up to 5 retries on
/// top of the initial attempt; total wall-clock cap = 2+4+8+16+30 = 60s.
/// Sized to ride out a hive-c0re restart (systemd usually has the unix
/// socket back inside ~5s) without the agent-side claude session having
/// to handle the transient itself — burning tokens on a tool-error retry
/// loop is more expensive than 60s of in-harness sleep.
const RETRY_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
/// Transparent retry wrapper around [`request_retried`] that throws away
/// the retry count. Use this from non-tool callers (the harness serve
/// loop, web UI, CLI subcommands) where we just want the socket-restart
/// resilience without surfacing the bookkeeping.
///
/// # Errors
///
/// Returns an error if the socket is unreachable after all retries, or if
/// serialization / deserialization of the request or response fails.
pub async fn request<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
request_retried(socket, req).await.map(|(resp, _)| resp)
}
/// Same wire shape as [`request`], but reports how many retries it took
/// past the initial attempt (0 = succeeded first try). MCP tool handlers
/// use this so they can append a one-line hint to the tool result when
/// retries happened — that way claude knows the prior socket flake
/// wasn't a content error and shouldn't trigger an LLM-level retry of
/// its own.
///
/// # Errors
///
/// Returns an error if all retries are exhausted, or on a fatal protocol
/// error (serialization / deserialization failure).
///
/// # Panics
///
/// Panics if `RETRY_BACKOFFS_MS.len()` does not fit in a `u32`, which
/// cannot happen with the current compile-time constant.
pub async fn request_retried<Req, Resp>(socket: &Path, req: &Req) -> Result<(Resp, u32)>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let mut last_err: Option<anyhow::Error> = None;
let max_retries = u32::try_from(RETRY_BACKOFFS_MS.len()).unwrap();
for attempt in 0..=max_retries {
match try_once::<Req, Resp>(socket, req).await {
Ok(resp) => return Ok((resp, attempt)),
Err(RequestError::Fatal(e)) => return Err(e),
Err(RequestError::Transient(e)) => {
if attempt < max_retries {
let sleep_ms = RETRY_BACKOFFS_MS[attempt as usize];
tracing::warn!(
attempt = attempt + 1,
sleep_ms,
error = %e,
"hive socket attempt failed; retrying"
);
last_err = Some(e);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
} else {
last_err = Some(e);
}
}
}
}
// 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
/// pipe). Fatal = serialization / deserialization / protocol error
/// where retrying would just repeat the same failure.
enum RequestError {
Transient(anyhow::Error),
Fatal(anyhow::Error),
}
async fn try_once<Req, Resp>(socket: &Path, req: &Req) -> Result<Resp, RequestError>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let stream = match UnixStream::connect(socket).await {
Ok(stream) => stream,
Err(e) => {
// A refused or missing socket usually means hive-c0re is
// mid-restart (operator redeploy / rebuild) — the socket is
// recreated on its boot and `request_retried` rides it out. When
// the error *does* surface (retries exhausted, or a non-retried
// caller) add that context so claude reads it as a likely
// transient rather than a hard failure worth escalating.
let restarting = matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
);
let mut err = anyhow::Error::new(e).context(format!("connect to {}", socket.display()));
if restarting {
err = err.context(
"hive-c0re may be restarting (e.g. an operator redeploy); \
the harness already retried ~60s before surfacing this",
);
}
return Err(RequestError::Transient(err));
}
};
let (read, mut write) = stream.into_split();
let mut payload = serde_json::to_string(req).map_err(|e| RequestError::Fatal(e.into()))?;
payload.push('\n');
write
.write_all(payload.as_bytes())
.await
.map_err(|e| RequestError::Transient(e.into()))?;
write
.flush()
.await
.map_err(|e| RequestError::Transient(e.into()))?;
let mut reader = BufReader::new(read);
let mut line = String::new();
let read_bytes = reader
.read_line(&mut line)
.await
.map_err(|e| RequestError::Transient(e.into()))?;
if read_bytes == 0 || line.is_empty() {
return Err(RequestError::Transient(anyhow!(
"server closed connection without responding"
)));
}
serde_json::from_str(line.trim()).map_err(|e| RequestError::Fatal(e.into()))
}
#[cfg(test)]
mod tests {
use super::{RequestError, try_once};
/// A connect to a non-existent socket path (ENOENT → `NotFound`) is
/// classified transient AND annotated with the "hive-c0re is restarting"
/// hint, so a surfaced tool error reads as the expected transient.
#[tokio::test]
async fn missing_socket_connect_is_transient_with_restart_hint() {
let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock");
match try_once::<(), serde_json::Value>(bogus, &()).await {
Err(RequestError::Transient(e)) => {
let msg = format!("{e:#}");
assert!(msg.contains("restarting"), "missing restart hint: {msg}");
assert!(msg.contains("connect to"), "missing connect context: {msg}");
}
Err(RequestError::Fatal(e)) => panic!("expected transient, got fatal: {e:#}"),
Ok(_) => panic!("expected connect failure to a non-existent socket"),
}
}
}

872
hive-agent/src/events.rs Normal file
View file

@ -0,0 +1,872 @@
//! Live event stream for the per-agent web UI. The harness emits one
//! `LiveEvent` per interesting thing that happens during a turn — wake-up
//! (the popped inbox message), every line claude prints on stdout
//! (parsed from `--output-format stream-json`), and the turn-end summary.
//! The web UI subscribes via SSE and renders rows live.
//!
//! Channel type is `tokio::sync::broadcast`. New subscribers see only
//! future events; the dashboard JS deals with the cold-start case by
//! showing "connecting…" until the first event arrives.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use hive_claude::TokenUsage;
use hive_sh4re::wire_time::now_unix;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use tokio::sync::broadcast;
use crate::harness_state::{
DEFAULT_EFFORT, DEFAULT_MODEL, configured_effort, configured_model, context_window_tokens,
load_effort, load_model, persist_effort, persist_model, read_harness_state,
write_harness_state,
};
const CHANNEL_CAPACITY: usize = 256;
/// Max `LiveEvent`s the `Bus` returns from `history()` and keeps in
/// sqlite. Older rows are vacuumed on a periodic sweep.
pub const HISTORY_CAPACITY: usize = 2000;
/// Path to the persisted event db. Overridable via `HYPERHIVE_EVENTS_DB`
/// for dev / tests; otherwise derived from the agent's harness dir.
fn events_db_path() -> PathBuf {
std::env::var_os("HYPERHIVE_EVENTS_DB").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-events.sqlite"),
PathBuf::from,
)
}
const SCHEMA: &str = "
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
kind TEXT NOT NULL,
payload_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_ts ON events (ts);
";
/// Envelope carried over the broadcast channel: the `LiveEvent` itself
/// plus a monotonic per-process seq stamped by `Bus::emit`. SSE consumers
/// serialize this directly (seq becomes a sibling of the `kind` tag);
/// clients use seq to dedupe their buffered live traffic against the
/// snapshot/history responses (drop anything with `seq <= snapshot.seq`).
#[derive(Debug, Clone, Serialize)]
pub struct BusEvent {
pub seq: u64,
/// Unix seconds at emit time. Serialized as a sibling of the `kind`
/// tag so the agent terminal can render turn start/end times (and
/// turn duration) on the live stream; history rows carry the same
/// `ts` field sourced from the persisted `events.ts` column, so the
/// renderer reads `ts` identically for live + scrollback.
pub ts: i64,
#[serde(flatten)]
pub event: LiveEvent,
}
/// A persisted event paired with its stored unix-seconds timestamp.
/// Serializes with `ts` as a sibling of the `kind` tag — same wire shape
/// as a live [`BusEvent`] minus `seq` — so the agent terminal reads `ts`
/// identically whether an event arrives live or is replayed from history.
#[derive(Debug, Clone, Serialize)]
pub struct StoredEvent {
pub ts: i64,
#[serde(flatten)]
pub event: LiveEvent,
}
/// One row of the agent's live stream. Serialised to JSON for SSE delivery.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LiveEvent {
/// Harness popped a wake-up message and is about to invoke claude.
/// `unread` is the count of *other* messages still in the inbox at
/// that moment — surfaced as a badge in the live panel header.
TurnStart {
from: String,
body: String,
unread: u64,
},
/// One line of claude's `--output-format stream-json` stdout, parsed as
/// a generic JSON value (so we don't have to track every claude-code
/// event variant). The frontend pretty-prints by `type` field.
Stream(serde_json::Value),
/// Free-form note from the harness (e.g. "claude exited 0",
/// "stream-json parse error: ..."). Useful when stream-json itself
/// fails so the UI doesn't just go silent.
///
/// Must be a struct variant (not `Note(String)`): internally-tagged
/// enums can't flatten a tag onto a primitive newtype, and serde
/// fails serialization at runtime — silently, because the SSE
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
/// sqlite history persisted them as the literal string `"null"`.
/// The web UI's `note` renderer already reads `ev.text`, so the
/// wire shape matches without a JS change.
Note { text: String },
/// Turn finished. `ok=false` means claude exited non-zero or the
/// harness hit a transport error.
TurnEnd { ok: bool, note: Option<String> },
/// Harness reachability flipped: `"online"` /
/// `"needs_login_idle"` / `"needs_login_in_progress"`. The web UI
/// drives the alive badge from this so the operator sees a login
/// land (or get revoked) without polling. Session detail
/// (`url`/`output`/`finished`) is still served by `/api/state`
/// during the short-lived in-progress window — the client
/// re-fetches only while that flow is active.
StatusChanged { status: String },
/// `/api/model` switched the active claude model. The web UI
/// updates the chip + the per-turn stats sink will key off this
/// to mark the boundary in its log.
ModelChanged { model: String },
/// `/api/effort` switched the active claude effort level. Applies on
/// the next session start; the web UI updates the picker chip to
/// reflect it.
EffortChanged { effort: String },
/// Token usage for the turn just ended. Carries two snapshots:
/// - `ctx` is the LAST inference's usage block (the actual context
/// window in use right now — what the operator needs to decide
/// whether to compact / reset).
/// - `cost` is the cumulative usage across every inference in the
/// turn (sum of per-call billed tokens — the cost signal). For
/// tool-heavy turns the cumulative blows past the model's window
/// because each tool call's prompt is rebilled.
TokenUsageChanged { ctx: TokenUsage, cost: TokenUsage },
/// Harness's `TurnState` transitioned (idle / thinking /
/// compacting). `since_unix` matches `Bus::state_snapshot().1`
/// so the client's elapsed-time ticker keeps progressing across
/// SSE reconnects without drift.
TurnStateChanged { state: TurnState, since_unix: i64 },
}
/// sqlite-backed event log. Wraps a `Connection` behind a `Mutex` so the
/// `Bus` (which clones cheaply) shares one writer.
struct EventStore {
conn: Mutex<Connection>,
}
impl EventStore {
fn open(path: &Path) -> rusqlite::Result<Self> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let conn = Connection::open(path)?;
conn.execute_batch(SCHEMA)?;
Ok(Self {
conn: Mutex::new(conn),
})
}
fn append(&self, event: &LiveEvent) -> rusqlite::Result<()> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let kind = match event {
LiveEvent::TurnStart { .. } => "turn_start",
LiveEvent::Stream(_) => "stream",
LiveEvent::Note { .. } => "note",
LiveEvent::TurnEnd { .. } => "turn_end",
LiveEvent::StatusChanged { .. } => "status_changed",
LiveEvent::ModelChanged { .. } => "model_changed",
LiveEvent::EffortChanged { .. } => "effort_changed",
LiveEvent::TokenUsageChanged { .. } => "token_usage_changed",
LiveEvent::TurnStateChanged { .. } => "turn_state_changed",
};
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO events (ts, kind, payload_json) VALUES (?1, ?2, ?3)",
params![ts, kind, payload],
)?;
Ok(())
}
/// Fetch up to `limit` events with id < `before_id` (or the most recent
/// `limit` events when `before_id` is `None`). Returns
/// `(events_oldest_first, min_row_id, has_more)`.
fn page(
&self,
before_id: Option<i64>,
limit: usize,
) -> rusqlite::Result<(Vec<StoredEvent>, Option<i64>, bool)> {
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
let conn = self.conn.lock().unwrap();
// Fetch one extra row so we can tell whether more exist.
let fetch = limit_i.saturating_add(1);
// `ts` is the persisted emit-time unix-seconds stamp; carried out
// alongside each event so history replay shows the same turn
// start/end times the live stream did.
let rows: Vec<(i64, StoredEvent)> = if let Some(bid) = before_id {
let mut stmt = conn.prepare(
"SELECT id, ts, payload_json FROM events
WHERE id < ?1
ORDER BY id DESC
LIMIT ?2",
)?;
stmt.query_map(params![bid, fetch], |row| {
let id: i64 = row.get(0)?;
let ts: i64 = row.get(1)?;
let s: String = row.get(2)?;
Ok(serde_json::from_str::<LiveEvent>(&s)
.ok()
.map(|event| (id, StoredEvent { ts, event })))
})?
.flatten()
.flatten()
.collect()
} else {
let mut stmt = conn.prepare(
"SELECT id, ts, payload_json FROM events
ORDER BY id DESC
LIMIT ?1",
)?;
stmt.query_map(params![fetch], |row| {
let id: i64 = row.get(0)?;
let ts: i64 = row.get(1)?;
let s: String = row.get(2)?;
Ok(serde_json::from_str::<LiveEvent>(&s)
.ok()
.map(|event| (id, StoredEvent { ts, event })))
})?
.flatten()
.flatten()
.collect()
};
let has_more = rows.len() > limit;
let mut rows: Vec<(i64, StoredEvent)> = rows.into_iter().take(limit).collect();
rows.reverse(); // oldest first
let min_id = rows.first().map(|(id, _)| *id);
let events = rows.into_iter().map(|(_, e)| e).collect();
Ok((events, min_id, has_more))
}
}
/// 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
/// poke the state on transitions.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnState {
/// Inbox is empty / waiting on `Recv`.
Idle,
/// `claude --print` is running for a turn.
Thinking,
/// Operator-triggered `/compact` is running on the persistent
/// session.
Compacting,
}
#[derive(Clone)]
pub struct Bus {
tx: Arc<broadcast::Sender<BusEvent>>,
/// Monotonic per-process counter stamped onto every `BusEvent`.
/// Persisted nowhere — a harness restart resets seq to 0; clients
/// always treat reconnect as "fresh state, fresh stream of seqs."
/// Historical events served from sqlite carry no seq (they predate
/// the live channel the seq is meant to dedupe against).
event_seq: Arc<AtomicU64>,
/// Persistent event log. `None` only if opening the sqlite db failed
/// at construction — we keep going so the harness doesn't die on a
/// missing state dir mount in dev / test scenarios.
store: Option<Arc<EventStore>>,
/// Current turn-loop state + since-when (unix seconds).
state: Arc<Mutex<(TurnState, i64)>>,
/// Model name passed to `claude --model`. Default `haiku`; the
/// operator can override at runtime via `POST /api/model`.
model: Arc<Mutex<String>>,
effort: Arc<Mutex<String>>,
/// Last-inference token usage from the most recent turn's final
/// `assistant` event. Represents the actual context window size at
/// turn-end — the number the operator watches to decide whether to
/// compact. `None` until the first turn completes.
last_ctx_usage: Arc<Mutex<Option<TokenUsage>>>,
/// Cumulative token usage from the most recent turn's `result`
/// event (sum across every inference in the turn). This is the cost
/// signal — tool-heavy turns rebill the cached prompt per call and
/// blow past the model window. `None` until the first turn completes.
last_cost_usage: Arc<Mutex<Option<TokenUsage>>>,
/// True while the harness is parked after a rate-limit response.
/// Set by `emit_status("rate_limited")`, cleared by
/// `emit_status("online")`. Also mirrored to a sentinel file at
/// `{state_dir}/hyperhive-rate-limited` so the host-side
/// `container_view` can surface the status on the dashboard without
/// a live socket call.
rate_limited: Arc<AtomicBool>,
/// One-shot: operator asked to reset the claude session (`POST
/// /api/new-session`). Consumed at the *next* turn boundary by
/// `turn::drive_turn`, which archives the current session so the turn
/// starts fresh. Deferred (not applied in the handler) so the archive
/// never races a claude process mid-write — one claude per container,
/// serialized by the serve loop, so the turn boundary is the only point
/// where no session file is open.
session_reset_pending: Arc<AtomicBool>,
/// One-shot: run `/compact` after the next turn ends. Consumed at the end
/// of the current/next turn by `turn::drive_turn`. Deferring to the turn
/// boundary keeps compaction from racing a live claude process mid-turn.
compact_pending: Arc<AtomicBool>,
/// Current fresh-claude-session id (FK to `sessions.id`). Set by the
/// bin loop after minting a session row on a fresh start; stamped onto
/// every `turn_stats` row until the next fresh session. `None` before
/// the first fresh turn or when the stats db is unavailable.
session_id: Arc<Mutex<Option<i64>>>,
/// One-shot: `run_claude` flips this true when it creates a fresh claude
/// session (`--name <title>` on a title miss). The bin loop takes-and-
/// clears it after the turn to decide whether to mint a new `sessions`
/// row. Session identity itself is handled entirely in `turn.rs` via the
/// constant title + archive — this flag is purely a stats signal.
fresh_session: Arc<AtomicBool>,
/// Per-turn tool-call counter. Reset by the bin loop between
/// turns via `take_tool_calls`. Populated by `observe_stream` as
/// the stdout pump parses each stream-json line. Powers the
/// `tool_call_count` + `tool_call_breakdown_json` columns on the
/// per-turn stats sink.
tool_calls: Arc<Mutex<std::collections::HashMap<String, u64>>>,
/// Unix timestamp of the most recent completed turn (set by
/// `record_turn_usage`). Used by the auto-reset heuristic in
/// `turn.rs` to compute how long the session has been idle and
/// whether the prompt cache has gone cold. `0` = no turn yet.
last_turn_ended_unix: Arc<AtomicI64>,
/// Per-inference context-window size as reported by the Anthropic API
/// in the stream-json `result` event (`modelUsage.*.contextWindow`).
/// Set by the stdout pump on every completed turn. Takes precedence
/// over the Nix-configured `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars
/// for compaction watermark calculations — it reflects the actual
/// limit the model enforces, which may differ from what the operator
/// configured (e.g. 200 k active window on a 1 M cache-enabled model).
api_context_window: Arc<Mutex<Option<u64>>>,
/// Resolved model id from the most recent turn's `assistant` events
/// (the API-echoed `message.model`, e.g. `claude-opus-4-8`), as
/// opposed to the requested `--model` name which may be a short
/// alias. Set once per turn by the stdout pump at result-time;
/// `None` until the first assistant event of a turn is seen. The
/// per-turn stats sink prefers this over the requested name so the
/// model-mix + cost rollup reflect the concrete version that ran.
last_resolved_model: Arc<Mutex<Option<String>>>,
}
impl Bus {
/// Open the events db (path from `events_db_path()`). On failure, fall back
/// to a no-store bus — the harness still works, just without persistent history.
#[must_use]
pub fn new() -> Self {
let path = events_db_path();
let store = match EventStore::open(&path) {
Ok(s) => Some(Arc::new(s)),
Err(e) => {
tracing::warn!(error = ?e, path = %path.display(), "events db open failed; running without history");
None
}
};
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
// Priority: HIVE_DEFAULT_MODEL (from hyperhive.model in agent.nix) >
// persisted runtime override > compiled-in DEFAULT_MODEL.
// The nix config always wins on rebuild; the persisted file is kept
// for within-session tracking only (see persist_model / set_model).
let initial_model = configured_model().map_or_else(
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
str::to_owned,
);
// Effort precedence is the inverse of model: a persisted operator
// pick wins over the nix `HIVE_DEFAULT_EFFORT` baseline so the
// runtime choice survives harness restart (it only resets on
// `--purge`). nix is the default for a never-touched agent.
let initial_effort = load_effort()
.or_else(|| configured_effort().map(str::to_owned))
.unwrap_or_else(|| DEFAULT_EFFORT.to_owned());
// Restore rate_limited (and needs_login) from the consolidated
// harness state file so the dashboard shows the correct status
// on cold load if the harness crashed while parked.
let (was_rate_limited, was_needs_login, _) = read_harness_state();
// Write the resolved active model to hyperhive-harness.json on
// startup so hive-c0re can surface the model badge without reading
// hyperhive-model directly. Written once here; updated on every
// set_model() call (runtime override) and emit_status() call.
write_harness_state(was_rate_limited, was_needs_login, Some(&initial_model));
Self {
tx: Arc::new(tx),
event_seq: Arc::new(AtomicU64::new(0)),
store,
state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))),
model: Arc::new(Mutex::new(initial_model)),
effort: Arc::new(Mutex::new(initial_effort)),
last_ctx_usage: Arc::new(Mutex::new(None)),
last_cost_usage: Arc::new(Mutex::new(None)),
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
session_reset_pending: Arc::new(AtomicBool::new(false)),
compact_pending: Arc::new(AtomicBool::new(false)),
session_id: Arc::new(Mutex::new(None)),
fresh_session: Arc::new(AtomicBool::new(false)),
tool_calls: Arc::new(Mutex::new(std::collections::HashMap::new())),
last_turn_ended_unix: Arc::new(AtomicI64::new(0)),
api_context_window: Arc::new(Mutex::new(None)),
last_resolved_model: Arc::new(Mutex::new(None)),
}
}
/// Current high-water seq. Snapshot endpoints read this before
/// gathering state so the resulting (snapshot.seq, snapshot) pair
/// satisfies: any live event with seq > snapshot.seq is post-snapshot
/// (not yet reflected). Clients dedupe buffered SSE traffic against
/// this value.
#[must_use]
pub fn current_seq(&self) -> u64 {
self.event_seq.load(Ordering::SeqCst)
}
fn next_seq(&self) -> u64 {
self.event_seq.fetch_add(1, Ordering::SeqCst) + 1
}
/// Request a session reset (operator `POST /api/new-session`). Deferred:
/// the flag is consumed at the next turn boundary by `drive_turn`, which
/// archives the current session so no claude process is mid-write when the
/// file is renamed. Idempotent — two clicks before the next turn still
/// archive once.
pub fn request_session_reset(&self) {
self.session_reset_pending.store(true, Ordering::SeqCst);
}
/// Take + clear the session-reset one-shot. Returns true iff `drive_turn`
/// should archive the current session before this turn.
#[must_use]
pub fn take_session_reset(&self) -> bool {
self.session_reset_pending.swap(false, Ordering::SeqCst)
}
/// Request a compaction after the next turn ends (deferred to the turn
/// boundary). Idempotent.
pub fn request_compact(&self) {
self.compact_pending.store(true, Ordering::SeqCst);
}
/// Take + clear the compact one-shot. Returns true iff `drive_turn` should
/// compact at the end of this turn.
#[must_use]
pub fn take_compact(&self) -> bool {
self.compact_pending.swap(false, Ordering::SeqCst)
}
/// Mark that the current turn started a fresh claude session.
/// `run_claude` calls this when it creates a new titled session.
pub fn mark_fresh_session(&self) {
self.fresh_session.store(true, Ordering::SeqCst);
}
/// Take + clear the fresh-session one-shot. The bin loop calls this
/// after the turn to decide whether to mint a new `sessions` row.
#[must_use]
pub fn take_fresh_session(&self) -> bool {
self.fresh_session.swap(false, Ordering::SeqCst)
}
/// Currently-active session id (FK to `sessions.id`), or `None`.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn current_session_id(&self) -> Option<i64> {
*self.session_id.lock().unwrap()
}
/// Set the active session id after minting a `sessions` row on a
/// fresh start.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_session_id(&self, id: Option<i64>) {
*self.session_id.lock().unwrap() = id;
}
/// Currently-selected claude model name. Read on every turn so a
/// `/model <name>` flip takes effect on the next turn.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn model(&self) -> String {
self.model.lock().unwrap().clone()
}
/// Switch the model for future turns. The current turn (if any)
/// keeps the model it was already running. Persisted to the agent's
/// state dir (`hyperhive-model`) so the override survives harness
/// restart and container rebuild (gone on `--purge`, matching
/// every other piece of agent state).
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_model(&self, name: impl Into<String>) {
let value: String = name.into();
self.model.lock().unwrap().clone_from(&value);
if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed");
}
// Mirror the resolved model into hyperhive-harness.json so
// hive-c0re can surface it on the dashboard badge without reading
// the hyperhive-model override file directly.
let (rate_limited, needs_login, _) = read_harness_state();
write_harness_state(rate_limited, needs_login, Some(&value));
self.emit(LiveEvent::ModelChanged { model: value });
}
/// Currently-selected claude effort level. Read at session start to
/// build the `--effort` arg.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn effort(&self) -> String {
self.effort.lock().unwrap().clone()
}
/// Switch the effort level for future sessions. Applies on the next
/// claude launch (no mid-session swap). Persisted to the agent's
/// state dir (`hyperhive-effort`) so the override survives harness
/// restart and container rebuild (gone on `--purge`). Callers must
/// pre-validate with [`crate::harness_state::is_valid_effort`].
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_effort(&self, level: impl Into<String>) {
let value: String = level.into();
self.effort.lock().unwrap().clone_from(&value);
if let Err(e) = persist_effort(&value) {
tracing::warn!(error = ?e, "effort: persist failed");
}
self.emit(LiveEvent::EffortChanged { effort: value });
}
/// Seed `last_ctx_usage` + `last_cost_usage` at startup without
/// emitting a SSE event. Used by the bin entrypoints to backfill
/// from the most recent `turn_stats` row so the per-agent web UI's
/// ctx + cost badges paint real numbers on cold load.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn seed_usage(&self, ctx: Option<TokenUsage>, cost: Option<TokenUsage>) {
if ctx.is_some() {
*self.last_ctx_usage.lock().unwrap() = ctx;
}
if cost.is_some() {
*self.last_cost_usage.lock().unwrap() = cost;
}
}
/// Record the just-ended turn's usage. `ctx` is the last inference's
/// usage (current context size); `cost` is the cumulative across
/// every inference in the turn (cost signal). One SSE event fires
/// per turn carrying both.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn record_turn_usage(&self, ctx: TokenUsage, cost: TokenUsage) {
*self.last_ctx_usage.lock().unwrap() = Some(ctx);
*self.last_cost_usage.lock().unwrap() = Some(cost);
self.last_turn_ended_unix
.store(now_unix(), Ordering::Relaxed);
self.emit(LiveEvent::TokenUsageChanged { ctx, cost });
}
/// Unix timestamp of the most recent completed turn (`record_turn_usage`
/// call), or `0` if no turn has finished yet.
#[must_use]
pub fn last_turn_ended_unix(&self) -> i64 {
self.last_turn_ended_unix.load(Ordering::Relaxed)
}
/// Record the resolved model id observed for the just-ended turn
/// (from `assistant` events' `message.model`). `None` clears it so a
/// degenerate turn that produced no assistant event doesn't inherit a
/// stale id — the stats sink then falls back to the requested name.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_resolved_model(&self, model: Option<String>) {
*self.last_resolved_model.lock().unwrap() = model;
}
/// Resolved model id from the most recent turn, if an `assistant`
/// event reported one. The per-turn stats sink prefers this over the
/// requested `--model` name.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_resolved_model(&self) -> Option<String> {
self.last_resolved_model.lock().unwrap().clone()
}
/// Update the API-reported context-window size from the stream-json
/// `result` event's `modelUsage.*.contextWindow` field. Called by the
/// stdout pump once per completed turn. `0` is ignored (sentinel for
/// "not reported").
pub fn set_api_context_window(&self, window: u64) {
if window > 0 {
*self.api_context_window.lock().unwrap() = Some(window);
}
}
/// Return the API-reported per-inference context-window size, if the
/// harness has seen at least one completed turn for this session.
/// `None` until the first result event is processed.
#[must_use]
pub fn api_context_window(&self) -> Option<u64> {
*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
/// assistant message — the field-check short-circuits.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn observe_stream(&self, v: &serde_json::Value) {
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return;
}
let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
else {
return;
};
let mut counts = self.tool_calls.lock().unwrap();
for block in content {
if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
continue;
}
let name = block
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("<unnamed>")
.to_owned();
*counts.entry(name).or_insert(0) += 1;
}
}
/// Snapshot + clear the per-turn tool-call counter. The harness
/// calls this between turns to fold the breakdown into a
/// `turn_stats` row, then start the next turn with an empty map.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn take_tool_calls(&self) -> std::collections::HashMap<String, u64> {
std::mem::take(&mut *self.tool_calls.lock().unwrap())
}
/// Last context-size snapshot (last inference of the most recent
/// turn), or `None` if no turn has completed yet.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_ctx_usage(&self) -> Option<TokenUsage> {
*self.last_ctx_usage.lock().unwrap()
}
/// Last cumulative cost snapshot (sum across the most recent turn's
/// inferences), or `None` if no turn has completed yet.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn last_cost_usage(&self) -> Option<TokenUsage> {
*self.last_cost_usage.lock().unwrap()
}
/// Update the harness's authoritative turn-loop state. Records
/// the transition time so `state_snapshot` can return a since-age.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn set_state(&self, next: TurnState) {
let since;
{
let mut guard = self.state.lock().unwrap();
if guard.0 == next {
return;
}
*guard = (next, now_unix());
since = guard.1;
}
self.emit(LiveEvent::TurnStateChanged {
state: next,
since_unix: since,
});
}
/// Broadcast a status flip (online / `needs_login_*` / `rate_limited`).
/// Called by the bin entry points + `turn::wait_for_login` + the
/// `post_login_*` handlers — every site that mutates the
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
/// drops its periodic /api/state poll while a turn loop is running.
///
/// `hyperhive-harness.json` persists across harness restarts so the
/// host-side dashboard can render the status without a live socket call:
/// - `"rate_limited"` sets `rate_limited: true` in the JSON.
/// - `"needs_login_idle"` sets `needs_login: true` in the JSON.
/// - `"online"` clears both fields — the agent is healthy again.
/// - Other statuses clear `rate_limited` only; `needs_login` is sticky
/// until `"online"` (re-auth completed successfully).
///
/// Writes are atomic (`.tmp` + `rename`) so hive-c0re never reads a
/// partial file during its ~10s sweep.
pub fn emit_status(&self, status: impl Into<String>) {
let status = status.into();
let new_rate_limited = status == "rate_limited";
if new_rate_limited {
self.rate_limited.store(true, Ordering::Relaxed);
} else {
self.rate_limited.store(false, Ordering::Relaxed);
}
// Read the current persisted needs_login so we don't flip it on
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
// is a transient mid-flow status; only `needs_login_idle` and
// `online` should change the persistent flag).
//
// Known non-atomicity: this is a read-modify-write. Two concurrent
// `emit_status` calls could clobber each other's `needs_login`
// change if they raced between the read and the write. In practice
// this is safe: the turn loop is sequential and the login flow
// (`needs_login_idle` / `online`) only fires outside of active
// turns, so the two callers never overlap. Documented rather than
// locked because adding a Mutex here would be overkill for the
// actual call pattern.
let (_, current_needs_login, _) = read_harness_state();
let new_needs_login = if status == "needs_login_idle" {
true
} else if status == "online" {
false
} else {
current_needs_login
};
let current_model = self.model.lock().unwrap().clone();
write_harness_state(new_rate_limited, new_needs_login, Some(&current_model));
self.emit(LiveEvent::StatusChanged { status });
}
/// Returns true while the harness is parked after a rate-limit response.
#[must_use]
pub fn is_rate_limited(&self) -> bool {
self.rate_limited.load(Ordering::Relaxed)
}
/// Current state + since-when (unix seconds). Snapshot copy, no lock held.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn state_snapshot(&self) -> (TurnState, i64) {
*self.state.lock().unwrap()
}
pub fn emit(&self, event: LiveEvent) {
if let Some(store) = &self.store
&& let Err(e) = store.append(&event)
{
tracing::warn!(error = ?e, "events: append failed");
}
let envelope = BusEvent {
seq: self.next_seq(),
ts: now_unix(),
event,
};
// Lagged subscribers drop events — fine; the UI is a tail, not a log.
let _ = self.tx.send(envelope);
}
#[must_use]
pub fn subscribe(&self) -> broadcast::Receiver<BusEvent> {
self.tx.subscribe()
}
/// Paginated history: up to `limit` events before `before_id`
/// (or the most recent `limit` when `before_id` is `None`).
/// Returns `(events_oldest_first, min_row_id, has_more)`.
/// `min_row_id` is the cursor for the next page; pass it as
/// `before_id` on the next call.
#[must_use]
pub fn history_page(
&self,
before_id: Option<i64>,
limit: usize,
) -> (Vec<StoredEvent>, Option<i64>, bool) {
let Some(store) = &self.store else {
return (Vec::new(), None, false);
};
store.page(before_id, limit).unwrap_or_default()
}
}
impl Default for Bus {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::{BusEvent, LiveEvent, StoredEvent};
#[test]
fn stored_event_serializes_ts_beside_kind() {
// History-row wire shape: `ts` is a flattened sibling of `kind`,
// which is what the agent terminal reads to time turn boundaries.
let v = serde_json::to_value(StoredEvent {
ts: 1_700_000_000,
event: LiveEvent::Note { text: "hi".into() },
})
.unwrap();
assert_eq!(v["ts"], 1_700_000_000_i64);
assert_eq!(v["kind"], "note");
assert_eq!(v["text"], "hi");
}
#[test]
fn bus_event_serializes_ts_and_seq_beside_kind() {
// Live SSE frame: same `ts` sibling as history (plus `seq`), so the
// renderer is path-agnostic between live + scrollback.
let v = serde_json::to_value(BusEvent {
seq: 7,
ts: 1_700_000_000,
event: LiveEvent::Note { text: "yo".into() },
})
.unwrap();
assert_eq!(v["seq"], 7);
assert_eq!(v["ts"], 1_700_000_000_i64);
assert_eq!(v["kind"], "note");
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,332 @@
//! File-backed harness state, split out of [`crate::events`] (which owns
//! the live event bus + sqlite store). This module holds the runtime
//! model/effort selection, the consolidated `hyperhive-harness.json`
//! state file, and the `forge_notify` delivery-dedupe cursor — the
//! persisted knobs the harness reads/writes across turns, none of which
//! are about the live event stream.
use std::path::PathBuf;
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
/// for dev / tests; otherwise derived from the agent's harness dir.
fn model_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_MODEL_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-model"),
PathBuf::from,
)
}
pub(crate) fn load_model() -> Option<String> {
let s = std::fs::read_to_string(model_file_path()).ok()?;
let name = s.trim();
if name.is_empty() {
None
} else {
Some(name.to_owned())
}
}
pub(crate) fn persist_model(name: &str) -> std::io::Result<()> {
let path = model_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{name}\n"))
}
/// Path to the persisted effort-level file. Sibling of `hyperhive-model`,
/// overridable via `HYPERHIVE_EFFORT_FILE` for dev / tests; otherwise
/// derived from the agent's harness dir.
fn effort_file_path() -> PathBuf {
std::env::var_os("HYPERHIVE_EFFORT_FILE").map_or_else(
|| crate::paths::harness_dir().join("hyperhive-effort"),
PathBuf::from,
)
}
pub(crate) fn load_effort() -> Option<String> {
let s = std::fs::read_to_string(effort_file_path()).ok()?;
let level = s.trim();
if level.is_empty() {
None
} else {
Some(level.to_owned())
}
}
pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
let path = effort_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(path, format!("{level}\n"))
}
// ---------------------------------------------------------------------------
// Consolidated harness state file
// ---------------------------------------------------------------------------
//
// `hyperhive-harness.json` replaces the two legacy boolean sentinel files
// (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically
// and had no shared schema. A single JSON file is self-documenting, atomic
// to write, and cheaper for hive-c0re to read on each sweep (one fopen vs
// two stat calls). See `docs/persistence.md::Harness state files`.
//
// Legacy sentinel files written by older harness builds are still honoured
// by `read_harness_state` so in-place upgrades don't lose state (the new
// harness re-normalises on first write). Old files are not deleted — they
// expire naturally when the state dir is purged. `hive-c0re::container_view`
// also checks the legacy paths as a fallback during the transition window.
const HARNESS_JSON: &str = "hyperhive-harness.json";
fn harness_json_path() -> PathBuf {
crate::paths::state_dir().join(HARNESS_JSON)
}
// Serialises the read-modify-write of `hyperhive-harness.json`. Two
// harness tasks touch it in the same process — the turn loop (rate-limit
// / needs-login / active-model) and the forge_notify poller (the
// delivery-dedupe cursor) — writing disjoint fields, so each writer must
// preserve the other's. The lock closes the lost-update window between a
// writer's read and its rename.
static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Read the consolidated state file as a JSON object, or an empty object
/// when it is missing / unparseable / not an object.
fn read_harness_json() -> serde_json::Value {
std::fs::read_to_string(harness_json_path())
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.filter(serde_json::Value::is_object)
.unwrap_or_else(|| serde_json::json!({}))
}
/// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re
/// never reads a partial file.
fn write_harness_json(v: &serde_json::Value) {
let path = harness_json_path();
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, v.to_string()).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
pub(crate) fn read_harness_state() -> (bool, bool, Option<String>) {
// Try the new consolidated file first.
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw)
{
let rate_limited = v
.get("rate_limited")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let needs_login = v
.get("needs_login")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let active_model = v
.get("active_model")
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_owned);
return (rate_limited, needs_login, active_model);
}
// Fall back to legacy sentinel files written by older harness builds.
let state_dir = crate::paths::state_dir();
let rate_limited = state_dir.join("hyperhive-rate-limited").exists();
let needs_login = state_dir.join("hyperhive-needs-login").exists();
(rate_limited, needs_login, None)
}
/// Write the turn-loop's harness state fields via a read-modify-write so
/// any other writer's fields (e.g. `forge_notify`'s `forge_cursor`) survive.
/// Pass `active_model: Some(s)` to update the resolved model (surfaced in
/// the dashboard badge); `None` leaves the stored value untouched.
pub(crate) fn write_harness_state(
rate_limited: bool,
needs_login: bool,
active_model: Option<&str>,
) {
let _guard = HARNESS_JSON_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut v = read_harness_json();
v["rate_limited"] = rate_limited.into();
v["needs_login"] = needs_login.into();
if let Some(model) = active_model {
v["active_model"] = model.into();
}
write_harness_json(&v);
}
/// Parse the `forge_notify` delivery-dedupe cursor (notification thread id
/// -> last-delivered `updated_at`) out of a harness-state JSON value.
/// Empty when the field is absent (first boot) or malformed.
fn forge_cursor_from_json(v: &serde_json::Value) -> std::collections::HashMap<u64, String> {
v.get("forge_cursor")
.cloned()
.and_then(|c| serde_json::from_value(c).ok())
.unwrap_or_default()
}
/// Restore the `forge_notify` delivery-dedupe cursor from the consolidated
/// state file so a container rebuild/restart doesn't re-deliver the whole
/// currently-unread backlog.
pub fn read_forge_cursor() -> std::collections::HashMap<u64, String> {
forge_cursor_from_json(&read_harness_json())
}
/// Persist the `forge_notify` delivery-dedupe cursor into the consolidated
/// state file, read-modify-write under the shared lock so the turn-loop's
/// own fields survive. Best-effort: a serialize failure is a no-op.
pub fn write_forge_cursor<S: std::hash::BuildHasher>(
cursor: &std::collections::HashMap<u64, String, S>,
) {
let Ok(value) = serde_json::to_value(cursor) else {
return;
};
let _guard = HARNESS_JSON_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut v = read_harness_json();
v["forge_cursor"] = value;
write_harness_json(&v);
}
/// Compiled-in fallback model used when neither `HIVE_DEFAULT_MODEL` nor a
/// persisted runtime override is present.
pub const DEFAULT_MODEL: &str = "haiku";
/// Return the model declared in `HIVE_DEFAULT_MODEL` (set from
/// `hyperhive.model` in `agent.nix`), or `None` if the env var is absent /
/// empty. When `Some`, this takes precedence over any persisted runtime
/// override so that nix config changes always take effect on rebuild.
#[must_use]
pub fn configured_model() -> Option<&'static str> {
// Leak once at startup — acceptable for a single config value.
std::env::var("HIVE_DEFAULT_MODEL")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| &*Box::leak(s.into_boxed_str()))
}
/// Compiled-in fallback effort level — matches the `effortLevel` baked
/// into `prompts/claude-settings.json`.
pub const DEFAULT_EFFORT: &str = "medium";
/// Valid claude `--effort` levels, ascending. The operator picker is
/// constrained to these; [`is_valid_effort`] guards the persist path.
pub const EFFORT_LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// True iff `level` is one of [`EFFORT_LEVELS`].
#[must_use]
pub fn is_valid_effort(level: &str) -> bool {
EFFORT_LEVELS.contains(&level)
}
/// Return the effort level declared in `HIVE_DEFAULT_EFFORT` (set from
/// `hyperhive.effortLevel` in `agent.nix`), or `None` if absent / empty.
/// Mirrors [`configured_model`]'s env shape. Unlike model, the persisted
/// runtime override takes precedence over this baseline (see `Bus::new`):
/// the operator's effort pick sticks across harness restart, with the nix
/// value only the default when no override was ever set.
#[must_use]
pub fn configured_effort() -> Option<&'static str> {
std::env::var("HIVE_DEFAULT_EFFORT")
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| &*Box::leak(s.into_boxed_str()))
}
/// Context-window size in tokens for a given model name.
///
/// Canonical per-model sizes are declared in the harness nix modules as
/// `hyperhive.contextWindowTokens` and injected as
/// `HIVE_CONTEXT_WINDOW_TOKENS_<KEY_UPPER>` env vars — so this function
/// normally just reads them. The Rust code carries no model knowledge;
/// updating model families only requires a Nix change.
///
/// Resolution order (first match wins):
/// 1. `HIVE_CONTEXT_WINDOW_TOKENS_<KEY>` — key (lowercased) is a
/// substring of the active model name. Populated by the Nix default
/// map for all known families; add/override in `agent.nix`.
/// 2. `HIVE_CONTEXT_WINDOW_TOKENS` — single global override (any model).
/// 3. Hard fallback: `200_000` (conservative; only hit outside NixOS).
#[must_use]
pub fn context_window_tokens(model: &str) -> u64 {
let m = model.to_ascii_lowercase();
// Per-model env vars set by `hyperhive.contextWindowTokens` in Nix.
for (key, val) in std::env::vars() {
if let Some(suffix) = key.strip_prefix("HIVE_CONTEXT_WINDOW_TOKENS_")
&& !suffix.is_empty()
&& m.contains(&suffix.to_ascii_lowercase())
&& let Ok(v) = val.trim().parse::<u64>()
&& v > 0
{
return v;
}
}
// Global override (single value, any model).
if let Ok(s) = std::env::var("HIVE_CONTEXT_WINDOW_TOKENS")
&& let Ok(v) = s.trim().parse::<u64>()
&& v > 0
{
return v;
}
// Hard fallback for dev/test outside NixOS where env vars aren't set.
200_000
}
#[cfg(test)]
mod tests {
use super::{DEFAULT_EFFORT, EFFORT_LEVELS, forge_cursor_from_json, is_valid_effort};
use serde_json::json;
#[test]
fn forge_cursor_absent_field_is_empty() {
// First boot / a state file that only carries the turn-loop fields:
// no cursor yet, so we re-deliver the currently-unread set once.
assert!(forge_cursor_from_json(&json!({ "rate_limited": false })).is_empty());
}
#[test]
fn forge_cursor_malformed_field_is_empty() {
// A wrong-typed / corrupt cursor degrades to empty rather than
// aborting the poller.
assert!(forge_cursor_from_json(&json!({ "forge_cursor": "nonsense" })).is_empty());
}
#[test]
fn forge_cursor_roundtrips_u64_keys() {
// serde_json stringifies integer map keys; confirm the u64 thread
// ids the cursor is keyed on survive the JSON round-trip.
let v = json!({
"forge_cursor": { "42": "2026-06-22T16:00:00Z", "99": "2026-06-22T17:30:00Z" }
});
let cursor = forge_cursor_from_json(&v);
assert_eq!(cursor.len(), 2);
assert_eq!(cursor.get(&42), Some(&"2026-06-22T16:00:00Z".to_owned()));
assert_eq!(cursor.get(&99), Some(&"2026-06-22T17:30:00Z".to_owned()));
}
#[test]
fn effort_validation_accepts_only_known_levels() {
for level in EFFORT_LEVELS {
assert!(is_valid_effort(level), "{level} should be valid");
}
// Out-of-set, empty, and wrong-case inputs are rejected so a bad
// picker POST never reaches `claude --effort`.
for bad in ["lowest", "", "MEDIUM", "ultra", "high "] {
assert!(!is_valid_effort(bad), "{bad:?} should be rejected");
}
}
#[test]
fn compiled_default_effort_is_selectable() {
// The fallback must itself be a valid level, and match the value
// baked into prompts/claude-settings.json.
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
assert_eq!(DEFAULT_EFFORT, "medium");
}
}

255
hive-agent/src/identity.rs Normal file
View file

@ -0,0 +1,255 @@
//! Agent identity helpers — short label + hive-qualified long name +
//! human display names for the hive and swarm. Full env var surface +
//! domain-vs-name distinction documented in
//! `docs/conventions.md::Hive identity (label + domain + display names)`.
use std::env;
/// Short, hive-local agent label. Read from `HIVE_LABEL`; falls back to an
/// empty string when the env var is missing, so callers downstream can decide
/// how to surface "unknown agent" rather than getting a panic from this
/// module.
#[must_use]
pub fn label() -> String {
env::var("HIVE_LABEL").unwrap_or_default()
}
/// `env::var(key)` reduced to `Some(value)` only when the var is set and
/// non-empty — the shared shape of the hive/swarm display-name lookups below.
fn non_empty_env(key: &str) -> Option<String> {
env::var(key).ok().filter(|s| !s.is_empty())
}
/// The hive's canonical DNS domain when set, otherwise None. Single-hive
/// deployments where `HYPERHIVE_HIVE_DOMAIN` is unset return None — callers
/// then degrade gracefully to the short label.
#[must_use]
pub fn hive_domain() -> Option<String> {
non_empty_env("HYPERHIVE_HIVE_DOMAIN")
}
/// Human display name of this hive (e.g. `pr1ma`). Distinct from
/// [`hive_domain`] — the domain is the machine-readable DNS address;
/// this is the prose label humans use in conversation. Returns None
/// when the host-side `services.hyperhive.hiveName` option is unset,
/// in which case callers fall back to the domain or the short label
/// at their discretion.
#[must_use]
pub fn hive_name() -> Option<String> {
non_empty_env("HYPERHIVE_HIVE_NAME")
}
/// Human display name of the wider swarm this hive belongs to (e.g.
/// `constellat1on`). Federated hives at different DNS domains can
/// share a swarm name. Returns None when the host-side
/// `services.hyperhive.swarmName` option is unset.
#[must_use]
pub fn swarm_name() -> Option<String> {
non_empty_env("HYPERHIVE_SWARM_NAME")
}
/// Hive-qualified agent identity. When the hive domain is configured, returns
/// `${label}@${domain}` (e.g. `iris@darkest.space`); when not, returns just
/// the short label so callers can render a single string regardless of
/// deployment shape. Callers that want to know whether the result is
/// qualified should check [`hive_domain`] directly.
#[must_use]
pub fn qualified_label() -> String {
qualify(&label())
}
/// Apply hive qualification to an arbitrary agent label (e.g. a peer name
/// from the broker). Mirrors [`qualified_label`] but lets a caller qualify
/// names it didn't read from the env. Returns `${label}@${domain}` when the
/// hive domain is set, else just `label`.
#[must_use]
pub fn qualify(label: &str) -> String {
match hive_domain() {
Some(domain) if !label.is_empty() => format!("{label}@{domain}"),
_ => label.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// cargo's test runner parallelises by default, so a `with_env`
/// helper that mutates process-wide env vars races between tests in
/// this module. Serialise on a module-scope mutex so each `with_env`
/// call holds the lock for its set / run / restore window. Cheap
/// (each test body is microseconds) and avoids pulling in
/// `serial_test` for just one module.
static ENV_LOCK: Mutex<()> = Mutex::new(());
/// Helper: run `f` with a clean env, restoring previous values on exit.
/// Acquires `ENV_LOCK` first so concurrent tests don't race the env-var
/// state. If a previous test panicked while holding the lock the
/// mutex would be poisoned — we use `lock().unwrap_or_else(|e| e.into_inner())`
/// to recover so a single test failure doesn't cascade through the
/// whole module.
fn with_env<F: FnOnce()>(label: Option<&str>, domain: Option<&str>, f: F) {
with_full_env(label, domain, None, None, f);
}
/// Extended form of [`with_env`] covering the display-name env vars
/// (hive name + swarm name) alongside label + domain. Same SAFETY
/// contract — serialised on `ENV_LOCK`, restore in scope.
fn with_full_env<F: FnOnce()>(
label: Option<&str>,
domain: Option<&str>,
hive_name: Option<&str>,
swarm_name: Option<&str>,
f: F,
) {
let _guard = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev_label = env::var("HIVE_LABEL").ok();
let prev_domain = env::var("HYPERHIVE_HIVE_DOMAIN").ok();
let prev_hive_name = env::var("HYPERHIVE_HIVE_NAME").ok();
let prev_swarm_name = env::var("HYPERHIVE_SWARM_NAME").ok();
// SAFETY: serialised by ENV_LOCK above; restore in the same scope.
unsafe {
match label {
Some(v) => env::set_var("HIVE_LABEL", v),
None => env::remove_var("HIVE_LABEL"),
}
match domain {
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
}
match hive_name {
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
}
match swarm_name {
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
}
}
f();
unsafe {
match prev_label {
Some(v) => env::set_var("HIVE_LABEL", v),
None => env::remove_var("HIVE_LABEL"),
}
match prev_domain {
Some(v) => env::set_var("HYPERHIVE_HIVE_DOMAIN", v),
None => env::remove_var("HYPERHIVE_HIVE_DOMAIN"),
}
match prev_hive_name {
Some(v) => env::set_var("HYPERHIVE_HIVE_NAME", v),
None => env::remove_var("HYPERHIVE_HIVE_NAME"),
}
match prev_swarm_name {
Some(v) => env::set_var("HYPERHIVE_SWARM_NAME", v),
None => env::remove_var("HYPERHIVE_SWARM_NAME"),
}
}
}
#[test]
fn qualified_label_with_domain_set() {
with_env(Some("iris"), Some("darkest.space"), || {
assert_eq!(qualified_label(), "iris@darkest.space");
assert_eq!(hive_domain().as_deref(), Some("darkest.space"));
});
}
#[test]
fn qualified_label_falls_back_to_short_when_domain_unset() {
with_env(Some("iris"), None, || {
assert_eq!(qualified_label(), "iris");
assert!(hive_domain().is_none());
});
}
#[test]
fn qualified_label_falls_back_to_short_when_domain_empty() {
// Empty string is treated the same as unset — a misconfigured
// module shouldn't surface `iris@` (no domain) to the operator.
with_env(Some("iris"), Some(""), || {
assert_eq!(qualified_label(), "iris");
assert!(hive_domain().is_none());
});
}
#[test]
fn qualify_takes_arbitrary_label() {
with_env(Some("iris"), Some("darkest.space"), || {
// Local label gets local hive applied; useful for rendering
// a peer's name when the caller knows it's hive-local.
assert_eq!(qualify("damocles"), "damocles@darkest.space");
});
}
#[test]
fn qualify_empty_label_stays_empty() {
with_env(Some("iris"), Some("darkest.space"), || {
assert_eq!(qualify(""), "");
});
}
#[test]
fn label_returns_empty_when_unset() {
with_env(None, None, || {
assert_eq!(label(), "");
});
}
#[test]
fn hive_name_returns_some_when_env_set() {
with_full_env(Some("iris"), None, Some("pr1ma"), None, || {
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
});
}
#[test]
fn hive_name_returns_none_when_env_unset_or_empty() {
with_full_env(Some("iris"), None, None, None, || {
assert!(hive_name().is_none());
});
with_full_env(Some("iris"), None, Some(""), None, || {
assert!(hive_name().is_none(), "empty string treated as unset");
});
}
#[test]
fn swarm_name_returns_some_when_env_set() {
with_full_env(Some("iris"), None, None, Some("constellat1on"), || {
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
});
}
#[test]
fn swarm_name_returns_none_when_env_unset_or_empty() {
with_full_env(Some("iris"), None, None, None, || {
assert!(swarm_name().is_none());
});
with_full_env(Some("iris"), None, None, Some(""), || {
assert!(swarm_name().is_none(), "empty string treated as unset");
});
}
#[test]
fn name_accessors_independent_from_domain() {
// hive_name + swarm_name surface without HYPERHIVE_HIVE_DOMAIN
// being set — the names are display labels, not derived from
// the DNS domain.
with_full_env(
Some("iris"),
None,
Some("pr1ma"),
Some("constellat1on"),
|| {
assert!(hive_domain().is_none());
assert_eq!(hive_name().as_deref(), Some("pr1ma"));
assert_eq!(swarm_name().as_deref(), Some("constellat1on"));
// qualified_label still degrades to short label without domain.
assert_eq!(qualified_label(), "iris");
},
);
}
}

348
hive-agent/src/login.rs Normal file
View file

@ -0,0 +1,348 @@
//! Login-state probe for the bind-mounted `~/.claude/` dir. The dir is
//! provided by hive-c0re and persists across container destroy/recreate so
//! OAuth tokens survive.
//!
//! "Has session" means the dir contains at least one of the credential files
//! in [`CRED_FILE_NAMES`] — the same set `/logout` (`web_ui::auth`) deletes to
//! force re-login. Keying both off one constant keeps boot detection and
//! logout in agreement: logout deliberately preserves session-history files,
//! so a "contains any regular file" check would wrongly report `Online` after
//! a logout + container recreate and burn a turn 401-ing before it reroutes.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::events::Bus;
/// Returns the Claude credentials directory for this agent. Delegates
/// to `paths::claude_dir`, which reads `$HOME/.claude`. The service
/// runs as a non-root unix user named after the agent so `$HOME`
/// resolves to `/home/<agent>` and the OAuth dir lives at
/// `/home/<agent>/.claude`. Overridable via `HYPERHIVE_CLAUDE_DIR`.
#[must_use]
pub fn default_dir() -> PathBuf {
crate::paths::claude_dir()
}
/// The credential files that constitute a logged-in claude session inside
/// [`default_dir`]. A session exists iff at least one is present; a login
/// "refresh" is a change to one of them. `/logout` (`web_ui::auth`) deletes
/// exactly these to force re-login while preserving session-history files —
/// so boot detection ([`has_session`]) and logout agree by construction.
/// Rationale + the previous wholesale-wipe shape we replaced live in
/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../docs/web-ui/agent.md)
/// (the `/api/logout` bullet).
///
/// `.credentials.json` is the actual OAuth session; `mcp-needs-auth-cache.json`
/// is claude-code's MCP-auth cache and a weaker signal. Both are kept in the
/// set only because `/logout` deletes both, so the "either present ⇒ logged
/// in" check can never disagree with a logout. (If a future edit ever removes
/// `.credentials.json` without the cache — a state `/logout` doesn't produce —
/// keying purely on `.credentials.json` would be the stronger boot signal.)
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;
};
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}")),
}
}
cleared
}
/// Login state the harness reports to its web UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginState {
/// `~/.claude/` has credentials; turn loop is running.
Online,
/// `~/.claude/` is empty; harness is up, web UI is bound, turn loop is NOT
/// running. Operator needs to complete login from the web UI.
NeedsLogin,
}
impl LoginState {
#[must_use]
pub fn from_dir(dir: &Path) -> Self {
if has_session(dir) {
Self::Online
} else {
Self::NeedsLogin
}
}
}
/// Block until the bound `~/.claude/` dir contains a session that
/// post-dates this call, polling on a `poll_ms` interval (min 2s).
/// Flips `state` to `Online` when login lands; caller resumes its
/// serve loop. Snapshots the dir at entry and only resumes when the
/// snapshot advances (mtime OR file-count change), avoiding the
/// infinite-401 loop a bare-existence check would produce when stale
/// credentials are already on disk. Mtime-snapshot resumption rationale
/// and `DirSnapshot` two-axis design: see
/// [`docs/turn-loop.md::The loop`](../../docs/turn-loop.md).
///
/// # Panics
///
/// Panics if the internal login-state lock is poisoned.
pub async fn wait_for_login(
claude_dir: &Path,
state: Arc<Mutex<LoginState>>,
bus: &Bus,
poll_ms: u64,
) {
tracing::warn!(
claude_dir = %claude_dir.display(),
"no claude session — staying in partial-run mode (web UI only)"
);
// Announce `needs_login_idle` to the bus so the sentinel file
// (`{state_dir}/hyperhive-needs-login`) gets written on every entry
// path — cold-boot, 401-mid-turn, and `/api/logout`. The host's
// `auth_failed_sentinel` reads that file to surface `needs_login`
// on the dashboard. Idempotent — `emit_status` is a `write` on a
// small empty file, so re-entering this function after a transient
// operator action is a no-op for the on-disk state.
bus.emit_status("needs_login_idle");
let snapshot = snapshot_dir(claude_dir);
let probe = Duration::from_millis(poll_ms.max(2000));
loop {
tokio::time::sleep(probe).await;
if session_refreshed(snapshot, snapshot_dir(claude_dir)) {
tracing::info!("claude session refreshed — entering turn loop");
*state.lock().unwrap() = LoginState::Online;
bus.emit_status("online");
return;
}
}
}
/// 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,
newest_mtime: Option<std::time::SystemTime>,
}
fn snapshot_dir(dir: &Path) -> DirSnapshot {
let Ok(entries) = std::fs::read_dir(dir) else {
return DirSnapshot::default();
};
let mut snap = DirSnapshot::default();
for entry in entries.flatten() {
if !is_cred_file(&entry) {
continue;
}
snap.file_count += 1;
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
if snap.newest_mtime.is_none_or(|cur| mtime > cur) {
snap.newest_mtime = Some(mtime);
}
}
snap
}
/// Has the credentials dir been written since `prev`? Used as the
/// exit condition for `wait_for_login`:
///
/// - `file_count` changed → something was added or removed, treat as
/// refresh (covers the "all files have unreadable mtime" edge case).
/// - `newest_mtime` advanced → existing file was rewritten in place
/// (the common claude re-login path).
/// - prev had no mtime (empty or all-unreadable) and now has one →
/// first useful signal we've seen, treat as refresh.
fn session_refreshed(prev: DirSnapshot, now: DirSnapshot) -> bool {
if now.file_count != prev.file_count {
return true;
}
match (prev.newest_mtime, now.newest_mtime) {
(None, Some(_)) => true,
(Some(p), Some(n)) => n > p,
_ => false,
}
}
#[cfg(test)]
mod tests {
use std::fs;
use std::time::{Duration, SystemTime};
use super::{DirSnapshot, has_session, session_refreshed, snapshot_dir};
#[test]
fn has_session_only_counts_credential_files() {
let dir = tempfile::tempdir().unwrap();
// Session-history files (what `/logout` preserves) must NOT read as a
// logged-in session — this is the logout+recreate 401 bug.
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
fs::write(dir.path().join("some-project-uuid.json"), b"{}").unwrap();
assert!(!has_session(dir.path()));
// A real credential file does.
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
assert!(has_session(dir.path()));
}
#[test]
fn snapshot_dir_ignores_non_credential_files() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("history.jsonl"), b"{}").unwrap();
let snap = snapshot_dir(dir.path());
assert_eq!(
snap.file_count, 0,
"history files must not count as session"
);
}
#[test]
fn snapshot_dir_empty_dir_is_default() {
let dir = tempfile::tempdir().unwrap();
let snap = snapshot_dir(dir.path());
assert_eq!(snap.file_count, 0);
assert!(snap.newest_mtime.is_none());
}
#[test]
fn snapshot_dir_missing_dir_is_default() {
// Defensive: a nonexistent dir must NOT panic. Bind mounts that
// disappear mid-poll (host purge during operator intervention)
// would otherwise crash the harness.
let missing = tempfile::tempdir()
.unwrap()
.path()
.join("never-created-subdir");
let snap = snapshot_dir(&missing);
assert_eq!(snap, DirSnapshot::default());
}
#[test]
fn snapshot_dir_picks_latest_mtime_and_counts_files() {
let dir = tempfile::tempdir().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("mcp-needs-auth-cache.json");
fs::write(&newer_path, b"{}").unwrap();
let snap = snapshot_dir(dir.path());
assert_eq!(snap.file_count, 2);
let newer_meta = fs::metadata(&newer_path).unwrap().modified().unwrap();
assert_eq!(snap.newest_mtime, Some(newer_meta));
}
#[test]
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();
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
}
#[test]
fn session_refreshed_stale_creds_dont_flip_immediately() {
// Stale credentials.json already exists at entry; wait_for_login
// 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();
let snapshot = snapshot_dir(dir.path());
assert_eq!(snapshot.file_count, 1);
// No change to the file → loop must NOT exit.
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
}
#[test]
fn session_refreshed_after_creds_rewrite_flips() {
// After the stale-creds snapshot, the operator's `/login/code`
// 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();
let snapshot = snapshot_dir(dir.path());
std::thread::sleep(Duration::from_millis(20));
fs::write(dir.path().join(".credentials.json"), b"{\"v\":2}").unwrap();
assert!(session_refreshed(snapshot, snapshot_dir(dir.path())));
}
#[test]
fn session_refreshed_snapshot_with_future_mtime_doesnt_flip() {
// Defensive: a snapshot set to a future timestamp (e.g. clock
// 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();
let snapshot = DirSnapshot {
file_count: 1,
newest_mtime: Some(SystemTime::now() + Duration::from_hours(1)),
};
assert!(!session_refreshed(snapshot, snapshot_dir(dir.path())));
}
#[test]
fn session_refreshed_count_change_flips_when_mtime_unreadable() {
// Defensive: if all files have unreadable `meta.modified()`
// (exotic fs / NFS), newest_mtime stays `None` forever — but
// file_count axis still catches new files appearing. Simulated
// here by forging a snapshot with file_count=1 + no mtime, then
// writing a second file.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join(".credentials.json"), b"{}").unwrap();
let forged = DirSnapshot {
file_count: 1,
newest_mtime: None,
};
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())));
}
}

View file

@ -0,0 +1,304 @@
//! `claude auth login` driver. Spawns the login command under plain stdio pipes,
//! accumulates stdout+stderr in a shared buffer (so the web UI can show
//! whatever URL/prompt claude emits), and writes paste-back codes from the
//! UI into the child's stdin.
//!
//! No PTY — we're betting `claude` produces a parseable URL on stdout and
//! accepts a code on stdin even when not on a terminal. If it refuses or
//! garbles, we'll redo this module backed by `portable-pty` (see PLAN.md
//! Phase 8).
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
const DEFAULT_CMD: &str = "claude";
const DEFAULT_ARGS: &[&str] = &["auth", "login"];
#[derive(Default)]
struct State {
/// Concatenated stdout+stderr as it streams from the child.
output: String,
/// First URL-looking substring we saw in the output. Surface this on the
/// web UI as the link the operator should open.
url: Option<String>,
/// Set when the child has exited. The web UI uses this to know whether
/// the operator can still paste a code.
finished: bool,
/// Exit status note (e.g. "exited with code 0", "killed by signal 15"),
/// shown next to a "finished" badge once the child returns.
exit_note: Option<String>,
}
/// A running `claude auth login` subprocess.
pub struct LoginSession {
child: Mutex<Child>,
/// Tokio mutex because we hold the guard across the `write_all().await`
/// in `submit_code`. The other locks are blocking-only and stay on
/// `std::sync::Mutex`.
stdin: tokio::sync::Mutex<Option<ChildStdin>>,
state: Arc<Mutex<State>>,
}
impl LoginSession {
/// Spawn the login command. The exact binary/args are configurable via
/// `HYPERHIVE_LOGIN_CMD` (single string, shell-split into argv); by
/// default we run `claude auth login`. Failing to spawn returns an error
/// before any state is registered.
///
/// # Errors
///
/// Returns an error if spawning the login command fails, or if the child's
/// stdio handles cannot be acquired.
pub fn start() -> Result<Self> {
let (cmd, args) = resolve_command();
tracing::info!(%cmd, ?args, "spawning login session");
let mut child = Command::new(&cmd)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// `claude` reads $HOME/.claude for the credentials dir. The
// harness service env sets HOME to /home/<agent> and the
// bind-mount lands the OAuth dir at the same path, so the
// child inherits the right HOME without any further wiring
// here.
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawn `{cmd}`"))?;
let stdin = child.stdin.take().context("child stdin")?;
let stdout = child.stdout.take().context("child stdout")?;
let stderr = child.stderr.take().context("child stderr")?;
let state = Arc::new(Mutex::new(State::default()));
tokio::spawn(pump(BufReader::new(stdout), state.clone(), "stdout"));
tokio::spawn(pump(BufReader::new(stderr), state.clone(), "stderr"));
Ok(Self {
child: Mutex::new(child),
stdin: tokio::sync::Mutex::new(Some(stdin)),
state,
})
}
/// Write `code` (plus a newline) to the child's stdin. Returns an error
/// if the stdin has already been closed (e.g. after the child exited or
/// after a prior submission consumed it).
///
/// # Errors
///
/// Returns an error if the login stdin is already closed, or if writing
/// to or flushing the stdin pipe fails.
pub async fn submit_code(&self, code: &str) -> Result<()> {
let mut guard = self.stdin.lock().await;
let stdin = guard.as_mut().context("login stdin already closed")?;
let line = format!("{}\n", code.trim());
stdin
.write_all(line.as_bytes())
.await
.context("write code to claude stdin")?;
stdin.flush().await.context("flush claude stdin")?;
Ok(())
}
/// Close stdin so claude sees EOF (useful if it's waiting for more input
/// after the code submit).
pub async fn close_stdin(&self) {
let _ = self.stdin.lock().await.take();
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn output(&self) -> String {
self.state.lock().unwrap().output.clone()
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn url(&self) -> Option<String> {
self.state.lock().unwrap().url.clone()
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn finished(&self) -> bool {
self.state.lock().unwrap().finished
}
/// # Panics
///
/// Panics if the internal lock is poisoned.
#[must_use]
pub fn exit_note(&self) -> Option<String> {
self.state.lock().unwrap().exit_note.clone()
}
/// Best-effort: poll the child once and update `finished`/`exit_note`.
/// Called by the web UI on each render so the state stays fresh without
/// running a dedicated reaper task.
///
/// # Panics
///
/// Panics if an internal lock is poisoned.
pub fn poll(&self) {
let mut child = self.child.lock().unwrap();
match child.try_wait() {
Ok(Some(status)) => {
let mut s = self.state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("{status}"));
}
Ok(None) => {}
Err(e) => {
let mut s = self.state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("try_wait error: {e}"));
}
}
}
/// Kill the child if it's still running. Idempotent.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn kill(&self) {
if let Err(e) = self.child.lock().unwrap().start_kill() {
tracing::warn!(error = ?e, "kill login child");
}
}
}
fn resolve_command() -> (String, Vec<String>) {
if let Ok(raw) = std::env::var("HYPERHIVE_LOGIN_CMD") {
// Whitespace-only split — no quote handling. Fine for "claude auth login"
// style overrides; if we need anything with embedded spaces we'll
// switch to shell-words.
let mut parts = raw.split_whitespace().map(str::to_owned);
if let Some(cmd) = parts.next() {
return (cmd, parts.collect());
}
}
(
DEFAULT_CMD.into(),
DEFAULT_ARGS.iter().map(|s| (*s).to_owned()).collect(),
)
}
async fn pump<R: tokio::io::AsyncRead + Unpin>(
mut reader: BufReader<R>,
state: Arc<Mutex<State>>,
tag: &'static str,
) {
let mut buf = String::new();
loop {
buf.clear();
// read_line breaks on \n; for claude's TUI output that flushes by
// line this is fine. If it ever blasts a single un-newlined blob,
// we'll miss it until EOF (acceptable for the URL surface — claude
// prints the URL on its own line).
match reader.read_line(&mut buf).await {
Ok(0) => {
state.lock().unwrap().finished = true;
break;
}
Ok(_) => {
let mut s = state.lock().unwrap();
if s.url.is_none()
&& let Some(url) = extract_url(&buf)
{
tracing::info!(%url, %tag, "login URL detected");
s.url = Some(url);
}
s.output.push_str(&buf);
}
Err(e) => {
tracing::warn!(error = ?e, %tag, "login pump read error");
let mut s = state.lock().unwrap();
s.finished = true;
s.exit_note = Some(format!("pump {tag} error: {e}"));
break;
}
}
}
}
/// Return the first `https://…` substring on the line, terminating at any
/// ASCII whitespace. Good enough for capturing claude's OAuth link without a
/// regex dependency.
fn extract_url(line: &str) -> Option<String> {
let start = line.find("https://")?;
let tail = &line[start..];
let end = tail
.find(|c: char| c.is_ascii_whitespace())
.unwrap_or(tail.len());
let url = tail[..end].trim_end_matches(['.', ',', ')', ']']);
if url.len() > "https://".len() {
Some(url.to_owned())
} else {
None
}
}
/// Helper used by the web UI to gate "is there a session running right now"
/// without holding both this module's mutex and the `AppState`'s at once.
///
/// # Panics
///
/// Panics if the internal lock is poisoned.
pub fn drop_if_finished(slot: &Mutex<Option<Arc<LoginSession>>>) {
let mut guard = slot.lock().unwrap();
if let Some(s) = guard.as_ref() {
s.poll();
if s.finished() {
*guard = None;
}
}
}
impl Drop for LoginSession {
fn drop(&mut self) {
// kill_on_drop on the Command also ensures the child dies, but we
// belt-and-brace it in case the runtime detaches.
let _ = self.child.lock().unwrap().start_kill();
}
}
#[cfg(test)]
mod tests {
use super::extract_url;
#[test]
fn picks_first_https() {
let line = " Go to https://claude.ai/oauth/abc?xyz=1 in your browser.\n";
assert_eq!(
extract_url(line).as_deref(),
Some("https://claude.ai/oauth/abc?xyz=1"),
);
}
#[test]
fn trailing_punctuation_stripped() {
let line = "Open https://example.com/abc).\n";
assert_eq!(
extract_url(line).as_deref(),
Some("https://example.com/abc"),
);
}
#[test]
fn no_url() {
assert_eq!(extract_url("nothing here\n"), None);
}
}

705
hive-agent/src/main.rs Normal file
View file

@ -0,0 +1,705 @@
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
//! claude turn per message. There is one role: agent. The `Surface`
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
//! Architecture lives in
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
//!
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
//! before lib + bin were collapsed into one) plus the serve loop.
mod client;
mod events;
mod forge_notify;
mod harness_state;
mod identity;
mod login;
mod login_session;
mod mcp_config;
mod paths;
mod plugins;
mod prompt;
mod serve_common;
mod stats;
mod turn;
mod turn_stats;
mod vacuum;
mod web_ui;
/// Default socket path inside the container — bind-mounted by `hive-c0re`.
const DEFAULT_SOCKET: &str = "/run/hive/mcp.sock";
/// Default web UI port — used when `HIVE_PORT` env is unset.
const DEFAULT_WEB_PORT: u16 = 8042;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::events::{Bus, LiveEvent, TurnState};
use crate::login::LoginState;
use crate::turn_stats::TurnStats;
use anyhow::Result;
use clap::Parser;
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
#[derive(Parser)]
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
struct Cli {
/// Path to the per-agent MCP socket (bind-mounted from the host).
#[arg(long, default_value = DEFAULT_SOCKET)]
socket: PathBuf,
/// Inbox poll interval in milliseconds.
#[arg(long, default_value_t = 1000)]
poll_ms: u64,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let cli = Cli::parse();
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
}
// ---------- shared turn helpers ----------
/// Surface a `SYSTEM_SENDER` message in the live event bus + tracing
/// log. Both agents and the manager receive `QuestionAnswered`,
/// `ContainerCrash`, reparent notifications, and friends; the parse
/// and log path is identical. Quiet no-op when `from` isn't
/// `SYSTEM_SENDER`.
fn log_system_event(bus: &Bus, from: &str, body: &str) {
if from != SYSTEM_SENDER {
return;
}
let parsed = serde_json::from_str::<HelperEvent>(body).ok();
if let Some(event) = parsed {
tracing::info!(?event, "helper event");
} else {
tracing::info!(%from, %body, "system message");
}
bus.emit(LiveEvent::Note {
text: format!("[system] {body}"),
});
}
/// Body string for the turn-failure notification we route to
/// `<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
/// misconfigured harness still produces a parseable line.
fn format_turn_failure(err: &anyhow::Error) -> String {
let who = crate::identity::qualified_label();
let who = if who.is_empty() {
"<unknown>".to_owned()
} else {
who
};
format!("[system] `{who}` claude turn failed:\n{err:#}")
}
/// Check for the `hyperhive-continue` sentinel under the state dir
/// (dropped by the `request_next_turn` MCP tool). Returns true and
/// consumes the file when present; false otherwise. Caller fires
/// the role-specific `Wake` request — the sentinel itself is wire-
/// agnostic so this helper lives outside both surfaces.
fn consume_continue_sentinel() -> bool {
let sentinel = crate::paths::state_dir().join("hyperhive-continue");
if !sentinel.exists() {
return false;
}
if let Err(e) = std::fs::remove_file(&sentinel) {
tracing::warn!(error = %e, "consume_continue_sentinel: remove sentinel failed");
return false;
}
true
}
/// What a finished turn tells the serve loop to do next. Replaces the
/// bare `auth_failed` bool so the loop can also act on a pending
/// `request_next_turn` without round-tripping a synthetic message
/// through the broker.
struct TurnControl {
/// The turn ended in `AuthFailed` — caller parks on login.
auth_failed: bool,
/// `request_next_turn` was called during the turn (the
/// `hyperhive-continue` sentinel was dropped + consumed).
continue_requested: bool,
/// Inbox unread count observed right after the turn. Used to
/// decide whether a self-continue is actually needed.
pending: u64,
}
/// Decide whether the serve loop should drive a self-continue turn
/// in-process. A continue is only "needed" when nothing else will
/// wake the agent: if real messages are already pending they drive
/// the next turn(s) and the continue is dropped (matches the
/// `request_next_turn` contract — "no effect if a new inbox message
/// arrives before this turn ends"). Auth-failed parks the loop on
/// login, so it suppresses the continue too.
fn should_self_continue(ctrl: &TurnControl) -> bool {
ctrl.continue_requested && !ctrl.auth_failed && ctrl.pending == 0
}
/// Synthesize the `from: "self"` / `body: "continue"` message that a
/// `request_next_turn` self-continue drives. Built in-process rather
/// than fetched from the broker — it never touches the send/recv
/// path, so it doesn't persist to sqlite or pollute the inbox.
/// `id = 0` is a non-broker sentinel: the synthetic message
/// has no DB row, and `AckTurn` keys off the recipient's in-flight
/// list (which is empty here) rather than this id.
fn synthetic_continue() -> hive_sh4re::DeliveredMessage {
hive_sh4re::DeliveredMessage {
from: "self".into(),
body: "continue".into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
/// Synthetic message that drives the single stop-checkpoint turn when c0re
/// signals a graceful stop. The agent gets one final turn to flush durable
/// `/state` before the container is stopped; new inbound is already fenced.
fn graceful_stop_message() -> hive_sh4re::DeliveredMessage {
hive_sh4re::DeliveredMessage {
from: "graceful-stop".into(),
body: "You are being gracefully stopped — the container will shut down after this turn, \
and new inbound messages are already fenced. This is your one checkpoint turn: \
flush anything worth keeping into your durable /state files now — update your \
notes / CLAUDE.md / TODO.md with in-flight task state, decisions made, important \
file paths, and whatever you'd need to resume cleanly later with only a summary \
of this conversation to go on. Do not start new work or reply to anyone; just \
write your notes and end your turn. The session may be compacted after this turn \
so a later cold start resumes cheaply."
.into(),
id: 0,
redelivered: false,
in_reply_to: None,
}
}
// ---------- surface trait ----------
/// What a `Recv` long-poll returned. Decoupled from the per-role
/// Response enum so `serve_loop` can pattern-match without seeing
/// either `AgentResponse` or `ManagerResponse` directly.
enum RecvOutcome {
/// Long-poll returned at least one message; first one is detached.
Message(hive_sh4re::DeliveredMessage),
/// Long-poll timed out cleanly (empty `Messages` response). Caller
/// sleeps then retries.
Empty,
/// Wire returned an error / unexpected variant. Caller logs +
/// retries; the surface impl is responsible for tracing the
/// detail before returning this.
TransportError,
/// c0re signalled a graceful stop for this agent. The serve loop runs
/// one stop-checkpoint turn (flush durable `/state`), reports
/// `GracefulStopComplete`, and exits so the container can be stopped.
GracefulStop,
}
/// Wire surface abstraction. `AgentSurface` is the only impl — the trait
/// exists to keep the turn loop generic and testable. Every function that
/// talks to the broker goes through this so there are zero hard-coded
/// `AgentRequest` / `AgentResponse` references in the turn loop itself.
trait Surface {
/// Ack the in-flight turn. Logs warnings on transport/broker
/// errors but never propagates — turn loop continues either way.
fn ack_turn(socket: &Path) -> impl Future<Output = ()>;
/// Requeue any messages that were "in-flight" (delivered but not
/// ack'd) — fires on harness boot to recover from a crash mid-turn.
fn requeue_inflight(socket: &Path) -> impl Future<Output = ()>;
/// Current inbox unread count via `Status`. Returns 0 on any
/// transport/wire error so the caller falls through cleanly.
fn inbox_unread(socket: &Path) -> impl Future<Output = u64>;
/// `(open_threads, open_reminders)` for the post-turn stats row.
/// Either field is `None` when the underlying request errors.
fn post_turn_counts(socket: &Path) -> impl Future<Output = (Option<u64>, Option<u64>)>;
/// Tell c0re the graceful-stop checkpoint is done and the harness is
/// exiting its serve loop (fire-and-forget; logs on error). Lets the
/// `GracefulStop` orchestration stop the container without waiting out
/// its timeout fallback.
fn graceful_stop_complete(socket: &Path) -> impl Future<Output = ()>;
/// Send a message addressed to `<parent>` (broker resolves the
/// sentinel via `topology::parent_of` at delivery time; root
/// agents/manager fall through to operator).
fn send_to_parent(socket: &Path, body: String) -> impl Future<Output = ()>;
/// Long-poll the broker for the next message. Wraps the
/// `Messages`/empty/error trichotomy in `RecvOutcome` so the
/// generic `serve_loop` doesn't need the per-role Response enum
/// at all.
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
}
// ---------- AgentSurface ----------
/// Zero-sized type tag for the agent wire surface.
/// Talks `AgentRequest` / `AgentResponse`.
struct AgentSurface;
/// Issue an `Ok`-expecting fire-and-forget broker request, logging any
/// rejection / unexpected response / transport error under `label`. Shared by
/// the `Surface` methods that don't need the reply (`ack_turn`,
/// `requeue_inflight`, `graceful_stop_complete`).
async fn fire_and_forget(socket: &Path, req: AgentRequest, label: &str) {
match client::request::<_, AgentResponse>(socket, &req).await {
Ok(AgentResponse::Ok) => {}
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "{label} rejected by broker");
}
Ok(other) => tracing::warn!(?other, "{label} unexpected response"),
Err(e) => tracing::warn!(error = ?e, "{label} transport error"),
}
}
impl Surface for AgentSurface {
async fn ack_turn(socket: &Path) {
fire_and_forget(socket, AgentRequest::AckTurn, "ack_turn").await;
}
async fn requeue_inflight(socket: &Path) {
fire_and_forget(socket, AgentRequest::RequeueInflight, "requeue_inflight").await;
}
async fn graceful_stop_complete(socket: &Path) {
fire_and_forget(
socket,
AgentRequest::GracefulStopComplete,
"graceful_stop_complete",
)
.await;
}
async fn inbox_unread(socket: &Path) -> u64 {
match client::request::<_, AgentResponse>(socket, &AgentRequest::Status).await {
Ok(AgentResponse::Status { unread }) => unread,
_ => 0,
}
}
async fn post_turn_counts(socket: &Path) -> (Option<u64>, Option<u64>) {
let threads = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::GetLooseEnds { agent: None },
)
.await
{
Ok(AgentResponse::LooseEnds { loose_ends }) => u64::try_from(loose_ends.len()).ok(),
_ => None,
};
let reminders = match client::request::<_, AgentResponse>(
socket,
&AgentRequest::CountPendingReminders { agent: None },
)
.await
{
Ok(AgentResponse::PendingRemindersCount { count }) => Some(count),
_ => None,
};
(threads, reminders)
}
async fn send_to_parent(socket: &Path, body: String) {
let res = client::request::<_, AgentResponse>(
socket,
&AgentRequest::Send {
to: hive_sh4re::PARENT_RECIPIENT.into(),
body,
in_reply_to: None,
},
)
.await;
if let Err(e) = res {
tracing::warn!(error = ?e, "failed to notify parent of turn failure");
}
}
async fn recv_next(socket: &Path) -> RecvOutcome {
let recv: Result<AgentResponse> = client::request(
socket,
&AgentRequest::Recv {
wait_seconds: Some(180),
max: None,
},
)
.await;
match recv {
Ok(AgentResponse::Messages { messages, .. }) if !messages.is_empty() => {
let first = messages.into_iter().next().expect("checked non-empty");
RecvOutcome::Message(first)
}
Ok(AgentResponse::Messages { .. }) => RecvOutcome::Empty,
Ok(AgentResponse::GracefulStop) => RecvOutcome::GracefulStop,
Ok(AgentResponse::Err { message }) => {
tracing::warn!(%message, "recv error");
RecvOutcome::TransportError
}
Ok(other) => {
tracing::warn!(?other, "recv produced unexpected response kind");
RecvOutcome::TransportError
}
Err(e) => {
tracing::warn!(error = ?e, "recv failed; retrying");
RecvOutcome::TransportError
}
}
}
}
// ---------- generic turn loop ----------
/// Boot — wires up the web UI, login state, stats, plugins, forge
/// notifier, and either drops into `serve_loop` directly (`Online`) or
/// parks on the login flow first (`NeedsLogin`). See
/// `docs/turn-loop.md::Boot wiring`.
async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
let port = std::env::var("HIVE_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(DEFAULT_WEB_PORT);
// `HIVE_LABEL` is set unconditionally by the meta-flake envelope
// for any container-deployed agent; the `"hive"` fallback here
// covers standalone `nix run .#hive` invocations and pre-meta
// dev shells. Role-independent: no semantic reason for the
// fallback to differ when the env var is missing.
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive".into());
let claude_dir = login::default_dir();
let initial = LoginState::from_dir(&claude_dir);
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
let login_state = Arc::new(Mutex::new(initial));
let bus = Bus::new();
let stats = TurnStats::open_default();
if let Some(s) = &stats {
let (ctx, cost) = s.last_usage();
if ctx.is_some() || cost.is_some() {
bus.seed_usage(ctx, cost);
}
}
let files = turn::TurnFiles::prepare(socket, &label).await?;
// Plugin install failures come back as a Vec<String> — route each
// through `<parent>` via the `send_to_parent` failure-notify path.
// The broker resolves `<parent>` per `topology::parent_of`;
// root agents fall through to operator.
for failure in plugins::install_configured().await {
S::send_to_parent(socket, failure).await;
}
tokio::spawn(crate::forge_notify::run(socket.to_path_buf()));
// Agent-side cleanup of this agent's own harness artifacts (completed
// bash-task files + verbose event rows). Runs here, not host-side in
// hive-c0re, because the files are agent-owned — see `vacuum` module docs.
tokio::spawn(crate::vacuum::run());
// Log web_ui::serve's error instead of dropping it. A bare
// `tokio::spawn(web_ui::serve(...))` discards the JoinHandle, so
// any Err (e.g. EACCES from `bind_unix` when HIVE_WEB_SOCKET points
// at a dir the agent user can't write) vanishes — leaving an
// operator with no log line and no socket, debuggable only by
// staring at lifecycle.rs.
let web_ui_args = (
label.clone(),
port,
login_state.clone(),
bus.clone(),
socket.to_path_buf(),
);
tokio::spawn(async move {
let (label, port, login_state, bus, socket) = web_ui_args;
if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await {
tracing::error!(error = %e, "web_ui::serve exited with error");
}
});
if matches!(initial, LoginState::NeedsLogin) {
login::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await;
} else {
// Clear any stale `hyperhive-needs-login` sentinel left over
// from a prior boot — `online` status writes the sentinel
// cleanup in `Bus::emit_status`.
bus.emit_status("online");
}
serve_loop::<S>(
socket,
Duration::from_millis(poll_ms),
login_state,
claude_dir,
bus,
stats,
&files,
)
.await
}
/// The long-running message loop. Long-polls the broker via
/// `S::recv_next`, drives a turn per message, parks on auth-failed,
/// otherwise retries.
#[allow(
clippy::too_many_arguments,
reason = "the harness's long-lived deps threaded into one serve loop, \
wired once from main; bundling into a struct would just move the \
same fields one level out (cf. Coordinator::open)"
)]
async fn serve_loop<S: Surface>(
socket: &Path,
interval: Duration,
login_state: Arc<Mutex<LoginState>>,
claude_dir: std::path::PathBuf,
bus: Bus,
stats: Option<TurnStats>,
files: &turn::TurnFiles,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "harness serve");
S::requeue_inflight(socket).await;
// The durable claude session, built once and reused for every turn +
// idle compaction below (it's effectively stateless).
let session = turn::make_session(&bus);
// Set when a turn calls `request_next_turn` and no real work is
// pending — the next iteration drives this synthetic message
// in-process instead of long-polling the broker. Never
// persisted: it lives entirely in this loop's stack.
let mut self_continue: Option<hive_sh4re::DeliveredMessage> = None;
loop {
let next = match self_continue.take() {
Some(msg) => msg,
None => match S::recv_next(socket).await {
RecvOutcome::Message(first) => first,
RecvOutcome::Empty => {
// Idle: no message this poll. Service a queued operator
// `/compact` here so it runs even when no turn is driving
// (the in-flight case is handled at the end of drive_turn).
let compacted = turn::run_pending_compact(files, &bus, &session).await;
if !compacted {
tokio::time::sleep(interval).await;
}
continue;
}
RecvOutcome::TransportError => {
// `recv_next` already logged the detail; just retry.
// No backoff: the long-poll wait is itself the throttle.
continue;
}
RecvOutcome::GracefulStop => {
// c0re fenced our inbox and wants a clean stop. Run one
// checkpoint turn so the agent flushes durable /state,
// report completion, then exit the loop → the harness
// process ends and the container can be stopped.
tracing::info!(
"graceful stop signalled — running stop-checkpoint turn, then exiting"
);
let _ = handle_turn::<S>(
socket,
&bus,
stats.as_ref(),
files,
&session,
graceful_stop_message(),
)
.await;
S::graceful_stop_complete(socket).await;
return Ok(());
}
},
};
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(
&claude_dir,
login_state.clone(),
&bus,
u64::try_from(interval.as_millis()).unwrap_or(2000),
)
.await;
} else if should_self_continue(&ctrl) {
tracing::info!("request_next_turn: driving self-continue turn in-process");
self_continue = Some(synthetic_continue());
}
}
}
/// Drive a single turn: emit boot-of-turn events, run claude, ack on
/// success / requeue on rate-limit-or-401 / notify parent on failure,
/// record stats, then pick up the `request_next_turn` sentinel if it's
/// been dropped during the turn. Returns a `TurnControl` carrying the
/// auth-failed flag, whether a self-continue was requested, and the
/// post-turn inbox count — the serve loop decides what to do next.
async fn handle_turn<S: Surface>(
socket: &Path,
bus: &Bus,
stats: Option<&TurnStats>,
files: &turn::TurnFiles,
session: &turn::AgentSession,
first: hive_sh4re::DeliveredMessage,
) -> TurnControl {
let from = first.from;
let body = first.body;
let redelivered = first.redelivered;
let msg_id = first.id;
log_system_event(bus, &from, &body);
tracing::info!(%from, %body, %redelivered, "inbox");
let unread = S::inbox_unread(socket).await;
bus.emit(LiveEvent::TurnStart {
from: from.clone(),
body: body.clone(),
unread,
});
bus.set_state(TurnState::Thinking);
let started_at = serve_common::now_unix();
let started_instant = std::time::Instant::now();
let model_at_start = bus.model();
let prompt = serve_common::format_wake_prompt(msg_id, &from, &body, unread, redelivered);
let outcome = turn::drive_turn(&prompt, files, bus, session).await;
turn::emit_turn_end(bus, &outcome);
bus.set_state(TurnState::Idle);
if outcome.is_ok() {
S::ack_turn(socket).await;
}
if matches!(outcome, Err(turn::TurnError::RateLimited)) {
let secs = turn::rate_limit_sleep_secs();
bus.emit_status("rate_limited");
bus.emit(LiveEvent::Note {
text: format!("API rate-limited — sleeping {secs}s before retry"),
});
tracing::warn!(sleep_secs = secs, "rate-limited; parking");
tokio::time::sleep(Duration::from_secs(secs)).await;
S::requeue_inflight(socket).await;
bus.emit_status("online");
}
if matches!(outcome, Err(turn::TurnError::ApiStall)) {
// Idle watchdog killed claude on a suspected API stall. Park briefly to
// let the API recover, then requeue — same shape as the rate-limit path.
let secs = turn::stall_sleep_secs();
bus.emit_status("api_stall");
bus.emit(LiveEvent::Note {
text: format!(
"API stall timeout — sleeping {secs}s before retry \
(tune HIVE_STALL_SLEEP_SECS; disable the watchdog with HIVE_TURN_IDLE_SECS=0)"
),
});
tracing::warn!(sleep_secs = secs, "API stall; parking before retry");
tokio::time::sleep(Duration::from_secs(secs)).await;
S::requeue_inflight(socket).await;
bus.emit_status("online");
}
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(),
});
tracing::warn!("auth-failed; parking until re-login");
S::requeue_inflight(socket).await;
}
if matches!(outcome, Err(turn::TurnError::PromptTooLong)) {
// `drive_turn` already archived the session; requeue the message so it
// redelivers into the fresh session (which fits — the wake prompt is
// tiny, the overflow was the now-cleared context). No status park: the
// agent is healthy, it just needs one more delivery.
tracing::warn!("prompt-too-long; session archived, requeueing message for a fresh turn");
S::requeue_inflight(socket).await;
}
if matches!(outcome, Err(turn::TurnError::SessionNotFound)) {
// "Shouldn't happen": resume missed and the lib's create self-heal
// didn't resolve it. Requeue rather than ack-and-drop so the wake
// message isn't silently lost; the next turn creates the session fresh.
tracing::warn!("session-not-found; 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 {
// Fresh session this turn → mint a `sessions` row and set its id on
// the bus so this turn (and subsequent ones until the next fresh
// start) stamp `turn_stats.session_id`. Takes the one-shot flag
// `run_claude` set when it suppressed `--continue`.
if bus.take_fresh_session() {
let sid = stats.start_session(started_at, &model_at_start);
bus.set_session_id(sid);
}
let ended_at = serve_common::now_unix();
let duration_ms = i64::try_from(started_instant.elapsed().as_millis()).unwrap_or(i64::MAX);
let (open_threads, open_reminders) = S::post_turn_counts(socket).await;
let row = serve_common::build_row(serve_common::TurnRowArgs {
started_at,
ended_at,
duration_ms,
model: model_at_start,
wake_from: from.clone(),
outcome: &outcome,
bus,
open_threads_count: open_threads,
open_reminders_count: open_reminders,
});
stats.record(&row);
}
let pending = S::inbox_unread(socket).await;
if pending > 0 {
tracing::info!(%pending, "pending messages after turn; fetching next");
}
TurnControl {
auth_failed: matches!(outcome, Err(turn::TurnError::AuthFailed)),
continue_requested: consume_continue_sentinel(),
pending,
}
}
#[cfg(test)]
mod continue_tests {
use super::{TurnControl, should_self_continue, synthetic_continue};
fn ctrl(auth_failed: bool, continue_requested: bool, pending: u64) -> TurnControl {
TurnControl {
auth_failed,
continue_requested,
pending,
}
}
#[test]
fn self_continue_when_requested_and_inbox_empty() {
assert!(should_self_continue(&ctrl(false, true, 0)));
}
#[test]
fn no_self_continue_when_not_requested() {
assert!(!should_self_continue(&ctrl(false, false, 0)));
}
#[test]
fn no_self_continue_when_real_messages_pending() {
// A real message will drive the next turn via recv — the
// continue is superseded, not needed (request_next_turn contract).
assert!(!should_self_continue(&ctrl(false, true, 3)));
}
#[test]
fn no_self_continue_when_auth_failed() {
// Auth-failed parks the loop on login; a queued continue must
// not jump the gate.
assert!(!should_self_continue(&ctrl(true, true, 0)));
}
#[test]
fn synthetic_continue_shape() {
let m = synthetic_continue();
assert_eq!(m.from, "self");
assert_eq!(m.body, "continue");
assert_eq!(m.id, 0);
assert!(!m.redelivered);
assert!(m.in_reply_to.is_none());
}
}

View file

@ -0,0 +1,389 @@
//! 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 (a separate binary) — the `send` allow-list check that
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
/// 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";
/// Default loopback port the built-in hyperhive MCP surface is served on
/// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable
/// via `hyperhive.mcp.httpPort`; **must match that option's default** in
/// `nix/templates/harness/`. Safe as a single fixed value across all
/// agents because each container runs in its own private network namespace,
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
/// 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/`). 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";
#[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>`.
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
/// is no per-turn stdio child for it. Merges in any extra MCP servers
/// declared via `hyperhive.extraMcpServers` (those stay stdio bridges).
#[must_use]
pub fn render_claude_config() -> String {
let mut servers = serde_json::Map::new();
// The built-in hyperhive surface is served exclusively over streamable
// HTTP by the persistent `hive-mcp-http` daemon (loopback, inside the
// agent's private network namespace). Point claude at the stable URL
// rather than 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. The port comes from `HYPERHIVE_MCP_HTTP_PORT` (always set by
// the harness); `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix
// default.
let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT")
.ok()
.and_then(|p| p.trim().parse::<u16>().ok())
.unwrap_or(DEFAULT_MCP_HTTP_PORT);
let hyperhive_entry = serde_json::json!({
"type": "http",
"url": format!("http://127.0.0.1:{port}/mcp"),
});
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")));
}
}

76
hive-agent/src/paths.rs Normal file
View file

@ -0,0 +1,76 @@
//! Per-agent path resolution for state, harness, and credential directories.
//!
//! All agents (including the manager `root`) use `/agents/{label}/state`
//! for agent-owned durable notes, and `/agents/{label}/harness` for
//! harness-internal files (`hyperhive-events.sqlite`, `hyperhive-model`, etc.)
//! that should not clutter what claude sees as "my notes dir".
//! Claude credentials live at `$HOME/.claude` (resolves to
//! `/home/<agent-name>/.claude` because the harness service runs as a
//! non-root unix user matching the agent label — see
//! `docs/persistence.md::First-boot agent-user migration`).
//!
//! All three paths can be overridden via env vars (`HYPERHIVE_STATE_DIR`,
//! `HYPERHIVE_HARNESS_DIR`, `HYPERHIVE_CLAUDE_DIR`) for dev / test scenarios.
use std::path::PathBuf;
/// Durable state directory for the current agent. Reads `HYPERHIVE_STATE_DIR`
/// first (always set by the meta flake to `/agents/{label}/state`); falls back
/// to the same pattern derived from `HIVE_LABEL` for dev/test environments
/// where the env var may not be set.
#[must_use]
pub fn state_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_STATE_DIR") {
return PathBuf::from(p);
}
let label = std::env::var("HIVE_LABEL").unwrap_or_default();
PathBuf::from(format!("/agents/{label}/state"))
}
/// Harness-internal state directory. Holds files the harness owns
/// (`hyperhive-events.sqlite`, `hyperhive-turn-stats.sqlite`,
/// `hyperhive-model`) so they do not appear inside the agent-visible
/// `/agents/{label}/state` tree. Delegates to the shared canonical
/// resolver in `hive_sh4re::paths` so the harness + every out-of-process
/// MCP daemon resolve this identically (reads `HYPERHIVE_HARNESS_DIR`,
/// then a `harness/` sibling of `HYPERHIVE_STATE_DIR`, then
/// `/agents/{HIVE_LABEL}/harness`).
#[must_use]
pub fn harness_dir() -> PathBuf {
hive_sh4re::paths::harness_dir()
}
/// Per-turn config dir for the regenerated claude-{mcp-config,settings,
/// system-prompt} files the harness drops before each turn. Set by
/// systemd via `RuntimeDirectory = "hive-config"`: a per-service runtime
/// dir owned by the agent unix user, auto-cleared on stop. Kept separate
/// from `/run/hive` (the host-owned mcp.sock bind) so the harness owns
/// its own write surface and we don't have to chown a bind-mounted dir.
/// Overridable via `HYPERHIVE_CONFIG_DIR` for dev / test scenarios.
#[must_use]
pub fn config_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_CONFIG_DIR") {
return PathBuf::from(p);
}
PathBuf::from("/run/hive-config")
}
/// Claude credentials directory for the current agent. `$HOME/.claude`
/// matches what the `claude` CLI reads at runtime — the harness sees
/// the same `$HOME` set by the per-service systemd `environment`
/// declaration (`/home/<agent>`). Falls back to `/root/.claude` for
/// dev / test environments where `HOME` isn't set so the previous
/// root-by-default shape keeps working without env wiring.
/// Overridable via `HYPERHIVE_CLAUDE_DIR` for dev / test scenarios.
#[must_use]
pub fn claude_dir() -> PathBuf {
if let Some(p) = std::env::var_os("HYPERHIVE_CLAUDE_DIR") {
return PathBuf::from(p);
}
if let Some(home) = std::env::var_os("HOME") {
let mut path = PathBuf::from(home);
path.push(".claude");
return path;
}
PathBuf::from("/root/.claude")
}

156
hive-agent/src/plugins.rs Normal file
View file

@ -0,0 +1,156 @@
//! Boot-time `claude plugin install` driver. Reads the list declared
//! via the `hyperhive.claudePlugins` NixOS option (rendered to
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
//! shells out `claude plugin install <spec>` for each entry. Runs once
//! per harness boot before the turn loop; `claude plugin install`
//! is expected to be idempotent so reinstalling on each container
//! recreate is fine. Failures log a warning but do not abort boot —
//! we'd rather start without a plugin than refuse to serve.
//!
//! Before installing, all configured marketplaces are updated so that
//! plugin specs resolve against current index data. Marketplace update
//! failures are non-fatal — stale index is better than no install attempt.
use tokio::process::Command;
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
/// an existing marketplace is treated as success (claude prints an
/// "already exists" message and exits non-zero on some versions).
/// Required before any `<plugin>@<marketplace>` install can resolve.
async fn add_marketplaces() {
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
return;
};
let sources: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
return;
}
};
for source in sources {
match Command::new("claude")
.args(["plugin", "marketplace", "add", &source])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!(source = %source, "claude plugin marketplace add ok");
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("already") {
tracing::debug!(source = %source, "marketplace already added");
} else {
tracing::warn!(
source = %source,
status = ?out.status,
stderr = %stderr,
"claude plugin marketplace add failed (non-fatal)",
);
}
}
Err(e) => {
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed");
}
}
}
}
/// Read the `hyperhive.claudePluginsAutoUpdate` flag written by the NixOS
/// module. Defaults to `false` when the file is absent or unparseable.
async fn auto_update_enabled() -> bool {
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
Err(_) => false,
}
}
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
/// on failure but does not abort the install sequence.
async fn update_marketplaces() {
match Command::new("claude")
.args(["plugin", "marketplace", "update"])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!("claude plugin marketplace update ok");
}
Ok(out) => {
tracing::warn!(
status = ?out.status,
stderr = %String::from_utf8_lossy(&out.stderr),
"claude plugin marketplace update failed (non-fatal)",
);
}
Err(e) => {
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed (non-fatal)");
}
}
}
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
/// Returns a list of human-readable failure messages so the caller can
/// route them through their own per-role surface (turn-failure-style
/// notification, see `Surface::send_to_parent`). Wire-agnostic: the
/// caller picks the recipient via the same `<parent>` sentinel that
/// failure-notify uses everywhere else.
pub async fn install_configured() -> Vec<String> {
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
return Vec::new();
};
let specs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
return Vec::new();
}
};
if specs.is_empty() {
return Vec::new();
}
add_marketplaces().await;
if auto_update_enabled().await {
update_marketplaces().await;
} else {
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
}
let mut failures = Vec::new();
for spec in specs {
match Command::new("claude")
.args(["plugin", "install", &spec])
.output()
.await
{
Ok(out) if out.status.success() => {
tracing::info!(spec = %spec, "claude plugin install ok");
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
tracing::warn!(
spec = %spec,
status = ?out.status,
stderr = %stderr,
"claude plugin install failed",
);
failures.push(format!(
"claude plugin install failed for `{spec}`:\n{}",
stderr.trim()
));
}
Err(e) => {
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed");
failures.push(format!(
"claude plugin install spawn failed for `{spec}`: {e}"
));
}
}
}
failures
}

384
hive-agent/src/prompt.rs Normal file
View file

@ -0,0 +1,384 @@
//! System-prompt renderer. Single `prompts/system.md` with
//! HTML-comment markers gating role-specific blocks; this module
//! assembles the final prompt (always "agent" role — there is only one
//! role). Marker grammar + placeholder substitution rules in
//! `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
/// Assemble the system prompt for a given label + pronouns + optional hive /
/// swarm display names. Pure function — no I/O. Splits out from
/// [`write_system_prompt`] so the marker logic + substitution is unit-testable
/// in isolation. The caller supplies the template body so tests can pass an
/// inline fixture and production reads it once at harness startup via
/// [`hive_sh4re::assets::prompt_template`]
/// (`$HIVE_ASSETS_DIR/prompts/system.md`). Substitution placeholders +
/// marker grammar documented in
/// `docs/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`).
#[must_use]
pub fn render(
template: &str,
label: &str,
operator_pronouns: &str,
hive_name: Option<&str>,
swarm_name: Option<&str>,
docs_dir: Option<&str>,
) -> String {
let body = filter_role_blocks(template, "agent");
let qualified = crate::identity::qualify(label);
let hive_identity = hive_name
.filter(|n| !n.is_empty())
.map_or(String::new(), |n| format!(" on hive `{n}`"));
let swarm_identity = swarm_name
.filter(|n| !n.is_empty())
.map_or(String::new(), |n| format!(" in swarm `{n}`"));
let rendered = body
.replace("{label}", label)
.replace("{qualified_label}", &qualified)
.replace("{operator_pronouns}", operator_pronouns)
.replace("{hive_identity}", &hive_identity)
.replace("{swarm_identity}", &swarm_identity);
// When the reference docs are mounted in-container (`hyperhive.docs.enable`
// wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer
// sentence so the agent knows they exist. Additive: it doesn't replace the
// agent's own memory/project instructions. Absent env → no change.
match docs_dir.filter(|d| !d.is_empty()) {
Some(dir) => format!(
"{rendered}\n\nThe hyperhive reference docs (the repo `docs/` tree \
describing this live system) are mounted read-only at `{dir}` — read \
them (start at the index, then the topic file for the area you're \
touching) rather than guessing.\n"
),
None => rendered,
}
}
/// 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.
let mut active_role: Option<&str> = None;
for line in template.lines() {
let trimmed = line.trim();
if let Some(role) = parse_open_marker(trimmed) {
active_role = Some(role);
continue;
}
if parse_close_marker(trimmed).is_some() {
active_role = None;
continue;
}
if active_role.is_none_or(|role| role == target) {
out.push_str(line);
out.push('\n');
}
}
out
}
/// `<!-- role:agent -->` → `Some("agent")`. Anything else returns
/// None. Whitespace inside the marker is tolerated so a future
/// author's `<!--role:foo-->` (no spaces) still parses; the dashboard
/// markdown renderer is equally lenient. Close tags (`/role:...`)
/// can't accidentally match — the `strip_prefix("role:")` rejects
/// the leading slash before we'd ever see it.
fn parse_open_marker(line: &str) -> Option<&str> {
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
let role = inside.strip_prefix("role:")?.trim();
Some(role)
}
/// `<!-- /role:agent -->` → `Some("agent")`. Mirror of
/// [`parse_open_marker`] for the closing tag.
fn parse_close_marker(line: &str) -> Option<&str> {
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
inside.strip_prefix("/role:").map(str::trim)
}
/// Write the assembled prompt to a stable path next to the harness
/// socket and return the path. The Rust harness passes this path to
/// `claude --system-prompt-file` so the per-turn prompts only carry
/// the role + tools instructions in the system slot; per-turn prompts
/// become much smaller (just the wake-message body).
///
/// # Errors
///
/// Returns an error if the system prompt file cannot be written.
pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf> {
let parent = crate::paths::config_dir();
tokio::fs::create_dir_all(&parent).await.ok();
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
let template_path = hive_sh4re::assets::prompt_template();
let template = tokio::fs::read_to_string(&template_path)
.await
.with_context(|| {
format!(
"read claude system prompt template from {}",
template_path.display()
)
})?;
// Surface hive + swarm display names in the prompt opener when
// configured. Both `None` falls back to the non-identity wording
// verbatim (single-hive deployments see no diff).
let hive_name = crate::identity::hive_name();
let swarm_name = crate::identity::swarm_name();
// `hyperhive.docs.enable` sets HIVE_DOCS_DIR (and the harness passes it to
// claude via `--add-dir`); when present, render() appends a pointer line.
let docs_dir = std::env::var("HIVE_DOCS_DIR")
.ok()
.filter(|d| !d.is_empty());
let body = render(
&template,
label,
&pronouns,
hive_name.as_deref(),
swarm_name.as_deref(),
docs_dir.as_deref(),
);
let path = parent.join("claude-system-prompt.md");
tokio::fs::write(&path, body).await?;
tracing::info!(path = %path.display(), "wrote claude system prompt");
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::LazyLock;
// The production template lives at `$HIVE_ASSETS_DIR/prompts/system.md`
// and is loaded at runtime. The unit tests below want to assert
// against the actual production wording (so the renderer + tool
// surface stay honest), so they resolve the same path at test
// runtime via two fallbacks:
// 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract
// production uses. The flake's `checks.cargo-test` derivation
// sets this to the `hyperhive-assets` output so `cargo test`
// inside the nix sandbox finds the file without needing
// `prompts/` in the cargo source tree. `packages.default`
// explicitly does NOT carry the assets dep, so a prompt edit
// doesn't bust the binary derivation — only this test check.
// 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain
// `cargo test --workspace` from a checked-out repo where the
// env var isn't set; `env!` is a compile-time string lookup,
// no file open at compile, so this still doesn't pull
// `prompts/` into the build hash.
// The combined effect is that the flake's `cleanSrc` no longer
// unions `./hive-agent/prompts` — tweaks to system.md don't bust
// the cargo cache anymore.
static PRODUCTION_TEMPLATE: LazyLock<String> = LazyLock::new(|| {
let path = match std::env::var("HIVE_ASSETS_DIR") {
Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"),
_ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(),
};
std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}"))
});
const SAMPLE: &str = "\
shared opener
<!-- role:agent -->
agent-only line
<!-- /role:agent -->
<!-- role:manager -->
manager-only line
<!-- /role:manager -->
shared closer
";
#[test]
fn filter_keeps_shared_and_target_role() {
let agent = filter_role_blocks(SAMPLE, "agent");
assert!(agent.contains("shared opener"));
assert!(agent.contains("agent-only line"));
assert!(!agent.contains("manager-only line"));
assert!(agent.contains("shared closer"));
// Marker lines themselves are stripped — no `<!--` left behind.
assert!(!agent.contains("<!--"));
}
#[test]
fn filter_for_manager_picks_manager_block() {
let manager = filter_role_blocks(SAMPLE, "manager");
assert!(manager.contains("shared opener"));
assert!(!manager.contains("agent-only line"));
assert!(manager.contains("manager-only line"));
assert!(manager.contains("shared closer"));
assert!(!manager.contains("<!--"));
}
#[test]
fn parse_open_marker_handles_whitespace_variants() {
assert_eq!(parse_open_marker("<!-- role:agent -->"), Some("agent"));
assert_eq!(parse_open_marker("<!--role:agent-->"), Some("agent"));
assert_eq!(parse_open_marker("<!-- role:manager -->"), Some("manager"));
// Close tags must NOT match open-tag parser.
assert_eq!(parse_open_marker("<!-- /role:agent -->"), None);
// Non-markers pass through (return None).
assert_eq!(parse_open_marker("just text"), None);
assert_eq!(parse_open_marker("<!-- not a role -->"), None);
}
#[test]
fn parse_close_marker_handles_whitespace_variants() {
assert_eq!(parse_close_marker("<!-- /role:agent -->"), Some("agent"));
assert_eq!(parse_close_marker("<!--/role:manager-->"), Some("manager"));
// Open tags must NOT match close-tag parser.
assert_eq!(parse_close_marker("<!-- role:agent -->"), None);
assert_eq!(parse_close_marker("just text"), None);
}
#[test]
fn render_substitutes_label_and_pronouns() {
// Real template's first agent line — keeps the renderer
// honest about the {label} / {operator_pronouns} pair the
// harness already relied on.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "they/them", None, None, None);
assert!(rendered.contains("hyperhive agent `alice`"));
assert!(rendered.contains("**they/them** pronouns"));
assert!(!rendered.contains("{label}"));
assert!(!rendered.contains("{operator_pronouns}"));
}
#[test]
fn render_no_role_markers_in_output() {
// No raw role markers should survive into the rendered prompt.
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
assert!(!rendered.contains("<!-- role:"));
assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear.
assert!(rendered.contains("mcp__hyperhive__recv"));
assert!(rendered.contains("mcp__hyperhive__ask"));
}
#[test]
fn render_uses_agent_opener() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, None);
assert!(rendered.starts_with("You are hyperhive agent"));
}
// Inline fixture for the {hive_identity} / {swarm_identity}
// placeholders. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
// builds at derivation time — a fresh placeholder added on the
// source side isn't in the shipped asset until the flake rebuilds,
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
// below carries just enough of the opener shape to exercise the
// substitution logic; nothing here depends on the production
// template's flavor markers.
const IDENTITY_FIXTURE: &str = "\
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**.
";
#[test]
fn render_substitutes_hive_identity_when_set() {
let rendered = render(
IDENTITY_FIXTURE,
"alice",
"she/her",
Some("pr1ma"),
None,
None,
);
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm"));
// No raw placeholder leaks.
assert!(!rendered.contains("{hive_identity}"));
assert!(!rendered.contains("{swarm_identity}"));
}
#[test]
fn render_substitutes_swarm_identity_when_set() {
let rendered = render(
IDENTITY_FIXTURE,
"ruth",
"she/her",
None,
Some("constellat1on"),
None,
);
assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive"));
}
#[test]
fn render_substitutes_both_when_both_set() {
let rendered = render(
IDENTITY_FIXTURE,
"iris",
"she/her",
Some("pr1ma"),
Some("constellat1on"),
None,
);
// Order: hive then swarm, both inline before "in a multi-agent
// system" — keeps the opener grammar intact.
assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`"));
}
#[test]
fn render_omits_identity_when_unset() {
// None / None must round-trip the non-identity opener verbatim
// — single-hive deployments see zero diff.
let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", None, None, None);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}"));
assert!(!rendered.contains("{swarm_identity}"));
}
#[test]
fn render_treats_empty_identity_as_none() {
// Defensive: an env var set to empty string round-trips
// through `identity::hive_name()` as None (the accessor
// filters empty), but `render` should still no-op on a
// direct `Some("")` from a test fixture or a future caller.
let rendered = render(
IDENTITY_FIXTURE,
"alice",
"she/her",
Some(""),
Some(""),
None,
);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
}
#[test]
fn render_appends_docs_pointer_when_docs_dir_set() {
let rendered = render(
&PRODUCTION_TEMPLATE,
"alice",
"she/her",
None,
None,
Some("/run/hive-docs"),
);
// Key on a phrase unique to the pointer sentence — "reference docs"
// alone also appears in the /knowledge blurb of the base template.
assert!(
rendered.contains("mounted read-only at `/run/hive-docs`"),
"expected docs pointer sentence with the dir:\n{rendered}"
);
}
#[test]
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
for docs in [None, Some("")] {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None, docs);
assert!(
!rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}"
);
}
}
}

Some files were not shown because too many files have changed in this diff Show more