diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 7cba626f..2aa32d7f 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -901,6 +901,16 @@ impl Bus { *self.api_context_window.lock().unwrap() } + /// The effective context window for `model`: the API-reported window if a + /// turn has completed ([`api_context_window`]), else the per-model default + /// ([`context_window_tokens`]). Single accessor so the state + dashboard + /// endpoints agree by construction. + #[must_use] + pub fn effective_context_window(&self, model: &str) -> u64 { + self.api_context_window() + .unwrap_or_else(|| context_window_tokens(model)) + } + /// Walk a stream-json value for `tool_use` blocks and bump the /// per-turn counter for each one we find. Called by the stdout /// pump on every parsed line. Cheap when the line isn't an diff --git a/hive-ag3nt/src/web_ui/actions.rs b/hive-ag3nt/src/web_ui/actions.rs index 3feae0a3..1132b9e6 100644 --- a/hive-ag3nt/src/web_ui/actions.rs +++ b/hive-ag3nt/src/web_ui/actions.rs @@ -44,10 +44,7 @@ pub(super) async fn post_send( } pub(super) async fn post_cancel_turn(State(state): State) -> Response { - let out = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; + let out = super::sigint_claude().await; let note = match out { Ok(o) if o.status.success() => "operator: /cancel — sent SIGINT to claude".to_owned(), Ok(o) if o.status.code() == Some(1) => { diff --git a/hive-ag3nt/src/web_ui/auth.rs b/hive-ag3nt/src/web_ui/auth.rs index 65f63a98..9c1cceb5 100644 --- a/hive-ag3nt/src/web_ui/auth.rs +++ b/hive-ag3nt/src/web_ui/auth.rs @@ -84,10 +84,7 @@ pub(super) async fn post_login_cancel(State(state): State) -> Response /// preservation invariants. pub(super) async fn post_logout(State(state): State) -> Response { // Step 1: SIGINT claude (best-effort, matches `post_cancel_turn`). - let _ = tokio::process::Command::new("pkill") - .args(["-INT", "claude"]) - .output() - .await; + let _ = super::sigint_claude().await; // Step 2: delete OAuth credential files only — login::clear_session owns // the file set and preserves session-history files alongside them. let dir = crate::paths::claude_dir(); diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs index ce158ff7..ec99fccb 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -259,6 +259,17 @@ fn read_gui_vnc_port() -> Option { std::env::var("HIVE_GUI_VNC_PORT").ok()?.parse().ok() } +/// SIGINT any running `claude` process in this container (best-effort). Shared +/// by `/api/cancel` and `/api/logout`. Returns the `pkill` `Output` so callers +/// can inspect the exit status (0 = signalled, 1 = no process matched) or +/// ignore it. +async fn sigint_claude() -> std::io::Result { + tokio::process::Command::new("pkill") + .args(["-INT", "claude"]) + .output() + .await +} + fn error_response(status: StatusCode, message: &str) -> Response { // Plain text — JS app surfaces in `alert()`, HTML wrapping would just // be noise. Status is per-caller: 400 for bad input, 409 for a diff --git a/hive-ag3nt/src/web_ui/state.rs b/hive-ag3nt/src/web_ui/state.rs index b2a8f289..78b7c378 100644 --- a/hive-ag3nt/src/web_ui/state.rs +++ b/hive-ag3nt/src/web_ui/state.rs @@ -36,10 +36,7 @@ pub(super) async fn api_state(State(state): State) -> axum::Json Vec { } } -/// 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 { - match super::broker_request( - socket, - &hive_sh4re::Request::ReminderRollup { - since_secs: window_secs, - agent: None, - }, - ) - .await - { - Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats), - _ => None, - } -} - /// 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 { 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 = raw + // Absent / empty / all-whitespace env all funnel to the single + // emptiness check below — no separate up-front guard needed. + let models: Vec = std::env::var("HIVE_AVAILABLE_MODELS") + .unwrap_or_default() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) diff --git a/hive-ag3nt/src/web_ui/stats.rs b/hive-ag3nt/src/web_ui/stats.rs index 183a0d9e..bd5982e4 100644 --- a/hive-ag3nt/src/web_ui/stats.rs +++ b/hive-ag3nt/src/web_ui/stats.rs @@ -5,7 +5,6 @@ use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::Deserialize; -use super::state::fetch_reminder_stats; use super::{AppState, error_response}; #[derive(Deserialize)] @@ -27,6 +26,27 @@ pub(super) async fn api_stats( axum::Json(snapshot) } +/// Fetch reminder activity stats from the broker via the per-agent / manager +/// socket. Returns None on any transport / decode failure — the stats are +/// decorative, not authoritative. +async fn fetch_reminder_stats( + socket: &std::path::Path, + window_secs: u64, +) -> Option { + match super::broker_request( + socket, + &hive_sh4re::Request::ReminderRollup { + since_secs: window_secs, + agent: None, + }, + ) + .await + { + Ok(hive_sh4re::Response::ReminderRollup(stats)) => Some(stats), + _ => None, + } +} + /// Proxy this agent's loose-ends list via the per-agent socket. The /// web UI surfaces the result as a collapsible section in the page /// so the operator can see at a glance what's pending against the diff --git a/hive-ag3nt/src/web_ui/stream.rs b/hive-ag3nt/src/web_ui/stream.rs index dabbcc45..e94a520a 100644 --- a/hive-ag3nt/src/web_ui/stream.rs +++ b/hive-ag3nt/src/web_ui/stream.rs @@ -56,15 +56,22 @@ pub(super) async fn events_stream( ) -> Sse>> { tracing::info!("sse: client subscribed"); let rx = state.bus.subscribe(); - // Drop a "hello" note into the bus so every new subscriber sees at - // least one event immediately and can clear the connecting placeholder. - state.bus.emit(crate::events::LiveEvent::Note { - text: "live stream attached".into(), - }); - let stream = BroadcastStream::new(rx).filter_map(|res| { + // Prime THIS connection with a one-off "hello" so it can clear the + // connecting placeholder immediately. Injected into this subscriber's own + // stream rather than emitted to the bus — a bus emit would spam every + // already-connected client with a spurious note each time anyone opens + // the stream. + let hello = Event::default().data( + serde_json::to_string(&crate::events::LiveEvent::Note { + text: "live stream attached".into(), + }) + .unwrap_or_default(), + ); + let live = BroadcastStream::new(rx).filter_map(|res| { let ev = res.ok()?; let json = serde_json::to_string(&ev).ok()?; Some(Ok(Event::default().data(json))) }); + let stream = tokio_stream::once(Ok(hello)).chain(live); Sse::new(stream).keep_alive(KeepAlive::default()) }