diff --git a/frontend/packages/agent/src/stats.html b/frontend/packages/agent/src/stats.html
index 52076b78..72fcf32a 100644
--- a/frontend/packages/agent/src/stats.html
+++ b/frontend/packages/agent/src/stats.html
@@ -33,6 +33,10 @@
token cost per bucket (sum across inferences)
turns by model per bucket — model drives token cost
+
+
result trend per bucket — errors / rate-limits / compactions over time
diff --git a/frontend/packages/agent/src/stats.js b/frontend/packages/agent/src/stats.js
index 08b2c23e..01c5ee92 100644
--- a/frontend/packages/agent/src/stats.js
+++ b/frontend/packages/agent/src/stats.js
@@ -340,8 +340,26 @@ window.Chart = Chart;
});
}
+ // "favorite tools" doughnut: most-run shell commands. The capture
+ // (hive-bash-mcp -> bash_commands table) lands separately, so until
+ // there's data we hide the whole card rather than show an empty
+ // doughnut. Runs independently of turn_count (a bash task is tied to
+ // a turn, but we don't want to couple the two reads).
+ function renderBashCard(s) {
+ const card = document.getElementById('card-bash');
+ const items = s.bash_breakdown || [];
+ if (!items.length) {
+ if (card) card.hidden = true;
+ destroy('chart-bash');
+ return;
+ }
+ if (card) card.hidden = false;
+ renderKeyCount('chart-bash', items, 'no bash commands');
+ }
+
function render(s) {
renderSummary(s);
+ renderBashCard(s);
if (s.turn_count === 0) {
paintEmpty('chart-turns', 'no turns in window');
paintEmpty('chart-duration', 'no turns in window');
diff --git a/hive-ag3nt/src/stats.rs b/hive-ag3nt/src/stats.rs
index f9fdf14e..ff28d916 100644
--- a/hive-ag3nt/src/stats.rs
+++ b/hive-ag3nt/src/stats.rs
@@ -90,6 +90,13 @@ pub struct Snapshot {
pub buckets: Vec,
/// Top tools by call count across the window. Capped to 10.
pub tool_breakdown: Vec,
+ /// Top shell commands ("favorite tools") by invocation count across
+ /// the window, capped to 10. Normalised command heads recorded per
+ /// bash task into the `bash_commands` table by hive-bash-mcp. Empty
+ /// until that capture lands (or on any agent that hasn't run a bash
+ /// task) — the table is created lazily by the writer, so a read
+ /// before the first insert returns an empty list, not an error.
+ pub bash_breakdown: Vec,
pub wake_mix: Vec,
pub result_mix: Vec,
/// Distinct models seen in the window, sorted. Each bucket's
@@ -176,6 +183,7 @@ fn empty_snapshot(window: Window) -> Snapshot {
turn_count: 0,
buckets,
tool_breakdown: Vec::new(),
+ bash_breakdown: Vec::new(),
wake_mix: Vec::new(),
result_mix: Vec::new(),
models: Vec::new(),
@@ -276,6 +284,7 @@ fn snapshot(path: &Path, window: Window) -> Result {
turn_count,
buckets,
tool_breakdown: top_n(tool_totals, 10),
+ bash_breakdown: read_bash_breakdown(&conn, from).unwrap_or_default(),
wake_mix: top_n(wake_totals, 20),
result_mix: top_n(result_totals, 20),
models,
@@ -284,6 +293,36 @@ fn snapshot(path: &Path, window: Window) -> Result {
})
}
+/// Aggregate the top shell-command heads ("favorite tools") over
+/// `[from, now]` from the `bash_commands` table — one row per bash task
+/// (`ts INTEGER NOT NULL, head TEXT NOT NULL`), written by hive-bash-mcp.
+///
+/// Returns `Err` (which the caller maps to an empty list) when the
+/// table doesn't exist yet — the writer creates it lazily on first
+/// insert, so any agent that hasn't run a bash task since the capture
+/// shipped simply has no table. Decoupling it this way means the read
+/// side is inert-until-data and needs no schema coordination here.
+fn read_bash_breakdown(conn: &Connection, from: i64) -> rusqlite::Result> {
+ let mut stmt = conn.prepare(
+ "SELECT head, COUNT(*) AS n
+ FROM bash_commands
+ WHERE ts >= ?1
+ GROUP BY head",
+ )?;
+ let rows = stmt.query_map([from], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ u64_from_i64(row.get::<_, i64>(1)?),
+ ))
+ })?;
+ let mut totals: HashMap = HashMap::new();
+ for r in rows {
+ let (head, n) = r?;
+ *totals.entry(head).or_insert(0) += n;
+ }
+ Ok(top_n(totals, 10))
+}
+
struct Row {
started_at: i64,
duration_ms: i64,
@@ -581,4 +620,60 @@ mod tests {
assert_eq!(s.bucket_seconds, 86_400);
assert!(s.buckets.len() >= 7);
}
+
+ /// `bash_breakdown` degrades gracefully when the `bash_commands`
+ /// table hasn't been created yet (the capture side hasn't shipped /
+ /// run on this agent). A `seed_db` DB has no such table, so the read
+ /// must yield an empty list rather than erroring the whole snapshot.
+ #[test]
+ fn bash_breakdown_empty_without_table() {
+ let db = tmp_db();
+ let _ = std::fs::remove_file(&db);
+ seed_db(&db, &[(now_secs() - 100, 1000, "opus", "recv", "ok", "{}")]);
+ let s = snapshot(&db, Window::Day).unwrap();
+ assert!(s.bash_breakdown.is_empty());
+ }
+
+ /// With a populated `bash_commands` table, `bash_breakdown` rolls up
+ /// per-head counts (busiest first) and respects the window cutoff.
+ #[test]
+ fn bash_breakdown_aggregates_heads() {
+ let db = tmp_db();
+ let _ = std::fs::remove_file(&db);
+ seed_db(&db, &[]);
+ let now = now_secs();
+ let conn = Connection::open(&db).unwrap();
+ conn.execute_batch("CREATE TABLE bash_commands (ts INTEGER NOT NULL, head TEXT NOT NULL);")
+ .unwrap();
+ // 3x cargo + 2x git inside the window, 1x rg outside it.
+ for (ts, head) in [
+ (now - 100, "cargo"),
+ (now - 200, "cargo"),
+ (now - 300, "cargo"),
+ (now - 400, "git"),
+ (now - 500, "git"),
+ (now - (2 * 24 * 3600), "rg"), // older than the 24h window
+ ] {
+ conn.execute(
+ "INSERT INTO bash_commands (ts, head) VALUES (?1, ?2)",
+ params![ts, head],
+ )
+ .unwrap();
+ }
+ let s = snapshot(&db, Window::Day).unwrap();
+ let map: HashMap<_, _> = s
+ .bash_breakdown
+ .iter()
+ .map(|kc| (kc.key.clone(), kc.count))
+ .collect();
+ assert_eq!(map.get("cargo").copied(), Some(3));
+ assert_eq!(map.get("git").copied(), Some(2));
+ // `rg` fell outside the 24h window — excluded.
+ assert_eq!(map.get("rg").copied(), None);
+ // top_n orders busiest first.
+ assert_eq!(
+ s.bash_breakdown.first().map(|kc| kc.key.as_str()),
+ Some("cargo")
+ );
+ }
}