diff --git a/CLAUDE.md b/CLAUDE.md index 1a8b0df0..1f446a2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,16 +33,9 @@ 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 *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`. + 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. - **`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 315feef0..744a340f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1333,7 +1333,6 @@ dependencies = [ "axum", "clap", "futures-util", - "hive-claude", "hive-sh4re", "reqwest", "rmcp", @@ -1393,16 +1392,6 @@ 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 128ba120..247e3637 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "hive-ag3nt", "hive-bash-mcp", "hive-c0re", - "hive-claude", "hive-forge", "hive-matrix-mcp", "hive-priv", @@ -36,8 +35,6 @@ 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 new file mode 100644 index 00000000..74613421 --- /dev/null +++ b/TODO.md @@ -0,0 +1,6 @@ +# 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 54814b42..7e704198 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -137,25 +137,13 @@ if a new inbox message arrives before this turn ends"). The ``` claude --print --verbose --output-format stream-json --model \ - --effort --resume # or --name <title> on first use \ + --effort <level> --continue \ --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` @@ -192,71 +180,45 @@ The effective window drives watermarks and is exposed at runtime via `/api/state.context_window_tokens` so the UI can show a percentage-of-window ctx badge. -**Session identity — a constant title.** Every turn keys on one fixed, -harness-owned session title (`turn::session_title()`, default -`hive-session`, override `HIVE_SESSION_TITLE`). The durable -`hive_claude::InfiniteSession` (built once by the serve loop via -`turn::make_session`, then reused) `--resume <title>`s it; the *first* use -(bootstrap, post-archive, post-purge) misses and the session re-runs the -same prompt once with `--name <title>` to mint it. That single self-heal -rule is the whole identity system — there is **no** scraped session-id -file. Because the -title is constant, `/compact` and its post-compact retry provably target -the same session (killing the old "compact ran on a different/empty -session" bug), and a `choom` invocation in the same cwd can't hijack the -context (it won't carry our title). claude stores sessions in -`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently); -`--name` writes the title into the file as a `custom-title` event, which -is what `--resume <title>` resolves against. We never pass bare -`--continue` (it resumes the *latest* session in the cwd — the hijack -vector). Auto-compact and auto-memory are disabled via the managed -settings at `/etc/claude-code/managed-settings.json` because hyperhive -owns compaction — see [Compaction](#compaction) below. - -**Session reset** is available via `POST /api/new-session` (or -`/new-session` slash command). It does *not* touch the session inline — -that would race a mid-write claude process. Instead `Bus::request_session_reset()` -sets a one-shot flag consumed at the next turn boundary by `drive_turn`, -which **archives** the current session: the backing `<uuid>.jsonl` is -renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl` -resolution glob, history preserved on disk, only the file carrying *our* -title — any `choom` session sharing the cwd is left alone). The next -turn's `--resume <title>` then misses and self-heals into a fresh session. +`--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. ### Compaction claude's own in-session auto-compact is off (via the managed settings -at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The -`hive_claude::InfiniteSession` keeps the session alive across the context -window with two triggers baked into its `run`: +at `/etc/claude-code/managed-settings.json`); hyperhive owns it +explicitly in `turn::drive_turn`. There are two triggers: -- **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 +- **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 notes-checkpoint turn is possible here: the detail is gone. -- **Proactive** — a turn finishes cleanly but the last inference's context - size crossed the policy watermark. While the session is still healthy it - runs one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` — - "context is filling up, flush durable state into `/state` now") and - *then* `/compact`s, so the agent can persist in-flight state before the - detail collapses into a summary. +- **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. -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. +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`. To disable proactive compaction for a specific agent, use the nix option: @@ -264,28 +226,25 @@ To disable proactive compaction for a specific agent, use the nix option: hyperhive.autoCompact = false; # default true ``` -Setting `autoCompact = false` sets `HIVE_COMPACT_WATERMARK_TOKENS=0`, which -the percent resolver still honours as a disable. Useful for large-context -models (sonnet/opus) where the 75% heuristic fires before the session is -actually full — the reactive path (compact-on-overflow at the hard limit) -still applies. +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. -- **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. +- **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. 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 @@ -305,7 +264,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, your session is intact". The +check /state/ for your notes, --continue session is intact". The next turn picks it up like any other inbox message. ### On-boot files @@ -358,16 +317,10 @@ socket at `/run/hive/` once at startup: empty-string env vars and missing env vars round-trip the same way. -The per-turn plumbing lives in `hive_ag3nt::turn`: `write_mcp_config` / -`write_system_prompt` (on-boot files), `make_session` (builds the durable -`InfiniteSession`, once), `drive_turn` (the policy state machine — -reset/auto-reset, the turn, 401-retry, deferred-compact-at-turn-end), -`run_pending_compact` (idle operator compact), `BusSink` (stream → bus + -`Telemetry` applied via `apply_telemetry`), `emit_turn_end`, `session_title` -/ `session_store` / `archive_session` (identity + turn-boundary reset). The -actual claude spawn, stream classification, and the reactive/proactive -compaction loop are in the `hive-claude` crate. Login-wait -(`wait_for_login`) lives in `hive_ag3nt::login`. +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. ### Reference docs (`hyperhive.docs.enable`) @@ -732,10 +685,9 @@ 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 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. +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. ### Tool envelope diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 817727a0..84c5b6fa 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -261,11 +261,9 @@ 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` → 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. +- `/compact` — `POST /api/compact` → host spawns + `turn::compact_session` in the background; output streams into + the live panel. - `/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 @@ -452,7 +450,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/stats.rs::api_stats`) three more chips appear: **reminders +`web_ui.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 84c2d36d..933e7f4d 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/state.rs::StateSnapshot` — when +`dashboard.rs::StateSnapshot` and `web_ui.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 53f566e5..655b5ab3 100644 --- a/hive-ag3nt/Cargo.toml +++ b/hive-ag3nt/Cargo.toml @@ -12,7 +12,6 @@ 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 37cf3864..967ff414 100644 --- a/hive-ag3nt/src/bin/hive.rs +++ b/hive-ag3nt/src/bin/hive.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use hive_ag3nt::web_ui::TurnLock; use anyhow::Result; use clap::{Parser, Subcommand}; @@ -428,6 +429,7 @@ 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`; @@ -452,15 +454,18 @@ 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) = web_ui_args; - if let Err(e) = web_ui::serve(label, port, login_state, bus, socket).await { + 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 + { tracing::error!(error = %e, "web_ui::serve exited with error"); } }); if matches!(initial, LoginState::NeedsLogin) { - login::wait_for_login(&claude_dir, login_state.clone(), &bus, poll_ms).await; + turn::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 @@ -475,6 +480,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> { bus, stats, &files, + turn_lock, ) .await } @@ -496,12 +502,10 @@ 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 @@ -513,13 +517,7 @@ async fn serve_loop<S: Surface>( None => match S::recv_next(socket).await { RecvOutcome::Message(first) => first, RecvOutcome::Empty => { - // Idle: no message this poll. Service a queued operator - // `/compact` here so it runs even when no turn is driving - // (the in-flight case is handled at the end of drive_turn). - let compacted = turn::run_pending_compact(files, &bus, &session).await; - if !compacted { - tokio::time::sleep(interval).await; - } + tokio::time::sleep(interval).await; continue; } RecvOutcome::TransportError => { @@ -540,7 +538,7 @@ async fn serve_loop<S: Surface>( &bus, stats.as_ref(), files, - &session, + &turn_lock, graceful_stop_message(), ) .await; @@ -549,11 +547,10 @@ async fn serve_loop<S: Surface>( } }, }; - let ctrl = - handle_turn::<S>(socket, &bus, stats.as_ref(), files, &session, next).await; + let ctrl = handle_turn::<S>(socket, &bus, stats.as_ref(), files, &turn_lock, next).await; if ctrl.auth_failed { *login_state.lock().unwrap() = LoginState::NeedsLogin; - login::wait_for_login( + turn::wait_for_login( &claude_dir, login_state.clone(), &bus, @@ -578,7 +575,7 @@ async fn handle_turn<S: Surface>( bus: &Bus, stats: Option<&TurnStats>, files: &turn::TurnFiles, - session: &turn::AgentSession, + turn_lock: &TurnLock, first: hive_sh4re::DeliveredMessage, ) -> TurnControl { let from = first.from; @@ -598,7 +595,10 @@ 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 = turn::drive_turn(&prompt, files, bus, session).await; + let outcome = { + let _guard = turn_lock.lock().await; + turn::drive_turn(&prompt, files, bus).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 d9721b34..c6fb2d46 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -12,7 +12,6 @@ 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; @@ -433,6 +432,111 @@ 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 @@ -576,28 +680,21 @@ pub struct Bus { /// `container_view` can surface the status on the dashboard without /// a live socket call. rate_limited: Arc<AtomicBool>, - /// One-shot: operator asked to reset the claude session (`POST - /// /api/new-session`). Consumed at the *next* turn boundary by - /// `turn::drive_turn`, which archives the current session so the turn - /// starts fresh. Deferred (not applied in the handler) so the archive - /// never races a claude process mid-write — one claude per container, - /// serialized by the serve loop, so the turn boundary is the only point - /// where no session file is open. - session_reset_pending: Arc<AtomicBool>, - /// One-shot: run `/compact` after the next turn ends. Consumed at the end - /// of the current/next turn by `turn::drive_turn`. Deferring to the turn - /// boundary keeps compaction from racing a live claude process mid-turn. - compact_pending: Arc<AtomicBool>, + /// 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>, /// Current fresh-claude-session id (FK to `sessions.id`). Set by the /// bin loop after minting a session row on a fresh start; stamped onto /// every `turn_stats` row until the next fresh session. `None` before /// the first fresh turn or when the stats db is unavailable. session_id: Arc<Mutex<Option<i64>>>, - /// One-shot: `run_claude` flips this true when it creates a fresh claude - /// session (`--name <title>` on a title miss). The bin loop takes-and- - /// clears it after the turn to decide whether to mint a new `sessions` - /// row. Session identity itself is handled entirely in `turn.rs` via the - /// constant title + archive — this flag is purely a stats signal. + /// 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. 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 @@ -676,8 +773,7 @@ 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)), - session_reset_pending: Arc::new(AtomicBool::new(false)), - compact_pending: Arc::new(AtomicBool::new(false)), + skip_continue_once: 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())), @@ -701,37 +797,23 @@ impl Bus { self.event_seq.fetch_add(1, Ordering::SeqCst) + 1 } - /// Request a session reset (operator `POST /api/new-session`). Deferred: - /// the flag is consumed at the next turn boundary by `drive_turn`, which - /// archives the current session so no claude process is mid-write when the - /// file is renamed. Idempotent — two clicks before the next turn still - /// archive once. - pub fn request_session_reset(&self) { - self.session_reset_pending.store(true, Ordering::SeqCst); + /// 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); } - /// Take + clear the session-reset one-shot. Returns true iff `drive_turn` - /// should archive the current session before this turn. + /// Take + clear the one-shot. Returns true iff the caller should + /// run claude without `--continue` for this turn. #[must_use] - pub fn take_session_reset(&self) -> bool { - self.session_reset_pending.swap(false, Ordering::SeqCst) - } - - /// Request a compaction after the next turn ends (deferred to the turn - /// boundary). Idempotent. - pub fn request_compact(&self) { - self.compact_pending.store(true, Ordering::SeqCst); - } - - /// Take + clear the compact one-shot. Returns true iff `drive_turn` should - /// compact at the end of this turn. - #[must_use] - pub fn take_compact(&self) -> bool { - self.compact_pending.swap(false, Ordering::SeqCst) + pub fn take_skip_continue(&self) -> bool { + self.skip_continue_once.swap(false, Ordering::SeqCst) } /// Mark that the current turn started a fresh claude session. - /// `run_claude` calls this when it creates a new titled session. + /// `run_claude` calls this when it suppresses `--continue`. pub fn mark_fresh_session(&self) { self.fresh_session.store(true, Ordering::SeqCst); } @@ -1121,8 +1203,8 @@ impl Default for Bus { #[cfg(test)] mod tests { use super::{ - BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, forge_cursor_from_json, - is_valid_effort, + BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage, + forge_cursor_from_json, is_valid_effort, }; use serde_json::json; @@ -1201,4 +1283,29 @@ 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 33a50a43..dd5d429a 100644 --- a/hive-ag3nt/src/login.rs +++ b/hive-ag3nt/src/login.rs @@ -9,10 +9,6 @@ //! 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 @@ -60,216 +56,3 @@ 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 f9afd86f..95af42f2 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -2330,10 +2330,8 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str } #[cfg(test)] -mod tests { +mod recv_hint_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() { @@ -2347,6 +2345,12 @@ mod 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 62661fbe..523ed568 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -1,17 +1,20 @@ -//! 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`). +//! 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). +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; -use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink}; -use serde_json::Value; +use anyhow::{Result, bail}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; use crate::events::{Bus, LiveEvent}; +use crate::login::LoginState; use crate::mcp; // Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json` @@ -23,22 +26,55 @@ 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. -/// 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"; +/// 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"; /// How long to sleep after detecting a rate-limit before re-entering the /// serve loop. Overridable via `HIVE_RATE_LIMIT_SLEEP_SECS`. Default is @@ -58,10 +94,6 @@ 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 @@ -149,9 +181,8 @@ 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" 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). + /// claude saw "Prompt is too long" — the session needs compacting. + /// Run `compact_session()` then retry the same wake-up prompt. 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 @@ -165,25 +196,15 @@ 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 { - env_u64_positive("HIVE_RATE_LIMIT_SLEEP_SECS", DEFAULT_RATE_LIMIT_SLEEP_SECS) + 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) } /// Resolve the effective context-window size for watermark calculations. @@ -208,135 +229,184 @@ fn effective_context_window(bus: &Bus) -> u64 { /// /// `0` disables auto-reset entirely. fn auto_reset_watermark_tokens(bus: &Bus) -> u64 { - env_u64("HIVE_AUTO_RESET_WATERMARK_TOKENS").unwrap_or_else(|| effective_context_window(bus) / 2) + 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 } /// Resolve the assumed cache TTL: `HIVE_CACHE_TTL_SECS` if set, else /// `DEFAULT_CACHE_TTL_SECS`. fn cache_ttl_secs() -> u64 { - env_u64_positive("HIVE_CACHE_TTL_SECS", DEFAULT_CACHE_TTL_SECS) + 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) } -/// 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; - } - let pct = env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); - u8::try_from(pct.min(100)).unwrap_or(DEFAULT_COMPACT_PERCENT) -} - -/// The agent's durable session type: the constant-title [`InfiniteSession`] -/// 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: +/// Resolve the proactive-compaction watermark. Priority order: +/// 1. `HIVE_COMPACT_WATERMARK_TOKENS` env var (explicit override). +/// 2. 75% of `effective_context_window(bus)`. /// -/// - **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). +/// `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; + } + effective_context_window(bus) * 3 / 4 +} + +/// Drive one turn end-to-end. Three paths layer on top of the raw `run_turn`: /// -/// 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 +/// - **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. +/// +/// 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, } } - Err(e) => error_to_turn(e), + // 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, }; - // 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; + // 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 { 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), 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. +/// 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. fn maybe_auto_reset(bus: &Bus) { let watermark = auto_reset_watermark_tokens(bus); if watermark == 0 { @@ -367,7 +437,7 @@ fn maybe_auto_reset(bus: &Bus) { — dropping session (cache cold, fresh start is equally cheap)" ), }); - archive_session(bus); + bus.request_new_session(); } /// Emit the per-turn `TurnEnd` event + log line. Single owner so outcome @@ -406,201 +476,545 @@ pub fn emit_turn_end(bus: &Bus, outcome: &TurnOutcome) { } } -/// 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; +/// 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, + } +} + +/// 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: "operator: /compact — running on idle session".into(), + text: "context overflow — running /compact on the persistent session".into(), }); - 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 { + 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 { text: "/compact done".into(), }), - Err(e) => bus.emit(LiveEvent::Note { - text: format!("/compact failed: {e}"), + 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:#}"), }), } - bus.set_state(crate::events::TurnState::Idle); - true + outcome } -/// 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 { +#[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). let state_dir = crate::paths::state_dir(); if state_dir.is_dir() { - state_dir - } else { - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + cmd.current_dir(&state_dir); } -} - -/// 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. + 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. if let Some(docs_dir) = std::env::var_os("HIVE_DOCS_DIR") && !docs_dir.is_empty() { - add_dirs.push(PathBuf::from(docs_dir)); + cmd.arg("--add-dir").arg(&docs_dir); } - 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 mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; -/// 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()), + if let Some(mut stdin) = child.stdin.take() { + stdin.write_all(prompt.as_bytes()).await?; + stdin.shutdown().await.ok(); + drop(stdin); } -} + let stdout = child.stdout.take().expect("piped stdout"); + let stderr = child.stderr.take().expect("piped stderr"); -/// 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 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}"), + }); + } } - 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 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}"), }); + 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); + } + 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 e314923e..c239aff2 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<hive_claude::TokenUsage>, - Option<hive_claude::TokenUsage>, + Option<crate::events::TokenUsage>, + Option<crate::events::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 = hive_claude::TokenUsage { + let cost = crate::events::TokenUsage { input_tokens: g(0)?, output_tokens: g(1)?, cache_read_input_tokens: g(2)?, cache_creation_input_tokens: g(3)?, }; - let last = hive_claude::TokenUsage { + let last = crate::events::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 == hive_claude::TokenUsage::default() { + let ctx = if last == crate::events::TokenUsage::default() { None } else { Some(last) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs new file mode 100644 index 00000000..1925ea90 --- /dev/null +++ b/hive-ag3nt/src/web_ui.rs @@ -0,0 +1,1276 @@ +//! 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 deleted file mode 100644 index 49fce257..00000000 --- a/hive-ag3nt/src/web_ui/actions.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! 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 deleted file mode 100644 index f3e175e0..00000000 --- a/hive-ag3nt/src/web_ui/auth.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! 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 deleted file mode 100644 index 1827e3da..00000000 --- a/hive-ag3nt/src/web_ui/mod.rs +++ /dev/null @@ -1,269 +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. -//! -//! 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 deleted file mode 100644 index e8a9babc..00000000 --- a/hive-ag3nt/src/web_ui/screen.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! 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 deleted file mode 100644 index f67ea0e2..00000000 --- a/hive-ag3nt/src/web_ui/state.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! `/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 deleted file mode 100644 index 9c0ace5e..00000000 --- a/hive-ag3nt/src/web_ui/stats.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! 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 deleted file mode 100644 index dabbcc45..00000000 --- a/hive-ag3nt/src/web_ui/stream.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! 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 deleted file mode 100644 index 63e6f608..00000000 --- a/hive-claude/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[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 deleted file mode 100644 index 957b7776..00000000 --- a/hive-claude/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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 deleted file mode 100644 index 93eb9255..00000000 --- a/hive-claude/src/classify.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! 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 deleted file mode 100644 index ae0cff93..00000000 --- a/hive-claude/src/config.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! 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 deleted file mode 100644 index 76c3d267..00000000 --- a/hive-claude/src/driver.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! 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 deleted file mode 100644 index e29deecd..00000000 --- a/hive-claude/src/error.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! 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 deleted file mode 100644 index 40cde76a..00000000 --- a/hive-claude/src/lib.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! `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 deleted file mode 100644 index 998b0968..00000000 --- a/hive-claude/src/policy.rs +++ /dev/null @@ -1,130 +0,0 @@ -//! 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 deleted file mode 100644 index 4fded3b5..00000000 --- a/hive-claude/src/session.rs +++ /dev/null @@ -1,180 +0,0 @@ -//! 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 deleted file mode 100644 index 0c82f6c2..00000000 --- a/hive-claude/src/sink.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! 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 deleted file mode 100644 index 064aa924..00000000 --- a/hive-claude/src/store.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! 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 deleted file mode 100644 index 7bc62f5b..00000000 --- a/hive-claude/src/telemetry.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! 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)); - } -}