1pub mod claude;
7pub mod codex;
8pub mod gemini;
9pub mod opencode;
10
11use crate::model::{Activity, Attribution, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage, ToolSpan};
12use crate::process::RawProc;
13use std::collections::{BTreeMap, HashSet, VecDeque};
14use std::path::{Path, PathBuf};
15use std::time::{Duration, SystemTime};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ParseHealth {
27 pub billable_messages: u64,
29 pub usage_records: u64,
32 pub empty_usage_records: u64,
35}
36
37impl ParseHealth {
38 const MIN_EVIDENCE: u64 = 3;
41
42 pub fn fields_unrecognised(&self) -> bool {
49 self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
50 }
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct SessionSummary {
55 pub harness: Option<Harness>,
56 pub session_id: Option<String>,
57 pub cwd: Option<PathBuf>,
58 pub model: Option<String>,
59 pub harness_version: Option<String>,
60 pub usage: TokenUsage,
61 pub cost_usd: f64,
62 pub cost_breakdown: CostBreakdown,
64 pub unpriced_tokens: u64,
65 pub turns: u64,
66 pub subagent_turns: u64,
67 pub tool_calls: u64,
68 pub web_searches: u64,
70 pub spans: SpanLog,
71 pub mcp: BTreeMap<String, McpUsage>,
73 pub health: ParseHealth,
74 pub activity: Activity,
75 pub started_at: Option<SystemTime>,
76 pub last_activity: Option<SystemTime>,
77 pub rate_limit: Option<crate::model::RateLimit>,
79}
80
81#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
84pub struct McpUsage {
85 pub calls: u64,
86 pub errors: u64,
87 pub last_call: Option<SystemTime>,
88}
89
90impl McpUsage {
91 pub fn add(&mut self, o: &McpUsage) {
92 self.calls += o.calls;
93 self.errors += o.errors;
94 self.last_call = self.last_call.max(o.last_call);
95 }
96}
97
98pub fn mcp_server_of(tool_name: &str) -> Option<&str> {
103 let rest = tool_name.strip_prefix("mcp__")?;
104 let server = match rest.rfind("__") {
105 Some(i) => &rest[..i],
106 None => rest,
107 };
108 if server.is_empty() { None } else { Some(server) }
109}
110
111pub const MAX_SPANS: usize = 256;
119
120#[derive(Debug, Clone)]
127pub struct SpanLog {
128 spans: VecDeque<ToolSpan>,
129 cap: usize,
130}
131
132impl Default for SpanLog {
133 fn default() -> Self {
134 SpanLog { spans: VecDeque::new(), cap: MAX_SPANS }
135 }
136}
137
138impl SpanLog {
139 pub fn unbounded() -> Self {
143 SpanLog { spans: VecDeque::new(), cap: usize::MAX }
144 }
145
146 pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
149 self.open_kind(id, name, at, sidechain, SpanKind::Tool);
150 }
151
152 pub fn open_kind(&mut self, id: String, name: String, at: SystemTime, sidechain: bool, kind: SpanKind) {
154 if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
155 return;
156 }
157 if self.spans.len() >= self.cap {
158 self.spans.pop_front();
159 }
160 self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false, kind });
161 }
162
163 pub fn end_at(&mut self, id: &str, at: SystemTime) {
167 let Some(s) = self.spans.iter_mut().rev().find(|s| s.id == id) else { return };
168 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
169 }
170
171 pub fn open_of_kind(&self, kind: SpanKind) -> Option<&ToolSpan> {
173 self.spans.iter().rev().find(|s| s.is_open() && s.kind == kind)
174 }
175
176 pub fn discard_open(&mut self, id: &str) {
180 if let Some(i) = self.spans.iter().rposition(|s| s.is_open() && s.id == id) {
181 self.spans.remove(i);
182 }
183 }
184
185 pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
188 let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
189 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
190 s.error = error;
191 }
192
193 pub fn len(&self) -> usize {
194 self.spans.len()
195 }
196
197 pub fn is_empty(&self) -> bool {
198 self.spans.is_empty()
199 }
200
201 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
204 self.spans.iter()
205 }
206
207 pub fn to_vec(&self) -> Vec<ToolSpan> {
208 self.spans.iter().cloned().collect()
209 }
210
211 pub fn merged<'a>(logs: impl IntoIterator<Item = &'a SpanLog>, cap: usize) -> SpanLog {
215 let mut spans: Vec<ToolSpan> = logs.into_iter().flat_map(|l| l.spans.iter().cloned()).collect();
216 spans.sort_by_key(|s| s.started_at);
217 if spans.len() > cap {
218 spans.drain(..spans.len() - cap);
219 }
220 SpanLog { spans: spans.into(), cap }
221 }
222
223 pub fn cap(&self) -> usize {
224 self.cap
225 }
226}
227
228#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
230pub enum SpanRetention {
231 #[default]
233 Recent,
234 All,
238}
239
240impl SpanRetention {
241 pub(crate) fn log(self) -> SpanLog {
242 match self {
243 SpanRetention::Recent => SpanLog::default(),
244 SpanRetention::All => SpanLog::unbounded(),
245 }
246 }
247}
248
249pub trait SessionTracker {
250 fn refresh(&mut self) -> anyhow::Result<bool>;
253 fn summary(&self) -> &SessionSummary;
254 fn path(&self) -> &Path;
255
256 fn refresh_all(&mut self) -> anyhow::Result<()> {
260 while self.refresh()? {}
261 Ok(())
262 }
263}
264
265#[derive(Debug, Clone, Default, PartialEq, Eq)]
269pub struct RegistryHints {
270 pub name: Option<String>,
271 pub session_id: Option<String>,
272 pub cwd: Option<PathBuf>,
273 pub version: Option<String>,
274 pub status: Option<String>,
277}
278
279pub struct AttributeContext<'a> {
282 pub cwd: Option<&'a Path>,
283 pub proc_start: SystemTime,
284 pub now: SystemTime,
285 pub attached: &'a HashSet<PathBuf>,
288 pub activity_timeout: Duration,
291}
292
293pub trait HarnessAdapter {
300 fn harness(&self) -> Harness;
301
302 fn rescan(&mut self, since: SystemTime);
305
306 fn prepare(&mut self, _roots: &[&ProcNode]) {}
311
312 fn hints(&self, _pid: u32) -> Option<RegistryHints> {
314 None
315 }
316
317 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution);
321
322 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf>;
324
325 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker>;
327
328 fn detect(&self, path: &Path) -> bool;
330
331 fn transcripts(&self) -> Vec<(String, PathBuf)>;
334}
335
336pub fn adapters() -> Vec<Box<dyn HarnessAdapter>> {
340 vec![
341 Box::new(codex::CodexAdapter::default()),
342 Box::new(gemini::GeminiAdapter::default()),
343 Box::new(opencode::OpenCodeAdapter::default()),
344 Box::new(claude::ClaudeAdapter::default()),
345 ]
346}
347
348pub fn adapter_for(harness: Harness) -> Option<Box<dyn HarnessAdapter>> {
350 adapters().into_iter().find(|a| a.harness() == harness)
351}
352
353pub fn detect(path: &Path) -> Option<Harness> {
356 adapters().iter().find(|a| a.detect(path)).map(|a| a.harness())
357}
358
359pub fn open_transcript(path: &Path, harness: Harness, spans: SpanRetention) -> Option<Box<dyn SessionTracker>> {
362 adapter_for(harness).map(|a| a.open(path, spans))
363}
364
365pub(crate) fn head_lines(path: &Path) -> Vec<serde_json::Value> {
367 use std::io::{BufRead, BufReader};
368 let Ok(f) = std::fs::File::open(path) else { return Vec::new() };
369 BufReader::new(f).lines().map_while(Result::ok).take(5).filter_map(|l| serde_json::from_str(&l).ok()).collect()
370}
371
372pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
375
376pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
380 let s = s.strip_suffix('Z')?;
381 let (date, time) = s.split_once('T')?;
382 let mut d = date.split('-');
383 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
384 let mut t = time.split(':');
385 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
386 let sec_str = t.next()?;
387 let (sec, frac) = match sec_str.split_once('.') {
388 Some((s, f)) => (s.parse::<u64>().ok()?, f),
389 None => (sec_str.parse::<u64>().ok()?, ""),
390 };
391 let nanos: u32 = if frac.is_empty() {
392 0
393 } else {
394 let mut f = frac.to_string();
395 f.truncate(9);
396 while f.len() < 9 {
397 f.push('0');
398 }
399 f.parse().ok()?
400 };
401 let days = days_from_civil(y, mo, da);
402 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
403 if secs < 0 {
404 return None;
405 }
406 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
407}
408
409fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
411 let y = if m <= 2 { y - 1 } else { y };
412 let era = if y >= 0 { y } else { y - 399 } / 400;
413 let yoe = y - era * 400;
414 let mp = (m as i64 + 9) % 12;
415 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
416 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
417 era * 146_097 + doe - 719_468
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use std::time::Duration;
424
425 fn at(secs: u64) -> SystemTime {
426 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
427 }
428
429 #[test]
430 fn pairs_spans_by_id_out_of_order() {
431 let mut log = SpanLog::default();
432 log.open("a".into(), "Bash".into(), at(10), false);
433 log.open("b".into(), "Read".into(), at(11), true);
434 log.open("a".into(), "Bash".into(), at(10), false);
436 log.close("b", at(12), false);
438 log.close("a", at(14), true);
439 log.close("zzz", at(15), false);
441 let v = log.to_vec();
442 assert_eq!(v.len(), 2);
443 assert_eq!(v[0].name, "Bash");
444 assert_eq!(v[0].duration_ms, Some(4_000));
445 assert!(v[0].error);
446 assert_eq!(v[1].duration_ms, Some(1_000));
447 assert!(v[1].sidechain);
448 assert!(!v[1].error);
449 }
450
451 #[test]
452 fn keeps_the_newest_spans_and_reports_open_ones() {
453 let mut log = SpanLog::default();
454 for i in 0..(MAX_SPANS + 10) {
455 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
456 log.close(&format!("id{i}"), at(i as u64), false);
457 }
458 assert_eq!(log.len(), MAX_SPANS);
459 assert_eq!(log.iter().next().unwrap().id, "id10");
460 log.open("live".into(), "Bash".into(), at(500), false);
461 let last = log.to_vec().pop().unwrap();
462 assert!(last.is_open());
463 assert_eq!(last.elapsed_ms(at(503)), 3_000);
464 }
465
466 #[test]
467 fn end_at_moves_the_end_of_any_kind_of_span() {
468 let mut log = SpanLog::default();
469 log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
470 assert!(log.open_of_kind(SpanKind::Inference).is_some());
471 assert!(log.open_of_kind(SpanKind::Turn).is_none());
472 log.end_at("inference:1", at(11));
474 log.end_at("inference:1", at(13));
475 log.end_at("nope", at(99));
476 let v = log.to_vec();
477 assert_eq!(v[0].duration_ms, Some(3_000));
478 assert_eq!(v[0].kind, SpanKind::Inference);
479 assert!(log.open_of_kind(SpanKind::Inference).is_none());
480 log.discard_open("inference:1");
482 assert_eq!(log.len(), 1);
483 log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
484 log.discard_open("inference:2");
485 assert_eq!(log.len(), 1);
486 }
487
488 #[test]
489 fn unbounded_log_keeps_everything() {
490 let mut log = SpanLog::unbounded();
491 for i in 0..(MAX_SPANS * 3) {
492 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
493 log.close(&format!("id{i}"), at(i as u64 + 1), false);
494 }
495 assert_eq!(log.len(), MAX_SPANS * 3);
496 assert_eq!(log.iter().next().unwrap().id, "id0");
497 assert_eq!(SpanRetention::default(), SpanRetention::Recent);
498 }
499
500 #[test]
501 fn detects_the_harness_from_the_first_lines() {
502 let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
503 std::fs::create_dir_all(&dir).unwrap();
504 let codex = dir.join("rollout.jsonl");
505 std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
506 let claude = dir.join("s.jsonl");
507 std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
509 let other = dir.join("other.jsonl");
510 std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
511 assert_eq!(detect(&codex), Some(Harness::Codex));
512 assert_eq!(detect(&claude), Some(Harness::Claude));
513 assert_eq!(detect(&other), None);
514 assert_eq!(detect(&dir.join("missing.jsonl")), None);
515 let _ = std::fs::remove_dir_all(&dir);
516 }
517
518 #[test]
519 fn names_the_server_behind_an_mcp_tool() {
520 assert_eq!(mcp_server_of("mcp__filesystem__read_file"), Some("filesystem"));
521 assert_eq!(mcp_server_of("mcp__chrome-devtools__take_screenshot"), Some("chrome-devtools"));
522 assert_eq!(mcp_server_of("mcp__claude_ai_Gmail__authenticate"), Some("claude_ai_Gmail"));
523 assert_eq!(mcp_server_of("mcp__odd"), Some("odd"));
524 assert_eq!(mcp_server_of("mcp____x"), None);
525 assert_eq!(mcp_server_of("Bash"), None);
526 }
527
528 #[test]
529 fn accuses_the_parser_only_with_enough_evidence() {
530 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
532 assert!(!h.fields_unrecognised());
533 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
535 assert!(!h.fields_unrecognised());
536 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
538 assert!(h.fields_unrecognised());
539 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
542 assert!(h.fields_unrecognised());
543 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
545 assert!(!h.fields_unrecognised());
546 assert!(!ParseHealth::default().fields_unrecognised());
548 }
549
550 #[test]
551 fn parses_timestamps() {
552 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
553 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
554 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
555 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
556 assert_eq!(secs.as_secs(), 1_788_419_734);
557 assert_eq!(secs.subsec_millis(), 500);
558 assert!(parse_rfc3339_utc("nope").is_none());
559 }
560}