1use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::time::SystemTime;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
9#[serde(rename_all = "kebab-case")]
10pub enum Harness {
11 Claude,
12 Codex,
13 Gemini,
14 OpenCode,
15 Aider,
16 Copilot,
17 Cursor,
18 Unknown,
19}
20
21impl Harness {
22 pub fn label(self) -> &'static str {
23 match self {
24 Harness::Claude => "claude",
25 Harness::Codex => "codex",
26 Harness::Gemini => "gemini",
27 Harness::OpenCode => "opencode",
28 Harness::Aider => "aider",
29 Harness::Copilot => "copilot",
30 Harness::Cursor => "cursor",
31 Harness::Unknown => "unknown",
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
42#[serde(rename_all = "kebab-case")]
43pub enum AgentState {
44 Running,
45 Idle,
46 Stopped,
47}
48
49impl AgentState {
50 pub fn label(self) -> &'static str {
51 match self {
52 AgentState::Running => "running",
53 AgentState::Idle => "idle",
54 AgentState::Stopped => "stopped",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
61#[serde(rename_all = "kebab-case")]
62pub enum Activity {
63 Working,
65 Waiting,
67 #[default]
68 Unknown,
69}
70
71#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
73pub struct TokenUsage {
74 pub input: u64,
75 pub cache_write_5m: u64,
76 pub cache_write_1h: u64,
77 pub cache_read: u64,
78 pub output: u64,
79}
80
81impl TokenUsage {
82 pub fn cache_write(&self) -> u64 {
83 self.cache_write_5m + self.cache_write_1h
84 }
85
86 pub fn total(&self) -> u64 {
88 self.input + self.cache_write() + self.cache_read + self.output
89 }
90
91 pub fn prompt(&self) -> u64 {
94 self.input + self.cache_write() + self.cache_read
95 }
96
97 pub fn cache_hit_rate(&self) -> Option<f64> {
103 let p = self.prompt();
104 (p > 0).then(|| self.cache_read as f64 / p as f64)
105 }
106
107 pub fn add(&mut self, other: &TokenUsage) {
108 self.input += other.input;
109 self.cache_write_5m += other.cache_write_5m;
110 self.cache_write_1h += other.cache_write_1h;
111 self.cache_read += other.cache_read;
112 self.output += other.output;
113 }
114
115 pub fn sub(&mut self, other: &TokenUsage) {
116 self.input = self.input.saturating_sub(other.input);
117 self.cache_write_5m = self.cache_write_5m.saturating_sub(other.cache_write_5m);
118 self.cache_write_1h = self.cache_write_1h.saturating_sub(other.cache_write_1h);
119 self.cache_read = self.cache_read.saturating_sub(other.cache_read);
120 self.output = self.output.saturating_sub(other.output);
121 }
122}
123
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, PartialOrd, Ord)]
127#[serde(rename_all = "kebab-case")]
128pub enum SpanKind {
129 #[default]
131 Tool,
132 Inference,
136 Turn,
139}
140
141impl SpanKind {
142 pub const ALL: [SpanKind; 3] = [SpanKind::Tool, SpanKind::Inference, SpanKind::Turn];
143
144 pub fn label(self) -> &'static str {
145 match self {
146 SpanKind::Tool => "tool",
147 SpanKind::Inference => "inference",
148 SpanKind::Turn => "turn",
149 }
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
164pub struct ToolSpan {
165 pub id: String,
168 pub name: String,
171 pub started_at: SystemTime,
172 pub duration_ms: Option<u64>,
174 pub sidechain: bool,
176 pub error: bool,
178 #[serde(default)]
180 pub kind: SpanKind,
181}
182
183impl ToolSpan {
184 pub fn is_open(&self) -> bool {
185 self.duration_ms.is_none()
186 }
187
188 pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
190 match self.duration_ms {
191 Some(ms) => ms,
192 None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
193 }
194 }
195
196 pub fn ended_at(&self, now: SystemTime) -> SystemTime {
197 self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
198 }
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct McpServer {
207 pub name: String,
210 pub pid: Option<u32>,
211 pub cmdline: Option<String>,
212 pub cpu_percent: f32,
213 pub rss_bytes: u64,
214 pub age_secs: Option<u64>,
215 pub calls: u64,
217 pub errors: u64,
219 pub last_call: Option<SystemTime>,
220 pub matched_by: McpMatch,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum McpMatch {
229 ProcessOnly,
232 TranscriptOnly,
235 Name,
237 Sole,
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "kebab-case")]
245pub enum ProcKind {
246 Agent,
248 Subagent,
250 Mcp,
252 Shell,
254 Tool,
256}
257
258impl ProcKind {
259 pub fn label(self) -> &'static str {
260 match self {
261 ProcKind::Agent => "agent",
262 ProcKind::Subagent => "subagent",
263 ProcKind::Mcp => "mcp",
264 ProcKind::Shell => "shell",
265 ProcKind::Tool => "tool",
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct ProcNode {
273 pub pid: u32,
274 pub ppid: Option<u32>,
275 pub name: String,
276 pub cmdline: String,
277 pub kind: ProcKind,
278 pub harness: Option<Harness>,
279 pub cpu_percent: f32,
280 pub rss_bytes: u64,
281 pub age_secs: u64,
282 pub cwd: Option<PathBuf>,
283 pub children: Vec<ProcNode>,
284}
285
286impl ProcNode {
287 pub fn totals(&self) -> (f32, u64, usize, usize) {
292 let mut cpu = self.cpu_percent;
293 let mut rss = self.rss_bytes;
294 let mut count = 1;
295 let mut mcp = usize::from(self.kind == ProcKind::Mcp);
296 for c in &self.children {
297 let (ccpu, crss, ccount, cmcp) = c.totals();
298 cpu += ccpu;
299 rss += crss;
300 count += ccount;
301 if self.kind != ProcKind::Mcp {
302 mcp += cmcp;
303 }
304 }
305 (cpu, rss, count, mcp)
306 }
307
308 pub fn mcp_roots(&self) -> Vec<&ProcNode> {
312 let mut out = Vec::new();
313 self.collect_mcp_roots(&mut out);
314 out
315 }
316
317 fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
318 if self.kind == ProcKind::Mcp {
319 out.push(self);
320 return;
321 }
322 for c in &self.children {
323 c.collect_mcp_roots(out);
324 }
325 }
326
327 pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
328 f(self, depth);
329 for c in &self.children {
330 c.walk(depth + 1, f);
331 }
332 }
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct Agent {
338 pub id: String,
340 pub name: String,
341 pub harness: Harness,
342 pub state: AgentState,
343 pub activity: Activity,
344 pub pid: Option<u32>,
345 pub session_id: Option<String>,
346 pub session_path: Option<PathBuf>,
347 pub cwd: Option<PathBuf>,
348 pub model: Option<String>,
349 pub harness_version: Option<String>,
350 pub usage: TokenUsage,
351 pub cost_usd: f64,
353 #[serde(default)]
356 pub cost_breakdown: CostBreakdown,
357 #[serde(default)]
359 pub price_source: Option<PriceSource>,
360 pub unpriced_tokens: u64,
362 pub turns: u64,
363 pub subagent_turns: u64,
364 pub tool_calls: u64,
365 #[serde(default)]
369 pub web_searches: u64,
370 pub spans: Vec<ToolSpan>,
373 pub age_secs: u64,
375 pub idle_secs: Option<u64>,
377 pub cpu_percent: f32,
378 pub rss_bytes: u64,
379 pub process_count: usize,
380 pub mcp_count: usize,
381 #[serde(default)]
384 pub mcp_servers: Vec<McpServer>,
385 pub tree: Option<ProcNode>,
386 pub attribution: Attribution,
388 #[serde(default)]
393 pub shares_process: bool,
394 pub parse_warning: Option<String>,
398 #[serde(default)]
400 pub rate_limit: Option<RateLimit>,
401}
402
403#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
407pub struct RateWindow {
408 pub used_percent: f64,
410 pub window_minutes: u64,
412 pub resets_at: Option<SystemTime>,
414}
415
416#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
420pub struct RateLimit {
421 pub primary: Option<RateWindow>,
423 pub secondary: Option<RateWindow>,
425 pub plan: Option<String>,
427 pub reached: bool,
429}
430
431impl RateLimit {
432 pub fn tightest(&self) -> Option<&RateWindow> {
434 [self.primary.as_ref(), self.secondary.as_ref()]
435 .into_iter()
436 .flatten()
437 .max_by(|a, b| a.used_percent.partial_cmp(&b.used_percent).unwrap_or(std::cmp::Ordering::Equal))
438 }
439}
440
441#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
445pub struct CostBreakdown {
446 pub input: f64,
447 pub cache_write_5m: f64,
448 pub cache_write_1h: f64,
449 pub cache_read: f64,
450 pub output: f64,
451 pub web_search: f64,
453}
454
455impl CostBreakdown {
456 pub fn total(&self) -> f64 {
457 self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
458 }
459
460 pub fn add(&mut self, o: &CostBreakdown) {
461 self.input += o.input;
462 self.cache_write_5m += o.cache_write_5m;
463 self.cache_write_1h += o.cache_write_1h;
464 self.cache_read += o.cache_read;
465 self.output += o.output;
466 self.web_search += o.web_search;
467 }
468
469 pub fn sub(&mut self, o: &CostBreakdown) {
470 self.input -= o.input;
471 self.cache_write_5m -= o.cache_write_5m;
472 self.cache_write_1h -= o.cache_write_1h;
473 self.cache_read -= o.cache_read;
474 self.output -= o.output;
475 self.web_search -= o.web_search;
476 }
477}
478
479#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(rename_all = "kebab-case")]
483pub enum PriceSource {
484 Builtin,
485 UserFile,
486}
487
488#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
489#[serde(rename_all = "kebab-case")]
490pub enum Attribution {
491 HarnessRegistry,
493 CommandLine,
495 OpenFile,
498 CwdHeuristic,
500 None,
502 TranscriptOnly,
504}
505
506#[derive(Debug, Clone, Default, Serialize, Deserialize)]
507pub struct HostStats {
508 pub hostname: Option<String>,
509 pub cpu_percent: f32,
510 pub cpu_count: usize,
511 pub mem_used_bytes: u64,
512 pub mem_total_bytes: u64,
513}
514
515#[derive(Debug, Clone, Default, Serialize, Deserialize)]
516pub struct Totals {
517 pub agents: usize,
518 pub running: usize,
519 pub idle: usize,
520 pub stopped: usize,
521 pub tokens: u64,
522 pub cost_usd: f64,
523 pub unpriced_tokens: u64,
524 pub processes: usize,
525 pub mcp_processes: usize,
526 pub orphaned_mcp: usize,
527 pub cpu_percent: f32,
528 pub rss_bytes: u64,
529}
530
531pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
534
535#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
540pub struct OrphanOrigin {
541 pub pid: u32,
542 pub first_seen: SystemTime,
543 pub orphaned_at: Option<SystemTime>,
546 pub parent: Option<OrphanParent>,
547}
548
549#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
551pub struct OrphanParent {
552 pub pid: u32,
553 pub agent_id: String,
554 pub name: String,
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct Snapshot {
560 pub schema_version: u32,
561 pub taken_at: SystemTime,
562 pub host: HostStats,
563 pub agents: Vec<Agent>,
564 pub orphans: Vec<ProcNode>,
566 #[serde(default)]
568 pub orphan_origins: Vec<OrphanOrigin>,
569 pub totals: Totals,
570}
571
572impl Snapshot {
573 pub fn compute_totals(&mut self) {
574 let mut t = Totals::default();
575 for a in &self.agents {
576 t.agents += 1;
577 match a.state {
578 AgentState::Running => t.running += 1,
579 AgentState::Idle => t.idle += 1,
580 AgentState::Stopped => t.stopped += 1,
581 }
582 t.tokens += a.usage.total();
583 t.cost_usd += a.cost_usd;
584 t.unpriced_tokens += a.unpriced_tokens;
585 t.processes += a.process_count;
586 t.mcp_processes += a.mcp_count;
587 t.cpu_percent += a.cpu_percent;
588 t.rss_bytes += a.rss_bytes;
589 }
590 t.orphaned_mcp = self.orphans.len();
591 self.totals = t;
592 }
593}
594
595#[cfg(test)]
596mod usage_tests {
597 use super::TokenUsage;
598
599 #[test]
600 fn cache_hit_rate_is_reads_over_the_prompt() {
601 let u = TokenUsage { input: 200, cache_read: 800, cache_write_5m: 0, cache_write_1h: 0, output: 50 };
602 assert_eq!(u.prompt(), 1000);
604 assert!((u.cache_hit_rate().unwrap() - 0.8).abs() < 1e-9);
605 let u = TokenUsage { input: 100, cache_read: 0, cache_write_5m: 900, cache_write_1h: 0, output: 0 };
607 assert_eq!(u.cache_hit_rate(), Some(0.0));
608 assert_eq!(TokenUsage::default().cache_hit_rate(), None);
610 }
611}