diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index a186030..91c3a21 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -550,6 +550,21 @@ }, `⏰ ${c.pending_reminders}`)); } + if (c.ctx_tokens != null) { + // Colour thresholds mirror the harness compaction watermarks: + // < 100k = safe (green), 100k–150k = approaching reset (yellow), + // ≥ 150k = compact territory (red). + const k = Math.round(c.ctx_tokens / 1000); + const ctxClass = c.ctx_tokens >= 150_000 ? 'badge-ctx-warn' + : c.ctx_tokens >= 100_000 ? 'badge-ctx-caution' + : 'badge-ctx-ok'; + head.append(el('span', + { + class: `badge ${ctxClass}`, + title: `last turn context size: ${c.ctx_tokens.toLocaleString()} tokens`, + }, + `ctx·${k}k`)); + } li.append(head); // ── line 2: action buttons ─────────────────────────────────── diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 7eaf67a..cb453c2 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -123,6 +123,20 @@ a:hover { color: var(--cyan); border-color: var(--cyan); text-shadow: 0 0 6px rgba(137, 220, 235, 0.4); } +/* Context-window usage badges on dashboard container rows. + Green < 100k, yellow 100–150k, red ≥ 150k (mirrors harness watermarks). */ +.badge-ctx-ok { + color: var(--green); border-color: var(--green); + opacity: 0.85; +} +.badge-ctx-caution { + color: var(--amber); border-color: var(--amber); + text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); +} +.badge-ctx-warn { + color: var(--red); border-color: var(--red); + text-shadow: 0 0 6px rgba(243, 139, 168, 0.5); +} .container-row.tombstone { border-style: dashed; background: rgba(24, 24, 37, 0.35); diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index a946a88..7db7328 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::path::Path; +use rusqlite::Connection; use serde::Serialize; use crate::coordinator::Coordinator; @@ -36,6 +37,13 @@ pub struct ContainerView { /// not real-time on remind/cancel-reminder but close enough. #[serde(default)] pub pending_reminders: u64, + /// Context-window size (prompt tokens) from the agent's most recent + /// completed turn, read directly from the turn-stats SQLite. + /// `None` when the file is absent or the agent has no turns yet. + /// Stale by up to one crash-watch cycle (~10s); good enough for + /// the "which agent is close to the window?" dashboard glance. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ctx_tokens: Option, } /// Build the full container list. Wraps `lifecycle::list()` and @@ -74,6 +82,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { .broker .count_pending_reminders_for(reminder_recipient) .unwrap_or(0); + let ctx_tokens = read_last_ctx_tokens(&logical); out.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -84,6 +93,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec { needs_login, deployed_sha, pending_reminders, + ctx_tokens, }); } out @@ -102,6 +112,32 @@ pub fn claude_has_session(dir: &Path) -> bool { .any(|e| e.file_type().is_ok_and(|t| t.is_file())) } +/// Read the most recent completed turn's context-window size (prompt +/// tokens) from the agent's turn-stats SQLite. Returns `None` when +/// the file is absent or has no rows. Best-effort — any DB error +/// silently yields `None` so a missing/corrupt file never blocks +/// `build_all`. +/// +/// Context tokens = `last_input_tokens + last_cache_read_input_tokens +/// + last_cache_creation_input_tokens`, mirroring +/// `hive_ag3nt::events::TokenUsage::context_tokens`. +fn read_last_ctx_tokens(name: &str) -> Option { + let path = Coordinator::agent_notes_dir(name).join("hyperhive-turn-stats.sqlite"); + let conn = Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .ok()?; + conn.query_row( + "SELECT last_input_tokens + last_cache_read_input_tokens + last_cache_creation_input_tokens \ + FROM turn_stats ORDER BY started_at DESC LIMIT 1", + [], + |row| row.get::<_, i64>(0), + ) + .ok() + .and_then(|v| u64::try_from(v).ok()) +} + /// Map of `agent-` → locked sha from meta's flake.lock. Used to /// render the `deployed:` chip per container row. fn read_meta_locked_revs() -> HashMap {