use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::SystemTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum Harness {
Claude,
Codex,
Gemini,
OpenCode,
Aider,
Copilot,
Cursor,
Unknown,
}
impl Harness {
pub fn label(self) -> &'static str {
match self {
Harness::Claude => "claude",
Harness::Codex => "codex",
Harness::Gemini => "gemini",
Harness::OpenCode => "opencode",
Harness::Aider => "aider",
Harness::Copilot => "copilot",
Harness::Cursor => "cursor",
Harness::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum AgentState {
Running,
Idle,
Stopped,
}
impl AgentState {
pub fn label(self) -> &'static str {
match self {
AgentState::Running => "running",
AgentState::Idle => "idle",
AgentState::Stopped => "stopped",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum Activity {
Working,
Waiting,
#[default]
Unknown,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct TokenUsage {
pub input: u64,
pub cache_write_5m: u64,
pub cache_write_1h: u64,
pub cache_read: u64,
pub output: u64,
}
impl TokenUsage {
pub fn cache_write(&self) -> u64 {
self.cache_write_5m + self.cache_write_1h
}
pub fn total(&self) -> u64 {
self.input + self.cache_write() + self.cache_read + self.output
}
pub fn prompt(&self) -> u64 {
self.input + self.cache_write() + self.cache_read
}
pub fn cache_hit_rate(&self) -> Option<f64> {
let p = self.prompt();
(p > 0).then(|| self.cache_read as f64 / p as f64)
}
pub fn add(&mut self, other: &TokenUsage) {
self.input += other.input;
self.cache_write_5m += other.cache_write_5m;
self.cache_write_1h += other.cache_write_1h;
self.cache_read += other.cache_read;
self.output += other.output;
}
pub fn sub(&mut self, other: &TokenUsage) {
self.input = self.input.saturating_sub(other.input);
self.cache_write_5m = self.cache_write_5m.saturating_sub(other.cache_write_5m);
self.cache_write_1h = self.cache_write_1h.saturating_sub(other.cache_write_1h);
self.cache_read = self.cache_read.saturating_sub(other.cache_read);
self.output = self.output.saturating_sub(other.output);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, PartialOrd, Ord)]
#[serde(rename_all = "kebab-case")]
pub enum SpanKind {
#[default]
Tool,
Inference,
Turn,
}
impl SpanKind {
pub const ALL: [SpanKind; 3] = [SpanKind::Tool, SpanKind::Inference, SpanKind::Turn];
pub fn label(self) -> &'static str {
match self {
SpanKind::Tool => "tool",
SpanKind::Inference => "inference",
SpanKind::Turn => "turn",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ToolSpan {
pub id: String,
pub name: String,
pub started_at: SystemTime,
pub duration_ms: Option<u64>,
pub sidechain: bool,
pub error: bool,
#[serde(default)]
pub kind: SpanKind,
}
impl ToolSpan {
pub fn is_open(&self) -> bool {
self.duration_ms.is_none()
}
pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
match self.duration_ms {
Some(ms) => ms,
None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
}
}
pub fn ended_at(&self, now: SystemTime) -> SystemTime {
self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct McpServer {
pub name: String,
pub pid: Option<u32>,
pub cmdline: Option<String>,
pub cpu_percent: f32,
pub rss_bytes: u64,
pub age_secs: Option<u64>,
pub calls: u64,
pub errors: u64,
pub last_call: Option<SystemTime>,
pub matched_by: McpMatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum McpMatch {
ProcessOnly,
TranscriptOnly,
Name,
Sole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ProcKind {
Agent,
Subagent,
Mcp,
Shell,
Tool,
}
impl ProcKind {
pub fn label(self) -> &'static str {
match self {
ProcKind::Agent => "agent",
ProcKind::Subagent => "subagent",
ProcKind::Mcp => "mcp",
ProcKind::Shell => "shell",
ProcKind::Tool => "tool",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcNode {
pub pid: u32,
pub ppid: Option<u32>,
pub name: String,
pub cmdline: String,
pub kind: ProcKind,
pub harness: Option<Harness>,
pub cpu_percent: f32,
pub rss_bytes: u64,
pub age_secs: u64,
pub cwd: Option<PathBuf>,
pub children: Vec<ProcNode>,
}
impl ProcNode {
pub fn totals(&self) -> (f32, u64, usize, usize) {
let mut cpu = self.cpu_percent;
let mut rss = self.rss_bytes;
let mut count = 1;
let mut mcp = usize::from(self.kind == ProcKind::Mcp);
for c in &self.children {
let (ccpu, crss, ccount, cmcp) = c.totals();
cpu += ccpu;
rss += crss;
count += ccount;
if self.kind != ProcKind::Mcp {
mcp += cmcp;
}
}
(cpu, rss, count, mcp)
}
pub fn mcp_roots(&self) -> Vec<&ProcNode> {
let mut out = Vec::new();
self.collect_mcp_roots(&mut out);
out
}
fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
if self.kind == ProcKind::Mcp {
out.push(self);
return;
}
for c in &self.children {
c.collect_mcp_roots(out);
}
}
pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
f(self, depth);
for c in &self.children {
c.walk(depth + 1, f);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Agent {
pub id: String,
pub name: String,
pub harness: Harness,
pub state: AgentState,
pub activity: Activity,
pub pid: Option<u32>,
pub session_id: Option<String>,
pub session_path: Option<PathBuf>,
pub cwd: Option<PathBuf>,
pub model: Option<String>,
pub harness_version: Option<String>,
pub usage: TokenUsage,
pub cost_usd: f64,
#[serde(default)]
pub cost_breakdown: CostBreakdown,
#[serde(default)]
pub price_source: Option<PriceSource>,
pub unpriced_tokens: u64,
pub turns: u64,
pub subagent_turns: u64,
pub tool_calls: u64,
#[serde(default)]
pub web_searches: u64,
pub spans: Vec<ToolSpan>,
pub age_secs: u64,
pub idle_secs: Option<u64>,
pub cpu_percent: f32,
pub rss_bytes: u64,
pub process_count: usize,
pub mcp_count: usize,
#[serde(default)]
pub mcp_servers: Vec<McpServer>,
pub tree: Option<ProcNode>,
pub attribution: Attribution,
#[serde(default)]
pub shares_process: bool,
pub parse_warning: Option<String>,
#[serde(default)]
pub rate_limit: Option<RateLimit>,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct RateWindow {
pub used_percent: f64,
pub window_minutes: u64,
pub resets_at: Option<SystemTime>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct RateLimit {
pub primary: Option<RateWindow>,
pub secondary: Option<RateWindow>,
pub plan: Option<String>,
pub reached: bool,
}
impl RateLimit {
pub fn tightest(&self) -> Option<&RateWindow> {
[self.primary.as_ref(), self.secondary.as_ref()]
.into_iter()
.flatten()
.max_by(|a, b| a.used_percent.partial_cmp(&b.used_percent).unwrap_or(std::cmp::Ordering::Equal))
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
pub struct CostBreakdown {
pub input: f64,
pub cache_write_5m: f64,
pub cache_write_1h: f64,
pub cache_read: f64,
pub output: f64,
pub web_search: f64,
}
impl CostBreakdown {
pub fn total(&self) -> f64 {
self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
}
pub fn add(&mut self, o: &CostBreakdown) {
self.input += o.input;
self.cache_write_5m += o.cache_write_5m;
self.cache_write_1h += o.cache_write_1h;
self.cache_read += o.cache_read;
self.output += o.output;
self.web_search += o.web_search;
}
pub fn sub(&mut self, o: &CostBreakdown) {
self.input -= o.input;
self.cache_write_5m -= o.cache_write_5m;
self.cache_write_1h -= o.cache_write_1h;
self.cache_read -= o.cache_read;
self.output -= o.output;
self.web_search -= o.web_search;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PriceSource {
Builtin,
UserFile,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Attribution {
HarnessRegistry,
CommandLine,
OpenFile,
CwdHeuristic,
None,
TranscriptOnly,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HostStats {
pub hostname: Option<String>,
pub cpu_percent: f32,
pub cpu_count: usize,
pub mem_used_bytes: u64,
pub mem_total_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Totals {
pub agents: usize,
pub running: usize,
pub idle: usize,
pub stopped: usize,
pub tokens: u64,
pub cost_usd: f64,
pub unpriced_tokens: u64,
pub processes: usize,
pub mcp_processes: usize,
pub orphaned_mcp: usize,
pub cpu_percent: f32,
pub rss_bytes: u64,
}
pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OrphanOrigin {
pub pid: u32,
pub first_seen: SystemTime,
pub orphaned_at: Option<SystemTime>,
pub parent: Option<OrphanParent>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OrphanParent {
pub pid: u32,
pub agent_id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Snapshot {
pub schema_version: u32,
pub taken_at: SystemTime,
pub host: HostStats,
pub agents: Vec<Agent>,
pub orphans: Vec<ProcNode>,
#[serde(default)]
pub orphan_origins: Vec<OrphanOrigin>,
pub totals: Totals,
}
impl Snapshot {
pub fn compute_totals(&mut self) {
let mut t = Totals::default();
for a in &self.agents {
t.agents += 1;
match a.state {
AgentState::Running => t.running += 1,
AgentState::Idle => t.idle += 1,
AgentState::Stopped => t.stopped += 1,
}
t.tokens += a.usage.total();
t.cost_usd += a.cost_usd;
t.unpriced_tokens += a.unpriced_tokens;
t.processes += a.process_count;
t.mcp_processes += a.mcp_count;
t.cpu_percent += a.cpu_percent;
t.rss_bytes += a.rss_bytes;
}
t.orphaned_mcp = self.orphans.len();
self.totals = t;
}
}
#[cfg(test)]
mod usage_tests {
use super::TokenUsage;
#[test]
fn cache_hit_rate_is_reads_over_the_prompt() {
let u = TokenUsage { input: 200, cache_read: 800, cache_write_5m: 0, cache_write_1h: 0, output: 50 };
assert_eq!(u.prompt(), 1000);
assert!((u.cache_hit_rate().unwrap() - 0.8).abs() < 1e-9);
let u = TokenUsage { input: 100, cache_read: 0, cache_write_5m: 900, cache_write_1h: 0, output: 0 };
assert_eq!(u.cache_hit_rate(), Some(0.0));
assert_eq!(TokenUsage::default().cache_hit_rate(), None);
}
}