diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index a15548c..0cf2227 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -180,6 +180,12 @@ pre.diff { font-size: 0.78em; letter-spacing: 0.04em; } +.token-usage { + color: var(--muted); + font-size: 0.8em; + letter-spacing: 0.04em; + cursor: default; +} .btn-dashlink { color: var(--cyan); border: 1px solid var(--cyan); diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 8f786c1..3c9461b 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -412,6 +412,21 @@ el_.hidden = false; el_.textContent = 'model · ' + model; } + function renderTokenUsage(u) { + const el_ = $('token-usage'); + if (!el_) return; + if (!u) { el_.hidden = true; return; } + const ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens; + const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + 'k' : String(n); + el_.hidden = false; + el_.title = [ + 'input: ' + u.input_tokens, + 'output: ' + u.output_tokens, + 'cache_read: ' + u.cache_read_input_tokens, + 'cache_write: ' + u.cache_creation_input_tokens, + ].join(' · '); + el_.textContent = '· ctx ' + fmt(ctx) + ' in · ' + fmt(u.output_tokens) + ' out'; + } function renderLastTurn(ms) { const el_ = $('last-turn'); if (!el_) return; @@ -485,6 +500,7 @@ setStateAbs(s.turn_state, s.turn_state_since); } renderModelChip(s.model); + renderTokenUsage(s.token_usage); // 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/assets/index.html b/hive-ag3nt/assets/index.html index c1e7ae4..5ebae41 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -17,6 +17,7 @@ … booting + diff --git a/hive-ag3nt/src/events.rs b/hive-ag3nt/src/events.rs index 979b5f4..09a724d 100644 --- a/hive-ag3nt/src/events.rs +++ b/hive-ag3nt/src/events.rs @@ -156,6 +156,23 @@ 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). + pub fn context_tokens(&self) -> u64 { + self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens + } +} + /// 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 @@ -191,6 +208,12 @@ pub struct Bus { /// Model name passed to `claude --model`. Default `haiku`; the /// operator can override at runtime via `POST /api/model`. model: Arc>, + /// Last token usage reported by claude (from the `result` stream-json + /// event). `None` until the first turn with usage data completes. + /// Updated on every turn; survives across turns within one harness + /// process lifetime (resets on container restart, which is fine — + /// it's a live indicator, not a cumulative counter). + last_usage: Arc>>, /// 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 @@ -220,6 +243,7 @@ impl Bus { store, state: Arc::new(Mutex::new((TurnState::Idle, now_unix()))), model: Arc::new(Mutex::new(initial_model)), + last_usage: Arc::new(Mutex::new(None)), skip_continue_once: Arc::new(AtomicBool::new(false)), } } @@ -258,6 +282,17 @@ impl Bus { } } + /// Record the latest token usage from a completed turn. + pub fn record_usage(&self, usage: TokenUsage) { + *self.last_usage.lock().unwrap() = Some(usage); + } + + /// Last known token usage, or `None` if no turn has completed yet. + #[must_use] + pub fn last_usage(&self) -> Option { + *self.last_usage.lock().unwrap() + } + /// 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) { diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 1ff442c..c660b08 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -276,7 +276,34 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result flag_out.store(true, Ordering::Relaxed); } match serde_json::from_str::(&line) { - Ok(v) => bus_out.emit(LiveEvent::Stream(v)), + Ok(v) => { + // Extract token usage from the final `result` event and + // store it in the bus for the web UI to surface. + if v.get("type").and_then(|t| t.as_str()) == Some("result") { + if let Some(u) = v.get("usage") { + let usage = crate::events::TokenUsage { + input_tokens: u + .get("input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + output_tokens: u + .get("output_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cache_read_input_tokens: u + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + cache_creation_input_tokens: u + .get("cache_creation_input_tokens") + .and_then(|v| v.as_u64()) + .unwrap_or(0), + }; + bus_out.record_usage(usage); + } + } + bus_out.emit(LiveEvent::Stream(v)); + } Err(_) => bus_out.emit(LiveEvent::Note(format!("(non-json) {line}"))), } } diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 02a9df2..80a142f 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -196,6 +196,9 @@ struct StateSnapshot { /// the operator can see what they just switched to (and what's /// in flight). Mutable at runtime via `POST /api/model`. model: String, + /// Token usage from the last completed turn. `null` until the + /// first turn with usage data finishes. + token_usage: Option, } #[derive(Serialize)] @@ -232,6 +235,7 @@ async fn api_state(State(state): State) -> axum::Json { let inbox = recent_inbox(&state.socket, state.flavor()).await; let (turn_state, turn_state_since) = state.bus.state_snapshot(); let model = state.bus.model(); + let token_usage = state.bus.last_usage(); axum::Json(StateSnapshot { label: state.label.clone(), dashboard_port, @@ -241,6 +245,7 @@ async fn api_state(State(state): State) -> axum::Json { turn_state, turn_state_since, model, + token_usage, }) }