From 637085644d74b73452da9d22f667abe97cd17de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 20:46:38 +0200 Subject: [PATCH] server-side TurnState in the harness, exposed via /api/state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new TurnState { Idle, Thinking, Compacting } on hive_ag3nt::events::Bus with set_state + state_snapshot. the turn loops in hive-ag3nt and hive-m1nd flip Thinking before drive_turn and Idle after; the web_ui's /api/compact handler flips Compacting around compact_session. per-agent /api/state grows turn_state + turn_state_since (unix seconds). frontend prefers the server-reported state over the client-derived one โ€” setStateAbs takes the absolute since-time so the 'last turn' chip reads the actual server-side duration instead of the client's perceived gap between SSE events. SSE turn_start / turn_end still drive state instantly between renders; /api/state re-anchors on each turn_end refresh. new compacting state gets its own purple badge with pulse animation (mirrors thinking's amber). napping will slot in the same way once the nap tool lands. --- TODO.md | 17 +++---------- hive-ag3nt/assets/agent.css | 5 ++++ hive-ag3nt/assets/app.js | 40 +++++++++++++++++------------ hive-ag3nt/src/bin/hive-ag3nt.rs | 4 ++- hive-ag3nt/src/bin/hive-m1nd.rs | 4 ++- hive-ag3nt/src/events.rs | 43 ++++++++++++++++++++++++++++++++ hive-ag3nt/src/web_ui.rs | 13 +++++++++- 7 files changed, 94 insertions(+), 32 deletions(-) diff --git a/TODO.md b/TODO.md index e545ba6..a55d917 100644 --- a/TODO.md +++ b/TODO.md @@ -27,19 +27,10 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX -- **State badge: compacting + napping states.** Idle/thinking already - ship (driven from SSE turn_start/turn_end). Add `compacting ๐Ÿ“ฆ` and - `napping ๐Ÿ˜ด` once the `/compact` trigger and `nap` tool exist โ€” - both need a harness signal (an explicit `LiveEvent::StateChange` - variant or piggyback on Note). -- **Server-side state badge.** Today the badge is computed client-side - from `turn_start`/`turn_end` events. On page reload mid-turn the - history replay re-derives it, but with a `compacting` / `napping` - state coming and a non-trivial state machine it's better to track - authoritative state in the harness and expose it via - `GET /api/state` (`status: "thinking" | "idle" | "compacting" | - "napping"`). JS just renders. Drops the - derive-from-events-and-pray code path. +- **State badge: napping state.** Idle / thinking / compacting + already ship from server-side `TurnState`. Add `napping ๐Ÿ˜ด` + once the `nap` tool exists โ€” it just adds a new `TurnState` + variant the harness flips into for the duration of the nap. - **Terminal: `/model` slash command.** Operator-typeable model override from the terminal. Depends on the model-override work above; once an override mechanism exists, wire a `/model ` diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 1303183..aeb165a 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -228,6 +228,11 @@ pre.diff { text-shadow: 0 0 6px rgba(250, 179, 135, 0.65); animation: badge-pulse 1.8s ease-in-out infinite; } +.state-badge.state-compacting { + color: var(--purple); border-color: var(--purple); + text-shadow: 0 0 6px rgba(203, 166, 247, 0.65); + animation: badge-pulse 1.8s ease-in-out infinite; +} .state-badge.state-just-changed { animation: state-flash 600ms ease-out; } diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 2702e7c..1e50679 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -291,10 +291,11 @@ // each second so the "ยท 12s" suffix stays current. State changes // trigger a short flash animation via .state-just-changed. const STATE_LABELS = { - loading: { glyph: 'โ€ฆ', text: 'booting' }, - offline: { glyph: 'โ—‹', text: 'offline' }, - idle: { glyph: '๐Ÿ’ค', text: 'idle' }, - thinking: { glyph: '๐Ÿง ', text: 'thinking' }, + loading: { glyph: 'โ€ฆ', text: 'booting' }, + offline: { glyph: 'โ—‹', text: 'offline' }, + idle: { glyph: '๐Ÿ’ค', text: 'idle' }, + thinking: { glyph: '๐Ÿง ', text: 'thinking' }, + compacting: { glyph: '๐Ÿ“ฆ', text: 'compacting' }, }; let stateName = 'loading'; let stateSince = Date.now(); @@ -318,19 +319,22 @@ if (cancelBtn) cancelBtn.hidden = stateName !== 'thinking'; } function setState(next) { - if (next === stateName) return; - // Capture the just-ending state's duration when leaving 'thinking' - // so the operator can eyeball turn length without scrolling the - // terminal back. + setStateAbs(next, Math.floor(Date.now() / 1000)); + } + /// Set state with an authoritative since-unix from the server. Lets + /// `last turn` track the actual server-side duration rather than + /// whatever the client perceived between SSE events. + function setStateAbs(next, sinceUnix) { + if (next === stateName && sinceUnix * 1000 === stateSince) return; if (stateName === 'thinking' && next !== 'thinking') { const elapsedMs = Date.now() - stateSince; renderLastTurn(elapsedMs); } + const flashing = next !== stateName; stateName = next; - stateSince = Date.now(); + stateSince = sinceUnix * 1000; const badge = $('state-badge'); - if (badge) { - // Re-add the flash class so the animation replays. + if (badge && flashing) { badge.classList.remove('state-just-changed'); void badge.offsetWidth; badge.classList.add('state-just-changed'); @@ -411,11 +415,15 @@ if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } renderTermInput(s.label, s.status === 'online'); renderInbox(s.inbox || []); - // Drive the state badge from the harness status. Live SSE events - // override to 'thinking' / 'idle' as turns start/end; this only - // kicks in for the not-online (offline) case and the initial seed. - if (s.status !== 'online') setState('offline'); - else if (stateName === 'loading' || stateName === 'offline') setState('idle'); + // Authoritative state comes from the harness via /api/state. + // Login-not-yet โ†’ 'offline'; otherwise use the server-reported + // turn_state (idle / thinking / compacting). stateSince in + // unix-seconds is converted to a client-side Date.now() anchor. + if (s.status !== 'online') { + setState('offline'); + } else if (s.turn_state) { + setStateAbs(s.turn_state, s.turn_state_since); + } // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself โ€” without this guard, the // operator's gets clobbered every cycle. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 6f899e6..ed9f32b 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Result; use clap::{Parser, Subcommand}; -use hive_ag3nt::events::{Bus, LiveEvent}; +use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui}; use hive_sh4re::{AgentRequest, AgentResponse}; @@ -126,6 +126,7 @@ async fn serve( body: body.clone(), unread, }); + bus.set_state(TurnState::Thinking); let prompt = format_wake_prompt(&from, &body, unread); let outcome = turn::drive_turn( &prompt, @@ -137,6 +138,7 @@ async fn serve( ) .await; turn::emit_turn_end(&bus, &outcome); + bus.set_state(TurnState::Idle); } Ok(AgentResponse::Empty) => {} Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 06a6170..7d3113c 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::Result; use clap::{Parser, Subcommand}; -use hive_ag3nt::events::{Bus, LiveEvent}; +use hive_ag3nt::events::{Bus, LiveEvent, TurnState}; use hive_ag3nt::login::{self, LoginState}; use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, turn, web_ui}; use hive_sh4re::{HelperEvent, ManagerRequest, ManagerResponse, SYSTEM_SENDER}; @@ -124,6 +124,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { unread, }); let prompt = format_wake_prompt(&from, &body, unread); + bus.set_state(TurnState::Thinking); let outcome = turn::drive_turn( &prompt, &mcp_config, @@ -134,6 +135,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { ) .await; turn::emit_turn_end(&bus, &outcome); + bus.set_state(TurnState::Idle); } Ok(ManagerResponse::Empty) => {} Ok( diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 47ba13e..4025137 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -24,6 +24,14 @@ const HISTORY_CAPACITY: usize = 2000; /// `HYPERHIVE_EVENTS_DB` env var (used in tests and one-shot tools). const DEFAULT_EVENTS_DB: &str = "/state/hyperhive-events.sqlite"; +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .and_then(|d| i64::try_from(d.as_secs()).ok()) + .unwrap_or(0) +} + const SCHEMA: &str = " CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -116,6 +124,22 @@ impl EventStore { } } +/// Authoritative turn-loop state. The harness owns it; the web UI +/// reads via `/api/state` and renders. Lives alongside the bus +/// because everyone who has a `Bus` already has the right handle to +/// poke the state on transitions. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TurnState { + /// Inbox is empty / waiting on `Recv`. + Idle, + /// `claude --print` is running for a turn. + Thinking, + /// Operator-triggered `/compact` is running on the persistent + /// session. + Compacting, +} + #[derive(Clone)] pub struct Bus { tx: Arc>, @@ -123,6 +147,8 @@ pub struct Bus { /// at construction โ€” we keep going so the harness doesn't die on a /// missing `/state/` mount in dev / test scenarios. store: Option>, + /// Current turn-loop state + since-when (unix seconds). + state: Arc>, } impl Bus { @@ -144,9 +170,26 @@ impl Bus { Self { tx: Arc::new(tx), store, + state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))), } } + /// Update the harness's authoritative turn-loop state. Records + /// the transition time so `state_snapshot` can return a since-age. + pub fn set_state(&self, next: TurnState) { + let mut guard = self.state.lock().unwrap(); + if guard.0 == next { + return; + } + *guard = (next, now_unix()); + } + + /// Current state + since-when (unix seconds). Snapshot copy, no lock held. + #[must_use] + pub fn state_snapshot(&self) -> (TurnState, i64) { + *self.state.lock().unwrap() + } + pub fn emit(&self, event: LiveEvent) { if let Some(store) = &self.store && let Err(e) = store.append(&event) diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 688f7a0..e03c740 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -153,6 +153,11 @@ struct StateSnapshot { /// from the broker via the per-agent socket on each render. /// Empty on transport failure. inbox: Vec, + /// 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, } #[derive(Serialize)] @@ -187,12 +192,15 @@ async fn api_state(State(state): State) -> axum::Json { .and_then(|s| s.parse::().ok()) .unwrap_or(7000); let inbox = recent_inbox(&state.socket, state.flavor).await; + let (turn_state, turn_state_since) = state.bus.state_snapshot(); axum::Json(StateSnapshot { label: state.label.clone(), dashboard_port, status, session: session_view, inbox, + turn_state, + turn_state_since, }) } @@ -359,7 +367,10 @@ async fn post_compact(State(state): State) -> Response { return; } }; - if let Err(e) = crate::turn::compact_session(&settings, &bus).await { + bus.set_state(crate::events::TurnState::Compacting); + let r = crate::turn::compact_session(&settings, &bus).await; + bus.set_state(crate::events::TurnState::Idle); + if let Err(e) = r { bus.emit(crate::events::LiveEvent::Note(format!( "/compact failed: {e:#}" )));