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,
})
}