diff --git a/CLAUDE.md b/CLAUDE.md index 1f446a2c..1a8b0df0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,16 @@ hand-maintained per-file tree drifts out of sync with the code. forge / matrix provisioning, per-container stats, and the axum operator dashboard (`dashboard.rs`). Largest crate. - **`hive-ag3nt/`** — in-container harness; one `hive` binary for every - agent. Turn loop (`turn.rs`), embedded MCP server (`mcp.rs`), per-agent - web UI (`web_ui.rs`), event + turn-stats sqlite sinks, login flow, - system-prompt renderer, forge-notify subscriber. + agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude` + driver, embedded MCP server (`mcp.rs`), per-agent web UI (`web_ui/` + module dir), event + turn-stats sqlite sinks, login flow, system-prompt + renderer, forge-notify subscriber. +- **`hive-claude/`** — reusable, app-agnostic driver for headless + `claude --print`: spawns the CLI, streams + classifies stream-json, + parses per-turn `Telemetry`, and drives a durable self-compacting + `InfiniteSession` (name + `SessionStore` + `CompactionPolicy`). Uses + `thiserror` (it's a library); the `hive-*` binaries consume it with + `anyhow`. See `hive-claude/README.md`. - **`hive-priv/`** — minimal root privileged-helper, socket-activated at `/run/hive/priv.sock`; performs the few root operations (bind-mount edits, nsenter) the unprivileged `hive-c0re` delegates to it. See diff --git a/Cargo.lock b/Cargo.lock index 744a340f..315feef0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1333,6 +1333,7 @@ dependencies = [ "axum", "clap", "futures-util", + "hive-claude", "hive-sh4re", "reqwest", "rmcp", @@ -1392,6 +1393,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "hive-claude" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "hive-forge" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 247e3637..128ba120 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "hive-ag3nt", "hive-bash-mcp", "hive-c0re", + "hive-claude", "hive-forge", "hive-matrix-mcp", "hive-priv", @@ -35,6 +36,8 @@ chrono = { version = "0.4", default-features = false, features = [ clap = { version = "4", features = ["derive"] } clap_complete = "4" hive-sh4re = { path = "hive-sh4re" } +hive-claude = { path = "hive-claude" } +thiserror = "2" tower-http = { version = "0.6", features = ["fs"] } rmcp = { version = "1.7", default-features = false, features = [ "server", diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 74613421..00000000 --- a/TODO.md +++ /dev/null @@ -1,6 +0,0 @@ -# Hyperhive TODOs - -The backlog moved to the forge issue tracker: - - -Operator/agent trust-boundary design rationale: [`docs/boundary.md`](docs/boundary.md). diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 7e704198..54814b42 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -137,13 +137,25 @@ if a new inbox message arrives before this turn ends"). The ``` claude --print --verbose --output-format stream-json --model \ - --effort --continue \ + --effort --resume # or --name <title> on first use \ --system-prompt-file /run/hive/claude-system-prompt.md \ --mcp-config /run/hive/claude-mcp-config.json --strict-mcp-config \ --tools <builtins> --allowedTools <builtins+mcp> # wake prompt piped over stdin ``` +**Crate split.** The generic subprocess mechanics — spawning +`claude --print`, streaming + classifying stream-json, session +lookup/archive, and the durable-session compaction loop — live in the +reusable **`hive-claude`** crate (`hive_claude::{Claude, InfiniteSession, +Attach, CompactionPolicy, PercentPolicy, Telemetry, Sink, SessionStore}`; +see `hive-claude/README.md`). `hive_ag3nt::turn` is the hyperhive **policy +layer** on top: it builds the per-turn config from the bus, bridges the +output stream onto the event bus (`BusSink`), and owns the compaction / +auto-reset / retry decisions in `drive_turn`. The lib returns everything it +parsed from a turn (usage, cost, context window, resolved model) as +`Telemetry`, which the policy layer applies to the bus. + Hive-enforced settings ship at `/etc/claude-code/managed-settings.json` (claude-code's canonical managed-settings path — precedence #1, read-only, un-overridable), wired in `nix/templates/harness-base.nix` @@ -180,45 +192,71 @@ The effective window drives watermarks and is exposed at runtime via `/api/state.context_window_tokens` so the UI can show a percentage-of-window ctx badge. -`--continue` keeps a persistent session per agent (claude stores -sessions in `~/.claude/projects/`, which is bind-mounted -persistently). Auto-compact and auto-memory are disabled via the -managed settings at `/etc/claude-code/managed-settings.json` because -hyperhive owns compaction — see [Compaction](#compaction) below. -A one-shot `--continue` suppression is available via -`POST /api/new-session` (or `/new-session` slash command in the -per-agent terminal) — `Bus::take_skip_continue()` flips an -`AtomicBool` once per turn, the next claude invocation drops -`--continue`, every subsequent turn resumes normal behaviour. +**Session identity — a constant title.** Every turn keys on one fixed, +harness-owned session title (`turn::session_title()`, default +`hive-session`, override `HIVE_SESSION_TITLE`). The durable +`hive_claude::InfiniteSession` (built once by the serve loop via +`turn::make_session`, then reused) `--resume <title>`s it; the *first* use +(bootstrap, post-archive, post-purge) misses and the session re-runs the +same prompt once with `--name <title>` to mint it. That single self-heal +rule is the whole identity system — there is **no** scraped session-id +file. Because the +title is constant, `/compact` and its post-compact retry provably target +the same session (killing the old "compact ran on a different/empty +session" bug), and a `choom` invocation in the same cwd can't hijack the +context (it won't carry our title). claude stores sessions in +`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); +`--name` writes the title into the file as a `custom-title` event, which +is what `--resume <title>` resolves against. We never pass bare +`--continue` (it resumes the *latest* session in the cwd — the hijack +vector). Auto-compact and auto-memory are disabled via the managed +settings at `/etc/claude-code/managed-settings.json` because hyperhive +owns compaction — see [Compaction](#compaction) below. + +**Session reset** is available via `POST /api/new-session` (or +`/new-session` slash command). It does *not* touch the session inline — +that would race a mid-write claude process. Instead `Bus::request_session_reset()` +sets a one-shot flag consumed at the next turn boundary by `drive_turn`, +which **archives** the current session: the backing `<uuid>.jsonl` is +renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` +resolution glob, history preserved on disk, only the file carrying *our* +title — any `choom` session sharing the cwd is left alone). The next +turn's `--resume <title>` then misses and self-heals into a fresh session. ### Compaction claude's own in-session auto-compact is off (via the managed settings -at `/etc/claude-code/managed-settings.json`); hyperhive owns it -explicitly in `turn::drive_turn`. There are two triggers: +at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The +`hive_claude::InfiniteSession` keeps the session alive across the context +window with two triggers baked into its `run`: -- **Reactive** — claude-code prints `Prompt is too long` (the - `PROMPT_TOO_LONG_MARKER`). The session is *already* past the context - window, so no turn can run on it — `drive_turn` runs `/compact` - straight away and retries the same wake-up prompt once. No +- **Reactive** — claude-code prints `Prompt is too long`. The session is + *already* past the window, so no turn can run on it — the session + `/compact`s straight away and retries the same wake-up prompt once. No notes-checkpoint turn is possible here: the detail is gone. -- **Proactive** — a turn finishes cleanly but the last inference's - context size (`Bus::last_ctx_usage().context_tokens()`) is at or - above a watermark. While the session is still healthy, `drive_turn` - injects one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` - — "context is filling up, flush durable state into `/state` now") - and *then* runs `/compact`. This gives the agent a chance to - persist in-flight task state, decisions, and file paths before the - conversation detail collapses into a summary. +- **Proactive** — a turn finishes cleanly but the last inference's context + size crossed the policy watermark. While the session is still healthy it + runs one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` — + "context is filling up, flush durable state into `/state` now") and + *then* `/compact`s, so the agent can persist in-flight state before the + detail collapses into a summary. -The compact watermark defaults to **75% of `context_window_tokens(model)`** -(dynamically derived — 150k for haiku, 750k for sonnet/opus). Override -with `HIVE_COMPACT_WATERMARK_TOKENS` (absolute token count); set to `0` -to disable proactive compaction entirely (the reactive path always -applies). The proactive path is best-effort — a failed checkpoint turn -or `/compact` is surfaced as a `Note` but never fails the turn that -already succeeded. The operator can also force a compaction any time -via `/api/compact`. +The **when** is a `hive_claude::CompactionPolicy` injected by the harness: +`turn::make_session` builds a `PercentPolicy` that compacts once the +model-reported context fill reaches `HIVE_COMPACT_WATERMARK_PERCENT` +(default **75%**), falling back to `events::context_window_tokens(model)` +for the window on turns the model didn't report one. `0` disables proactive +compaction (the reactive path always applies). The proactive path is +best-effort — a failed checkpoint or `/compact` never fails the turn that +already succeeded. + +The operator can force a compaction any time via `POST /api/compact`. It's +**deferred**: the handler sets `Bus::request_compact()` and returns +immediately; the harness runs the `/compact` at the next turn boundary +(end of the in-flight turn in `drive_turn`, or — when the agent is idle — +in `turn::run_pending_compact` on the serve loop's next empty poll). This +lets `/api/compact` work mid-turn instead of only when idle, without racing +a live claude process. To disable proactive compaction for a specific agent, use the nix option: @@ -226,25 +264,28 @@ To disable proactive compaction for a specific agent, use the nix option: hyperhive.autoCompact = false; # default true ``` -Setting `autoCompact = false` is equivalent to `HIVE_COMPACT_WATERMARK_TOKENS=0`. -Useful for agents running large-context models (sonnet/opus) where the 75% -heuristic fires before the session is actually full — the reactive path -(compact-on-overflow when the session hits the hard limit) still applies. +Setting `autoCompact = false` sets `HIVE_COMPACT_WATERMARK_TOKENS=0`, which +the percent resolver still honours as a disable. Useful for large-context +models (sonnet/opus) where the 75% heuristic fires before the session is +actually full — the reactive path (compact-on-overflow at the hard limit) +still applies. -- **Auto session-reset** — a third path that fires when both - conditions hold: context is ≥ a watermark (`HIVE_AUTO_RESET_WATERMARK_TOKENS`, - default **50% of `context_window_tokens(model)`**) AND the time since - the last turn exceeds the assumed prompt-cache TTL - (`HIVE_CACHE_TTL_SECS`, default `3600`). - Claude's prompt cache lives ~5 minutes; if the cache is already - cold, resuming with `--continue` pays the full re-upload cost of - the current context with no benefit over starting fresh. So: - `drive_turn` injects one `AUTO_RESET_CHECKPOINT_PROMPT` notes turn - ("flush state to files, cache is cold") then arms - `Bus::take_skip_continue()` for the real turn — the next turn runs - without `--continue`, starting a fresh session. Unlike proactive - compaction the session is dropped entirely, not compacted. Set - `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. +- **Auto session-reset** — a third path (`turn::maybe_auto_reset`, + pre-turn) that fires when both conditions hold: context is ≥ a watermark + (`HIVE_AUTO_RESET_WATERMARK_TOKENS`, default **50% of + `context_window_tokens(model)`**) AND the time since the last turn + exceeds the assumed prompt-cache TTL (`HIVE_CACHE_TTL_SECS`, default + `3600`). Claude's prompt cache goes cold after a while; once it's cold, + `--resume`-ing a large session pays the full re-upload cost with no + benefit over starting fresh. So `drive_turn` **archives** the current + session (same mechanism as the operator reset — rename `<uuid>.jsonl` → + `.archived`) so the next turn's `--resume <title>` misses and starts + fresh. Unlike proactive compaction the session is dropped entirely, not + compacted — and *no* preceding checkpoint turn runs, because any turn + before the reset would just re-warm the cache and defeat the purpose. + Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Auto-reset and the + operator reset are mutually exclusive per turn (both archive → fresh + turn), so an explicit operator reset short-circuits the heuristic. The child runs with `cwd = /state` (when the bind exists; falls back to the parent's cwd in dev), so any relative path in a tool @@ -264,7 +305,7 @@ the embedded MCP server. Whenever hive-c0re starts / restarts / rebuilds a container, it also drops a `system` message into the agent's inbox via `Coordinator::kick_agent` — a one-line "you were just (re)started, -check /state/ for your notes, --continue session is intact". The +check /state/ for your notes, your session is intact". The next turn picks it up like any other inbox message. ### On-boot files @@ -317,10 +358,16 @@ socket at `/run/hive/` once at startup: empty-string env vars and missing env vars round-trip the same way. -The shared per-turn plumbing lives in `hive_ag3nt::turn::{write_mcp_config, -write_settings, write_system_prompt, run_turn, drive_turn, -emit_turn_end, wait_for_login, compact_session}` — the single code path -every agent role runs through. +The per-turn plumbing lives in `hive_ag3nt::turn`: `write_mcp_config` / +`write_system_prompt` (on-boot files), `make_session` (builds the durable +`InfiniteSession`, once), `drive_turn` (the policy state machine — +reset/auto-reset, the turn, 401-retry, deferred-compact-at-turn-end), +`run_pending_compact` (idle operator compact), `BusSink` (stream → bus + +`Telemetry` applied via `apply_telemetry`), `emit_turn_end`, `session_title` +/ `session_store` / `archive_session` (identity + turn-boundary reset). The +actual claude spawn, stream classification, and the reactive/proactive +compaction loop are in the `hive-claude` crate. Login-wait +(`wait_for_login`) lives in `hive_ag3nt::login`. ### Reference docs (`hyperhive.docs.enable`) @@ -685,9 +732,10 @@ addition to the broadcast channel and the events history. Variants: The harness flips state at the relevant transitions (`set_state(Thinking)` before `drive_turn`, `set_state(Idle)` -after; `set_state(Compacting)` around `compact_session`). Exposed -via `/api/state.turn_state` + `turn_state_since` (unix seconds); -the agent page renders this rather than deriving from SSE events. +after; `set_state(Compacting)` around an idle operator compact in +`turn::run_pending_compact`). Exposed via `/api/state.turn_state` + +`turn_state_since` (unix seconds); the agent page renders this rather +than deriving from SSE events. ### Tool envelope diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 84c5b6fa..817727a0 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -261,9 +261,11 @@ Slash commands today: - `/cancel` — `POST /api/cancel` → host shellouts `pkill -INT claude`, emits a Note. Also surfaces as a `■ cancel turn` button in the state row while state=thinking. -- `/compact` — `POST /api/compact` → host spawns - `turn::compact_session` in the background; output streams into - the live panel. +- `/compact` — `POST /api/compact` → sets the deferred + `Bus::request_compact()` flag and returns immediately. The harness + runs the `/compact` at the next turn boundary (end of the in-flight + turn, or `turn::run_pending_compact` when idle); output streams into + the live panel. Works mid-turn, not only when idle. - `/model <name>` — `POST /api/model` flipping `Bus::set_model`. Takes effect on the next turn; persisted to `/state/hyperhive-model` so the override survives harness @@ -450,7 +452,7 @@ token-efficiency chips derived from the bucket sums: **cache hit-rate** (`cache_read` over all input-side tokens) and **tokens/turn**. When `reminder_stats` is present (fetched via `ReminderRollup` RPC and merged into the snapshot in -`web_ui.rs::api_stats`) three more chips appear: **reminders +`web_ui/stats.rs::api_stats`) three more chips appear: **reminders scheduled / delivered / pending** for the window. When the per-session capture has data, a **first-turn ctx** chip shows the input tokens of the most recent fresh claude session's first turn — diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 933e7f4d..84c2d36d 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -185,7 +185,7 @@ listener: read `data-confirm`, swap the button to a spinner, POST `application/x-www-form-urlencoded`, re-enable the button on success (refreshState may keep the form mounted, so we don't rely on a re-render), call `refreshState()`. State shapes live in -`dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` — when +`dashboard.rs::StateSnapshot` and `web_ui/state.rs::StateSnapshot` — when adding state fields, plumb through the snapshot struct and the relevant domain module (`assets/swarm.js`, `assets/call.js`, etc.) render function. diff --git a/hive-ag3nt/Cargo.toml b/hive-ag3nt/Cargo.toml index 655b5ab3..53f566e5 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -12,6 +12,7 @@ axum.workspace = true reqwest.workspace = true futures-util = "0.3" clap.workspace = true +hive-claude.workspace = true hive-sh4re.workspace = true rmcp.workspace = true rusqlite.workspace = true diff --git a/hive-ag3nt/src/bin/hive.rs b/hive-ag3nt/src/bin/hive.rs index 967ff414..37cf3864 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -8,7 +8,6 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use hive_ag3nt::web_ui::TurnLock; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -429,7 +428,6 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> { } } let files = turn::TurnFiles::prepare(socket, &label).await?; - let turn_lock: TurnLock = Arc::new(tokio::sync::Mutex::new(())); // 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`; @@ -454,18 +452,15 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> { login_state.clone(), bus.clone(), socket.to_path_buf(), - files.clone(), - turn_lock.clone(), ); tokio::spawn(async move { - let (label, port, login_state, bus, socket, files, turn_lock) = web_ui_args; - if let Err(e) = web_ui::serve(label, port, login_state, bus, socket, files, turn_lock).await - { + 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) { - turn::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await; + 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 @@ -480,7 +475,6 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> { bus, stats, &files, - turn_lock, ) .await } @@ -502,10 +496,12 @@ async fn serve_loop<S: Surface>( bus: Bus, stats: Option<TurnStats>, files: &turn::TurnFiles, - turn_lock: TurnLock, ) -> 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 @@ -517,7 +513,13 @@ async fn serve_loop<S: Surface>( None => match S::recv_next(socket).await { RecvOutcome::Message(first) => first, RecvOutcome::Empty => { - tokio::time::sleep(interval).await; + // 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 => { @@ -538,7 +540,7 @@ async fn serve_loop<S: Surface>( &bus, stats.as_ref(), files, - &turn_lock, + &session, graceful_stop_message(), ) .await; @@ -547,10 +549,11 @@ async fn serve_loop<S: Surface>( } }, }; - let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &turn_lock, next).await; + let ctrl = + handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await; if ctrl.auth_failed { *login_state.lock().unwrap() = LoginState::NeedsLogin; - turn::wait_for_login( + login::wait_for_login( &claude_dir, login_state.clone(), &bus, @@ -575,7 +578,7 @@ async fn handle_turn<S: Surface>( bus: &Bus, stats: Option<&TurnStats>, files: &turn::TurnFiles, - turn_lock: &TurnLock, + session: &turn::AgentSession, first: hive_sh4re::DeliveredMessage, ) -> TurnControl { let from = first.from; @@ -595,10 +598,7 @@ async fn handle_turn<S: Surface>( 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 = { - let _guard = turn_lock.lock().await; - turn::drive_turn(&prompt, files, bus).await - }; + let outcome = turn::drive_turn(&prompt, files, bus, session).await; turn::emit_turn_end(bus, &outcome); bus.set_state(TurnState::Idle); if matches!( diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index c6fb2d46..d9721b34 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use hive_claude::TokenUsage; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; use tokio::sync::broadcast; @@ -432,111 +433,6 @@ impl EventStore { } } -/// Token usage emitted by claude in the final `result` stream-json event. -/// All counts are in tokens. `None` fields mean the server didn't report them. -#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct TokenUsage { - pub input_tokens: u64, - pub output_tokens: u64, - pub cache_read_input_tokens: u64, - pub cache_creation_input_tokens: u64, -} - -impl TokenUsage { - /// Total context consumed this turn (input + cache reads + cache writes). - /// This is the per-inference context footprint that counts against the - /// model's `contextWindow` limit. Tracked from the last `assistant` event - /// in the stream-json (per-inference usage, not the cumulative `result` - /// event which sums across all inferences in a tool-heavy turn and can - /// far exceed the per-inference window). - #[must_use] - pub fn context_tokens(&self) -> u64 { - self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens - } - - /// Parse usage from the terminal `result` stream-json event. This is the - /// **cumulative** sum across every inference in the turn — useful as a - /// cost signal, but NOT the current context size (a tool-heavy turn - /// sums per-call cached prompts and easily exceeds the model window). - #[must_use] - pub fn from_stream_event(v: &serde_json::Value) -> Option<Self> { - if v.get("type").and_then(|t| t.as_str()) != Some("result") { - return None; - } - Some(Self::from_usage_obj(v.get("usage")?)) - } - - /// Parse usage from a per-inference `assistant` event's - /// `.message.usage` block. Each turn fires one of these for every - /// model call; tracking the LAST one over the turn gives the actual - /// conversation context size — the number to watch for compaction. - #[must_use] - pub fn from_assistant_event(v: &serde_json::Value) -> Option<Self> { - if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { - return None; - } - Some(Self::from_usage_obj(v.get("message")?.get("usage")?)) - } - - fn from_usage_obj(u: &serde_json::Value) -> Self { - let field = |k: &str| u.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0); - Self { - input_tokens: field("input_tokens"), - output_tokens: field("output_tokens"), - cache_read_input_tokens: field("cache_read_input_tokens"), - cache_creation_input_tokens: field("cache_creation_input_tokens"), - } - } - - /// Extract the per-inference context-window limit from a `result` - /// stream-json event's `modelUsage` map. The API reports this as - /// `modelUsage.<model-name>.contextWindow`; we take the first non-zero - /// value across all model keys. - /// - /// Returns `None` if the event is not a `result` type or has no - /// `contextWindow` field. The returned value is the authoritative - /// per-inference active window (e.g. 200 000 for `claude-sonnet-4-6`). - /// It may be smaller than the full prompt-cache capacity (which can - /// be several million tokens via cache reads). - #[must_use] - pub fn context_window_from_result_event(v: &serde_json::Value) -> Option<u64> { - if v.get("type").and_then(|t| t.as_str()) != Some("result") { - return None; - } - let model_usage = v.get("modelUsage")?; - let map = model_usage.as_object()?; - for (_model, stats) in map { - if let Some(w) = stats - .get("contextWindow") - .and_then(serde_json::Value::as_u64) - && w > 0 - { - return Some(w); - } - } - None - } - - /// Extract the *resolved* model id from an `assistant` stream-json - /// event (`message.model`). Unlike the requested `--model` name - /// (which may be a short alias like `opus` or a default), the API - /// echoes the concrete version it actually ran on (e.g. - /// `claude-opus-4-8`). Recording the resolved id (not the requested - /// name) is what lets the ST4TS model-mix + cost rollup label the - /// exact version that ran. Returns `None` for non-assistant events - /// or ones missing `message.model`. - #[must_use] - pub fn model_from_assistant_event(v: &serde_json::Value) -> Option<String> { - if v.get("type").and_then(|t| t.as_str()) != Some("assistant") { - return None; - } - v.get("message") - .and_then(|m| m.get("model")) - .and_then(serde_json::Value::as_str) - .filter(|s| !s.is_empty()) - .map(ToOwned::to_owned) - } -} /// Authoritative turn-loop state. The harness owns it; the web UI /// reads via `/api/state` and renders. Lives alongside the bus @@ -680,21 +576,28 @@ pub struct Bus { /// `container_view` can surface the status on the dashboard without /// a live socket call. rate_limited: Arc<AtomicBool>, - /// One-shot: next `run_claude` call drops `--continue`, starting - /// a fresh claude session. Set by `POST /api/new-session` from - /// the per-agent web UI; consumed (cleared back to false) by the - /// next turn. Subsequent turns resume normal `--continue` - /// behavior. Atomic so the consumer can take-and-clear without a - /// lock. - skip_continue_once: 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 suppresses - /// `--continue` (a fresh session). The bin loop takes-and-clears it - /// after the turn to decide whether to mint a new `sessions` row. + /// 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 @@ -773,7 +676,8 @@ impl Bus { 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)), - skip_continue_once: Arc::new(AtomicBool::new(false)), + 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())), @@ -797,23 +701,37 @@ impl Bus { self.event_seq.fetch_add(1, Ordering::SeqCst) + 1 } - /// Arm the one-shot: the next claude invocation will run without - /// `--continue`, dropping any prior session context. Idempotent - /// — calling twice in a row before the next turn still consumes - /// to a single fresh-start. - pub fn request_new_session(&self) { - self.skip_continue_once.store(true, Ordering::SeqCst); + /// 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 one-shot. Returns true iff the caller should - /// run claude without `--continue` for this turn. + /// 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_skip_continue(&self) -> bool { - self.skip_continue_once.swap(false, Ordering::SeqCst) + 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 suppresses `--continue`. + /// `run_claude` calls this when it creates a new titled session. pub fn mark_fresh_session(&self) { self.fresh_session.store(true, Ordering::SeqCst); } @@ -1203,8 +1121,8 @@ impl Default for Bus { #[cfg(test)] mod tests { use super::{ - BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage, - forge_cursor_from_json, is_valid_effort, + BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, forge_cursor_from_json, + is_valid_effort, }; use serde_json::json; @@ -1283,29 +1201,4 @@ mod tests { assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT)); assert_eq!(DEFAULT_EFFORT, "medium"); } - - #[test] - fn resolved_model_from_assistant_event() { - let v = json!({ - "type": "assistant", - "message": { "model": "claude-opus-4-8", "role": "assistant" } - }); - assert_eq!( - TokenUsage::model_from_assistant_event(&v), - Some("claude-opus-4-8".to_owned()) - ); - } - - #[test] - fn resolved_model_ignores_non_assistant_and_missing() { - // Wrong event type. - let result = json!({ "type": "result", "message": { "model": "claude-opus-4-8" } }); - assert_eq!(TokenUsage::model_from_assistant_event(&result), None); - // Assistant event missing message.model. - let no_model = json!({ "type": "assistant", "message": { "role": "assistant" } }); - assert_eq!(TokenUsage::model_from_assistant_event(&no_model), None); - // Empty model string is treated as absent. - let empty = json!({ "type": "assistant", "message": { "model": "" } }); - assert_eq!(TokenUsage::model_from_assistant_event(&empty), None); - } } diff --git a/hive-ag3nt/src/login.rs b/hive-ag3nt/src/login.rs index dd5d429a..33a50a43 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -9,6 +9,10 @@ //! exact layout is locked in. 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 @@ -56,3 +60,216 @@ impl LoginState { } } } + +/// 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 credentials dir at a point in time: number of +/// regular files + newest `mtime` across them. The two axes are both +/// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): +/// mtime catches the common case (re-login overwrites an existing +/// credentials file in-place), `file_count` catches the pathological case +/// where `meta.modified()` errors on every file (exotic fs, NFS quirks) +/// so the mtime axis stays `None` forever but new files still trigger a +/// resume. Defaults to `{0, None}` on `read_dir` failure (missing or +/// unreadable dir) — `wait_for_login` then resumes when files first +/// appear. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct DirSnapshot { + file_count: usize, + 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 !entry.file_type().is_ok_and(|t| t.is_file()) { + 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, session_refreshed, snapshot_dir}; + + #[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("old.json"), b"{}").unwrap(); + // Sleep so the second file's mtime is strictly greater than + // the first on filesystems with low timestamp resolution. + std::thread::sleep(Duration::from_millis(20)); + let newer_path = dir.path().join("newer.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_any_file() { + // Empty-dir snapshot → any file appearing means a fresh + // login landed. First-time login semantics. + let dir = tempfile::tempdir().unwrap(); + let snapshot = snapshot_dir(dir.path()); + assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); + fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); + 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("a"), b"{}").unwrap(); + let forged = DirSnapshot { + file_count: 1, + newest_mtime: None, + }; + fs::write(dir.path().join("b"), b"{}").unwrap(); + // Real snapshot has file_count=2, so refresh fires even + // though the mtime axis would be inconclusive. + assert!(session_refreshed(forged, snapshot_dir(dir.path()))); + } +} diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 95af42f2..f9afd86f 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -2330,8 +2330,10 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str } #[cfg(test)] -mod recv_hint_tests { +mod tests { use super::{IDLE_WAIT_HINT, SocketReply, format_recv}; + use super::{SERVER_NAME, allowed_mcp_tools}; + use hive_sh4re::ToolGroup; #[test] fn empty_recv_after_wait_appends_idle_hint() { @@ -2345,12 +2347,6 @@ mod recv_hint_tests { let out = format_recv(Ok(SocketReply::Messages(vec![])), false); assert_eq!(out, "(empty)"); } -} - -#[cfg(test)] -mod allowed_tools_tests { - use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::ToolGroup; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 523ed568..62661fbe 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -1,20 +1,17 @@ -//! Per-turn claude invocation. The spawn shape, arg-vector, stdin plumbing, -//! and stream-json pumping are shared across all roles (there is only one -//! role: agent). +//! Per-turn claude policy layer. The generic subprocess mechanics — spawning +//! `claude --print`, streaming + classifying stream-json, session +//! lookup/archive — live in the `hive-claude` crate. This module owns the +//! hyperhive-specific policy on top: building the per-turn config from the +//! bus, bridging the output stream onto the event bus (`BusSink`), and the +//! compaction / auto-reset / retry state machine (`drive_turn`). -use std::collections::VecDeque; use std::path::{Path, PathBuf}; -use std::process::Stdio; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use anyhow::{Result, bail}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::Command; +use anyhow::Result; +use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink}; +use serde_json::Value; use crate::events::{Bus, LiveEvent}; -use crate::login::LoginState; use crate::mcp; // Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json` @@ -26,55 +23,22 @@ use crate::mcp; // notes persistence under `/state`). Unknown keys are silently ignored by // claude-code; if a key gets renamed we'll spot it because the // corresponding behavior will start firing mid-turn again. +// +// The subprocess mechanics — spawning `claude --print`, streaming + +// classifying stream-json, session lookup/archive — live in the generic +// `hive-claude` crate. This module is the hyperhive *policy* layer on top: +// it builds the per-turn [`Config`] from the bus, forwards the stream to the +// event bus via [`BusSink`], and owns compaction / auto-reset / retry. -/// Regex-ish marker claude-code emits when context overflows. Same string -/// bitburner-agent watches for. Empirically reliable across claude-code -/// versions; if it ever changes, compaction won't fire and we'll see a -/// claude exit with a useful error in the live view. -const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long"; - -/// Substrings that indicate the Anthropic API is refusing the request due -/// to a rate limit, per-account usage cap, or exhausted credit balance. -/// Matched against both stdout and stderr; any hit returns -/// `TurnOutcome::RateLimited` so the serve loop can park + retry instead -/// of propagating a hard failure that looks identical to a crash. -const RATE_LIMIT_MARKERS: &[&str] = &[ - "rate_limit_error", - "overloaded_error", - "Credit balance is too low", - "Usage limit reached", - "Request rate limit exceeded", -]; - -/// Substrings that indicate the Anthropic API rejected the request as -/// unauthenticated — the OAuth session in `$HOME/.claude/` has expired -/// or been revoked. Surfaced as `TurnOutcome::AuthFailed`, which the -/// harness uses to flip the container into `needs_login_idle` so the -/// dashboard's re-auth flow takes over. Matched against both stdout -/// JSON `error` events and stderr; the markers come from claude-code's -/// `api_retry` events (`{"error":"authentication_failed", -/// "error_status":401,...}`) and the human-readable -/// "Failed to authenticate. API Error: 401" line claude prints on giveup. -/// See [`docs/turn-loop.md::The loop`](../../docs/turn-loop.md) for the -/// re-auth resumption path. -const AUTH_FAIL_MARKERS: &[&str] = &[ - "\"error\":\"authentication_failed\"", - "\"error_status\":401", - "Failed to authenticate. API Error: 401", -]; - -/// Substring claude-code emits when `--resume <id>` is handed a session id -/// that doesn't exist in this cwd's project (stale persisted id, or a crash -/// before the first turn ever completed a `.jsonl`). On a hit we clear the -/// persisted id so the NEXT turn starts a fresh session and re-captures — -/// the agent self-heals instead of failing `--resume` forever. -const SESSION_NOT_FOUND_MARKER: &str = "No conversation found with session ID"; - -/// Name of the harness-owned file under `paths::harness_dir()` that holds -/// the claude session id to resume. Written after every turn with the id -/// claude reported on its stream (the id can change across resume turns in -/// some claude-code versions, so we always rewrite with the last-seen value). -const CLAUDE_SESSION_ID_FILE: &str = "claude-session-id"; +/// Fixed, harness-owned claude session title. Every turn / compact / +/// checkpoint resumes THIS title (`--resume <title>`); the create path +/// names it (`--name <title>`). One constant identity per agent means +/// compaction and the post-compact retry provably target the same session — +/// there is no scraped UUID to go stale, empty, or diverge. Each agent runs +/// in its own container (own `~/.claude` + own `/state` cwd), so even the +/// shared default never collides across agents. Override via +/// `HIVE_SESSION_TITLE`. +const DEFAULT_SESSION_TITLE: &str = "hive-session"; /// How long to sleep after detecting a rate-limit before re-entering the /// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is @@ -94,6 +58,10 @@ const DEFAULT_RATE_LIMIT_SLEEP_SECS: u64 = 300; /// `0` to disable (always resume). const DEFAULT_CACHE_TTL_SECS: u64 = 3600; +/// Default proactive-compaction watermark, as a percent of the effective +/// context window. Overridable via `HIVE_COMPACT_WATERMARK_PERCENT`. +const DEFAULT_COMPACT_PERCENT: u8 = 75; + /// Synthetic wake prompt for the proactive notes-checkpoint turn. Not an /// inbox message — the harness injects it directly so the agent gets one /// turn to persist durable state before `/compact` collapses the @@ -181,8 +149,9 @@ pub enum TurnOutcome { /// as `result_kind = "compacted"` in turn stats so the stats page can /// distinguish normal turns from turns that triggered a compaction. Compacted, - /// claude saw "Prompt is too long" — the session needs compacting. - /// Run `compact_session()` then retry the same wake-up prompt. + /// claude saw "Prompt is too long" and even a reactive compact + retry + /// (inside [`InfiniteSession::run`]) couldn't bring it back under the + /// window. Rare; the serve loop treats it like `Ok` (acks the turn). PromptTooLong, /// The Anthropic API refused the request due to a rate limit, per-account /// usage cap, or exhausted credit balance. The serve loop should park for @@ -196,15 +165,25 @@ pub enum TurnOutcome { Failed(anyhow::Error), } +/// Parse an env var as `u64`, ignoring absent / blank / unparseable values. +/// Returns the raw value including `0` (several knobs use `0` as "disable"). +fn env_u64(name: &str) -> Option<u64> { + std::env::var(name) + .ok() + .and_then(|s| s.trim().parse::<u64>().ok()) +} + +/// Like [`env_u64`] but also rejects `0`, falling back to `default` — for +/// knobs where `0` is meaningless rather than a "disable" sentinel. +fn env_u64_positive(name: &str, default: u64) -> u64 { + env_u64(name).filter(|&v| v > 0).unwrap_or(default) +} + /// How long to sleep after a rate-limit before re-entering the serve loop. /// Reads `HIVE_RATE_LIMIT_SLEEP_SECS` if set to a valid positive integer. #[must_use] pub fn rate_limit_sleep_secs() -> u64 { - std::env::var("HIVE_RATE_LIMIT_SLEEP_SECS") - .ok() - .and_then(|s| s.trim().parse::<u64>().ok()) - .filter(|&v| v > 0) - .unwrap_or(DEFAULT_RATE_LIMIT_SLEEP_SECS) + env_u64_positive("HIVE_RATE_LIMIT_SLEEP_SECS", DEFAULT_RATE_LIMIT_SLEEP_SECS) } /// Resolve the effective context-window size for watermark calculations. @@ -229,184 +208,135 @@ fn effective_context_window(bus: &Bus) -> u64 { /// /// `0` disables auto-reset entirely. fn auto_reset_watermark_tokens(bus: &Bus) -> u64 { - if let Some(v) = std::env::var("HIVE_AUTO_RESET_WATERMARK_TOKENS") - .ok() - .and_then(|s| s.trim().parse::<u64>().ok()) - { - return v; - } - effective_context_window(bus) / 2 + env_u64("HIVE_AUTO_RESET_WATERMARK_TOKENS").unwrap_or_else(|| effective_context_window(bus) / 2) } /// Resolve the assumed cache TTL: `HIVE_CACHE_TTL_SECS` if set, else /// `DEFAULT_CACHE_TTL_SECS`. fn cache_ttl_secs() -> u64 { - std::env::var("HIVE_CACHE_TTL_SECS") - .ok() - .and_then(|s| s.trim().parse::<u64>().ok()) - .filter(|&v| v > 0) - .unwrap_or(DEFAULT_CACHE_TTL_SECS) + env_u64_positive("HIVE_CACHE_TTL_SECS", DEFAULT_CACHE_TTL_SECS) } -/// Resolve the proactive-compaction watermark. Priority order: -/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override). -/// 2. 75% of `effective_context_window(bus)`. -/// -/// `0` disables proactive compaction (reactive path still applies). -fn compact_watermark_tokens(bus: &Bus) -> u64 { - if let Some(v) = std::env::var("HIVE_COMPACT_WATERMARK_TOKENS") - .ok() - .and_then(|s| s.trim().parse::<u64>().ok()) - { - return v; +/// Proactive-compaction watermark as a percent of the effective context +/// window (default [`DEFAULT_COMPACT_PERCENT`]). `0` disables proactive +/// compaction — the reactive on-overflow path still applies. Reads +/// `HIVE_COMPACT_WATERMARK_PERCENT`; the legacy `autoCompact = false` switch +/// (which sets `HIVE_COMPACT_WATERMARK_TOKENS=0`) is still honoured as disable. +fn compact_percent() -> u8 { + if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) { + return 0; } - effective_context_window(bus) * 3 / 4 + let pct = env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); + u8::try_from(pct.min(100)).unwrap_or(DEFAULT_COMPACT_PERCENT) } -/// Drive one turn end-to-end. Three paths layer on top of the raw `run_turn`: +/// The agent's durable session type: the constant-title [`InfiniteSession`] +/// with hyperhive's percent-of-window compaction policy. Built once by the +/// serve loop (see [`make_session`]) and threaded through the turns, rather +/// than rebuilt each time — it's effectively stateless, so one instance serves +/// the whole run. +pub type AgentSession = InfiniteSession<PercentPolicy>; + +/// Construct the agent's durable session: constant title + on-disk store + a +/// percent-of-window compaction policy that checkpoints (`CHECKPOINT_PROMPT`) +/// before compacting. Called once at serve-loop start. `percent` comes from a +/// boot-time env var and `default_window` is only a fallback for turns where +/// the model didn't report a window, so a single build at startup is fine. +#[must_use] +pub fn make_session(bus: &Bus) -> AgentSession { + InfiniteSession::new( + session_title(), + session_store(), + PercentPolicy { + percent: compact_percent(), + default_window: Some(effective_context_window(bus)), + checkpoint_prompt: Some(CHECKPOINT_PROMPT.to_string()), + }, + ) +} + +/// Drive one turn end-to-end. The durable [`InfiniteSession`] owns the +/// resume-or-create + compaction loop (reactive on overflow, and proactive per +/// the percent policy — including the pre-compaction checkpoint turn). This +/// layer wraps it with the two hyperhive-specific concerns: /// -/// - **Auto-reset (pre-turn)** — context is large AND the prompt cache has -/// gone cold (idle gap ≥ cache TTL). Resuming would re-upload the full -/// transcript uncached at the same cost as a fresh start. The harness runs -/// one checkpoint turn (agent flushes state), then arms a one-shot -/// `request_new_session` so the actual turn starts fresh. -/// - **Reactive (on overflow)** — `run_turn` returns `PromptTooLong`: the -/// session is already past the context window and *no* turn can run on it, -/// so we compact immediately and retry the same wake-up prompt once. No -/// notes-checkpoint turn is possible here — the detail is gone. -/// - **Proactive (post-turn)** — the turn finished cleanly but its context -/// size has crept past the watermark: while the session is still healthy we -/// give the agent one dedicated turn to checkpoint its `/state` notes, then -/// compact. This keeps a later turn from hitting the reactive path (where -/// there is no chance to save anything first). The graceful-stop path takes -/// the same proactive route — a checkpoint turn before `/compact` is cheap -/// insurance and keeps a later cold-start resume small. +/// - **Session reset (pre-turn)** — an operator reset (`/api/new-session`) or +/// the auto-reset heuristic (context large AND prompt cache gone cold) +/// archives the current session at this turn boundary so the run starts +/// fresh. The two are mutually exclusive. This is deliberately *not* part of +/// the infinite-session abstraction — it's the hive escape hatch. +/// - **401 retry** — a transient token-refresh race can 401 once and clear, so +/// the whole turn is retried a single time before bubbling `AuthFailed` to +/// the serve loop (which parks for re-login). /// -/// Called once per turn by the `hive` serve loop (every agent role). -pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { - maybe_auto_reset(bus); - let outcome = match run_turn(prompt, files, bus).await { - TurnOutcome::PromptTooLong => { - // Compact has its own three-flag surface (it's the same claude - // binary). Treat any non-Ok outcome the same as if `run_turn` - // had returned it — the serve loop already knows what to do - // with each variant, no point re-wrapping. PromptTooLong from - // /compact itself would be absurd recursion; bubble it up as - // a normal failure path. - match compact_session(files, bus).await { - TurnOutcome::Ok | TurnOutcome::Compacted => run_turn(prompt, files, bus).await, - other => return other, +/// Called once per turn by the `hive` serve loop, which owns the shared +/// `session` ([`make_session`]) and threads it in. +pub async fn drive_turn( + prompt: &str, + files: &TurnFiles, + bus: &Bus, + session: &AgentSession, +) -> TurnOutcome { + if bus.take_session_reset() { + // Operator-requested (deferred from `POST /api/new-session`). + bus.emit(LiveEvent::Note { + text: "operator: resetting session — archiving before this turn".into(), + }); + archive_session(bus); + } else { + // Heuristic: context large AND prompt cache gone cold. + maybe_auto_reset(bus); + } + let config = claude_config(bus, files); + let sink = BusSink::new(bus); + let mut result = session.run(&config, prompt, &sink).await; + if matches!(result, Err(hive_claude::Error::AuthFailed)) { + bus.emit(LiveEvent::Note { + text: "got 401 — retrying once before parking for re-login".into(), + }); + result = session.run(&config, prompt, &sink).await; + } + let outcome = match result { + Ok(progress) => { + // Apply the turn's parsed usage / model / context-window to the bus + // (badges, stats, auto-reset watermark input). + apply_telemetry(bus, &progress.telemetry); + if progress.created { + // Fresh session minted this turn → flag it so the bin loop + // mints a `sessions` row + stamps its id onto this turn's stats. + bus.mark_fresh_session(); + bus.emit(LiveEvent::Note { + text: format!("created fresh session titled \"{}\"", session_title()), + }); + } + if progress.compacted { + TurnOutcome::Compacted + } else { + TurnOutcome::Ok } } - // Rate-limited: no point retrying immediately — bubble up so the - // serve loop can park + emit status before the next attempt. - TurnOutcome::RateLimited => return TurnOutcome::RateLimited, - // Auth failed: may be a transient token-refresh race or brief API - // hiccup. Retry once before bubbling up so the serve loop parks for - // re-login. The retry outcome is passed through unchanged — if it - // fails again the serve loop handles it as usual. - TurnOutcome::AuthFailed => { - bus.emit(LiveEvent::Note { - text: "got 401 — retrying once before parking for re-login".into(), - }); - run_turn(prompt, files, bus).await - } - other => other, + Err(e) => error_to_turn(e), }; - // Proactive: a turn just completed on a still-healthy session. If its - // context crossed the watermark, run a separate notes-checkpoint turn so - // the agent can flush durable state, then compact before a later turn - // overflows into the reactive path. Best-effort — never changes the - // outcome of the turn that already succeeded, but records it as - // `Compacted` so turn stats can distinguish it from a plain `Ok`. - if matches!(outcome, TurnOutcome::Ok) && maybe_checkpoint_and_compact(files, bus).await { + // Operator `/compact` (`POST /api/compact`) deferred to the turn boundary: + // run it now that the turn is done, so it works mid-turn rather than only + // when the agent is idle. Only on a healthy turn — no point spawning a + // compaction after a rate-limited / auth-failed / crashed one. + if bus.take_compact() && matches!(outcome, TurnOutcome::Ok | TurnOutcome::Compacted) { + bus.emit(LiveEvent::Note { + text: "operator: /compact — running at turn end".into(), + }); + let _ = session.compact(&config, &sink).await; return TurnOutcome::Compacted; } outcome } -/// Proactive post-turn compaction. If the last inference's context size -/// has crossed the watermark, run one notes-checkpoint turn so the agent -/// can persist durable state, then `/compact`. Best-effort: a failed -/// checkpoint or compaction is logged + surfaced as a Note but never -/// fails the turn that already succeeded. Returns `true` if compaction -/// was attempted (watermark crossed), `false` if skipped. -async fn maybe_checkpoint_and_compact(files: &TurnFiles, bus: &Bus) -> bool { - let Some(used) = watermark_crossed(bus) else { - return false; - }; - let watermark = compact_watermark_tokens(bus); - bus.emit(LiveEvent::Note { - text: format!( - "context at {used} tokens (watermark {watermark}) — running a \ - notes-checkpoint turn before /compact" - ), - }); - // Give the agent one turn to flush durable state into /state. If the - // session is somehow already too far gone to run even this, fall - // through to compaction anyway — the checkpoint is best-effort. - match run_turn(CHECKPOINT_PROMPT, files, bus).await { - TurnOutcome::Ok | TurnOutcome::Compacted => {} - TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note { - text: "checkpoint turn overflowed the window — compacting without it".into(), - }), - TurnOutcome::RateLimited => bus.emit(LiveEvent::Note { - text: "checkpoint turn was rate-limited — compacting anyway".into(), - }), - TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note { - text: "checkpoint turn hit 401 — skipping compaction, parking for re-login".into(), - }), - TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note { - text: format!("checkpoint turn failed ({e:#}) — compacting anyway"), - }), - } - do_compact(files, bus).await; - true -} - -/// Returns `Some(used_tokens)` when proactive compaction is enabled AND the -/// last inference's context size has reached the watermark; `None` when -/// compaction is disabled (watermark 0), there's no usage reading yet, or -/// the context is still below the watermark. -fn watermark_crossed(bus: &Bus) -> Option<u64> { - let watermark = compact_watermark_tokens(bus); - if watermark == 0 { - return None; // proactive compaction disabled - } - let used = bus.last_ctx_usage().map(|u| u.context_tokens())?; - (used >= watermark).then_some(used) -} - -/// Run `/compact`, surfacing each failure mode as a best-effort Note. -/// Never changes the outcome of the turn that already succeeded — the -/// next real turn surfaces any underlying issue (rate-limit / 401 / etc.) -/// through the normal path anyway. -async fn do_compact(files: &TurnFiles, bus: &Bus) { - match compact_session(files, bus).await { - TurnOutcome::Ok | TurnOutcome::Compacted => {} - TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note { - text: "/compact unexpectedly returned PromptTooLong — next turn will retry".into(), - }), - TurnOutcome::RateLimited => bus.emit(LiveEvent::Note { - text: "/compact was rate-limited — next turn will park + retry".into(), - }), - TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note { - text: "/compact hit 401 — next turn will trigger the re-login flow".into(), - }), - TurnOutcome::Failed(e) => { - tracing::warn!(error = %format!("{e:#}"), "post-checkpoint compact failed"); - bus.emit(LiveEvent::Note { - text: format!("/compact after checkpoint failed: {e:#}"), - }); - } - } -} - /// Pre-turn auto-reset check. If context is large AND the prompt cache has -/// gone cold (idle time >= cache TTL), arm `request_new_session` so the -/// next wake-up turn starts fresh. No preceding checkpoint turn — running -/// any turn before the reset would re-upload and re-warm the cache, which -/// defeats the cost-optimisation purpose entirely. +/// gone cold (idle time >= cache TTL), archive the current session so the +/// next wake-up turn's `--resume <title>` misses and self-heals into a fresh +/// `--name <title>` session. No preceding checkpoint turn — running any turn +/// before the reset would re-upload and re-warm the cache, which defeats the +/// cost-optimisation purpose entirely. fn maybe_auto_reset(bus: &Bus) { let watermark = auto_reset_watermark_tokens(bus); if watermark == 0 { @@ -437,7 +367,7 @@ fn maybe_auto_reset(bus: &Bus) { — dropping session (cache cold, fresh start is equally cheap)" ), }); - bus.request_new_session(); + archive_session(bus); } /// Emit the per-turn `TurnEnd` event + log line. Single owner so outcome @@ -476,545 +406,201 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) { } } -/// 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; - } +/// Service a pending operator `/compact` (`Bus::request_compact`) while the +/// agent is idle — the serve loop calls this when a `recv` returns no message, +/// so a queued `/compact` runs even when no turn is driving. (The in-flight +/// case is handled at the end of [`drive_turn`].) Resume-only via +/// [`InfiniteSession::compact`]: a missing session is a harmless no-op. Returns +/// `true` if a compaction ran. +pub async fn run_pending_compact(files: &TurnFiles, bus: &Bus, session: &AgentSession) -> bool { + if !bus.take_compact() { + return false; } -} - -/// Snapshot of the credentials dir at a point in time: number of -/// regular files + newest `mtime` across them. The two axes are both -/// load-bearing for `wait_for_login`'s refresh check (`session_refreshed`): -/// mtime catches the common case (re-login overwrites an existing -/// credentials file in-place), `file_count` catches the pathological case -/// where `meta.modified()` errors on every file (exotic fs, NFS quirks) -/// so the mtime axis stays `None` forever but new files still trigger a -/// resume. Defaults to `{0, None}` on `read_dir` failure (missing or -/// unreadable dir) — `wait_for_login` then resumes when files first -/// appear. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct DirSnapshot { - file_count: usize, - 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 !entry.file_type().is_ok_and(|t| t.is_file()) { - 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, - } -} - -/// Spawn `claude` for one turn and pump `stream-json` stdout into the -/// live event bus. Prompt goes over stdin (variadic -/// `--allowedTools`/`--tools` would otherwise eat a trailing positional -/// prompt). The session is persistent across turns via `--resume <id>` -/// against the harness's own captured session id (NOT bare `--continue`, -/// which resumes the *latest* session in this cwd and so lets a `choom` -/// session hijack the live harness context). claude's in-session -/// auto-compact is disabled via the managed -/// settings at `/etc/claude-code/managed-settings.json` so it doesn't -/// stall mid-turn — hyperhive owns compaction. -pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome { - match run_claude(prompt, files, bus).await { - Ok((true, _, _)) => TurnOutcome::PromptTooLong, - Ok((_, true, _)) => TurnOutcome::RateLimited, - Ok((_, _, true)) => TurnOutcome::AuthFailed, - Ok(_) => TurnOutcome::Ok, - Err(e) => TurnOutcome::Failed(e), - } -} - -/// Run claude's built-in `/compact` slash command on the persistent -/// session. Takes the *same* params as `run_turn` because compact -/// re-initialises claude with the full session shape — same MCP -/// surface, same system prompt, same allowed-tools — so the post- -/// compact state matches a normal turn's. Only the prompt over stdin -/// differs (`/compact` vs the wake-up payload). -/// -/// Returns the same `TurnOutcome` shape as `run_turn` so callers can -/// react identically to all three failure flags (`prompt_too_long`, -/// `rate_limited`, `auth_failed`). The reactive caller bubbles any -/// non-Ok outcome up so the serve loop's normal handling (park + -/// retry on rate-limit, flip to `needs_login` on 401, etc.) kicks in; -/// the proactive post-checkpoint caller stays best-effort and only -/// emits a Note for each failure mode. -pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> TurnOutcome { bus.emit(LiveEvent::Note { - text: "context overflow — running /compact on the persistent session".into(), + text: "operator: /compact — running on idle session".into(), }); - let outcome = match run_claude("/compact", files, bus).await { - Ok((true, _, _)) => TurnOutcome::PromptTooLong, - Ok((_, true, _)) => TurnOutcome::RateLimited, - Ok((_, _, true)) => TurnOutcome::AuthFailed, - Ok(_) => TurnOutcome::Ok, - Err(e) => TurnOutcome::Failed(e), - }; - match &outcome { - TurnOutcome::Ok | TurnOutcome::Compacted => bus.emit(LiveEvent::Note { + bus.set_state(crate::events::TurnState::Compacting); + let config = claude_config(bus, files); + let sink = BusSink::new(bus); + match session.compact(&config, &sink).await { + Ok(()) => bus.emit(LiveEvent::Note { text: "/compact done".into(), }), - TurnOutcome::PromptTooLong => bus.emit(LiveEvent::Note { - text: "/compact reported PromptTooLong — bubbling up".into(), - }), - TurnOutcome::RateLimited => bus.emit(LiveEvent::Note { - text: "/compact was rate-limited — bubbling up".into(), - }), - TurnOutcome::AuthFailed => bus.emit(LiveEvent::Note { - text: "/compact hit 401 — bubbling up for re-login flow".into(), - }), - TurnOutcome::Failed(e) => bus.emit(LiveEvent::Note { - text: format!("/compact failed: {e:#}"), + Err(e) => bus.emit(LiveEvent::Note { + text: format!("/compact failed: {e}"), }), } - outcome + bus.set_state(crate::events::TurnState::Idle); + true } -#[allow( - clippy::too_many_lines, - reason = "one linear subprocess driver: spawn claude, stream + classify \ - stdout/stderr, then assemble the outcome; splitting it would \ - fragment the streaming state across helpers" -)] -async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<(bool, bool, bool)> { - // Keep the last STDERR_TAIL_LINES of stderr so a non-zero exit can - // include real context in the bail message (and downstream in the - // failure notification to the manager) instead of just "exit 1". - const STDERR_TAIL_LINES: usize = 20; - let model = bus.model(); - let effort = bus.effort(); - // Resolve which claude session to resume. We NEVER pass bare - // `--continue`: that resumes the latest session in this cwd, which a - // `choom` invocation (same cwd) can hijack, wiping the harness context. - // Instead we `--resume <id>` against the id claude reported on a prior - // turn, persisted under `harness_dir()/claude-session-id`. - let persist_path = crate::paths::harness_dir().join(CLAUDE_SESSION_ID_FILE); - let resume_id: Option<String> = if bus.take_skip_continue() { - // Fresh session requested: mint a new one (no --resume). Flag it so - // the bin loop mints a new `sessions` row + stamps its id onto this - // turn's stats. Drop any stale persisted id — the new id claude - // reports this turn is captured + written below. - bus.mark_fresh_session(); - let _ = std::fs::remove_file(&persist_path); - bus.emit(LiveEvent::Note { - text: "fresh session (continue suppressed for this turn)".into(), - }); - None - } else { - // Continue: resume OUR captured id. Absent (first turn / just - // self-healed from a stale id) → fall through to a fresh session - // and capture the new id below. - match std::fs::read_to_string(&persist_path) { - Ok(s) if !s.trim().is_empty() => Some(s.trim().to_string()), - _ => None, - } - }; - let mut cmd = Command::new("claude"); - // Spawn inside the agent's state dir so relative paths in tool calls - // (Read foo.md, Bash ls, Write notes.md) land in the durable dir - // instead of wherever the harness systemd unit started. Falls back - // silently if the dir is missing (dev / test without the bind mount). +/// The constant session title for this agent. `HIVE_SESSION_TITLE` overrides +/// the compiled-in [`DEFAULT_SESSION_TITLE`]; each agent runs in its own +/// container (own `~/.claude` + own `/state` cwd), so even the shared default +/// never collides across agents. +#[must_use] +pub fn session_title() -> String { + std::env::var("HIVE_SESSION_TITLE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| DEFAULT_SESSION_TITLE.to_string()) +} + +/// The cwd claude is spawned in: the agent's durable `/state` dir when it +/// exists, else the harness process cwd. Claude derives its per-project +/// session dir from this path, so the same value feeds both the [`Config`] and +/// the [`hive_claude::SessionStore`]. +fn session_cwd() -> PathBuf { let state_dir = crate::paths::state_dir(); if state_dir.is_dir() { - cmd.current_dir(&state_dir); + state_dir + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) } - cmd.arg("--print") - .arg("--verbose") - .arg("--output-format") - .arg("stream-json") - .arg("--model") - .arg(&model) - .arg("--effort") - .arg(&effort); - if let Some(id) = &resume_id { - cmd.arg("--resume").arg(id); - } - cmd.arg("--system-prompt-file").arg(&files.system_prompt); - cmd.arg("--mcp-config") - .arg(&files.mcp_config) - .arg("--strict-mcp-config") - .arg("--tools") - .arg(mcp::builtin_tools_arg()) - .arg("--allowedTools") - .arg(mcp::allowed_tools_arg()); - // hyperhive.docs.enable wires HIVE_DOCS_DIR to the in-container - // reference-docs tree (with a generic CLAUDE.md pointer at its root). - // Expose it to claude as an additional directory so the docs are - // readable; harness-base.nix also sets - // CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 so claude loads that - // pointer additively (never replacing the agent's own CLAUDE.md). - // Unset (docs disabled) → the flag is not passed. +} + +/// The on-disk session store for this agent (claude home + spawn cwd), used to +/// locate + archive the harness session by title. +fn session_store() -> hive_claude::SessionStore { + hive_claude::SessionStore::new(crate::paths::claude_dir(), session_cwd()) +} + +/// Build the per-turn `hive_claude::Config` from the bus (model / effort) and +/// the materialised `TurnFiles` (system prompt + MCP config), plus the fixed +/// tool allow-lists and the optional docs `--add-dir`. +fn claude_config(bus: &Bus, files: &TurnFiles) -> Config { + let mut add_dirs = Vec::new(); + // hyperhive.docs.enable wires HIVE_DOCS_DIR to the in-container reference + // docs; expose it as an additional readable directory when set. if let Some(docs_dir) = std::env::var_os("HIVE_DOCS_DIR") && !docs_dir.is_empty() { - cmd.arg("--add-dir").arg(&docs_dir); + add_dirs.push(PathBuf::from(docs_dir)); } - let mut child = cmd - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn()?; - - if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(prompt.as_bytes()).await?; - stdin.shutdown().await.ok(); - drop(stdin); + let cwd = { + let state_dir = crate::paths::state_dir(); + state_dir.is_dir().then_some(state_dir) + }; + Config { + model: bus.model(), + effort: Some(bus.effort()), + cwd, + system_prompt_file: Some(files.system_prompt.clone()), + mcp_config: Some(files.mcp_config.clone()), + strict_mcp_config: true, + tools: Some(mcp::builtin_tools_arg()), + allowed_tools: Some(mcp::allowed_tools_arg()), + add_dirs, + ..Config::default() } - let stdout = child.stdout.take().expect("piped stdout"); - let stderr = child.stderr.take().expect("piped stderr"); +} - let prompt_too_long = Arc::new(AtomicBool::new(false)); - let rate_limited = Arc::new(AtomicBool::new(false)); - let auth_failed = Arc::new(AtomicBool::new(false)); - // `--resume` against a stale/missing id: clear the persist file so the - // next turn self-heals into a fresh session. - let session_not_found = Arc::new(AtomicBool::new(false)); - // Last `session_id` claude reported on its stream this turn; persisted - // after the child exits so the next turn `--resume`s it. - let session_id_seen = Arc::new(Mutex::new(None::<String>)); - let flag_out = prompt_too_long.clone(); - let flag_err = prompt_too_long.clone(); - let rate_out = rate_limited.clone(); - let rate_err = rate_limited.clone(); - let auth_out = auth_failed.clone(); - let auth_err = auth_failed.clone(); - let notfound_out = session_not_found.clone(); - let notfound_err = session_not_found.clone(); - let session_id_out = session_id_seen.clone(); - let bus_out = bus.clone(); - let bus_err = bus.clone(); - let pump_stdout = tokio::spawn(async move { - let mut reader = BufReader::new(stdout).lines(); - // Track usage as the turn unfolds. `last_inference` overwrites on - // every assistant event so at result-time it holds the most recent - // model call's usage — the actual context size. The `result` event - // carries the cumulative-across-the-turn usage (cost signal). Both - // get handed to `record_turn_usage` together so a single SSE - // event updates both badges. - let mut last_inference: Option<crate::events::TokenUsage> = None; - // Resolved model id (API-echoed `message.model`) from this turn's - // assistant events; recorded onto the bus at result-time so the - // per-turn stats label the concrete version that ran, not the - // requested `--model` alias. - let mut last_model: Option<String> = None; - while let Ok(Some(line)) = reader.next_line().await { - if line.contains(PROMPT_TOO_LONG_MARKER) { - flag_out.store(true, Ordering::Relaxed); - } - // Auth-fail check happens on the raw line first so we - // catch both the `api_retry` JSON events (which can land - // before they're fully parseable) and any stderr-shaped - // text that snuck onto stdout. - if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { - auth_out.store(true, Ordering::Relaxed); - } - if line.contains(SESSION_NOT_FOUND_MARKER) { - notfound_out.store(true, Ordering::Relaxed); - } - if let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) { - // Track the session id claude reports (init + result events - // both carry it). Persisted after exit so the next turn - // `--resume`s it; re-captured each turn since the id can - // change across resumes in some claude-code versions. - if let Some(sid) = v.get("session_id").and_then(|s| s.as_str()) - && !sid.is_empty() - { - *session_id_out.lock().unwrap() = Some(sid.to_string()); - } - // Rate-limit detection: only fire on JSON `error` events, - // not on arbitrary text content. An agent discussing a past - // rate limit in its response would otherwise trigger a false - // positive (the full conversation flows through stdout as - // stream-json, so any text the model outputs is visible here). - if v.get("type").and_then(|t| t.as_str()) == Some("error") { - let raw = v.to_string(); - if RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) { - rate_out.store(true, Ordering::Relaxed); - } - } - if let Some(u) = crate::events::TokenUsage::from_assistant_event(&v) { - last_inference = Some(u); - } - if let Some(m) = crate::events::TokenUsage::model_from_assistant_event(&v) { - last_model = Some(m); - } - if let Some(cost) = crate::events::TokenUsage::from_stream_event(&v) { - // Fallback to `cost` if the turn somehow produced - // a result without any assistant event — keeps the - // ctx badge from going stale on a degenerate turn. - let ctx = last_inference.unwrap_or(cost); - bus_out.record_turn_usage(ctx, cost); - // Pin the resolved model for this turn's stats row - // (cleared to None if no assistant event reported one - // → stats sink falls back to the requested name). - bus_out.set_resolved_model(last_model.clone()); - } - // Seed the API-reported context-window from the result - // event's `modelUsage.*.contextWindow` field. This is - // the authoritative per-inference active window used for - // compaction watermarks — it reflects what the model - // actually enforces, which may differ from the Nix - // config (e.g. 200k active window on a 1M cache model). - if let Some(w) = crate::events::TokenUsage::context_window_from_result_event(&v) { - bus_out.set_api_context_window(w); - } - bus_out.observe_stream(&v); - bus_out.emit(LiveEvent::Stream(v)); - } else { - // Non-JSON stdout: raw text check is fine here since these - // are claude CLI messages, not conversation content. - if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { - rate_out.store(true, Ordering::Relaxed); - } - bus_out.emit(LiveEvent::Note { - text: format!("(non-json) {line}"), - }); - } - } - }); - let stderr_tail: Arc<Mutex<VecDeque<String>>> = - Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_TAIL_LINES))); - let tail_clone = stderr_tail.clone(); - let pump_stderr = tokio::spawn(async move { - let mut reader = BufReader::new(stderr).lines(); - while let Ok(Some(line)) = reader.next_line().await { - if line.contains(PROMPT_TOO_LONG_MARKER) { - flag_err.store(true, Ordering::Relaxed); - } - if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { - rate_err.store(true, Ordering::Relaxed); - } - if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { - auth_err.store(true, Ordering::Relaxed); - } - if line.contains(SESSION_NOT_FOUND_MARKER) { - notfound_err.store(true, Ordering::Relaxed); - } - // Mirror to journald so post-mortems work without the web UI - // or the events sqlite. The bus event is what the dashboard - // renders; the tracing line is what `journalctl -M <c> -b` - // surfaces when claude exits non-zero. - tracing::warn!(line = %line, "claude stderr"); - bus_err.emit(LiveEvent::Note { - text: format!("stderr: {line}"), +/// Map a `hive_claude::Error` onto the harness's `TurnOutcome`. The recognized +/// sentinels become their matching outcomes; a residual `SessionNotFound` +/// (create path itself missed — shouldn't happen) settles as `Ok`; genuine +/// failures become `Failed` (converting the typed lib error into `anyhow`). +fn error_to_turn(err: hive_claude::Error) -> TurnOutcome { + use hive_claude::Error; + match err { + Error::PromptTooLong => TurnOutcome::PromptTooLong, + Error::RateLimited => TurnOutcome::RateLimited, + Error::AuthFailed => TurnOutcome::AuthFailed, + Error::SessionNotFound => TurnOutcome::Ok, + other => TurnOutcome::Failed(other.into()), + } +} + +/// Bridges a claude run's raw output stream onto the hyperhive event bus: +/// per-turn tool-call counting (`observe_stream`), the live SSE stream, and +/// non-JSON stdout + stderr as Notes. Stateless — usage/model/context-window +/// parsing lives in `hive-claude` and is applied from the run's returned +/// `Telemetry` (see `apply_telemetry`). +struct BusSink<'a> { + bus: &'a Bus, +} + +impl<'a> BusSink<'a> { + fn new(bus: &'a Bus) -> Self { + Self { bus } + } +} + +impl Sink for BusSink<'_> { + fn on_event(&self, event: &Value) { + // Raw-event concerns only: per-turn tool-call counting + the live SSE + // stream. Usage / model / context-window parsing lives in the lib now + // and is applied from the run's returned `Telemetry` (see `drive_turn` + // → `apply_telemetry`). + self.bus.observe_stream(event); + self.bus.emit(LiveEvent::Stream(event.clone())); + } + + fn on_stdout_line(&self, line: &str) { + self.bus.emit(LiveEvent::Note { + text: format!("(non-json) {line}"), + }); + } + + fn on_stderr_line(&self, line: &str) { + // Mirror to journald so post-mortems work without the web UI / events + // sqlite; the bus Note is what the dashboard renders. + tracing::warn!(line = %line, "claude stderr"); + self.bus.emit(LiveEvent::Note { + text: format!("stderr: {line}"), + }); + } +} + +/// Apply a completed turn's parsed [`hive_claude::Telemetry`] to the bus: +/// per-inference context usage + cumulative cost, the resolved model id, and +/// the API-reported context window (the authoritative window for the auto-reset +/// watermark). Skips a degenerate turn that parsed nothing so it doesn't reset +/// the badges to zero. +fn apply_telemetry(bus: &Bus, telemetry: &hive_claude::Telemetry) { + if telemetry.context.context_tokens() == 0 && telemetry.cost.context_tokens() == 0 { + return; + } + bus.record_turn_usage(telemetry.context, telemetry.cost); + bus.set_resolved_model(telemetry.model.clone()); + if let Some(window) = telemetry.context_window { + bus.set_api_context_window(window); + } +} + +/// Archive (do NOT delete) the harness's own session so the next turn's +/// `--resume <title>` misses and self-heals into a fresh `--name <title>` +/// session. Delegates the rename to [`hive_claude::SessionStore::archive_by_title`] +/// (which touches only the file carrying OUR `customTitle`, leaving any `choom` +/// session sharing the cwd alone) and surfaces the result as a Note. Best- +/// effort: never fails a turn. Only ever called at a turn boundary (top of +/// `drive_turn` for an operator reset, or `maybe_auto_reset` pre-turn) so no +/// claude process holds the session file open when it's renamed. +fn archive_session(bus: &Bus) { + let title = session_title(); + match session_store().archive_by_title(&title) { + Ok(Some(path)) => { + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("?") + .to_string(); + tracing::info!(path = %path.display(), "archived claude session"); + bus.emit(LiveEvent::Note { + text: format!("archived session \"{title}\" ({name}) — next turn starts fresh"), }); - let mut t = tail_clone.lock().unwrap(); - if t.len() >= STDERR_TAIL_LINES { - t.pop_front(); - } - t.push_back(line); } - }); - - let status = child.wait().await?; - let _ = pump_stdout.await; - let _ = pump_stderr.await; - let too_long = prompt_too_long.load(Ordering::Relaxed); - let is_rate_limited = rate_limited.load(Ordering::Relaxed); - let is_auth_failed = auth_failed.load(Ordering::Relaxed); - // Session-id bookkeeping. On a stale/missing `--resume` id, drop the - // persist file so the next turn starts fresh and self-heals. Otherwise - // rewrite it with the id claude reported this turn (handles the id - // changing across resumes in some claude-code versions). - if session_not_found.load(Ordering::Relaxed) { - let _ = std::fs::remove_file(&persist_path); - } else if let Some(sid) = session_id_seen.lock().unwrap().clone() { - if let Some(parent) = persist_path.parent() { - let _ = std::fs::create_dir_all(parent); + Ok(None) => bus.emit(LiveEvent::Note { + text: format!( + "no existing session titled \"{title}\" to archive — next turn starts fresh" + ), + }), + Err(e) => { + tracing::warn!(error = %e, "failed to archive claude session"); + bus.emit(LiveEvent::Note { + text: format!("failed to archive session \"{title}\": {e}"), + }); } - let _ = std::fs::write(&persist_path, sid); } - if !status.success() && !too_long && !is_rate_limited && !is_auth_failed { - let tail = stderr_tail.lock().unwrap(); - if tail.is_empty() { - bail!("claude exited {status} (no stderr)"); - } - let tail_str = tail.iter().cloned().collect::<Vec<_>>().join("\n"); - bail!("claude exited {status}\nstderr tail:\n{tail_str}"); - } - Ok((too_long, is_rate_limited, is_auth_failed)) } -#[cfg(test)] -mod tests { - use std::fs; - use std::time::{Duration, SystemTime}; - - use super::{DirSnapshot, session_refreshed, snapshot_dir}; - - #[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("old.json"), b"{}").unwrap(); - // Sleep so the second file's mtime is strictly greater than - // the first on filesystems with low timestamp resolution. - std::thread::sleep(Duration::from_millis(20)); - let newer_path = dir.path().join("newer.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_any_file() { - // Empty-dir snapshot → any file appearing means a fresh - // login landed. First-time login semantics. - let dir = tempfile::tempdir().unwrap(); - let snapshot = snapshot_dir(dir.path()); - assert!(!session_refreshed(snapshot, snapshot_dir(dir.path()))); - fs::write(dir.path().join("credentials.json"), b"{}").unwrap(); - 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("a"), b"{}").unwrap(); - let forged = DirSnapshot { - file_count: 1, - newest_mtime: None, - }; - fs::write(dir.path().join("b"), b"{}").unwrap(); - // Real snapshot has file_count=2, so refresh fires even - // though the mtime axis would be inconclusive. - assert!(session_refreshed(forged, snapshot_dir(dir.path()))); - } -} diff --git a/hive-ag3nt/src/turn_stats.rs b/hive-ag3nt/src/turn_stats.rs index c239aff2..e314923e 100644 --- a/hive-ag3nt/src/turn_stats.rs +++ b/hive-ag3nt/src/turn_stats.rs @@ -268,8 +268,8 @@ impl TurnStats { pub fn last_usage( &self, ) -> ( - Option<crate::events::TokenUsage>, - Option<crate::events::TokenUsage>, + Option<hive_claude::TokenUsage>, + Option<hive_claude::TokenUsage>, ) { let conn = self.inner.lock().unwrap(); conn.query_row( @@ -285,19 +285,19 @@ impl TurnStats { let g = |i: usize| -> rusqlite::Result<u64> { Ok(u64::try_from(row.get::<_, i64>(i)?).unwrap_or(0)) }; - let cost = crate::events::TokenUsage { + let cost = hive_claude::TokenUsage { input_tokens: g(0)?, output_tokens: g(1)?, cache_read_input_tokens: g(2)?, cache_creation_input_tokens: g(3)?, }; - let last = crate::events::TokenUsage { + let last = hive_claude::TokenUsage { input_tokens: g(4)?, output_tokens: g(5)?, cache_read_input_tokens: g(6)?, cache_creation_input_tokens: g(7)?, }; - let ctx = if last == crate::events::TokenUsage::default() { + let ctx = if last == hive_claude::TokenUsage::default() { None } else { Some(last) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs deleted file mode 100644 index 1925ea90..00000000 --- a/hive-ag3nt/src/web_ui.rs +++ /dev/null @@ -1,1276 +0,0 @@ -//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; -//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page -//! state as JSON; the JS app renders. Live events stream on -//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a -//! 303 Redirect (for browsers that submit the form normally) or just -//! 200 OK — the JS app re-fetches `/api/state` afterwards. - -use std::convert::Infallible; -use std::net::SocketAddr; -use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; - -use anyhow::{Context, Result}; -use axum::{ - Form, Router, - extract::State, - http::StatusCode, - response::{ - IntoResponse, Response, - sse::{Event, KeepAlive, Sse}, - }, - routing::{get, post}, -}; -use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; -use tower_http::services::ServeDir; - -use crate::client; -use crate::events::Bus; -use crate::login::LoginState; -use crate::login_session::{LoginSession, drop_if_finished}; -use crate::turn::TurnFiles; - -/// Deadline for broker-backed fetches on web-UI request paths. The -/// page's critical fields (status, turn state, usage) are all -/// in-memory; a busy or stalled hive-c0re must degrade the -/// socket-backed extras (inbox rows, loose ends, reminder stats) -/// instead of hanging the whole response — an unbounded await here is -/// what let `/api/state` stall long enough to bork the terminal. -const SOCKET_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); - -/// Live login state for the web UI. The harness updates this in place as it -/// transitions between `NeedsLogin` and `Online`; the UI reads on each -/// render. -pub type LoginStateCell = Arc<Mutex<LoginState>>; - -/// Shared turn lock. The serve loop acquires this (as an async mutex) for the -/// duration of every `drive_turn` call. The `/api/compact` handler tries -/// `try_lock()` and rejects immediately if a turn is in flight, preventing -/// concurrent access to the claude session. -pub type TurnLock = Arc<tokio::sync::Mutex<()>>; - -#[derive(Clone)] -struct AppState { - label: String, - login: LoginStateCell, - session: Arc<Mutex<Option<Arc<LoginSession>>>>, - bus: Bus, - socket: PathBuf, - /// Same `TurnFiles` the harness's turn loop uses. Shared so - /// `/api/compact` re-uses the exact MCP config / system prompt - /// claude saw on the last regular turn — keeps the session shape - /// identical across compact + normal turns. - files: TurnFiles, - /// Prevents `/api/compact` from racing with an in-flight normal turn. - turn_lock: TurnLock, - /// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup. - /// `None` when unset (gui not enabled for this agent). - gui_vnc_port: Option<u16>, -} - -/// Bind the per-container web listener and serve the SPA. -/// -/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the -/// dual-mode transition + gateway-side consumer live in -/// [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md) and -/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md). -/// -/// # Errors -/// -/// Returns an error if neither the TCP listener (default) nor the -/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or -/// if `HIVE_STATIC_DIR` is missing. -pub async fn serve( - label: String, - port: u16, - login: LoginStateCell, - bus: Bus, - socket: PathBuf, - files: TurnFiles, - turn_lock: TurnLock, -) -> Result<()> { - let gui_vnc_port = read_gui_vnc_port(); - let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") - .map(PathBuf::from) - .context( - "HIVE_STATIC_DIR env var not set — point it at the merged \ - per-agent dist (see hyperhive.frontend.mergedDist in nix)", - )?; - if !static_dir.is_dir() { - anyhow::bail!( - "HIVE_STATIC_DIR ({}) is not a directory", - static_dir.display() - ); - } - tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved"); - let state = AppState { - label, - login, - session: Arc::new(Mutex::new(None)), - bus, - socket, - files, - turn_lock, - gui_vnc_port, - }; - let app = Router::new() - .route("/api/state", get(api_state)) - .route("/api/dashboard-state", get(api_dashboard_state)) - .route("/events/stream", get(events_stream)) - .route("/events/history", get(events_history)) - .route("/send", post(post_send)) - .route("/login/start", post(post_login_start)) - .route("/login/code", post(post_login_code)) - .route("/login/cancel", post(post_login_cancel)) - .route("/api/cancel", post(post_cancel_turn)) - .route("/api/compact", post(post_compact)) - .route("/api/model", post(post_set_model)) - .route("/api/effort", post(post_set_effort)) - .route("/api/new-session", post(post_new_session)) - .route("/api/logout", post(post_logout)) - .route("/api/loose-ends", get(api_loose_ends)) - .route("/api/bash-tasks", get(api_bash_tasks)) - .route("/api/stats", get(api_stats)) - .route("/screen/ws", get(screen_ws)) - .route("/icon", get(serve_icon)) - // Anything else (`/`, `/stats`, `/screen`, `/static/*`) - // falls through to the merged dist. ServeDir auto-appends - // `.html` when the URL is a bare path that matches a file - // (so `/stats` → `dist/stats.html`, `/screen` → `dist/ - // screen.html`). Per-agent `extraFiles` additions are - // already layered into this same directory (see - // hyperhive.frontend.mergedDist in nix). - .fallback_service(ServeDir::new(&static_dir)) - .with_state(state); - // `HIVE_WEB_SOCKET` opt-in: when set + non-empty, bind a - // `UnixListener` at the given path. Empty string treated as - // unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an - // un-bindable empty path. Falls through to the TCP path below - // otherwise. See docs/gateway.md::Per-agent unix-socket upstream - // for the gateway-side consumer. - if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET") - && !socket_path.is_empty() - { - let path = PathBuf::from(socket_path); - let listener = bind_unix(&path)?; - tracing::info!(socket = %path.display(), "web UI listening on unix socket"); - axum::serve(listener, app).await?; - return Ok(()); - } - let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = bind_with_retry(addr, "web UI").await?; - tracing::info!(%port, "web UI listening on tcp"); - axum::serve(listener, app).await?; - Ok(()) -} - -/// Bind a `UnixListener` at `path` and drop a `.bound` marker next -/// to it so c0re's gateway-map writer knows the socket is live. -/// Best-effort unlinks any stale socket left from a crashed previous -/// harness (clean exit removes it, but `bind(2)` refuses to overwrite -/// an existing file) and `mkdir -p`s the parent for first-boot. Mode -/// `0o666` — world-accessible so the gateway container's nginx process -/// can `connect(2)` without sharing a group with the agent user. -/// The per-agent subdir (`/run/hive-agent/<name>/`) is only accessible -/// to containers that have it bind-mounted, so world-accessible sockets -/// are not a material risk. -/// -/// Marker-gating + the gateway-side consumer: see -/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md). -fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> { - use std::os::unix::fs::PermissionsExt; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create socket parent dir {}", parent.display()))?; - } - // Best-effort: ENOENT is fine (no stale file); any other error - // surfaces via the bind below with a clearer "AddrInUse" / perms - // message than a partial cleanup would. - let _ = std::fs::remove_file(path); - let listener = tokio::net::UnixListener::bind(path) - .with_context(|| format!("bind unix socket at {}", path.display()))?; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)) - .with_context(|| format!("set perms on {}", path.display()))?; - // Best-effort ready marker: failed write isn't fatal (the harness - // still binds + serves), it just means the gateway side keeps the - // TCP upstream for one more sync tick. - if let Some(parent) = path.parent() { - let marker = parent.join("hyperhive-socket-bound"); - if let Err(e) = std::fs::write(&marker, b"") { - tracing::warn!( - marker = %marker.display(), error = %e, - "failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream" - ); - } - } - Ok(listener) -} - -// --------------------------------------------------------------------------- -// Static assets + state snapshot -// --------------------------------------------------------------------------- - -/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on -/// `AddrInUse` indefinitely with exponential backoff capped at 2s. -/// First 12 attempts log at WARN; subsequent attempts log at INFO so -/// a long-held stale socket doesn't flood the journal. -/// -/// Uncapped retry + dashboard-banner-on-real-collision rationale: -/// see [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md). -async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> { - let mut delay_ms = 250u64; - let mut attempts = 0u32; - loop { - match try_bind(addr) { - Ok(l) => { - if attempts > 0 { - tracing::info!( - %addr, attempts, - "{label}: bind succeeded after retry" - ); - } - return Ok(l); - } - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { - let attempt = attempts + 1; - if attempt <= 12 { - tracing::warn!( - %addr, attempt, - "{label}: AddrInUse, retrying in {delay_ms}ms" - ); - } else { - tracing::info!( - %addr, attempt, - "{label}: AddrInUse still holding, retrying in {delay_ms}ms" - ); - } - tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; - attempts += 1; - delay_ms = (delay_ms * 2).min(2000); - } - Err(e) => { - return Err(e).with_context(|| format!("bind {label} on {addr}")); - } - } - } -} - -fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> { - let sock = match addr { - SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?, - SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?, - }; - sock.set_reuseaddr(true)?; - sock.bind(addr)?; - sock.listen(1024) -} - -/// This agent's icon. Serves the operator-configured SVG from -/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix -/// option) when present, otherwise the bundled default hyperhive logo. -/// Always returns an image, so consumers (dashboard, favicon) can hit -/// `/icon` unconditionally without probing whether one is configured. -async fn serve_icon() -> impl IntoResponse { - // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` - // (set via the `hyperhive.icon` agent.nix option); the bundled - // default is resolved at runtime from - // `$HIVE_ASSETS_DIR/branding/hyperhive.svg`. If neither file can - // be read we serve an empty body — keeps the response a valid SVG - // content-type without a panic on a misconfigured container. - let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| { - std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default() - }); - ([("content-type", "image/svg+xml")], body) -} - -/// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var -/// the harness service sets when gui is enabled (see weston-vnc.nix). -/// `None` when unset (gui not enabled for this agent) or unparseable. -/// The port is a fixed, container-local value — no per-agent hashing, no -/// marker file — because network isolation is unconditional (each agent -/// has its own netns, so the port can't collide across containers). -fn read_gui_vnc_port() -> Option<u16> { - std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() -} - -/// WebSocket handler: upgrade then pump bytes between the WS client and -/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not -/// enabled for this agent. -async fn screen_ws( - ws: axum::extract::ws::WebSocketUpgrade, - State(state): State<AppState>, -) -> Response { - let Some(vnc_port) = state.gui_vnc_port else { - return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response(); - }; - ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port)) -} - -/// Pure byte pump: forwards raw bytes between the WebSocket client and -/// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`). -async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) { - // Import futures traits locally so they don't conflict with - // tokio_stream::StreamExt used at module scope. - use axum::extract::ws::Message; - use futures_util::{SinkExt, StreamExt as _}; - - let addr = format!("127.0.0.1:{vnc_port}"); - let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else { - tracing::warn!(%addr, "screen/ws: could not connect to VNC server"); - return; - }; - let (mut tcp_rx, mut tcp_tx) = tcp.into_split(); - let (mut ws_tx, mut ws_rx) = socket.split(); - - // WS → TCP - let ws_to_tcp = tokio::spawn(async move { - while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await { - match msg { - Message::Binary(data) if tcp_tx.write_all(&data).await.is_err() => { - break; - } - Message::Close(_) => break, - _ => {} // ping/pong/text: ignore - } - } - }); - - // TCP → WS - let tcp_to_ws = tokio::spawn(async move { - let mut buf = vec![0u8; 8192]; - loop { - match tcp_rx.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(n) => { - if ws_tx - .send(Message::Binary(buf[..n].to_vec().into())) - .await - .is_err() - { - break; - } - } - } - } - }); - - // Wait for either direction to close, then let both tasks drop. - tokio::select! { - _ = ws_to_tcp => {} - _ = tcp_to_ws => {} - } -} - -#[derive(Deserialize)] -struct StatsQuery { - window: Option<String>, -} - -async fn api_stats( - State(state): State<AppState>, - axum::extract::Query(q): axum::extract::Query<StatsQuery>, -) -> axum::Json<crate::stats::Snapshot> { - let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); - let mut snapshot = crate::stats::snapshot_default(window); - // Pass the window span to the reminder-stats RPC so the broker - // filters its counts to the same time range as the chart data. - let window_secs = window.span_secs(); - let window_secs_u = u64::try_from(window_secs).unwrap_or(0); - snapshot.reminder_stats = fetch_reminder_stats(&state.socket, window_secs_u).await; - axum::Json(snapshot) -} - -#[derive(Serialize)] -struct StateSnapshot { - /// Bus seq at the moment this snapshot was assembled. Clients dedupe - /// their buffered SSE traffic against this value: events with - /// `seq <= snapshot.seq` are already reflected (or pre-date the - /// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on - /// harness restart — clients treat reconnect as a fresh world. - seq: u64, - label: String, - /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when - /// the host has been configured for a multi-hive swarm; falls back - /// to the short label when the hive domain env var is unset. - /// The frontend uses this for the page title / agent self-introduction; - /// when it equals `label`, the page renders the short form unchanged. - qualified_label: String, - dashboard_port: u16, - /// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`. - status: &'static str, - /// Present when `status == "needs_login_in_progress"`. - session: Option<SessionView>, - /// Last N messages addressed to this agent, newest-first. Pulled - /// from the broker via the per-agent socket on each render. - /// Empty on transport failure. - inbox: Vec<hive_sh4re::InboxRow>, - /// Authoritative turn-loop state from the harness and the unix - /// timestamp the state was entered. The JS computes the age - /// client-side off this rather than tracking it from SSE events. - turn_state: crate::events::TurnState, - turn_state_since: i64, - /// Currently-active claude model name. Reflected on the page so - /// the operator can see what they just switched to (and what's - /// in flight). Mutable at runtime via `POST /api/model`. - model: String, - /// Effective context-window token budget for the current model. - /// Primary source: API-reported `modelUsage.*.contextWindow` from - /// the last result event (authoritative per-inference active window). - /// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000. - /// Consumers (e.g. dashboard badge) use this to render ctx-usage %. - context_window_tokens: u64, - /// Last-inference token usage from the most recent completed - /// turn — represents the current context-window size at turn-end. - /// `null` until the first turn finishes. - ctx_usage: Option<crate::events::TokenUsage>, - /// Cumulative token usage across the most recent turn's inferences - /// (cost signal). `null` until the first turn finishes. - cost_usage: Option<crate::events::TokenUsage>, - /// Navigation links for this agent page. Also served via - /// `DashboardState.links` (`GET /api/dashboard-state`) for the - /// dashboard card's icon strip. Both are produced by `agent_links()` - /// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`] - /// for the frontend resolver + which links appear in which conditions. - links: Vec<AgentLink>, - /// Public URL of the forge served by hive-gateway (e.g. - /// `"https://forge.pr1ma.darkest.space"`). Sourced from - /// `HIVE_FORGE_PUBLIC_URL`; `None` when `forge.behindGateway=false` - /// or the env var is absent. The frontend uses this to build forge - /// nav-strip links instead of hardcoding `<hostname>:3000`. - forge_public_url: Option<String>, - /// Human name of this hive instance (e.g. `"pr1ma"`). Sourced - /// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend - /// uses this for the page `<title>` and header label so browser - /// tabs disambiguate when multiple hives are open in parallel. - hive_name: Option<String>, - /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from - /// `HYPERHIVE_SWARM_NAME`; `None` when unset. - swarm_name: Option<String>, - /// Ordered list of model short-names the operator has declared as - /// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS` - /// (comma-separated, set by `services.hyperhive.availableModels`). - /// Falls back to `["haiku", "sonnet", "opus"]` when the env var is - /// absent or empty. The frontend model quick-picker renders one button - /// per entry in this list, so operators can add new models or drop - /// ones they don't want without touching the frontend code. - available_models: Vec<String>, - /// Currently-active claude effort level. Reflected on the page so the - /// operator's effort picker shows the live selection. Mutable at - /// runtime via `POST /api/effort`; applies on the next session. - effort: String, - /// Selectable effort levels for the picker, ascending. Fixed set - /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from - /// [`crate::events::EFFORT_LEVELS`], not operator-configurable like - /// `available_models`. The frontend renders one button per entry. - available_efforts: Vec<String>, -} - -/// One navigation link in the agent page header row. The same JSON -/// shape appears in both `StateSnapshot.links` (`GET /api/state`, -/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`, -/// dashboard card icon strip). `agent_links()` is the single source -/// of truth for what links an agent exposes. -#[derive(Serialize)] -struct AgentLink { - /// `kind = Container | Forge` → path; `kind = External` → full URL. - /// The frontend prepends the right base before rendering. - url: String, - icon: String, - label: String, - kind: AgentLinkKind, -} - -/// Resolution hint for `AgentLink.url`. The agent backend can't know -/// which hostname the browser sees (especially when the dashboard -/// proxies the call from a different origin), so it labels each link -/// and lets the frontend prepend the right base. -#[derive(Serialize, Clone, Copy)] -#[serde(rename_all = "snake_case")] -enum AgentLinkKind { - /// `url` is a path on the agent's container web UI (`/stats`, - /// `/screen`). Agent page: same-origin path. Dashboard: - /// `http://<host>:<container.port><url>`. - Container, - /// `url` is a path on the local Forgejo (`/<label>`, - /// `/agent-configs/<label>`). Both surfaces: - /// `http://<host>:3000<url>`. - Forge, - /// `url` is already a fully-qualified absolute URL — use as-is. - /// Agent-declared `hyperhive.dashboardLinks` extras arrive here. - External, -} - -#[derive(Serialize)] -struct SessionView { - /// First `https://…` claude emitted on stdout, if any. - url: Option<String>, - /// Accumulated stdout + stderr. - output: String, - finished: bool, - exit_note: Option<String>, -} - -/// Proxy this agent's loose-ends list via the per-agent socket. The -/// web UI surfaces the result as a collapsible section in the page -/// so the operator can see at a glance what's pending against the -/// agent (questions asked by it, peer questions targeting it, -/// reminders it scheduled, approvals for the manager). Same data -/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the -/// container. -async fn api_loose_ends(State(state): State<AppState>) -> Response { - let loose_ends: Vec<hive_sh4re::LooseEnd> = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::GetLooseEnds { agent: None }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends, - Ok(Ok(hive_sh4re::Response::Err { message })) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("get_loose_ends: {message}"), - ); - } - Ok(Ok(other)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("unexpected response: {other:?}"), - ); - } - Ok(Err(e)) => { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("transport: {e:#}"), - ); - } - Err(_) => { - return error_response( - StatusCode::CONFLICT, - "get_loose_ends: timed out — hive-c0re busy, retry", - ); - } - }; - axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() -} - -/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks. -/// -/// The `hive-bash-mcp` daemon runs in this same container and writes one -/// `<id>.json` ([`hive_sh4re::TaskFile`]) per task under the harness -/// `bash-tasks/` dir. This reads that dir and returns the tasks still -/// `Pending` or `Running`, so the agent page can show what's running without -/// going through the broker. Snapshot only — the page polls/refreshes it like -/// `/api/loose-ends`; there's no live SSE push for task state yet. Unreadable -/// or malformed files (incl. the daemon's `.json.tmp` scratch writes, which -/// don't match the `.json` extension) are skipped so one stray file can't -/// fail the whole list. -async fn api_bash_tasks() -> Response { - let dir = crate::paths::harness_dir().join("bash-tasks"); - // The dir scan + per-file reads are blocking fs I/O; run them off the - // async executor so a slow or large tasks dir can't stall other requests. - let tasks = tokio::task::spawn_blocking(move || { - let mut tasks: Vec<hive_sh4re::TaskFile> = Vec::new(); - let Ok(rd) = std::fs::read_dir(&dir) else { - return tasks; - }; - for entry in rd.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { - continue; - } - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - let Ok(task) = serde_json::from_str::<hive_sh4re::TaskFile>(&text) else { - continue; - }; - if matches!( - task.status, - hive_sh4re::TaskStatus::Pending | hive_sh4re::TaskStatus::Running - ) { - tasks.push(task); - } - } - // Running before Pending, then oldest-first so a long-runner sits on top. - tasks.sort_by(|a, b| { - let rank = |s: &hive_sh4re::TaskStatus| match s { - hive_sh4re::TaskStatus::Running => 0, - _ => 1, - }; - rank(&a.status) - .cmp(&rank(&b.status)) - .then(a.created_at.cmp(&b.created_at)) - }); - tasks - }) - .await - .unwrap_or_default(); - axum::Json(serde_json::json!({ "tasks": tasks })).into_response() -} - -async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> { - // Capture seq *before* any reads so the dedupe contract is - // "events with seq > snapshot.seq are post-snapshot, never missed." - let seq = state.bus.current_seq(); - drop_if_finished(&state.session); - let login = *state.login.lock().unwrap(); - let session_snapshot = state.session.lock().unwrap().clone(); - let (status, session_view) = match (login, session_snapshot) { - (LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None), - (LoginState::Online, _) => ("online", None), - (LoginState::NeedsLogin, None) => ("needs_login_idle", None), - (LoginState::NeedsLogin, Some(s)) => ( - "needs_login_in_progress", - Some(SessionView { - url: s.url(), - output: s.output(), - finished: s.finished(), - exit_note: s.exit_note(), - }), - ), - }; - let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") - .ok() - .and_then(|s| s.parse::<u16>().ok()) - .unwrap_or(7000); - let inbox = recent_inbox(&state.socket).await; - let (turn_state, turn_state_since) = state.bus.state_snapshot(); - let model = state.bus.model(); - let context_window_tokens = state - .bus - .api_context_window() - .unwrap_or_else(|| crate::events::context_window_tokens(&model)); - let ctx_usage = state.bus.last_ctx_usage(); - let cost_usage = state.bus.last_cost_usage(); - let effort = state.bus.effort(); - axum::Json(StateSnapshot { - seq, - label: state.label.clone(), - qualified_label: crate::identity::qualify(&state.label), - dashboard_port, - status, - session: session_view, - inbox, - turn_state, - turn_state_since, - model, - context_window_tokens, - ctx_usage, - cost_usage, - links: agent_links(&state.label, state.gui_vnc_port.is_some()), - forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") - .ok() - .filter(|s| !s.is_empty()), - hive_name: crate::identity::hive_name(), - swarm_name: crate::identity::swarm_name(), - available_models: available_models(), - effort, - available_efforts: crate::events::EFFORT_LEVELS - .iter() - .map(ToString::to_string) - .collect(), - }) -} - -/// Lean snapshot of the agent-owned fields that the dashboard card -/// needs. Served at `GET /api/dashboard-state` (accessible through the -/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard -/// fetches this once per running agent to get fresh, agent-authoritative -/// values instead of relying on hive-c0re's periodic file-reads. -/// -/// Structural fields (running, `needs_update`, `deployed_sha`, parent, …) -/// continue to come from hive-c0re's `/api/state`; this endpoint covers -/// only the fields the agent itself is the source of truth for. -#[derive(serde::Serialize)] -struct DashboardState { - /// Free-text status set by `set_status`, read directly from the - /// `hyperhive-status` file the harness writes. `None` when unset. - #[serde(skip_serializing_if = "Option::is_none")] - status_text: Option<String>, - /// Unix timestamp (seconds) when the status file was last written. - /// `None` when no status is set. - #[serde(skip_serializing_if = "Option::is_none")] - status_set_at: Option<i64>, - /// Full context-window size from the most recent completed turn - /// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation). - /// `None` until the first turn finishes. Drives the `ctx·Nk` card badge. - #[serde(skip_serializing_if = "Option::is_none")] - ctx_tokens: Option<u64>, - /// Effective context-window budget for the current model. Same - /// derivation as `StateSnapshot::context_window_tokens`. - context_window_tokens: u64, - /// True while the harness is parked after a rate-limit response. - rate_limited: bool, - /// Navigation links for the dashboard card's icon strip. This is - /// the authoritative source — includes the screen link (GUI agents) - /// which hive-c0re's disk-based fallback cannot determine. - links: Vec<AgentLink>, -} - -/// Read the agent's own free-text status and the timestamp when it was -/// set, directly from the `hyperhive-status` file in the state dir. -/// Mirrors `hive_c0re::container_view::read_agent_status` but runs -/// inside the agent container using its own state dir. -fn read_own_status() -> (Option<String>, Option<i64>) { - let path = crate::paths::state_dir().join("hyperhive-status"); - let meta = std::fs::metadata(&path).ok(); - let text = std::fs::read_to_string(&path) - .ok() - .as_deref() - .map(str::trim) - .filter(|t| !t.is_empty()) - .map(str::to_owned); - let mtime = meta.and_then(|m| { - m.modified().ok().and_then(|t| { - t.duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|d| i64::try_from(d.as_secs()).ok()) - }) - }); - if text.is_none() { - (None, None) - } else { - (text, mtime) - } -} - -async fn api_dashboard_state(State(state): State<AppState>) -> axum::Json<DashboardState> { - let (status_text, status_set_at) = read_own_status(); - let rate_limited = state.bus.is_rate_limited(); - let model = state.bus.model(); - let context_window_tokens = state - .bus - .api_context_window() - .unwrap_or_else(|| crate::events::context_window_tokens(&model)); - // Full context-window size = input + cache-read + cache-creation. Using - // raw `input_tokens` here reported only the *uncached* sliver, which is - // ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match - // the agent page (which ships the whole `ctx_usage` and sums it) and the - // cache-TTL logic in turn.rs, both of which use `context_tokens()`. - let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens()); - axum::Json(DashboardState { - status_text, - status_set_at, - ctx_tokens, - context_window_tokens, - rate_limited, - links: agent_links(&state.label, state.gui_vnc_port.is_some()), - }) -} - -/// Build the navigation link list for the agent page header. URLs -/// are paths (relative) for `Container`/`Forge` targets and absolute -/// for `External`; the frontend resolves each against its `kind` -/// against the right base so the backend never has to guess the -/// operator's browser host. See -/// [`docs/web-ui/dashboard.md::Container row`](../../../docs/web-ui/dashboard.md) for -/// the resolver + how `deployed:<sha>` ships alongside. -fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> { - let mut links = Vec::new(); - - links.push(AgentLink { - url: "stats.html".to_owned(), - icon: "📊".to_owned(), - label: "stats".to_owned(), - kind: AgentLinkKind::Container, - }); - - if gui_enabled { - links.push(AgentLink { - url: "screen.html".to_owned(), - icon: "🖥".to_owned(), - label: "screen".to_owned(), - kind: AgentLinkKind::Container, - }); - } - - if crate::paths::state_dir().join("forge-token").is_file() { - links.push(AgentLink { - url: format!("/{label}"), - icon: "⬡".to_owned(), - label: "forge".to_owned(), - kind: AgentLinkKind::Forge, - }); - links.push(AgentLink { - url: format!("/agent-configs/{label}"), - icon: "↳".to_owned(), - label: "config".to_owned(), - kind: AgentLinkKind::Forge, - }); - } - - // Agent-declared extras (`hyperhive.dashboardLinks` → the - // `hive-dashboard-links` NixOS oneshot writes them to - // `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk - // is `{label, icon, url}` with absolute URLs — those become - // `kind = External` links, passed through verbatim. - let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json"); - if let Ok(text) = std::fs::read_to_string(&extras_path) - && !text.trim().is_empty() - && let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text) - { - for e in extras { - links.push(AgentLink { - url: e.url, - icon: e.icon, - label: e.label, - kind: AgentLinkKind::External, - }); - } - } - - links -} - -/// On-disk shape of `hyperhive-dashboard-links.json` (the -/// `hive-dashboard-links` NixOS oneshot's output). Mapped to -/// `AgentLink { kind: External }` inside `agent_links`. -#[derive(serde::Deserialize)] -struct ExtraLink { - label: String, - #[serde(default)] - icon: String, - url: String, -} - -/// Best-effort: pull the last 30 messages addressed to us via the -/// per-agent / manager socket. Empty list on any transport / decode -/// failure — the inbox section is decorative, not authoritative. -async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> { - const LIMIT: u64 = 30; - // Deadline-bounded: `/api/state` must render even when hive-c0re is - // busy — an empty inbox section beats a hung snapshot. - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::Recent { limit: LIMIT }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Recent { rows })) => rows, - _ => Vec::new(), - } -} - -/// Fetch reminder activity stats from the broker via the per-agent / -/// manager socket. Returns None on any transport / decode failure — the -/// stats are decorative, not authoritative. -async fn fetch_reminder_stats( - socket: &std::path::Path, - window_secs: u64, -) -> Option<hive_sh4re::ReminderStats> { - match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - socket, - &hive_sh4re::Request::ReminderRollup { - since_secs: window_secs, - agent: None, - }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::ReminderRollup(stats))) => Some(stats), - _ => None, - } -} - -// --------------------------------------------------------------------------- -// Action handlers -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -struct SendForm { - body: String, -} - -async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> Response { - let body = form.body.trim().to_owned(); - if body.is_empty() { - return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); - } - let result = match tokio::time::timeout( - SOCKET_FETCH_TIMEOUT, - client::request::<_, hive_sh4re::Response>( - &state.socket, - &hive_sh4re::Request::OperatorMsg { body }, - ), - ) - .await - { - Ok(Ok(hive_sh4re::Response::Ok)) => Ok(()), - Ok(Ok(hive_sh4re::Response::Err { message })) => Err(message), - Ok(Ok(other)) => Err(format!("unexpected response: {other:?}")), - Ok(Err(e)) => Err(format!("transport: {e:#}")), - Err(_) => Err("timed out — hive-c0re busy, retry".to_owned()), - }; - match result { - // 200 instead of 303 → the client doesn't refetch /api/state. - // The operator message becomes a broker `Sent` (already shown - // server-side in the dashboard); on the agent side, the - // resulting `TurnStart` SSE event drives the terminal + the - // inbox row gets consumed by the time `TurnEnd` fires the - // existing turn-end refresh. - Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), - Err(e) => error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("send failed: {e}"), - ), - } -} - -/// Query params for the paginated history endpoint. -#[derive(Debug, Deserialize)] -struct HistoryParams { - /// Cursor: only return events with sqlite row id < `before`. - /// Omit for the initial (most-recent) page. - before: Option<i64>, - /// Page size (default 100, capped at `HISTORY_CAPACITY`). - limit: Option<usize>, -} - -async fn events_history( - State(state): State<AppState>, - axum::extract::Query(params): axum::extract::Query<HistoryParams>, -) -> axum::Json<serde_json::Value> { - use crate::events::HISTORY_CAPACITY; - let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY); - let before = params.before; - let is_initial = before.is_none(); - - // Capture seq *before* the read on initial loads so the SSE dedupe - // window is "drop buffered events you've already seen in history", - // never "lose an event that fired between the read and the seq." - // On paginated loads (`before` is set) seq is not needed. - let seq = if is_initial { - Some(state.bus.current_seq()) - } else { - None - }; - - let (events, min_id, has_more) = state.bus.history_page(before, limit); - let mut resp = serde_json::json!({ - "events": events, - "min_id": min_id, - "has_more": has_more, - }); - if let Some(s) = seq { - resp["seq"] = serde_json::json!(s); - } - axum::Json(resp) -} - -async fn events_stream( - State(state): State<AppState>, -) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { - tracing::info!("sse: client subscribed"); - let rx = state.bus.subscribe(); - // Drop a "hello" note into the bus so every new subscriber sees at - // least one event immediately and can clear the connecting placeholder. - state.bus.emit(crate::events::LiveEvent::Note { - text: "live stream attached".into(), - }); - let stream = BroadcastStream::new(rx).filter_map(|res| { - let ev = res.ok()?; - let json = serde_json::to_string(&ev).ok()?; - Some(Ok(Event::default().data(json))) - }); - Sse::new(stream).keep_alive(KeepAlive::default()) -} - -async fn post_login_start(State(state): State<AppState>) -> Response { - drop_if_finished(&state.session); - { - let guard = state.session.lock().unwrap(); - if guard.is_some() { - return (axum::http::StatusCode::OK, "ok").into_response(); - } - } - match LoginSession::start() { - Ok(session) => { - *state.session.lock().unwrap() = Some(Arc::new(session)); - // Flip status from needs_login_idle → needs_login_in_progress - // so the web UI's badge + polling kick in (polling is still - // the right tool for the streaming session output during - // the login flow itself; events drop the poll for - // *everything else*). - state.bus.emit_status("needs_login_in_progress"); - (axum::http::StatusCode::OK, "ok").into_response() - } - Err(e) => error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("login start failed: {e:#}"), - ), - } -} - -#[derive(Deserialize)] -struct CodeForm { - code: String, -} - -async fn post_login_code(State(state): State<AppState>, Form(form): Form<CodeForm>) -> Response { - let session = state.session.lock().unwrap().clone(); - let Some(session) = session else { - return error_response(StatusCode::CONFLICT, "no login session running"); - }; - if let Err(e) = session.submit_code(&form.code).await { - return error_response( - StatusCode::INTERNAL_SERVER_ERROR, - &format!("submit code failed: {e:#}"), - ); - } - (axum::http::StatusCode::OK, "ok").into_response() -} - -async fn post_login_cancel(State(state): State<AppState>) -> Response { - let session = state.session.lock().unwrap().take(); - if let Some(session) = session { - session.close_stdin().await; - session.kill(); - } - // Back to needs_login_idle (LoginState unchanged, session gone). - state.bus.emit_status("needs_login_idle"); - (axum::http::StatusCode::OK, "ok").into_response() -} - -/// Operator-initiated session compaction. Spawns `turn::compact_session` -/// in the background — the HTTP handler returns immediately so the -/// async-form spinner can clear. Output (claude's compaction stream, -/// the "/compact done" note) lands in the live event panel like any -/// other turn. If a regular turn is in flight, claude's own session -/// lock will reject this one and we surface the error as a Note. -#[derive(Deserialize)] -struct ModelForm { - model: String, -} - -/// Switch the model for future turns. The current turn (if any) -/// keeps its model; `/model <name>` applies starting with the next -/// `recv` cycle. Empty / whitespace-only inputs are rejected. No -/// claude-side validation — we just hand the string through to -/// `claude --model <name>`; an unknown model surfaces as a turn -/// failure in the live panel and the operator can revert. -async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelForm>) -> Response { - let name = form.model.trim(); - if name.is_empty() { - return error_response(StatusCode::BAD_REQUEST, "model: name required"); - } - state.bus.set_model(name); - state.bus.emit(crate::events::LiveEvent::Note { - text: format!("operator: /model — claude model set to '{name}' for future turns"), - }); - tracing::info!(%name, "operator set model"); - (axum::http::StatusCode::OK, "ok").into_response() -} - -#[derive(Deserialize)] -struct EffortForm { - effort: String, -} - -/// Switch the claude effort level for future sessions. Operator-only -/// (the dashboard picker POSTs here through the gateway). Validated -/// server-side against [`crate::events::EFFORT_LEVELS`] — an out-of-set -/// value is rejected rather than handed to `claude --effort`, since an -/// unknown level would fail every subsequent launch. Applies on the next -/// session start (no mid-session swap). -async fn post_set_effort(State(state): State<AppState>, Form(form): Form<EffortForm>) -> Response { - let level = form.effort.trim(); - if !crate::events::is_valid_effort(level) { - return error_response( - StatusCode::BAD_REQUEST, - &format!( - "effort: level must be one of {}", - crate::events::EFFORT_LEVELS.join(", ") - ), - ); - } - state.bus.set_effort(level); - state.bus.emit(crate::events::LiveEvent::Note { - text: format!("operator: /effort — claude effort set to '{level}' for future sessions"), - }); - tracing::info!(%level, "operator set effort"); - (axum::http::StatusCode::OK, "ok").into_response() -} - -async fn post_compact(State(state): State<AppState>) -> Response { - // Clone the Arc before locking so the guard's lifetime is tied to the - // clone (which we can move into the spawn) rather than to `state`. - let lock = state.turn_lock.clone(); - // Reject immediately if a normal turn is in flight — concurrent access - // to the claude session is unsafe and produces garbled output. - let Ok(guard) = lock.try_lock_owned() else { - return error_response( - StatusCode::CONFLICT, - "turn in flight — wait for it to finish before compacting", - ); - }; - let bus = state.bus.clone(); - let files = state.files.clone(); - tokio::spawn(async move { - let _guard = guard; // keep lock alive for the duration of compaction - bus.emit(crate::events::LiveEvent::Note { - text: "operator: /compact — running on persistent session".into(), - }); - bus.set_state(crate::events::TurnState::Compacting); - let outcome = crate::turn::compact_session(&files, &bus).await; - bus.set_state(crate::events::TurnState::Idle); - // Best-effort manual /compact from the operator: compact_session - // already emits a Note per outcome, so we don't need to re-emit - // here — just record any underlying error to the harness log. - if let crate::turn::TurnOutcome::Failed(e) = outcome { - tracing::warn!(error = %format!("{e:#}"), "operator /compact failed"); - } - }); - (axum::http::StatusCode::OK, "ok").into_response() -} - -/// Cancel the in-flight claude turn. Coarse-grained: shells out -/// `pkill -INT claude` since there's at most one claude per container. -/// SIGINT (not SIGTERM) so claude flushes anything in-flight and emits a -/// final result row. Emits a Note so the operator sees the cancel -/// landed; the actual state transition back to `idle` happens when -/// `run_claude` wakes up and the harness emits `TurnEnd`. -/// Arm a one-shot: the next claude turn drops `--continue`, starting a -/// fresh session. Subsequent turns resume normal `--continue` -/// behavior. Idempotent before the next turn fires — calling twice -/// still results in a single fresh start. Useful when the -/// session-resume context is poisoned (claude went off the rails, -/// hit an unrecoverable refusal, etc.) and a full reset is cheaper -/// than asking claude to forget mid-stream. -async fn post_new_session(State(state): State<AppState>) -> Response { - state.bus.request_new_session(); - state.bus.emit(crate::events::LiveEvent::Note { - text: "operator: new session armed — next turn runs without --continue".into(), - }); - (axum::http::StatusCode::OK, "ok").into_response() -} - -/// OAuth credential filenames inside `paths::claude_dir()`. Wiping -/// only these (and not the rest of `~/.claude/`) preserves session -/// history so `claude --continue` keeps working after a fresh login. -/// Rationale + the previous wholesale-wipe shape we replaced live in -/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) -/// (the `/api/logout` bullet). -const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; - -/// Operator-driven `/logout`: SIGINT claude, delete the credential -/// files in `CRED_FILE_NAMES`, flip `LoginState::NeedsLogin`. The -/// turn loop's next iteration parks into `wait_for_login` which -/// resumes when a fresh credentials file appears via `/login/code`. -/// Always returns 200 with a body describing what happened. See -/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) -/// (the `/api/logout` bullet) for the three-step rationale + -/// preservation invariants. -async fn post_logout(State(state): State<AppState>) -> Response { - // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). - let _ = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; - // Step 2: delete OAuth credential files only — preserve session - // history files alongside them. - let dir = crate::paths::claude_dir(); - let mut warnings: Vec<String> = Vec::new(); - let mut wiped: Vec<&str> = Vec::new(); - for name in CRED_FILE_NAMES { - let path = dir.join(name); - match tokio::fs::remove_file(&path).await { - Ok(()) => wiped.push(name), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // Already gone — operator clicked /logout while - // already logged out, or the file simply didn't exist - // for this agent. Idempotent. - } - Err(e) => warnings.push(format!("{name}: {e}")), - } - } - let wipe_summary = if wiped.is_empty() { - "no credential files present (already logged out)".to_owned() - } else { - format!("wiped {}", wiped.join(", ")) - }; - let warn_suffix = if warnings.is_empty() { - String::new() - } else { - format!(" (warnings: {})", warnings.join("; ")) - }; - // Step 3: flip LoginState + emit Note. Turn loop sees the flip on - // its next iteration and parks into wait_for_login. - *state.login.lock().unwrap() = LoginState::NeedsLogin; - state.bus.emit(crate::events::LiveEvent::Note { - text: format!( - "operator: /logout — {wipe_summary} in {}{warn_suffix}", - dir.display() - ), - }); - state.bus.emit_status("needs_login_idle"); - ( - axum::http::StatusCode::OK, - format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()), - ) - .into_response() -} - -async fn post_cancel_turn(State(state): State<AppState>) -> Response { - let out = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; - let note = match out { - Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(), - Ok(o) if o.status.code() == Some(1) => { - "operator: /cancel — no claude process to interrupt".to_owned() - } - Ok(o) => format!( - "operator: /cancel — pkill exited {} stderr={}", - o.status, - String::from_utf8_lossy(&o.stderr).trim() - ), - Err(e) => format!("operator: /cancel — pkill failed: {e}"), - }; - state - .bus - .emit(crate::events::LiveEvent::Note { text: note }); - (axum::http::StatusCode::OK, "ok").into_response() -} - -fn error_response(status: StatusCode, message: &str) -> Response { - // Plain text — JS app surfaces in `alert()`, HTML wrapping would just - // be noise. Status is per-caller: 400 for bad input, 409 for a - // retryable state conflict (turn in flight / hive-c0re busy), 500 only - // for a genuine server/transport failure — the frontend shows the code - // in its alert, so a benign "busy, retry" must not read as a 500. - (status, message.to_owned()).into_response() -} - -/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by -/// `services.hyperhive.availableModels`) and return the parsed list. -/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent -/// or resolves to an empty list after trimming. -fn available_models() -> Vec<String> { - const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; - let raw = match std::env::var("HIVE_AVAILABLE_MODELS") { - Ok(v) if !v.trim().is_empty() => v, - _ => return DEFAULT.iter().map(ToString::to_string).collect(), - }; - let models: Vec<String> = raw - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - if models.is_empty() { - DEFAULT.iter().map(ToString::to_string).collect() - } else { - models - } -} diff --git a/hive-ag3nt/src/web_ui/actions.rs b/hive-ag3nt/src/web_ui/actions.rs new file mode 100644 index 00000000..49fce257 --- /dev/null +++ b/hive-ag3nt/src/web_ui/actions.rs @@ -0,0 +1,172 @@ +//! Operator action POST handlers (send, cancel, compact, model, effort, reset). + +use axum::{ + Form, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use crate::client; + +use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; + +#[derive(Deserialize)] +pub(super) struct SendForm { + body: String, +} + +pub(super) async fn post_send( + State(state): State<AppState>, + Form(form): Form<SendForm>, +) -> Response { + let body = form.body.trim().to_owned(); + if body.is_empty() { + return error_response(StatusCode::BAD_REQUEST, "send: `body` required"); + } + let result = match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + client::request::<_, hive_sh4re::Response>( + &state.socket, + &hive_sh4re::Request::OperatorMsg { body }, + ), + ) + .await + { + Ok(Ok(hive_sh4re::Response::Ok)) => Ok(()), + Ok(Ok(hive_sh4re::Response::Err { message })) => Err(message), + Ok(Ok(other)) => Err(format!("unexpected response: {other:?}")), + Ok(Err(e)) => Err(format!("transport: {e:#}")), + Err(_) => Err("timed out — hive-c0re busy, retry".to_owned()), + }; + match result { + // 200 instead of 303 → the client doesn't refetch /api/state. + // The operator message becomes a broker `Sent` (already shown + // server-side in the dashboard); on the agent side, the + // resulting `TurnStart` SSE event drives the terminal + the + // inbox row gets consumed by the time `TurnEnd` fires the + // existing turn-end refresh. + Ok(()) => (axum::http::StatusCode::OK, "ok").into_response(), + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("send failed: {e}"), + ), + } +} + +pub(super) async fn post_cancel_turn(State(state): State<AppState>) -> Response { + let out = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await; + let note = match out { + Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(), + Ok(o) if o.status.code() == Some(1) => { + "operator: /cancel — no claude process to interrupt".to_owned() + } + Ok(o) => format!( + "operator: /cancel — pkill exited {} stderr={}", + o.status, + String::from_utf8_lossy(&o.stderr).trim() + ), + Err(e) => format!("operator: /cancel — pkill failed: {e}"), + }; + state + .bus + .emit(crate::events::LiveEvent::Note { text: note }); + (axum::http::StatusCode::OK, "ok").into_response() +} + +/// Operator-initiated `/compact`. Deferred: sets the `compact_pending` flag +/// that `turn::drive_turn` consumes at the end of the current/next turn, so it +/// works while a turn is in flight (a mid-turn compaction would race the live +/// claude process) rather than only when the agent is idle. Returns 200 +/// immediately; the compaction stream lands in the live panel when it runs. +pub(super) async fn post_compact(State(state): State<AppState>) -> Response { + state.bus.request_compact(); + state.bus.emit(crate::events::LiveEvent::Note { + text: "operator: /compact queued — runs at the end of the current turn".into(), + }); + (axum::http::StatusCode::OK, "ok").into_response() +} + +/// Request a session reset. The current session is archived (its backing +/// `<uuid>.jsonl` renamed out of claude's resolution glob) at the next turn +/// boundary, so the following turn's `--resume` misses and self-heals into a +/// freshly-named session. History is preserved on disk, not deleted. +/// +/// Deferred (a one-shot flag consumed by `drive_turn`) rather than applied +/// here: renaming the session file while a claude turn is mid-write would +/// race the live process. Between turns there is no open session file (one +/// claude per container, serialized by the serve loop), so the archive is +/// safe there. Useful when the session-resume context is poisoned (claude +/// went off the rails, hit an unrecoverable refusal, etc.) and a full reset +/// is cheaper than asking claude to forget mid-stream. +pub(super) async fn post_new_session(State(state): State<AppState>) -> Response { + state.bus.request_session_reset(); + state.bus.emit(crate::events::LiveEvent::Note { + text: "operator: session reset queued — takes effect at the next turn".into(), + }); + (axum::http::StatusCode::OK, "ok").into_response() +} + +#[derive(Deserialize)] +pub(super) struct ModelForm { + model: String, +} + +/// Switch the model for future turns. The current turn (if any) +/// keeps its model; `/model <name>` applies starting with the next +/// `recv` cycle. Empty / whitespace-only inputs are rejected. No +/// claude-side validation — we just hand the string through to +/// `claude --model <name>`; an unknown model surfaces as a turn +/// failure in the live panel and the operator can revert. +pub(super) async fn post_set_model( + State(state): State<AppState>, + Form(form): Form<ModelForm>, +) -> Response { + let name = form.model.trim(); + if name.is_empty() { + return error_response(StatusCode::BAD_REQUEST, "model: name required"); + } + state.bus.set_model(name); + state.bus.emit(crate::events::LiveEvent::Note { + text: format!("operator: /model — claude model set to '{name}' for future turns"), + }); + tracing::info!(%name, "operator set model"); + (axum::http::StatusCode::OK, "ok").into_response() +} + +#[derive(Deserialize)] +pub(super) struct EffortForm { + effort: String, +} + +/// Switch the claude effort level for future sessions. Operator-only +/// (the dashboard picker POSTs here through the gateway). Validated +/// server-side against [`crate::events::EFFORT_LEVELS`] — an out-of-set +/// value is rejected rather than handed to `claude --effort`, since an +/// unknown level would fail every subsequent launch. Applies on the next +/// session start (no mid-session swap). +pub(super) async fn post_set_effort( + State(state): State<AppState>, + Form(form): Form<EffortForm>, +) -> Response { + let level = form.effort.trim(); + if !crate::events::is_valid_effort(level) { + return error_response( + StatusCode::BAD_REQUEST, + &format!( + "effort: level must be one of {}", + crate::events::EFFORT_LEVELS.join(", ") + ), + ); + } + state.bus.set_effort(level); + state.bus.emit(crate::events::LiveEvent::Note { + text: format!("operator: /effort — claude effort set to '{level}' for future sessions"), + }); + tracing::info!(%level, "operator set effort"); + (axum::http::StatusCode::OK, "ok").into_response() +} diff --git a/hive-ag3nt/src/web_ui/auth.rs b/hive-ag3nt/src/web_ui/auth.rs new file mode 100644 index 00000000..f3e175e0 --- /dev/null +++ b/hive-ag3nt/src/web_ui/auth.rs @@ -0,0 +1,141 @@ +//! Login / logout flow handlers (`/login/*`, `/api/logout`). + +use std::sync::Arc; + +use axum::{ + Form, + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; + +use crate::login::LoginState; +use crate::login_session::{LoginSession, drop_if_finished}; + +use super::{AppState, error_response}; + +pub(super) async fn post_login_start(State(state): State<AppState>) -> Response { + drop_if_finished(&state.session); + { + let guard = state.session.lock().unwrap(); + if guard.is_some() { + return (axum::http::StatusCode::OK, "ok").into_response(); + } + } + match LoginSession::start() { + Ok(session) => { + *state.session.lock().unwrap() = Some(Arc::new(session)); + // Flip status from needs_login_idle → needs_login_in_progress + // so the web UI's badge + polling kick in (polling is still + // the right tool for the streaming session output during + // the login flow itself; events drop the poll for + // *everything else*). + state.bus.emit_status("needs_login_in_progress"); + (axum::http::StatusCode::OK, "ok").into_response() + } + Err(e) => error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("login start failed: {e:#}"), + ), + } +} + +#[derive(Deserialize)] +pub(super) struct CodeForm { + code: String, +} + +pub(super) async fn post_login_code( + State(state): State<AppState>, + Form(form): Form<CodeForm>, +) -> Response { + let session = state.session.lock().unwrap().clone(); + let Some(session) = session else { + return error_response(StatusCode::CONFLICT, "no login session running"); + }; + if let Err(e) = session.submit_code(&form.code).await { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("submit code failed: {e:#}"), + ); + } + (axum::http::StatusCode::OK, "ok").into_response() +} + +pub(super) async fn post_login_cancel(State(state): State<AppState>) -> Response { + let session = state.session.lock().unwrap().take(); + if let Some(session) = session { + session.close_stdin().await; + session.kill(); + } + // Back to needs_login_idle (LoginState unchanged, session gone). + state.bus.emit_status("needs_login_idle"); + (axum::http::StatusCode::OK, "ok").into_response() +} + +/// OAuth credential filenames inside `paths::claude_dir()`. Wiping +/// only these (and not the rest of `~/.claude/`) preserves session +/// history so `claude --continue` keeps working after a fresh login. +/// Rationale + the previous wholesale-wipe shape we replaced live in +/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) +/// (the `/api/logout` bullet). +const CRED_FILE_NAMES: &[&str] = &[".credentials.json", "mcp-needs-auth-cache.json"]; + +/// Operator-driven `/logout`: SIGINT claude, delete the credential +/// files in `CRED_FILE_NAMES`, flip `LoginState::NeedsLogin`. The +/// turn loop's next iteration parks into `wait_for_login` which +/// resumes when a fresh credentials file appears via `/login/code`. +/// Always returns 200 with a body describing what happened. See +/// [`docs/web-ui/agent.md::Per-agent endpoints`](../../../docs/web-ui/agent.md) +/// (the `/api/logout` bullet) for the three-step rationale + +/// preservation invariants. +pub(super) async fn post_logout(State(state): State<AppState>) -> Response { + // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). + let _ = tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await; + // Step 2: delete OAuth credential files only — preserve session + // history files alongside them. + let dir = crate::paths::claude_dir(); + let mut warnings: Vec<String> = Vec::new(); + let mut wiped: Vec<&str> = Vec::new(); + for name in CRED_FILE_NAMES { + let path = dir.join(name); + match tokio::fs::remove_file(&path).await { + Ok(()) => wiped.push(name), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Already gone — operator clicked /logout while + // already logged out, or the file simply didn't exist + // for this agent. Idempotent. + } + Err(e) => warnings.push(format!("{name}: {e}")), + } + } + let wipe_summary = if wiped.is_empty() { + "no credential files present (already logged out)".to_owned() + } else { + format!("wiped {}", wiped.join(", ")) + }; + let warn_suffix = if warnings.is_empty() { + String::new() + } else { + format!(" (warnings: {})", warnings.join("; ")) + }; + // Step 3: flip LoginState + emit Note. Turn loop sees the flip on + // its next iteration and parks into wait_for_login. + *state.login.lock().unwrap() = LoginState::NeedsLogin; + state.bus.emit(crate::events::LiveEvent::Note { + text: format!( + "operator: /logout — {wipe_summary} in {}{warn_suffix}", + dir.display() + ), + }); + state.bus.emit_status("needs_login_idle"); + ( + axum::http::StatusCode::OK, + format!("ok: {wipe_summary} in {}{warn_suffix}", dir.display()), + ) + .into_response() +} diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs new file mode 100644 index 00000000..1827e3da --- /dev/null +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -0,0 +1,269 @@ +//! Per-container HTTP UI. SPA shape: `GET /` returns a static shell; +//! `GET /static/*` serves CSS + JS; `GET /api/state` returns the page +//! state as JSON; the JS app renders. Live events stream on +//! `/events/stream`. Action POSTs (`/send`, `/login/*`) return either a +//! 303 Redirect (for browsers that submit the form normally) or just +//! 200 OK — the JS app re-fetches `/api/state` afterwards. +//! +//! Handlers are split by concern into the submodules below; this file owns the +//! shared [`AppState`], the listener + router wiring in [`serve`], and a couple +//! of small shared helpers ([`error_response`], [`SOCKET_FETCH_TIMEOUT`]). + +mod actions; +mod auth; +mod screen; +mod state; +mod stats; +mod stream; + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use anyhow::{Context, Result}; +use axum::{ + Router, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use tower_http::services::ServeDir; + +use crate::events::Bus; +use crate::login::LoginState; +use crate::login_session::LoginSession; + +/// Deadline for broker-backed fetches on web-UI request paths. The +/// page's critical fields (status, turn state, usage) are all +/// in-memory; a busy or stalled hive-c0re must degrade the +/// socket-backed extras (inbox rows, loose ends, reminder stats) +/// instead of hanging the whole response — an unbounded await here is +/// what let `/api/state` stall long enough to bork the terminal. +const SOCKET_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); + +/// Live login state for the web UI. The harness updates this in place as it +/// transitions between `NeedsLogin` and `Online`; the UI reads on each +/// render. +pub type LoginStateCell = Arc<Mutex<LoginState>>; + +#[derive(Clone)] +struct AppState { + label: String, + login: LoginStateCell, + session: Arc<Mutex<Option<Arc<LoginSession>>>>, + bus: Bus, + socket: PathBuf, + /// VNC port from the `HIVE_GUI_VNC_PORT` env var at startup. + /// `None` when unset (gui not enabled for this agent). + gui_vnc_port: Option<u16>, +} + +/// Bind the per-container web listener and serve the SPA. +/// +/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the +/// dual-mode transition + gateway-side consumer live in +/// [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md) and +/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md). +/// +/// # Errors +/// +/// Returns an error if neither the TCP listener (default) nor the +/// unix-socket bind (`HIVE_WEB_SOCKET`, if set) can be acquired, or +/// if `HIVE_STATIC_DIR` is missing. +pub async fn serve( + label: String, + port: u16, + login: LoginStateCell, + bus: Bus, + socket: PathBuf, +) -> Result<()> { + let gui_vnc_port = read_gui_vnc_port(); + let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR") + .map(PathBuf::from) + .context( + "HIVE_STATIC_DIR env var not set — point it at the merged \ + per-agent dist (see hyperhive.frontend.mergedDist in nix)", + )?; + if !static_dir.is_dir() { + anyhow::bail!( + "HIVE_STATIC_DIR ({}) is not a directory", + static_dir.display() + ); + } + tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved"); + let state = AppState { + label, + login, + session: Arc::new(Mutex::new(None)), + bus, + socket, + gui_vnc_port, + }; + let app = Router::new() + .route("/api/state", get(state::api_state)) + .route("/api/dashboard-state", get(state::api_dashboard_state)) + .route("/events/stream", get(stream::events_stream)) + .route("/events/history", get(stream::events_history)) + .route("/send", post(actions::post_send)) + .route("/login/start", post(auth::post_login_start)) + .route("/login/code", post(auth::post_login_code)) + .route("/login/cancel", post(auth::post_login_cancel)) + .route("/api/cancel", post(actions::post_cancel_turn)) + .route("/api/compact", post(actions::post_compact)) + .route("/api/model", post(actions::post_set_model)) + .route("/api/effort", post(actions::post_set_effort)) + .route("/api/new-session", post(actions::post_new_session)) + .route("/api/logout", post(auth::post_logout)) + .route("/api/loose-ends", get(stats::api_loose_ends)) + .route("/api/bash-tasks", get(stats::api_bash_tasks)) + .route("/api/stats", get(stats::api_stats)) + .route("/screen/ws", get(screen::screen_ws)) + .route("/icon", get(screen::serve_icon)) + // Anything else (`/`, `/stats`, `/screen`, `/static/*`) + // falls through to the merged dist. ServeDir auto-appends + // `.html` when the URL is a bare path that matches a file + // (so `/stats` → `dist/stats.html`, `/screen` → `dist/ + // screen.html`). Per-agent `extraFiles` additions are + // already layered into this same directory (see + // hyperhive.frontend.mergedDist in nix). + .fallback_service(ServeDir::new(&static_dir)) + .with_state(state); + // `HIVE_WEB_SOCKET` opt-in: when set + non-empty, bind a + // `UnixListener` at the given path. Empty string treated as + // unset so a stray `HIVE_WEB_SOCKET=` doesn't trap us into an + // un-bindable empty path. Falls through to the TCP path below + // otherwise. See docs/gateway.md::Per-agent unix-socket upstream + // for the gateway-side consumer. + if let Some(socket_path) = std::env::var_os("HIVE_WEB_SOCKET") + && !socket_path.is_empty() + { + let path = PathBuf::from(socket_path); + let listener = bind_unix(&path)?; + tracing::info!(socket = %path.display(), "web UI listening on unix socket"); + axum::serve(listener, app).await?; + return Ok(()); + } + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + let listener = bind_with_retry(addr, "web UI").await?; + tracing::info!(%port, "web UI listening on tcp"); + axum::serve(listener, app).await?; + Ok(()) +} + +/// Bind a `UnixListener` at `path` and drop a `.bound` marker next +/// to it so c0re's gateway-map writer knows the socket is live. +/// Best-effort unlinks any stale socket left from a crashed previous +/// harness (clean exit removes it, but `bind(2)` refuses to overwrite +/// an existing file) and `mkdir -p`s the parent for first-boot. Mode +/// `0o666` — world-accessible so the gateway container's nginx process +/// can `connect(2)` without sharing a group with the agent user. +/// The per-agent subdir (`/run/hive-agent/<name>/`) is only accessible +/// to containers that have it bind-mounted, so world-accessible sockets +/// are not a material risk. +/// +/// Marker-gating + the gateway-side consumer: see +/// [`docs/gateway.md::Per-agent unix-socket upstream`](../../../docs/gateway.md). +fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> { + use std::os::unix::fs::PermissionsExt; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create socket parent dir {}", parent.display()))?; + } + // Best-effort: ENOENT is fine (no stale file); any other error + // surfaces via the bind below with a clearer "AddrInUse" / perms + // message than a partial cleanup would. + let _ = std::fs::remove_file(path); + let listener = tokio::net::UnixListener::bind(path) + .with_context(|| format!("bind unix socket at {}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o666)) + .with_context(|| format!("set perms on {}", path.display()))?; + // Best-effort ready marker: failed write isn't fatal (the harness + // still binds + serves), it just means the gateway side keeps the + // TCP upstream for one more sync tick. + if let Some(parent) = path.parent() { + let marker = parent.join("hyperhive-socket-bound"); + if let Err(e) = std::fs::write(&marker, b"") { + tracing::warn!( + marker = %marker.display(), error = %e, + "failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream" + ); + } + } + Ok(listener) +} + +/// Maximum bind attempts before `bind_with_retry` gives up on `AddrInUse`. +const MAX_BIND_ATTEMPTS: u32 = 12; + +/// Bind a TCP listener with `SO_REUSEADDR` set, retrying on `AddrInUse` with +/// exponential backoff capped at 2s, up to [`MAX_BIND_ATTEMPTS`] attempts. If +/// the port is still held after the final attempt, returns the `AddrInUse` +/// error rather than looping forever (a genuine collision needs the operator, +/// not an unbounded wait). +/// +/// Retry rationale + dashboard-banner-on-real-collision: +/// see [`docs/web-ui/shape.md::Listener bind`](../../../docs/web-ui/shape.md). +async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result<tokio::net::TcpListener> { + let mut delay_ms = 250u64; + let mut attempts = 0u32; + loop { + match try_bind(addr) { + Ok(l) => { + if attempts > 0 { + tracing::info!( + %addr, attempts, + "{label}: bind succeeded after retry" + ); + } + return Ok(l); + } + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + let attempt = attempts + 1; + if attempt >= MAX_BIND_ATTEMPTS { + return Err(e).with_context(|| { + format!("bind {label} on {addr}: still AddrInUse after {attempt} attempts") + }); + } + tracing::warn!( + %addr, attempt, + "{label}: AddrInUse, retrying in {delay_ms}ms" + ); + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + attempts += 1; + delay_ms = (delay_ms * 2).min(2000); + } + Err(e) => { + return Err(e).with_context(|| format!("bind {label} on {addr}")); + } + } + } +} + +fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> { + let sock = match addr { + SocketAddr::V4(_) => tokio::net::TcpSocket::new_v4()?, + SocketAddr::V6(_) => tokio::net::TcpSocket::new_v6()?, + }; + sock.set_reuseaddr(true)?; + sock.bind(addr)?; + sock.listen(1024) +} + +/// The fixed VNC port weston bound, from the `HIVE_GUI_VNC_PORT` env var +/// the harness service sets when gui is enabled (see weston-vnc.nix). +/// `None` when unset (gui not enabled for this agent) or unparseable. +/// The port is a fixed, container-local value — no per-agent hashing, no +/// marker file — because network isolation is unconditional (each agent +/// has its own netns, so the port can't collide across containers). +fn read_gui_vnc_port() -> Option<u16> { + std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() +} + +fn error_response(status: StatusCode, message: &str) -> Response { + // Plain text — JS app surfaces in `alert()`, HTML wrapping would just + // be noise. Status is per-caller: 400 for bad input, 409 for a + // retryable state conflict (turn in flight / hive-c0re busy), 500 only + // for a genuine server/transport failure — the frontend shows the code + // in its alert, so a benign "busy, retry" must not read as a 500. + (status, message.to_owned()).into_response() +} diff --git a/hive-ag3nt/src/web_ui/screen.rs b/hive-ag3nt/src/web_ui/screen.rs new file mode 100644 index 00000000..e8a9babc --- /dev/null +++ b/hive-ag3nt/src/web_ui/screen.rs @@ -0,0 +1,96 @@ +//! VNC screen websocket relay + agent icon. + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::AppState; + +/// This agent's icon. Serves the operator-configured SVG from +/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix +/// option) when present, otherwise the bundled default hyperhive logo. +/// Always returns an image, so consumers (dashboard, favicon) can hit +/// `/icon` unconditionally without probing whether one is configured. +pub(super) async fn serve_icon() -> impl IntoResponse { + // Per-agent icon overrides go through `/etc/hyperhive/icon.svg` + // (set via the `hyperhive.icon` agent.nix option); the bundled + // default is resolved at runtime from + // `$HIVE_ASSETS_DIR/branding/hyperhive.svg`. If neither file can + // be read we serve an empty body — keeps the response a valid SVG + // content-type without a panic on a misconfigured container. + let body = std::fs::read_to_string("/etc/hyperhive/icon.svg").unwrap_or_else(|_| { + std::fs::read_to_string(hive_sh4re::assets::branding_svg()).unwrap_or_default() + }); + ([("content-type", "image/svg+xml")], body) +} + +/// WebSocket handler: upgrade then pump bytes between the WS client and +/// the VNC server on `127.0.0.1:<vnc_port>`. Returns 404 when gui is not +/// enabled for this agent. +pub(super) async fn screen_ws( + ws: axum::extract::ws::WebSocketUpgrade, + State(state): State<AppState>, +) -> Response { + let Some(vnc_port) = state.gui_vnc_port else { + return (StatusCode::NOT_FOUND, "gui not enabled for this agent").into_response(); + }; + ws.on_upgrade(move |socket| relay_ws_vnc(socket, vnc_port)) +} + +/// Pure byte pump: forwards raw bytes between the WebSocket client and +/// the VNC TCP stream. Transparent to any RFB variant (plain, `VeNCrypt`). +async fn relay_ws_vnc(socket: axum::extract::ws::WebSocket, vnc_port: u16) { + // Import futures traits locally so they don't conflict with + // tokio_stream::StreamExt used at module scope. + use axum::extract::ws::Message; + use futures_util::{SinkExt, StreamExt as _}; + + let addr = format!("127.0.0.1:{vnc_port}"); + let Ok(tcp) = tokio::net::TcpStream::connect(&addr).await else { + tracing::warn!(%addr, "screen/ws: could not connect to VNC server"); + return; + }; + let (mut tcp_rx, mut tcp_tx) = tcp.into_split(); + let (mut ws_tx, mut ws_rx) = socket.split(); + + // WS → TCP + let ws_to_tcp = tokio::spawn(async move { + while let Some(Ok(msg)) = futures_util::StreamExt::next(&mut ws_rx).await { + match msg { + Message::Binary(data) if tcp_tx.write_all(&data).await.is_err() => { + break; + } + Message::Close(_) => break, + _ => {} // ping/pong/text: ignore + } + } + }); + + // TCP → WS + let tcp_to_ws = tokio::spawn(async move { + let mut buf = vec![0u8; 8192]; + loop { + match tcp_rx.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if ws_tx + .send(Message::Binary(buf[..n].to_vec().into())) + .await + .is_err() + { + break; + } + } + } + } + }); + + // Wait for either direction to close, then let both tasks drop. + tokio::select! { + _ = ws_to_tcp => {} + _ = tcp_to_ws => {} + } +} diff --git a/hive-ag3nt/src/web_ui/state.rs b/hive-ag3nt/src/web_ui/state.rs new file mode 100644 index 00000000..f67ea0e2 --- /dev/null +++ b/hive-ag3nt/src/web_ui/state.rs @@ -0,0 +1,434 @@ +//! `/api/state` + `/api/dashboard-state` snapshot builders. + +use axum::extract::State; +use serde::Serialize; + +use crate::client; +use crate::login::LoginState; +use crate::login_session::drop_if_finished; + +use super::{AppState, SOCKET_FETCH_TIMEOUT}; + +pub(super) async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> { + // Capture seq *before* any reads so the dedupe contract is + // "events with seq > snapshot.seq are post-snapshot, never missed." + let seq = state.bus.current_seq(); + drop_if_finished(&state.session); + let login = *state.login.lock().unwrap(); + let session_snapshot = state.session.lock().unwrap().clone(); + let (status, session_view) = match (login, session_snapshot) { + (LoginState::Online, _) if state.bus.is_rate_limited() => ("rate_limited", None), + (LoginState::Online, _) => ("online", None), + (LoginState::NeedsLogin, None) => ("needs_login_idle", None), + (LoginState::NeedsLogin, Some(s)) => ( + "needs_login_in_progress", + Some(SessionView { + url: s.url(), + output: s.output(), + finished: s.finished(), + exit_note: s.exit_note(), + }), + ), + }; + let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT") + .ok() + .and_then(|s| s.parse::<u16>().ok()) + .unwrap_or(7000); + let inbox = recent_inbox(&state.socket).await; + let (turn_state, turn_state_since) = state.bus.state_snapshot(); + let model = state.bus.model(); + let context_window_tokens = state + .bus + .api_context_window() + .unwrap_or_else(|| crate::events::context_window_tokens(&model)); + let ctx_usage = state.bus.last_ctx_usage(); + let cost_usage = state.bus.last_cost_usage(); + let effort = state.bus.effort(); + axum::Json(StateSnapshot { + seq, + label: state.label.clone(), + qualified_label: crate::identity::qualify(&state.label), + dashboard_port, + status, + session: session_view, + inbox, + turn_state, + turn_state_since, + model, + context_window_tokens, + ctx_usage, + cost_usage, + links: agent_links(&state.label, state.gui_vnc_port.is_some()), + forge_public_url: std::env::var("HIVE_FORGE_PUBLIC_URL") + .ok() + .filter(|s| !s.is_empty()), + hive_name: crate::identity::hive_name(), + swarm_name: crate::identity::swarm_name(), + available_models: available_models(), + effort, + available_efforts: crate::events::EFFORT_LEVELS + .iter() + .map(ToString::to_string) + .collect(), + }) +} + +/// Lean snapshot of the agent-owned fields that the dashboard card +/// needs. Served at `GET /api/dashboard-state` (accessible through the +/// gateway at `/agent/<name>/api/dashboard-state`). The dashboard +/// fetches this once per running agent to get fresh, agent-authoritative +/// values instead of relying on hive-c0re's periodic file-reads. +/// +/// Structural fields (running, `needs_update`, `deployed_sha`, parent, …) +/// continue to come from hive-c0re's `/api/state`; this endpoint covers +/// only the fields the agent itself is the source of truth for. +pub(super) async fn api_dashboard_state( + State(state): State<AppState>, +) -> axum::Json<DashboardState> { + let (status_text, status_set_at) = read_own_status(); + let rate_limited = state.bus.is_rate_limited(); + let model = state.bus.model(); + let context_window_tokens = state + .bus + .api_context_window() + .unwrap_or_else(|| crate::events::context_window_tokens(&model)); + // Full context-window size = input + cache-read + cache-creation. Using + // raw `input_tokens` here reported only the *uncached* sliver, which is + // ~0 once prompt caching kicks in — so every card showed `ctx·0k`. Match + // the agent page (which ships the whole `ctx_usage` and sums it) and the + // cache-TTL logic in turn.rs, both of which use `context_tokens()`. + let ctx_tokens = state.bus.last_ctx_usage().map(|u| u.context_tokens()); + axum::Json(DashboardState { + status_text, + status_set_at, + ctx_tokens, + context_window_tokens, + rate_limited, + links: agent_links(&state.label, state.gui_vnc_port.is_some()), + }) +} + +#[derive(Serialize)] +pub(super) struct StateSnapshot { + /// Bus seq at the moment this snapshot was assembled. Clients dedupe + /// their buffered SSE traffic against this value: events with + /// `seq <= snapshot.seq` are already reflected (or pre-date the + /// snapshot); `seq > snapshot.seq` is post-snapshot. Reset to 0 on + /// harness restart — clients treat reconnect as a fresh world. + seq: u64, + label: String, + /// Hive-qualified long name (`${label}@${hyperhive.domain}`) when + /// the host has been configured for a multi-hive swarm; falls back + /// to the short label when the hive domain env var is unset. + /// The frontend uses this for the page title / agent self-introduction; + /// when it equals `label`, the page renders the short form unchanged. + qualified_label: String, + dashboard_port: u16, + /// `"online"` | `"rate_limited"` | `"needs_login_idle"` | `"needs_login_in_progress"`. + status: &'static str, + /// Present when `status == "needs_login_in_progress"`. + session: Option<SessionView>, + /// Last N messages addressed to this agent, newest-first. Pulled + /// from the broker via the per-agent socket on each render. + /// Empty on transport failure. + inbox: Vec<hive_sh4re::InboxRow>, + /// Authoritative turn-loop state from the harness and the unix + /// timestamp the state was entered. The JS computes the age + /// client-side off this rather than tracking it from SSE events. + turn_state: crate::events::TurnState, + turn_state_since: i64, + /// Currently-active claude model name. Reflected on the page so + /// the operator can see what they just switched to (and what's + /// in flight). Mutable at runtime via `POST /api/model`. + model: String, + /// Effective context-window token budget for the current model. + /// Primary source: API-reported `modelUsage.*.contextWindow` from + /// the last result event (authoritative per-inference active window). + /// Falls back to `HIVE_CONTEXT_WINDOW_TOKENS_*` env vars, then 200 000. + /// Consumers (e.g. dashboard badge) use this to render ctx-usage %. + context_window_tokens: u64, + /// Last-inference token usage from the most recent completed + /// turn — represents the current context-window size at turn-end. + /// `null` until the first turn finishes. + ctx_usage: Option<hive_claude::TokenUsage>, + /// Cumulative token usage across the most recent turn's inferences + /// (cost signal). `null` until the first turn finishes. + cost_usage: Option<hive_claude::TokenUsage>, + /// Navigation links for this agent page. Also served via + /// `DashboardState.links` (`GET /api/dashboard-state`) for the + /// dashboard card's icon strip. Both are produced by `agent_links()` + /// — single source of truth. See [`docs/web-ui/dashboard.md::Container row`] + /// for the frontend resolver + which links appear in which conditions. + links: Vec<AgentLink>, + /// Public URL of the forge served by hive-gateway (e.g. + /// `"https://forge.pr1ma.darkest.space"`). Sourced from + /// `HIVE_FORGE_PUBLIC_URL`; `None` when `forge.behindGateway=false` + /// or the env var is absent. The frontend uses this to build forge + /// nav-strip links instead of hardcoding `<hostname>:3000`. + forge_public_url: Option<String>, + /// Human name of this hive instance (e.g. `"pr1ma"`). Sourced + /// from `HYPERHIVE_HIVE_NAME`; `None` when unset. The frontend + /// uses this for the page `<title>` and header label so browser + /// tabs disambiguate when multiple hives are open in parallel. + hive_name: Option<String>, + /// Human name of the swarm (e.g. `"constellat1on"`). Sourced from + /// `HYPERHIVE_SWARM_NAME`; `None` when unset. + swarm_name: Option<String>, + /// Ordered list of model short-names the operator has declared as + /// available on this hive. Sourced from `HIVE_AVAILABLE_MODELS` + /// (comma-separated, set by `services.hyperhive.availableModels`). + /// Falls back to `["haiku", "sonnet", "opus"]` when the env var is + /// absent or empty. The frontend model quick-picker renders one button + /// per entry in this list, so operators can add new models or drop + /// ones they don't want without touching the frontend code. + available_models: Vec<String>, + /// Currently-active claude effort level. Reflected on the page so the + /// operator's effort picker shows the live selection. Mutable at + /// runtime via `POST /api/effort`; applies on the next session. + effort: String, + /// Selectable effort levels for the picker, ascending. Fixed set + /// (`low`, `medium`, `high`, `xhigh`, `max`) — sourced from + /// [`crate::events::EFFORT_LEVELS`], not operator-configurable like + /// `available_models`. The frontend renders one button per entry. + available_efforts: Vec<String>, +} + +#[derive(Serialize)] +struct SessionView { + /// First `https://…` claude emitted on stdout, if any. + url: Option<String>, + /// Accumulated stdout + stderr. + output: String, + finished: bool, + exit_note: Option<String>, +} + +/// One navigation link in the agent page header row. The same JSON +/// shape appears in both `StateSnapshot.links` (`GET /api/state`, +/// per-agent page) and `DashboardState.links` (`GET /api/dashboard-state`, +/// dashboard card icon strip). `agent_links()` is the single source +/// of truth for what links an agent exposes. +#[derive(Serialize)] +struct AgentLink { + /// `kind = Container | Forge` → path; `kind = External` → full URL. + /// The frontend prepends the right base before rendering. + url: String, + icon: String, + label: String, + kind: AgentLinkKind, +} + +/// Resolution hint for `AgentLink.url`. The agent backend can't know +/// which hostname the browser sees (especially when the dashboard +/// proxies the call from a different origin), so it labels each link +/// and lets the frontend prepend the right base. +#[derive(Serialize, Clone, Copy)] +#[serde(rename_all = "snake_case")] +enum AgentLinkKind { + /// `url` is a path on the agent's container web UI (`/stats`, + /// `/screen`). Agent page: same-origin path. Dashboard: + /// `http://<host>:<container.port><url>`. + Container, + /// `url` is a path on the local Forgejo (`/<label>`, + /// `/agent-configs/<label>`). Both surfaces: + /// `http://<host>:3000<url>`. + Forge, + /// `url` is already a fully-qualified absolute URL — use as-is. + /// Agent-declared `hyperhive.dashboardLinks` extras arrive here. + External, +} + +#[derive(serde::Serialize)] +pub(super) struct DashboardState { + /// Free-text status set by `set_status`, read directly from the + /// `hyperhive-status` file the harness writes. `None` when unset. + #[serde(skip_serializing_if = "Option::is_none")] + status_text: Option<String>, + /// Unix timestamp (seconds) when the status file was last written. + /// `None` when no status is set. + #[serde(skip_serializing_if = "Option::is_none")] + status_set_at: Option<i64>, + /// Full context-window size from the most recent completed turn + /// (`ctx_usage.context_tokens()` = input + cache-read + cache-creation). + /// `None` until the first turn finishes. Drives the `ctx·Nk` card badge. + #[serde(skip_serializing_if = "Option::is_none")] + ctx_tokens: Option<u64>, + /// Effective context-window budget for the current model. Same + /// derivation as `StateSnapshot::context_window_tokens`. + context_window_tokens: u64, + /// True while the harness is parked after a rate-limit response. + rate_limited: bool, + /// Navigation links for the dashboard card's icon strip. This is + /// the authoritative source — includes the screen link (GUI agents) + /// which hive-c0re's disk-based fallback cannot determine. + links: Vec<AgentLink>, +} + +/// Read the agent's own free-text status and the timestamp when it was +/// set, directly from the `hyperhive-status` file in the state dir. +/// Mirrors `hive_c0re::container_view::read_agent_status` but runs +/// inside the agent container using its own state dir. +fn read_own_status() -> (Option<String>, Option<i64>) { + let path = crate::paths::state_dir().join("hyperhive-status"); + let meta = std::fs::metadata(&path).ok(); + let text = std::fs::read_to_string(&path) + .ok() + .as_deref() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned); + let mtime = meta.and_then(|m| { + m.modified().ok().and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + }) + }); + if text.is_none() { + (None, None) + } else { + (text, mtime) + } +} + +/// Build the navigation link list for the agent page header. URLs +/// are paths (relative) for `Container`/`Forge` targets and absolute +/// for `External`; the frontend resolves each against its `kind` +/// against the right base so the backend never has to guess the +/// operator's browser host. See +/// [`docs/web-ui/dashboard.md::Container row`](../../../docs/web-ui/dashboard.md) for +/// the resolver + how `deployed:<sha>` ships alongside. +fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> { + let mut links = Vec::new(); + + links.push(AgentLink { + url: "stats.html".to_owned(), + icon: "📊".to_owned(), + label: "stats".to_owned(), + kind: AgentLinkKind::Container, + }); + + if gui_enabled { + links.push(AgentLink { + url: "screen.html".to_owned(), + icon: "🖥".to_owned(), + label: "screen".to_owned(), + kind: AgentLinkKind::Container, + }); + } + + if crate::paths::state_dir().join("forge-token").is_file() { + links.push(AgentLink { + url: format!("/{label}"), + icon: "⬡".to_owned(), + label: "forge".to_owned(), + kind: AgentLinkKind::Forge, + }); + links.push(AgentLink { + url: format!("/agent-configs/{label}"), + icon: "↳".to_owned(), + label: "config".to_owned(), + kind: AgentLinkKind::Forge, + }); + } + + // Agent-declared extras (`hyperhive.dashboardLinks` → the + // `hive-dashboard-links` NixOS oneshot writes them to + // `{state_dir}/hyperhive-dashboard-links.json`). Shape on disk + // is `{label, icon, url}` with absolute URLs — those become + // `kind = External` links, passed through verbatim. + let extras_path = crate::paths::state_dir().join("hyperhive-dashboard-links.json"); + if let Ok(text) = std::fs::read_to_string(&extras_path) + && !text.trim().is_empty() + && let Ok(extras) = serde_json::from_str::<Vec<ExtraLink>>(&text) + { + for e in extras { + links.push(AgentLink { + url: e.url, + icon: e.icon, + label: e.label, + kind: AgentLinkKind::External, + }); + } + } + + links +} + +/// On-disk shape of `hyperhive-dashboard-links.json` (the +/// `hive-dashboard-links` NixOS oneshot's output). Mapped to +/// `AgentLink { kind: External }` inside `agent_links`. +#[derive(serde::Deserialize)] +struct ExtraLink { + label: String, + #[serde(default)] + icon: String, + url: String, +} + +/// Best-effort: pull the last 30 messages addressed to us via the +/// per-agent / manager socket. Empty list on any transport / decode +/// failure — the inbox section is decorative, not authoritative. +async fn recent_inbox(socket: &std::path::Path) -> Vec<hive_sh4re::InboxRow> { + const LIMIT: u64 = 30; + // Deadline-bounded: `/api/state` must render even when hive-c0re is + // busy — an empty inbox section beats a hung snapshot. + match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + client::request::<_, hive_sh4re::Response>( + socket, + &hive_sh4re::Request::Recent { limit: LIMIT }, + ), + ) + .await + { + Ok(Ok(hive_sh4re::Response::Recent { rows })) => rows, + _ => Vec::new(), + } +} + +/// Fetch reminder activity stats from the broker via the per-agent / +/// manager socket. Returns None on any transport / decode failure — the +/// stats are decorative, not authoritative. +pub(super) async fn fetch_reminder_stats( + socket: &std::path::Path, + window_secs: u64, +) -> Option<hive_sh4re::ReminderStats> { + match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + client::request::<_, hive_sh4re::Response>( + socket, + &hive_sh4re::Request::ReminderRollup { + since_secs: window_secs, + agent: None, + }, + ), + ) + .await + { + Ok(Ok(hive_sh4re::Response::ReminderRollup(stats))) => Some(stats), + _ => None, + } +} + +/// Read `HIVE_AVAILABLE_MODELS` (comma-separated short names injected by +/// `services.hyperhive.availableModels`) and return the parsed list. +/// Falls back to `["haiku", "sonnet", "opus"]` when the env var is absent +/// or resolves to an empty list after trimming. +fn available_models() -> Vec<String> { + const DEFAULT: &[&str] = &["haiku", "sonnet", "opus"]; + let raw = match std::env::var("HIVE_AVAILABLE_MODELS") { + Ok(v) if !v.trim().is_empty() => v, + _ => return DEFAULT.iter().map(ToString::to_string).collect(), + }; + let models: Vec<String> = raw + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + if models.is_empty() { + DEFAULT.iter().map(ToString::to_string).collect() + } else { + models + } +} diff --git a/hive-ag3nt/src/web_ui/stats.rs b/hive-ag3nt/src/web_ui/stats.rs new file mode 100644 index 00000000..9c0ace5e --- /dev/null +++ b/hive-ag3nt/src/web_ui/stats.rs @@ -0,0 +1,131 @@ +//! Stats + loose-ends + bash-tasks read endpoints. + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +use crate::client; + +use super::state::fetch_reminder_stats; +use super::{AppState, SOCKET_FETCH_TIMEOUT, error_response}; + +#[derive(Deserialize)] +pub(super) struct StatsQuery { + window: Option<String>, +} + +pub(super) async fn api_stats( + State(state): State<AppState>, + axum::extract::Query(q): axum::extract::Query<StatsQuery>, +) -> axum::Json<crate::stats::Snapshot> { + let window = crate::stats::Window::parse(q.window.as_deref().unwrap_or("24h")); + let mut snapshot = crate::stats::snapshot_default(window); + // Pass the window span to the reminder-stats RPC so the broker + // filters its counts to the same time range as the chart data. + let window_secs = window.span_secs(); + let window_secs_u = u64::try_from(window_secs).unwrap_or(0); + snapshot.reminder_stats = fetch_reminder_stats(&state.socket, window_secs_u).await; + axum::Json(snapshot) +} + +/// Proxy this agent's loose-ends list via the per-agent socket. The +/// web UI surfaces the result as a collapsible section in the page +/// so the operator can see at a glance what's pending against the +/// agent (questions asked by it, peer questions targeting it, +/// reminders it scheduled, approvals for the manager). Same data +/// the `mcp__hyperhive__get_loose_ends` tool sees from inside the +/// container. +pub(super) async fn api_loose_ends(State(state): State<AppState>) -> Response { + let loose_ends: Vec<hive_sh4re::LooseEnd> = match tokio::time::timeout( + SOCKET_FETCH_TIMEOUT, + client::request::<_, hive_sh4re::Response>( + &state.socket, + &hive_sh4re::Request::GetLooseEnds { agent: None }, + ), + ) + .await + { + Ok(Ok(hive_sh4re::Response::LooseEnds { loose_ends })) => loose_ends, + Ok(Ok(hive_sh4re::Response::Err { message })) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("get_loose_ends: {message}"), + ); + } + Ok(Ok(other)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("unexpected response: {other:?}"), + ); + } + Ok(Err(e)) => { + return error_response( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("transport: {e:#}"), + ); + } + Err(_) => { + return error_response( + StatusCode::CONFLICT, + "get_loose_ends: timed out — hive-c0re busy, retry", + ); + } + }; + axum::Json(serde_json::json!({ "loose_ends": loose_ends })).into_response() +} + +/// `GET /api/bash-tasks` — snapshot of this agent's in-flight bash tasks. +/// +/// The `hive-bash-mcp` daemon runs in this same container and writes one +/// `<id>.json` ([`hive_sh4re::TaskFile`]) per task under the harness +/// `bash-tasks/` dir. This reads that dir and returns the tasks still +/// `Pending` or `Running`, so the agent page can show what's running without +/// going through the broker. Snapshot only — the page polls/refreshes it like +/// `/api/loose-ends`; there's no live SSE push for task state yet. Unreadable +/// or malformed files (incl. the daemon's `.json.tmp` scratch writes, which +/// don't match the `.json` extension) are skipped so one stray file can't +/// fail the whole list. +pub(super) async fn api_bash_tasks() -> Response { + let dir = crate::paths::harness_dir().join("bash-tasks"); + // The dir scan + per-file reads are blocking fs I/O; run them off the + // async executor so a slow or large tasks dir can't stall other requests. + let tasks = tokio::task::spawn_blocking(move || { + let mut tasks: Vec<hive_sh4re::TaskFile> = Vec::new(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return tasks; + }; + for entry in rd.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(task) = serde_json::from_str::<hive_sh4re::TaskFile>(&text) else { + continue; + }; + if matches!( + task.status, + hive_sh4re::TaskStatus::Pending | hive_sh4re::TaskStatus::Running + ) { + tasks.push(task); + } + } + // Running before Pending, then oldest-first so a long-runner sits on top. + tasks.sort_by(|a, b| { + let rank = |s: &hive_sh4re::TaskStatus| match s { + hive_sh4re::TaskStatus::Running => 0, + _ => 1, + }; + rank(&a.status) + .cmp(&rank(&b.status)) + .then(a.created_at.cmp(&b.created_at)) + }); + tasks + }) + .await + .unwrap_or_default(); + axum::Json(serde_json::json!({ "tasks": tasks })).into_response() +} diff --git a/hive-ag3nt/src/web_ui/stream.rs b/hive-ag3nt/src/web_ui/stream.rs new file mode 100644 index 00000000..dabbcc45 --- /dev/null +++ b/hive-ag3nt/src/web_ui/stream.rs @@ -0,0 +1,70 @@ +//! Live SSE event stream + history endpoints. + +use std::convert::Infallible; + +use axum::Json; +use axum::extract::{Query, State}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use serde::Deserialize; +use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream}; + +use super::AppState; + +/// Query params for the paginated history endpoint. +#[derive(Debug, Deserialize)] +pub(super) struct HistoryParams { + /// Cursor: only return events with sqlite row id < `before`. + /// Omit for the initial (most-recent) page. + before: Option<i64>, + /// Page size (default 100, capped at `HISTORY_CAPACITY`). + limit: Option<usize>, +} + +pub(super) async fn events_history( + State(state): State<AppState>, + Query(params): Query<HistoryParams>, +) -> Json<serde_json::Value> { + use crate::events::HISTORY_CAPACITY; + let limit = params.limit.unwrap_or(100).min(HISTORY_CAPACITY); + let before = params.before; + let is_initial = before.is_none(); + + // Capture seq *before* the read on initial loads so the SSE dedupe + // window is "drop buffered events you've already seen in history", + // never "lose an event that fired between the read and the seq." + // On paginated loads (`before` is set) seq is not needed. + let seq = if is_initial { + Some(state.bus.current_seq()) + } else { + None + }; + + let (events, min_id, has_more) = state.bus.history_page(before, limit); + let mut resp = serde_json::json!({ + "events": events, + "min_id": min_id, + "has_more": has_more, + }); + if let Some(s) = seq { + resp["seq"] = serde_json::json!(s); + } + Json(resp) +} + +pub(super) async fn events_stream( + State(state): State<AppState>, +) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { + tracing::info!("sse: client subscribed"); + let rx = state.bus.subscribe(); + // Drop a "hello" note into the bus so every new subscriber sees at + // least one event immediately and can clear the connecting placeholder. + state.bus.emit(crate::events::LiveEvent::Note { + text: "live stream attached".into(), + }); + let stream = BroadcastStream::new(rx).filter_map(|res| { + let ev = res.ok()?; + let json = serde_json::to_string(&ev).ok()?; + Some(Ok(Event::default().data(json))) + }); + Sse::new(stream).keep_alive(KeepAlive::default()) +} diff --git a/hive-claude/Cargo.toml b/hive-claude/Cargo.toml new file mode 100644 index 00000000..63e6f608 --- /dev/null +++ b/hive-claude/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "hive-claude" +edition.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true diff --git a/hive-claude/README.md b/hive-claude/README.md new file mode 100644 index 00000000..957b7776 --- /dev/null +++ b/hive-claude/README.md @@ -0,0 +1,75 @@ +# hive-claude + +A small, reusable async driver for headless `claude --print` (Claude Code CLI) +sessions. It spawns the CLI, streams and classifies the `stream-json` output, +and reports the result. It knows only about the Claude Code CLI — **no +hyperhive types, policy, watermarks, or logging.** Compaction, retry, and reset +policy belong to the caller. + +## When to use it + +Reach for this crate whenever you need to run the `claude` CLI from Rust and +react to how a turn ended. It is the shared substrate under +`hive-ag3nt`'s turn loop; new callers (tools, tests, other agents) should build +on it rather than shelling out to `claude` by hand. + +## Shape + +Two layers — reach for the high-level one: + +- **`InfiniteSession { name, store, policy }`** — a durable session that keeps + itself alive across the context window. `.run(&config, prompt, &sink)` → + `Result<Progress, Error>` does resume-or-create, compacts **reactively** on + overflow (compact + retry once), and **proactively** after a clean turn when + the policy says so (optional checkpoint turn, then compact). `.compact(…)` + forces one. `Progress { created, compacted, telemetry }` reports what happened + — including the turn's parsed `Telemetry`, finalised at the `result` event. +- **`Claude::run(&config, &attach, prompt, &sink)`** → `Result<(), Error>` — + the low-level driver: one turn, one `Attach` target (`Resume` / `Create` / + `Continue` / `OneOff`). A clean turn is `Ok(())`; every other state is an + `Error` variant. + +Supporting pieces: + +- **`CompactionPolicy`** — decides *when* to compact. **`PercentPolicy`** + (`percent`, `default_window`, `checkpoint_prompt`) compacts at a percent of + the model window; **`NeverCompact`** never does. +- **`Config`** — the invocation (model, effort, cwd, prompt/MCP files, tools, + extra args). +- **`Sink`** — a trait with no-op defaults; implement what you care about to + observe stream events, non-JSON stdout, and stderr. `NoopSink` ignores all. +- **`SessionStore`** — locate and archive on-disk sessions by title. +- **`Telemetry`** (`context`, `cost`, `context_window`, `model`) — everything + the driver parses from a turn's stream, returned in `Progress`. **`Usage`** is + the minimal slice (`context_tokens`, `context_window`) the policy sees. + +`Error` unifies the two things that can stop a turn: recognized **sentinels** +(`PromptTooLong`, `RateLimited`, `AuthFailed`, `SessionNotFound`) and **hard +failures** (`Spawn`, `Stdin`, `Wait`, `Exit`, `Io`). Sentinels are expected +control-flow, not crashes — the caller compacts, parks, re-auths, or creates a +session in response. + +```rust +use hive_claude::{Claude, Config, Error, NoopSink, Session}; + +let config = Config { model: "haiku".into(), ..Default::default() }; +match Claude::run(&config, &Session::Resume("my-session".into()), "hello", &NoopSink).await { + Ok(()) => {} + Err(Error::PromptTooLong) => { /* compact + retry */ } + Err(Error::RateLimited) => { /* park + retry */ } + Err(other) => eprintln!("claude: {other}"), +} +``` + +## `thiserror` here, `anyhow` in the apps + +This is a **library**, so it returns a concrete, matchable `Error` enum built +with `thiserror`: callers can tell a rate-limit from a spawn failure and act +accordingly. Libraries should never force their callers into `anyhow`'s +type-erased error. + +The **applications** (the `hive-*` binaries) use `anyhow` instead — at the top +level you usually only want to add context and bubble a failure up, not match +on it. A `hive_claude::Error` converts into an `anyhow::Error` for free at the +`?` boundary. Rule of thumb: **libraries return `thiserror` enums, binaries +consume them with `anyhow`.** diff --git a/hive-claude/src/classify.rs b/hive-claude/src/classify.rs new file mode 100644 index 00000000..93eb9255 --- /dev/null +++ b/hive-claude/src/classify.rs @@ -0,0 +1,107 @@ +//! Sentinel detection: mapping claude-code CLI output onto [`crate::Error`] +//! variants. +//! +//! These marker strings are claude-code CLI knowledge, not app knowledge. They +//! are empirically stable across CLI versions; if one drifts the run degrades +//! gracefully (a clean turn, or a hard [`crate::Error::Exit`] on a non-zero +//! exit) rather than misbehaving. + +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Emitted when the prompt/context exceeds the model's window. +const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long"; + +/// Substrings indicating the API refused for rate-limit / usage-cap / credit +/// reasons. On stdout these are only trusted inside a JSON `error` event (see +/// [`Sentinels::scan_stdout_json`] / [`Sentinels::scan_rate_limit_text`]) so a +/// model *discussing* a rate limit in prose can't trigger a false positive. +const RATE_LIMIT_MARKERS: [&str; 5] = [ + "rate_limit_error", + "overloaded_error", + "Credit balance is too low", + "Usage limit reached", + "Request rate limit exceeded", +]; + +/// Substrings indicating the API rejected the request as unauthenticated (401) +/// — an expired/revoked OAuth session. Sourced from claude-code's `api_retry` +/// JSON events and its human-readable give-up line. +const AUTH_FAIL_MARKERS: [&str; 3] = [ + "\"error\":\"authentication_failed\"", + "\"error_status\":401", + "Failed to authenticate. API Error: 401", +]; + +/// Substrings indicating `--resume` could not resolve its target: no session +/// with the given title, or no conversation with the given id. +const SESSION_NOT_FOUND_MARKERS: [&str; 2] = [ + "does not match any session title", + "No conversation found with session ID", +]; + +/// Shared, lock-free sentinel flags accumulated while both output streams are +/// pumped concurrently. Read once after the child exits. +#[derive(Default)] +pub(crate) struct Sentinels { + prompt_too_long: AtomicBool, + rate_limited: AtomicBool, + auth_failed: AtomicBool, + session_not_found: AtomicBool, +} + +impl Sentinels { + /// Scan a raw line (stdout or stderr) for the always-on markers: + /// prompt-too-long, auth-failed, session-not-found. Rate-limit is handled + /// separately because on stdout it must only fire on JSON `error` events. + pub(crate) fn scan_line(&self, line: &str) { + if line.contains(PROMPT_TOO_LONG_MARKER) { + self.prompt_too_long.store(true, Ordering::Relaxed); + } + if AUTH_FAIL_MARKERS.iter().any(|m| line.contains(m)) { + self.auth_failed.store(true, Ordering::Relaxed); + } + if SESSION_NOT_FOUND_MARKERS.iter().any(|m| line.contains(m)) { + self.session_not_found.store(true, Ordering::Relaxed); + } + } + + /// Trust a rate-limit hit only on a JSON `error` event (so a model + /// *discussing* a rate limit in prose can't trigger it). The `type` gate + /// needs the parsed `event`; the marker match runs on `raw`, the original + /// line — the same bytes, so we don't re-serialize the value. + pub(crate) fn scan_stdout_json(&self, event: &serde_json::Value, raw: &str) { + if event.get("type").and_then(|t| t.as_str()) == Some("error") + && RATE_LIMIT_MARKERS.iter().any(|m| raw.contains(m)) + { + self.rate_limited.store(true, Ordering::Relaxed); + } + } + + /// Trust a rate-limit hit on raw text (non-JSON stdout, or any stderr) — + /// these are CLI messages, not conversation content. + pub(crate) fn scan_rate_limit_text(&self, line: &str) { + if RATE_LIMIT_MARKERS.iter().any(|m| line.contains(m)) { + self.rate_limited.store(true, Ordering::Relaxed); + } + } + + /// The recognized-sentinel error, if any fired — `None` means no sentinel + /// (so the run either completed or failed hard on its exit code). The + /// sentinels keep a fixed priority (too-long > rate > auth); a + /// session-not-found can only arise on a resume that made no model call, + /// so it never coincides with the others. + pub(crate) fn soft_error(&self) -> Option<crate::Error> { + use crate::Error; + if self.prompt_too_long.load(Ordering::Relaxed) { + Some(Error::PromptTooLong) + } else if self.rate_limited.load(Ordering::Relaxed) { + Some(Error::RateLimited) + } else if self.auth_failed.load(Ordering::Relaxed) { + Some(Error::AuthFailed) + } else if self.session_not_found.load(Ordering::Relaxed) { + Some(Error::SessionNotFound) + } else { + None + } + } +} diff --git a/hive-claude/src/config.rs b/hive-claude/src/config.rs new file mode 100644 index 00000000..ae0cff93 --- /dev/null +++ b/hive-claude/src/config.rs @@ -0,0 +1,59 @@ +//! Invocation config: how to build one `claude --print` command line. + +use std::path::PathBuf; + +/// How a run attaches to a claude session — the low-level session flag. Kept +/// separate from [`Config`] so one config can drive resume + create + +/// `/compact` of the same logical session. For a self-managing durable +/// session, prefer [`crate::InfiniteSession`] over hand-picking an `Attach`. +#[derive(Debug, Clone)] +pub enum Attach { + /// `--resume <id-or-title>` — resume an existing session by UUID or by the + /// display title set via [`Attach::Create`]. Yields + /// [`crate::Error::SessionNotFound`] if nothing matches. + Resume(String), + /// `--name <title>` — start a new session carrying the given display title + /// (persisted as a `custom-title` event, which `--resume <title>` later + /// resolves against). + Create(String), + /// `--continue` — resume the most recent session in the cwd. Ambiguous + /// when other claude processes share the cwd; prefer titled sessions. + Continue, + /// No session flag — a one-off, unnamed session. + OneOff, +} + +/// Everything needed to build one headless `claude --print` invocation, minus +/// the [`Attach`] target (passed separately to [`crate::Claude::run`]). +/// +/// Fields map one-to-one to CLI flags; `None`/empty means "don't pass the +/// flag". `--print --verbose --output-format stream-json` are always set by +/// the driver and are not configurable here (the driver depends on the +/// stream-json shape). +#[derive(Debug, Clone, Default)] +pub struct Config { + /// `--model`. Empty omits the flag (claude falls back to its own default). + pub model: String, + /// `--effort <level>`. `None` omits the flag. + pub effort: Option<String>, + /// Working directory for the child. Claude derives its per-project session + /// dir from this path. `None` inherits the parent process cwd. + pub cwd: Option<PathBuf>, + /// `--system-prompt-file <path>`. + pub system_prompt_file: Option<PathBuf>, + /// `--mcp-config <path>`. + pub mcp_config: Option<PathBuf>, + /// Pass `--strict-mcp-config` (only the configured MCP servers, no + /// discovery). + pub strict_mcp_config: bool, + /// `--tools <expr>` — the built-in tool allow-list expression. + pub tools: Option<String>, + /// `--allowedTools <expr>`. + pub allowed_tools: Option<String>, + /// `--add-dir <path>` (repeatable) — extra readable directories. + pub add_dirs: Vec<PathBuf>, + /// Any additional raw args appended verbatim after the ones above. + pub extra_args: Vec<String>, + /// Program to spawn. `None` defaults to `claude` (resolved on `PATH`). + pub program: Option<String>, +} diff --git a/hive-claude/src/driver.rs b/hive-claude/src/driver.rs new file mode 100644 index 00000000..76c3d267 --- /dev/null +++ b/hive-claude/src/driver.rs @@ -0,0 +1,179 @@ +//! The subprocess driver: spawn claude, pump + classify its streams, and +//! assemble the result. + +use std::collections::VecDeque; +use std::process::Stdio; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::{ChildStderr, ChildStdout, Command}; + +use crate::classify::Sentinels; +use crate::{Attach, Config, Error, Result, Sink}; + +/// Default program name spawned when [`Config::program`] is unset. +const DEFAULT_PROGRAM: &str = "claude"; + +/// How many trailing stderr lines to keep for [`Error::Exit`]. +const STDERR_TAIL_LINES: usize = 20; + +/// The low-level driver entry point. A namespace for the run function — there +/// is nothing to construct; call `Claude::run(…)` directly. For a durable, +/// self-compacting session, use [`crate::InfiniteSession`] instead. +pub struct Claude; + +impl Claude { + /// Spawn one headless `claude --print` turn, stream its output through + /// `sink`, and report the result. + /// + /// The wake prompt is written to claude's stdin. stdout (`stream-json`) + /// and stderr are pumped concurrently while the child runs. A clean turn + /// returns `Ok(())`; every non-completion state — recognized sentinel or + /// hard failure — is an [`Error`] variant, so callers branch with a single + /// `match`. + /// + /// # Errors + /// + /// - A recognized sentinel: [`Error::PromptTooLong`], [`Error::RateLimited`], + /// [`Error::AuthFailed`], [`Error::SessionNotFound`]. + /// - [`Error::Spawn`] if the binary can't be launched. + /// - [`Error::Stdin`] / [`Error::Wait`] on stdin-write / child-wait failure. + /// - [`Error::Exit`] on a non-zero exit that raised no sentinel. + pub async fn run( + config: &Config, + attach: &Attach, + prompt: &str, + sink: &impl Sink, + ) -> Result<()> { + let program = config.program.as_deref().unwrap_or(DEFAULT_PROGRAM); + let mut cmd = build_command(program, config, attach); + + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| Error::Spawn { + program: program.to_string(), + source, + })?; + + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(prompt.as_bytes()).await.map_err(Error::Stdin)?; + // Best-effort flush/close; claude sees EOF and starts the turn. + stdin.shutdown().await.ok(); + } + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let sentinels = Sentinels::default(); + // Pump both streams and wait for exit concurrently on this task — no + // `spawn`, so the sink needn't be `'static` and borrows stay simple. + let ((), stderr_tail, status) = tokio::join!( + pump_stdout(stdout, sink, &sentinels), + pump_stderr(stderr, sink, &sentinels), + child.wait(), + ); + let status = status.map_err(Error::Wait)?; + + // A recognized sentinel takes precedence over the exit code; otherwise + // a non-zero exit with no sentinel is a hard failure. + if let Some(sentinel) = sentinels.soft_error() { + return Err(sentinel); + } + if !status.success() { + return Err(Error::Exit { + status, + stderr_tail, + }); + } + Ok(()) + } +} + +/// Assemble the argv. `--print --verbose --output-format stream-json` are +/// mandatory (the driver parses that shape); everything else is gated on the +/// [`Config`] / [`Attach`]. +fn build_command(program: &str, config: &Config, attach: &Attach) -> Command { + let mut cmd = Command::new(program); + if let Some(cwd) = &config.cwd { + cmd.current_dir(cwd); + } + cmd.arg("--print") + .arg("--verbose") + .arg("--output-format") + .arg("stream-json"); + if !config.model.is_empty() { + cmd.arg("--model").arg(&config.model); + } + if let Some(effort) = &config.effort { + cmd.arg("--effort").arg(effort); + } + match attach { + Attach::Resume(id) => { + cmd.arg("--resume").arg(id); + } + Attach::Create(title) => { + cmd.arg("--name").arg(title); + } + Attach::Continue => { + cmd.arg("--continue"); + } + Attach::OneOff => {} + } + if let Some(path) = &config.system_prompt_file { + cmd.arg("--system-prompt-file").arg(path); + } + if let Some(path) = &config.mcp_config { + cmd.arg("--mcp-config").arg(path); + } + if config.strict_mcp_config { + cmd.arg("--strict-mcp-config"); + } + if let Some(tools) = &config.tools { + cmd.arg("--tools").arg(tools); + } + if let Some(allowed) = &config.allowed_tools { + cmd.arg("--allowedTools").arg(allowed); + } + for dir in &config.add_dirs { + cmd.arg("--add-dir").arg(dir); + } + for extra in &config.extra_args { + cmd.arg(extra); + } + cmd +} + +/// Read stdout line by line: classify each line, parse JSON, hand events (or +/// raw non-JSON lines) to the sink. +async fn pump_stdout(stdout: ChildStdout, sink: &impl Sink, sentinels: &Sentinels) { + let mut lines = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = lines.next_line().await { + sentinels.scan_line(&line); + if let Ok(event) = serde_json::from_str::<serde_json::Value>(&line) { + sentinels.scan_stdout_json(&event, &line); + sink.on_event(&event); + } else { + sentinels.scan_rate_limit_text(&line); + sink.on_stdout_line(&line); + } + } +} + +/// Read stderr line by line: classify, forward to the sink, and retain the +/// last [`STDERR_TAIL_LINES`] for a possible [`Error::Exit`]. Returns the +/// newline-joined tail. +async fn pump_stderr(stderr: ChildStderr, sink: &impl Sink, sentinels: &Sentinels) -> String { + let mut lines = BufReader::new(stderr).lines(); + let mut tail: VecDeque<String> = VecDeque::with_capacity(STDERR_TAIL_LINES); + while let Ok(Some(line)) = lines.next_line().await { + sentinels.scan_line(&line); + sentinels.scan_rate_limit_text(&line); + sink.on_stderr_line(&line); + if tail.len() >= STDERR_TAIL_LINES { + tail.pop_front(); + } + tail.push_back(line); + } + tail.into_iter().collect::<Vec<_>>().join("\n") +} diff --git a/hive-claude/src/error.rs b/hive-claude/src/error.rs new file mode 100644 index 00000000..e29deecd --- /dev/null +++ b/hive-claude/src/error.rs @@ -0,0 +1,77 @@ +//! Typed errors for the driver. See the crate-level docs for why this is a +//! `thiserror` enum rather than `anyhow`. + +use std::process::ExitStatus; + +use thiserror::Error; + +/// Why a claude run did not complete cleanly. A normal, finished turn is +/// `Ok(())`; everything else is one of these variants. +/// +/// Two families share the enum on purpose, so a caller can handle them with a +/// single `match` on the `Result`: +/// +/// - **Recognized sentinels** — [`Error::PromptTooLong`], +/// [`Error::RateLimited`], [`Error::AuthFailed`], [`Error::SessionNotFound`]. +/// These are *expected* non-completion states parsed from claude's output, +/// not crashes; callers typically compact, park-and-retry, re-auth, or +/// create-a-session in response. +/// - **Hard failures** — [`Error::Spawn`], [`Error::Stdin`], [`Error::Wait`], +/// [`Error::Exit`], [`Error::Io`]. The process couldn't run, or died with no +/// recognizable reason. +#[derive(Debug, Error)] +pub enum Error { + /// `Prompt is too long` — the session is past the model's context window. + #[error("prompt is too long for the model's context window")] + PromptTooLong, + + /// The API refused for rate-limit / usage-cap / credit-balance reasons. + #[error("request was rate-limited or hit a usage/credit cap")] + RateLimited, + + /// The API rejected the request with 401 (auth/session expired or revoked). + #[error("authentication failed (HTTP 401)")] + AuthFailed, + + /// `--resume` matched no session for the given id or title (e.g. the title + /// was never created, or its backing file was moved away). + #[error("no session matched the requested id or title")] + SessionNotFound, + + /// The `claude` binary could not be spawned (not on `PATH`, not + /// executable, …). + #[error("failed to spawn `{program}`: {source}")] + Spawn { + /// The program name we tried to run. + program: String, + /// The underlying spawn error. + #[source] + source: std::io::Error, + }, + + /// Writing the prompt to claude's stdin failed. + #[error("writing prompt to claude stdin failed: {0}")] + Stdin(#[source] std::io::Error), + + /// Awaiting the child process failed. + #[error("waiting on claude failed: {0}")] + Wait(#[source] std::io::Error), + + /// claude exited non-zero and raised none of the recognized sentinels. + /// `stderr_tail` is the last handful of stderr lines (empty if there were + /// none), included so the caller can surface a real diagnostic. + #[error("claude exited {status}\n{stderr_tail}")] + Exit { + /// The child's exit status. + status: ExitStatus, + /// Tail of stderr, newline-joined; empty when claude wrote nothing. + stderr_tail: String, + }, + + /// A filesystem operation (session lookup / archive) failed. + #[error("session store i/o failed: {0}")] + Io(#[from] std::io::Error), +} + +/// Convenience alias for results from this crate. +pub type Result<T> = std::result::Result<T, Error>; diff --git a/hive-claude/src/lib.rs b/hive-claude/src/lib.rs new file mode 100644 index 00000000..40cde76a --- /dev/null +++ b/hive-claude/src/lib.rs @@ -0,0 +1,74 @@ +//! `hive-claude` — a small, reusable async driver for headless +//! `claude --print` (Claude Code CLI) sessions. +//! +//! It spawns the CLI, streams and classifies its `stream-json` output, and +//! reports the result as a `Result<(), Error>`: a clean turn is `Ok(())`, and +//! every non-completion state — both recognized sentinels (rate-limit, +//! prompt-too-long, …) and hard failures (spawn, non-zero exit) — is a variant +//! of the single [`Error`] enum, so callers branch with one `match`. The crate +//! also locates and archives on-disk sessions by title ([`SessionStore`]). It +//! knows only about the Claude Code CLI — no application types, hard-coded +//! watermarks, or logging. Callers wire streaming output through a [`Sink`]. +//! +//! Two layers: +//! +//! - [`Claude::run`] — the low-level driver: one turn, one [`Attach`] target. +//! - [`InfiniteSession`] — a durable session (name + [`SessionStore`] + +//! [`CompactionPolicy`]) that keeps itself alive across the context window by +//! compacting reactively (on overflow) and proactively (per policy — e.g. +//! [`PercentPolicy`]). This is the one you usually want. +//! +//! # `thiserror` here, `anyhow` in the apps +//! +//! This is a **library**, so it exposes a concrete, matchable error enum built +//! with [`thiserror`](https://docs.rs/thiserror): a caller can distinguish +//! `Error::Exit { status, .. }` from `Error::Spawn { .. }` and branch on it. +//! A library should never force its callers to reach into `anyhow`'s +//! type-erased error to find out what went wrong. +//! +//! The **applications** in this workspace (the `hive-*` binaries) use +//! [`anyhow`](https://docs.rs/anyhow) instead. At the top level you usually +//! only want to attach context and log or bubble a failure up — not match on +//! it — and `anyhow::Result` + `?` + `.context()` is the ergonomic fit. +//! `anyhow::Error` implements `From<E>` for any `std::error::Error`, so a +//! `hive_claude::Error` converts into an `anyhow::Error` for free at the `?` +//! boundary. Rule of thumb: **libraries return `thiserror` enums, binaries +//! consume them with `anyhow`.** +//! +//! # Example +//! +//! ```no_run +//! # async fn ex() { +//! use hive_claude::{Attach, Claude, Config, Error, NoopSink}; +//! +//! let config = Config { +//! model: "haiku".into(), +//! ..Default::default() +//! }; +//! match Claude::run(&config, &Attach::Resume("my-session".into()), "hello", &NoopSink).await { +//! Ok(()) => {} +//! Err(Error::PromptTooLong) => { /* caller compacts + retries */ } +//! Err(Error::RateLimited) => { /* caller parks + retries */ } +//! Err(other) => eprintln!("claude: {other}"), +//! } +//! # } +//! ``` + +mod classify; +mod config; +mod driver; +mod error; +mod policy; +mod session; +mod sink; +mod store; +mod telemetry; + +pub use config::{Attach, Config}; +pub use driver::Claude; +pub use error::{Error, Result}; +pub use policy::{CompactionPolicy, NeverCompact, PercentPolicy}; +pub use session::{InfiniteSession, Progress}; +pub use sink::{NoopSink, Sink}; +pub use store::SessionStore; +pub use telemetry::{Telemetry, TokenUsage, Usage}; diff --git a/hive-claude/src/policy.rs b/hive-claude/src/policy.rs new file mode 100644 index 00000000..998b0968 --- /dev/null +++ b/hive-claude/src/policy.rs @@ -0,0 +1,130 @@ +//! When a durable session should compact. + +use crate::Usage; + +/// Decides, after a completed turn, whether an [`crate::InfiniteSession`] +/// should proactively compact — and what to say in the optional checkpoint +/// turn that runs first. Injected by the caller so the driver stays free of +/// any app-specific policy. +pub trait CompactionPolicy { + /// Given the last turn's context [`Usage`], compact now (before the window + /// fills)? + fn should_compact(&self, usage: Usage) -> bool; + + /// Prompt for a pre-compaction checkpoint turn (a chance for the agent to + /// flush durable state before detail collapses into a summary), or `None` + /// to compact without one. Default: `None`. + fn checkpoint_prompt(&self) -> Option<&str> { + None + } +} + +/// Compact once the context reaches `percent` of the model window. +/// +/// The window is the one the model reported this turn ([`Usage::context_window`]), +/// falling back to [`PercentPolicy::default_window`] when the turn reported +/// none (e.g. a degenerate turn with no `result` usage). `percent == 0` +/// disables proactive compaction entirely. +#[derive(Debug, Clone, Default)] +pub struct PercentPolicy { + /// Watermark as a percent of the context window (e.g. `75`). `0` disables. + pub percent: u8, + /// Window to assume when the turn didn't report one. `None` → never + /// compact until a window is observed. + pub default_window: Option<u64>, + /// Prompt for the pre-compaction checkpoint turn; `None` skips it. + pub checkpoint_prompt: Option<String>, +} + +impl CompactionPolicy for PercentPolicy { + fn should_compact(&self, usage: Usage) -> bool { + if self.percent == 0 { + return false; + } + let Some(window) = usage.context_window.or(self.default_window).filter(|&w| w > 0) else { + return false; + }; + usage.context_tokens.saturating_mul(100) >= u64::from(self.percent) * window + } + + fn checkpoint_prompt(&self) -> Option<&str> { + self.checkpoint_prompt.as_deref() + } +} + +/// A policy that never compacts. Turns run until the session overflows and the +/// reactive path in [`crate::InfiniteSession::run`] takes over. +#[derive(Debug, Clone, Copy, Default)] +pub struct NeverCompact; + +impl CompactionPolicy for NeverCompact { + fn should_compact(&self, _usage: Usage) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::{CompactionPolicy, NeverCompact, PercentPolicy}; + use crate::Usage; + + fn policy(percent: u8, default_window: Option<u64>) -> PercentPolicy { + PercentPolicy { + percent, + default_window, + checkpoint_prompt: None, + } + } + + #[test] + fn fires_at_or_above_watermark() { + let p = policy(75, None); + // 75% of a 200k window = 150k. + assert!(!p.should_compact(Usage { + context_tokens: 149_999, + context_window: Some(200_000), + })); + assert!(p.should_compact(Usage { + context_tokens: 150_000, + context_window: Some(200_000), + })); + } + + #[test] + fn zero_percent_disables() { + assert!(!policy(0, Some(200_000)).should_compact(Usage { + context_tokens: 199_999, + context_window: Some(200_000), + })); + } + + #[test] + fn falls_back_to_default_window_when_unreported() { + let p = policy(50, Some(100_000)); + assert!(p.should_compact(Usage { + context_tokens: 50_000, + context_window: None, + })); + // Reported window takes precedence over the default. + assert!(!p.should_compact(Usage { + context_tokens: 50_000, + context_window: Some(200_000), + })); + } + + #[test] + fn no_window_anywhere_never_fires() { + assert!(!policy(75, None).should_compact(Usage { + context_tokens: u64::MAX, + context_window: None, + })); + } + + #[test] + fn never_compact_is_never() { + assert!(!NeverCompact.should_compact(Usage { + context_tokens: u64::MAX, + context_window: Some(1), + })); + } +} diff --git a/hive-claude/src/session.rs b/hive-claude/src/session.rs new file mode 100644 index 00000000..4fded3b5 --- /dev/null +++ b/hive-claude/src/session.rs @@ -0,0 +1,180 @@ +//! A durable, self-compacting ("infinite") claude session. + +use std::sync::Mutex; + +use serde_json::Value; + +use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Telemetry}; + +/// A named claude session that outlives the model's context window by +/// compacting itself. Bundles the three things a durable session needs: +/// +/// - a **name** (the constant session title it resumes / creates under), +/// - a **store** ([`SessionStore`], to find the backing file so a resume vs. +/// create is decided without a wasted spawn), and +/// - a **policy** ([`CompactionPolicy`], deciding *when* to compact). +/// +/// [`InfiniteSession::run`] keeps the session alive across turns: +/// +/// - **resume-or-create** — resumes the titled session, creating it on first +/// use (or after its file was archived away); +/// - **reactive** — if a turn overflows ([`Error::PromptTooLong`]), it compacts +/// and retries the same prompt once; +/// - **proactive** — after a clean turn it consults the policy and, if due, +/// runs an optional checkpoint turn then compacts. +/// +/// Resetting/archiving the session is intentionally *not* part of this type — +/// that stays with the caller. +pub struct InfiniteSession<P: CompactionPolicy> { + name: String, + store: SessionStore, + policy: P, +} + +/// What [`InfiniteSession::run`] did, beyond streaming the turn to the sink. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Progress { + /// The turn resumed no existing session — a fresh one was created. + pub created: bool, + /// A compaction ran (reactively on overflow, or proactively per policy). + pub compacted: bool, + /// Everything parsed from the answering turn's stream (usage, cost, + /// context window, resolved model). The authoritative copy — finalised at + /// the turn's `result` event. + pub telemetry: Telemetry, +} + +impl<P: CompactionPolicy> InfiniteSession<P> { + /// Build a durable session for `name`, backed by `store`, governed by + /// `policy`. + pub fn new(name: impl Into<String>, store: SessionStore, policy: P) -> Self { + Self { + name: name.into(), + store, + policy, + } + } + + /// Run one turn, keeping the session infinite (see the type docs). + /// + /// # Errors + /// + /// Propagates any non-`SessionNotFound` [`Error`] from the underlying + /// runs. A reactive retry that *still* overflows surfaces as + /// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate + /// unchanged for the caller to handle. + pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> { + let meter = TelemetrySink::new(sink); + let created = match self.attempt(config, prompt, &meter).await { + Ok(created) => created, + Err(Error::PromptTooLong) => { + // The session is already past the window — no turn can run on + // it and the detail is gone (no checkpoint possible). Compact, + // then retry the same prompt once; the retry is the answering + // turn, so its telemetry is what we report. + self.compact(config, sink).await?; + let retry = TelemetrySink::new(sink); + let created = self.attempt(config, prompt, &retry).await?; + return Ok(Progress { + created, + compacted: true, + telemetry: retry.snapshot(), + }); + } + Err(other) => return Err(other), + }; + let telemetry = meter.snapshot(); + + // Proactive: the turn completed on a healthy session. If the policy + // says it's due, checkpoint (best-effort) then compact. + if self.policy.should_compact(telemetry.usage()) { + if let Some(checkpoint) = self.policy.checkpoint_prompt() { + let _ = self.attempt(config, checkpoint, sink).await; + } + let _ = self.compact(config, sink).await; + return Ok(Progress { + created, + compacted: true, + telemetry, + }); + } + Ok(Progress { + created, + compacted: false, + telemetry, + }) + } + + /// Force a `/compact` on the session (e.g. operator-driven). Resume-only: + /// if the session doesn't exist there is nothing to compact, so a + /// [`Error::SessionNotFound`] is swallowed as a no-op `Ok`. + /// + /// # Errors + /// + /// Propagates any error other than `SessionNotFound` from the compact run. + pub async fn compact(&self, config: &Config, sink: &impl Sink) -> Result<()> { + match Claude::run(config, &Attach::Resume(self.name.clone()), "/compact", sink).await { + Err(Error::SessionNotFound) => Ok(()), + other => other, + } + } + + /// One resume-or-create turn. Uses the store to pick resume vs. create up + /// front (avoiding a wasted resume-miss spawn), and still self-heals if the + /// backing file vanished between the check and the run. Returns whether a + /// fresh session was created. + async fn attempt(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<bool> { + let exists = self.store.find_by_title(&self.name).is_some(); + let attach = if exists { + Attach::Resume(self.name.clone()) + } else { + Attach::Create(self.name.clone()) + }; + match Claude::run(config, &attach, prompt, sink).await { + Ok(()) => Ok(!exists), + // We thought it existed but the resume missed (raced an archive) — + // self-heal by creating. + Err(Error::SessionNotFound) if exists => { + Claude::run(config, &Attach::Create(self.name.clone()), prompt, sink).await?; + Ok(true) + } + Err(other) => Err(other), + } + } +} + +/// A [`Sink`] that forwards to an inner sink while accumulating the turn's +/// [`Telemetry`] from the stream. Cheap; the driver calls it synchronously +/// from one reader task, so the `Mutex` only satisfies `&self`. +struct TelemetrySink<'a, S: Sink> { + inner: &'a S, + telemetry: Mutex<Telemetry>, +} + +impl<'a, S: Sink> TelemetrySink<'a, S> { + fn new(inner: &'a S) -> Self { + Self { + inner, + telemetry: Mutex::new(Telemetry::default()), + } + } + + fn snapshot(&self) -> Telemetry { + self.telemetry.lock().unwrap().clone() + } +} + +impl<S: Sink> Sink for TelemetrySink<'_, S> { + fn on_event(&self, event: &Value) { + self.telemetry.lock().unwrap().observe(event); + self.inner.on_event(event); + } + + fn on_stdout_line(&self, line: &str) { + self.inner.on_stdout_line(line); + } + + fn on_stderr_line(&self, line: &str) { + self.inner.on_stderr_line(line); + } +} diff --git a/hive-claude/src/sink.rs b/hive-claude/src/sink.rs new file mode 100644 index 00000000..0c82f6c2 --- /dev/null +++ b/hive-claude/src/sink.rs @@ -0,0 +1,30 @@ +//! Streaming-output consumer hook. + +use serde_json::Value; + +/// Consumer callbacks for a claude run's output streams. Every method has a +/// no-op default, so an implementor overrides only what it needs. +/// +/// Methods are called synchronously from the stdout/stderr readers as lines +/// arrive, so keep them cheap — the idiomatic body forwards to a channel or an +/// event bus rather than blocking. The driver handles sentinel classification +/// (rate-limit, prompt-too-long, …) itself; a sink only *observes* the stream. +pub trait Sink { + /// A parsed `stream-json` object from stdout (assistant/result/system/… + /// event). The driver has already classified it for sentinels. + fn on_event(&self, _event: &Value) {} + + /// A stdout line that was not valid JSON — occasional Claude Code CLI + /// chatter rather than conversation content. + fn on_stdout_line(&self, _line: &str) {} + + /// A stderr line, delivered verbatim. The last several are also retained + /// by the driver for [`crate::Error::Exit`]. + fn on_stderr_line(&self, _line: &str) {} +} + +/// A [`Sink`] that discards everything. Useful for fire-and-forget runs where +/// only the [`crate::Outcome`] matters. +pub struct NoopSink; + +impl Sink for NoopSink {} diff --git a/hive-claude/src/store.rs b/hive-claude/src/store.rs new file mode 100644 index 00000000..064aa924 --- /dev/null +++ b/hive-claude/src/store.rs @@ -0,0 +1,95 @@ +//! Locating and archiving on-disk claude sessions by title. + +use std::io::BufRead as _; +use std::path::PathBuf; + +use crate::{Error, Result}; + +/// On-disk claude session store scoped to one working directory. +/// +/// Claude Code keeps sessions under +/// `<claude_home>/projects/<slug(cwd)>/<uuid>.jsonl`, one project dir per cwd. +/// This type locates and archives those files by their display title. It is +/// generic Claude Code layout knowledge — no application specifics. +#[derive(Debug, Clone)] +pub struct SessionStore { + claude_home: PathBuf, + cwd: PathBuf, +} + +impl SessionStore { + /// Build a store for `cwd`, with claude's home dir (normally `~/.claude`) + /// at `claude_home`. + pub fn new(claude_home: impl Into<PathBuf>, cwd: impl Into<PathBuf>) -> Self { + Self { + claude_home: claude_home.into(), + cwd: cwd.into(), + } + } + + /// `<claude_home>/projects/<slug>` for this cwd. Claude slugises the + /// absolute cwd by replacing every `/` and `.` with `-` (verified against + /// claude 2.1.197 — e.g. `/agents/iris/state` → `-agents-iris-state`). + #[must_use] + pub fn project_dir(&self) -> PathBuf { + let slug: String = self + .cwd + .to_string_lossy() + .chars() + .map(|c| if c == '/' || c == '.' { '-' } else { c }) + .collect(); + self.claude_home.join("projects").join(slug) + } + + /// Find the `<uuid>.jsonl` in the project dir whose `customTitle` equals + /// `title` (the value `--name` sets, stored in a `custom-title` event). + /// + /// Reads each session file line by line and stops at the first match, so a + /// huge transcript isn't slurped into memory. Returns `None` if no session + /// carries the title or the project dir is absent. Non-`.jsonl` files + /// (including anything already archived to `*.jsonl.archived`) are skipped. + #[must_use] + pub fn find_by_title(&self, title: &str) -> Option<PathBuf> { + let marker = format!("\"customTitle\":\"{title}\""); + for entry in std::fs::read_dir(self.project_dir()).ok()?.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("jsonl") { + continue; + } + let Ok(file) = std::fs::File::open(&path) else { + continue; + }; + if std::io::BufReader::new(file) + .lines() + .map_while(std::result::Result::ok) + .any(|line| line.contains(&marker)) + { + return Some(path); + } + } + None + } + + /// Archive the session titled `title` by renaming its backing file + /// `<uuid>.jsonl` → `<uuid>.jsonl.archived`. That drops it out of claude's + /// `*.jsonl` resolution glob (so a later `--resume <title>` misses) while + /// preserving the full transcript on disk. Only the file with a matching + /// `customTitle` is touched. + /// + /// Returns the archived file's new path, or `None` if no session carried + /// the title. + /// + /// # Errors + /// + /// [`Error::Io`] if the rename fails. + pub fn archive_by_title(&self, title: &str) -> Result<Option<PathBuf>> { + let Some(path) = self.find_by_title(title) else { + return Ok(None); + }; + let mut target = path.clone().into_os_string(); + target.push(".archived"); + let target = PathBuf::from(target); + std::fs::rename(&path, &target).map_err(Error::Io)?; + Ok(Some(target)) + } +} diff --git a/hive-claude/src/telemetry.rs b/hive-claude/src/telemetry.rs new file mode 100644 index 00000000..7bc62f5b --- /dev/null +++ b/hive-claude/src/telemetry.rs @@ -0,0 +1,182 @@ +//! What the driver parses out of a turn's stream-json output. +//! +//! The lib is the single source of truth for reading claude's usage/model +//! reporting. A turn's [`Telemetry`] is accumulated by the driver as events +//! stream and handed back from [`crate::InfiniteSession::run`]; consumers that +//! also want the raw events (for their own SSE / tool-call accounting) still +//! get them through their [`crate::Sink`]. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Token counts from one `usage` block. All in tokens; missing fields read `0`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cache_read_input_tokens: u64, + pub cache_creation_input_tokens: u64, +} + +impl TokenUsage { + /// Context footprint counting against the model window: input + both cache + /// classes (not output). + #[must_use] + pub fn context_tokens(&self) -> u64 { + self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens + } + + fn from_obj(u: &Value) -> Self { + let field = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0); + Self { + input_tokens: field("input_tokens"), + output_tokens: field("output_tokens"), + cache_read_input_tokens: field("cache_read_input_tokens"), + cache_creation_input_tokens: field("cache_creation_input_tokens"), + } + } +} + +/// Everything the driver tracks from one turn's stream-json output. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Telemetry { + /// Most recent per-inference usage (from `assistant` events) — the live + /// context footprint (the number to watch for compaction). + pub context: TokenUsage, + /// Cumulative usage across the turn (from the terminal `result` event) — + /// the cost signal. Sums per-call prompts and can exceed the window. + pub cost: TokenUsage, + /// Model-reported active context window (`modelUsage.*.contextWindow` on + /// the `result` event), if reported. + pub context_window: Option<u64>, + /// Resolved model id echoed by the API (`assistant.message.model`, e.g. + /// `claude-opus-4-8`) — the concrete version, not the requested alias. + pub model: Option<String>, +} + +impl Telemetry { + /// Fold one stream-json event into the running telemetry. + pub(crate) fn observe(&mut self, event: &Value) { + match event.get("type").and_then(Value::as_str) { + Some("assistant") => { + let Some(message) = event.get("message") else { + return; + }; + if let Some(usage) = message.get("usage") { + self.context = TokenUsage::from_obj(usage); + } + if let Some(model) = message + .get("model") + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + { + self.model = Some(model.to_string()); + } + } + Some("result") => { + if let Some(usage) = event.get("usage") { + self.cost = TokenUsage::from_obj(usage); + } + if let Some(window) = context_window_from_result(event) { + self.context_window = Some(window); + } + } + _ => {} + } + } + + /// The minimal signal a [`crate::CompactionPolicy`] needs. + #[must_use] + pub fn usage(&self) -> Usage { + Usage { + context_tokens: self.context.context_tokens(), + context_window: self.context_window, + } + } +} + +/// First non-zero `contextWindow` across the `result` event's `modelUsage` map. +fn context_window_from_result(event: &Value) -> Option<u64> { + for (_model, stats) in event.get("modelUsage")?.as_object()? { + if let Some(w) = stats.get("contextWindow").and_then(Value::as_u64) + && w > 0 + { + return Some(w); + } + } + None +} + +/// The compaction signal: the live context size and the window it's measured +/// against. Derived from [`Telemetry`] via [`Telemetry::usage`]. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Usage { + /// Tokens in the last inference's context. `0` until the first `assistant` + /// event. + pub context_tokens: u64, + /// The model-reported active window, if the turn reported one. + pub context_window: Option<u64>, +} + +#[cfg(test)] +mod tests { + use super::Telemetry; + use serde_json::json; + + #[test] + fn context_tokens_sum_excludes_output() { + let mut t = Telemetry::default(); + t.observe(&json!({ + "type": "assistant", + "message": { "model": "claude-opus-4-8", "usage": { + "input_tokens": 100, "output_tokens": 999, + "cache_read_input_tokens": 20, "cache_creation_input_tokens": 5, + }} + })); + assert_eq!(t.context.context_tokens(), 125); + assert_eq!(t.model.as_deref(), Some("claude-opus-4-8")); + } + + #[test] + fn last_assistant_wins() { + let mut t = Telemetry::default(); + for n in [10, 20, 30] { + t.observe(&json!({ + "type": "assistant", + "message": { "usage": { "input_tokens": n } } + })); + } + assert_eq!(t.context.input_tokens, 30); + } + + #[test] + fn result_event_sets_cost_and_window() { + let mut t = Telemetry::default(); + t.observe(&json!({ + "type": "result", + "usage": { "input_tokens": 5_000, "output_tokens": 1_000 }, + "modelUsage": { "claude-opus-4-8": { "contextWindow": 200_000 } } + })); + assert_eq!(t.cost.input_tokens, 5_000); + assert_eq!(t.context_window, Some(200_000)); + } + + #[test] + fn empty_model_ignored_and_non_events_no_op() { + let mut t = Telemetry::default(); + t.observe(&json!({ "type": "assistant", "message": { "model": "" } })); + assert_eq!(t.model, None); + t.observe(&json!({ "type": "system", "subtype": "init" })); + assert_eq!(t, Telemetry::default()); + } + + #[test] + fn usage_view_derives_from_context_and_window() { + let mut t = Telemetry::default(); + t.observe(&json!({ "type": "assistant", "message": { "usage": { "input_tokens": 42 } } })); + t.observe(&json!({ "type": "result", "modelUsage": { "m": { "contextWindow": 100 } } })); + let u = t.usage(); + assert_eq!(u.context_tokens, 42); + assert_eq!(u.context_window, Some(100)); + } +}