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 add(&mut self, other: &TokenUsage) {
92 self.input += other.input;
93 self.cache_write_5m += other.cache_write_5m;
94 self.cache_write_1h += other.cache_write_1h;
95 self.cache_read += other.cache_read;
96 self.output += other.output;
97 }
98
99 pub fn sub(&mut self, other: &TokenUsage) {
100 self.input = self.input.saturating_sub(other.input);
101 self.cache_write_5m = self.cache_write_5m.saturating_sub(other.cache_write_5m);
102 self.cache_write_1h = self.cache_write_1h.saturating_sub(other.cache_write_1h);
103 self.cache_read = self.cache_read.saturating_sub(other.cache_read);
104 self.output = self.output.saturating_sub(other.output);
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, PartialOrd, Ord)]
111#[serde(rename_all = "kebab-case")]
112pub enum SpanKind {
113 #[default]
115 Tool,
116 Inference,
120 Turn,
123}
124
125impl SpanKind {
126 pub const ALL: [SpanKind; 3] = [SpanKind::Tool, SpanKind::Inference, SpanKind::Turn];
127
128 pub fn label(self) -> &'static str {
129 match self {
130 SpanKind::Tool => "tool",
131 SpanKind::Inference => "inference",
132 SpanKind::Turn => "turn",
133 }
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct ToolSpan {
149 pub id: String,
152 pub name: String,
155 pub started_at: SystemTime,
156 pub duration_ms: Option<u64>,
158 pub sidechain: bool,
160 pub error: bool,
162 #[serde(default)]
164 pub kind: SpanKind,
165}
166
167impl ToolSpan {
168 pub fn is_open(&self) -> bool {
169 self.duration_ms.is_none()
170 }
171
172 pub fn elapsed_ms(&self, now: SystemTime) -> u64 {
174 match self.duration_ms {
175 Some(ms) => ms,
176 None => now.duration_since(self.started_at).map(|d| d.as_millis() as u64).unwrap_or(0),
177 }
178 }
179
180 pub fn ended_at(&self, now: SystemTime) -> SystemTime {
181 self.started_at + std::time::Duration::from_millis(self.elapsed_ms(now))
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct McpServer {
191 pub name: String,
194 pub pid: Option<u32>,
195 pub cmdline: Option<String>,
196 pub cpu_percent: f32,
197 pub rss_bytes: u64,
198 pub age_secs: Option<u64>,
199 pub calls: u64,
201 pub errors: u64,
203 pub last_call: Option<SystemTime>,
204 pub matched_by: McpMatch,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "kebab-case")]
212pub enum McpMatch {
213 ProcessOnly,
216 TranscriptOnly,
219 Name,
221 Sole,
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(rename_all = "kebab-case")]
229pub enum ProcKind {
230 Agent,
232 Subagent,
234 Mcp,
236 Shell,
238 Tool,
240}
241
242impl ProcKind {
243 pub fn label(self) -> &'static str {
244 match self {
245 ProcKind::Agent => "agent",
246 ProcKind::Subagent => "subagent",
247 ProcKind::Mcp => "mcp",
248 ProcKind::Shell => "shell",
249 ProcKind::Tool => "tool",
250 }
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ProcNode {
257 pub pid: u32,
258 pub ppid: Option<u32>,
259 pub name: String,
260 pub cmdline: String,
261 pub kind: ProcKind,
262 pub harness: Option<Harness>,
263 pub cpu_percent: f32,
264 pub rss_bytes: u64,
265 pub age_secs: u64,
266 pub cwd: Option<PathBuf>,
267 pub children: Vec<ProcNode>,
268}
269
270impl ProcNode {
271 pub fn totals(&self) -> (f32, u64, usize, usize) {
276 let mut cpu = self.cpu_percent;
277 let mut rss = self.rss_bytes;
278 let mut count = 1;
279 let mut mcp = usize::from(self.kind == ProcKind::Mcp);
280 for c in &self.children {
281 let (ccpu, crss, ccount, cmcp) = c.totals();
282 cpu += ccpu;
283 rss += crss;
284 count += ccount;
285 if self.kind != ProcKind::Mcp {
286 mcp += cmcp;
287 }
288 }
289 (cpu, rss, count, mcp)
290 }
291
292 pub fn mcp_roots(&self) -> Vec<&ProcNode> {
296 let mut out = Vec::new();
297 self.collect_mcp_roots(&mut out);
298 out
299 }
300
301 fn collect_mcp_roots<'a>(&'a self, out: &mut Vec<&'a ProcNode>) {
302 if self.kind == ProcKind::Mcp {
303 out.push(self);
304 return;
305 }
306 for c in &self.children {
307 c.collect_mcp_roots(out);
308 }
309 }
310
311 pub fn walk<'a>(&'a self, depth: usize, f: &mut dyn FnMut(&'a ProcNode, usize)) {
312 f(self, depth);
313 for c in &self.children {
314 c.walk(depth + 1, f);
315 }
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct Agent {
322 pub id: String,
324 pub name: String,
325 pub harness: Harness,
326 pub state: AgentState,
327 pub activity: Activity,
328 pub pid: Option<u32>,
329 pub session_id: Option<String>,
330 pub session_path: Option<PathBuf>,
331 pub cwd: Option<PathBuf>,
332 pub model: Option<String>,
333 pub harness_version: Option<String>,
334 pub usage: TokenUsage,
335 pub cost_usd: f64,
337 #[serde(default)]
340 pub cost_breakdown: CostBreakdown,
341 #[serde(default)]
343 pub price_source: Option<PriceSource>,
344 pub unpriced_tokens: u64,
346 pub turns: u64,
347 pub subagent_turns: u64,
348 pub tool_calls: u64,
349 #[serde(default)]
353 pub web_searches: u64,
354 pub spans: Vec<ToolSpan>,
357 pub age_secs: u64,
359 pub idle_secs: Option<u64>,
361 pub cpu_percent: f32,
362 pub rss_bytes: u64,
363 pub process_count: usize,
364 pub mcp_count: usize,
365 #[serde(default)]
368 pub mcp_servers: Vec<McpServer>,
369 pub tree: Option<ProcNode>,
370 pub attribution: Attribution,
372 #[serde(default)]
377 pub shares_process: bool,
378 pub parse_warning: Option<String>,
382 #[serde(default)]
384 pub rate_limit: Option<RateLimit>,
385}
386
387#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
391pub struct RateWindow {
392 pub used_percent: f64,
394 pub window_minutes: u64,
396 pub resets_at: Option<SystemTime>,
398}
399
400#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
404pub struct RateLimit {
405 pub primary: Option<RateWindow>,
407 pub secondary: Option<RateWindow>,
409 pub plan: Option<String>,
411 pub reached: bool,
413}
414
415impl RateLimit {
416 pub fn tightest(&self) -> Option<&RateWindow> {
418 [self.primary.as_ref(), self.secondary.as_ref()]
419 .into_iter()
420 .flatten()
421 .max_by(|a, b| a.used_percent.partial_cmp(&b.used_percent).unwrap_or(std::cmp::Ordering::Equal))
422 }
423}
424
425#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq)]
429pub struct CostBreakdown {
430 pub input: f64,
431 pub cache_write_5m: f64,
432 pub cache_write_1h: f64,
433 pub cache_read: f64,
434 pub output: f64,
435 pub web_search: f64,
437}
438
439impl CostBreakdown {
440 pub fn total(&self) -> f64 {
441 self.input + self.cache_write_5m + self.cache_write_1h + self.cache_read + self.output + self.web_search
442 }
443
444 pub fn add(&mut self, o: &CostBreakdown) {
445 self.input += o.input;
446 self.cache_write_5m += o.cache_write_5m;
447 self.cache_write_1h += o.cache_write_1h;
448 self.cache_read += o.cache_read;
449 self.output += o.output;
450 self.web_search += o.web_search;
451 }
452
453 pub fn sub(&mut self, o: &CostBreakdown) {
454 self.input -= o.input;
455 self.cache_write_5m -= o.cache_write_5m;
456 self.cache_write_1h -= o.cache_write_1h;
457 self.cache_read -= o.cache_read;
458 self.output -= o.output;
459 self.web_search -= o.web_search;
460 }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
466#[serde(rename_all = "kebab-case")]
467pub enum PriceSource {
468 Builtin,
469 UserFile,
470}
471
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
473#[serde(rename_all = "kebab-case")]
474pub enum Attribution {
475 HarnessRegistry,
477 CommandLine,
479 OpenFile,
482 CwdHeuristic,
484 None,
486 TranscriptOnly,
488}
489
490#[derive(Debug, Clone, Default, Serialize, Deserialize)]
491pub struct HostStats {
492 pub hostname: Option<String>,
493 pub cpu_percent: f32,
494 pub cpu_count: usize,
495 pub mem_used_bytes: u64,
496 pub mem_total_bytes: u64,
497}
498
499#[derive(Debug, Clone, Default, Serialize, Deserialize)]
500pub struct Totals {
501 pub agents: usize,
502 pub running: usize,
503 pub idle: usize,
504 pub stopped: usize,
505 pub tokens: u64,
506 pub cost_usd: f64,
507 pub unpriced_tokens: u64,
508 pub processes: usize,
509 pub mcp_processes: usize,
510 pub orphaned_mcp: usize,
511 pub cpu_percent: f32,
512 pub rss_bytes: u64,
513}
514
515pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1;
518
519#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
524pub struct OrphanOrigin {
525 pub pid: u32,
526 pub first_seen: SystemTime,
527 pub orphaned_at: Option<SystemTime>,
530 pub parent: Option<OrphanParent>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535pub struct OrphanParent {
536 pub pid: u32,
537 pub agent_id: String,
538 pub name: String,
539}
540
541#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct Snapshot {
544 pub schema_version: u32,
545 pub taken_at: SystemTime,
546 pub host: HostStats,
547 pub agents: Vec<Agent>,
548 pub orphans: Vec<ProcNode>,
550 #[serde(default)]
552 pub orphan_origins: Vec<OrphanOrigin>,
553 pub totals: Totals,
554}
555
556impl Snapshot {
557 pub fn compute_totals(&mut self) {
558 let mut t = Totals::default();
559 for a in &self.agents {
560 t.agents += 1;
561 match a.state {
562 AgentState::Running => t.running += 1,
563 AgentState::Idle => t.idle += 1,
564 AgentState::Stopped => t.stopped += 1,
565 }
566 t.tokens += a.usage.total();
567 t.cost_usd += a.cost_usd;
568 t.unpriced_tokens += a.unpriced_tokens;
569 t.processes += a.process_count;
570 t.mcp_processes += a.mcp_count;
571 t.cpu_percent += a.cpu_percent;
572 t.rss_bytes += a.rss_bytes;
573 }
574 t.orphaned_mcp = self.orphans.len();
575 self.totals = t;
576 }
577}