1pub mod claude;
7pub mod codex;
8pub mod gemini;
9
10use crate::model::{Activity, Attribution, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage, ToolSpan};
11use crate::process::RawProc;
12use std::collections::{HashSet, VecDeque};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct ParseHealth {
26 pub billable_messages: u64,
28 pub usage_records: u64,
31 pub empty_usage_records: u64,
34}
35
36impl ParseHealth {
37 const MIN_EVIDENCE: u64 = 3;
40
41 pub fn fields_unrecognised(&self) -> bool {
48 self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
49 }
50}
51
52#[derive(Debug, Clone, Default)]
53pub struct SessionSummary {
54 pub harness: Option<Harness>,
55 pub session_id: Option<String>,
56 pub cwd: Option<PathBuf>,
57 pub model: Option<String>,
58 pub harness_version: Option<String>,
59 pub usage: TokenUsage,
60 pub cost_usd: f64,
61 pub cost_breakdown: CostBreakdown,
63 pub unpriced_tokens: u64,
64 pub turns: u64,
65 pub subagent_turns: u64,
66 pub tool_calls: u64,
67 pub web_searches: u64,
69 pub spans: SpanLog,
70 pub health: ParseHealth,
71 pub activity: Activity,
72 pub started_at: Option<SystemTime>,
73 pub last_activity: Option<SystemTime>,
74}
75
76pub const MAX_SPANS: usize = 256;
84
85#[derive(Debug, Clone)]
92pub struct SpanLog {
93 spans: VecDeque<ToolSpan>,
94 cap: usize,
95}
96
97impl Default for SpanLog {
98 fn default() -> Self {
99 SpanLog { spans: VecDeque::new(), cap: MAX_SPANS }
100 }
101}
102
103impl SpanLog {
104 pub fn unbounded() -> Self {
108 SpanLog { spans: VecDeque::new(), cap: usize::MAX }
109 }
110
111 pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
114 self.open_kind(id, name, at, sidechain, SpanKind::Tool);
115 }
116
117 pub fn open_kind(&mut self, id: String, name: String, at: SystemTime, sidechain: bool, kind: SpanKind) {
119 if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
120 return;
121 }
122 if self.spans.len() >= self.cap {
123 self.spans.pop_front();
124 }
125 self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false, kind });
126 }
127
128 pub fn end_at(&mut self, id: &str, at: SystemTime) {
132 let Some(s) = self.spans.iter_mut().rev().find(|s| s.id == id) else { return };
133 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
134 }
135
136 pub fn open_of_kind(&self, kind: SpanKind) -> Option<&ToolSpan> {
138 self.spans.iter().rev().find(|s| s.is_open() && s.kind == kind)
139 }
140
141 pub fn discard_open(&mut self, id: &str) {
145 if let Some(i) = self.spans.iter().rposition(|s| s.is_open() && s.id == id) {
146 self.spans.remove(i);
147 }
148 }
149
150 pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
153 let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
154 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
155 s.error = error;
156 }
157
158 pub fn len(&self) -> usize {
159 self.spans.len()
160 }
161
162 pub fn is_empty(&self) -> bool {
163 self.spans.is_empty()
164 }
165
166 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
169 self.spans.iter()
170 }
171
172 pub fn to_vec(&self) -> Vec<ToolSpan> {
173 self.spans.iter().cloned().collect()
174 }
175
176 pub fn merged<'a>(logs: impl IntoIterator<Item = &'a SpanLog>, cap: usize) -> SpanLog {
180 let mut spans: Vec<ToolSpan> = logs.into_iter().flat_map(|l| l.spans.iter().cloned()).collect();
181 spans.sort_by_key(|s| s.started_at);
182 if spans.len() > cap {
183 spans.drain(..spans.len() - cap);
184 }
185 SpanLog { spans: spans.into(), cap }
186 }
187
188 pub fn cap(&self) -> usize {
189 self.cap
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
195pub enum SpanRetention {
196 #[default]
198 Recent,
199 All,
203}
204
205impl SpanRetention {
206 pub(crate) fn log(self) -> SpanLog {
207 match self {
208 SpanRetention::Recent => SpanLog::default(),
209 SpanRetention::All => SpanLog::unbounded(),
210 }
211 }
212}
213
214pub trait SessionTracker {
215 fn refresh(&mut self) -> anyhow::Result<bool>;
218 fn summary(&self) -> &SessionSummary;
219 fn path(&self) -> &Path;
220
221 fn refresh_all(&mut self) -> anyhow::Result<()> {
225 while self.refresh()? {}
226 Ok(())
227 }
228}
229
230#[derive(Debug, Clone, Default, PartialEq, Eq)]
234pub struct RegistryHints {
235 pub name: Option<String>,
236 pub session_id: Option<String>,
237 pub cwd: Option<PathBuf>,
238 pub version: Option<String>,
239 pub status: Option<String>,
242}
243
244pub struct AttributeContext<'a> {
247 pub cwd: Option<&'a Path>,
248 pub proc_start: SystemTime,
249 pub now: SystemTime,
250 pub attached: &'a HashSet<PathBuf>,
253 pub activity_timeout: Duration,
256}
257
258pub trait HarnessAdapter {
265 fn harness(&self) -> Harness;
266
267 fn rescan(&mut self, since: SystemTime);
270
271 fn prepare(&mut self, _roots: &[&ProcNode]) {}
276
277 fn hints(&self, _pid: u32) -> Option<RegistryHints> {
279 None
280 }
281
282 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution);
286
287 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf>;
289
290 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker>;
292
293 fn detect(&self, path: &Path) -> bool;
295
296 fn transcripts(&self) -> Vec<(String, PathBuf)>;
299}
300
301pub fn adapters() -> Vec<Box<dyn HarnessAdapter>> {
305 vec![Box::new(codex::CodexAdapter::default()), Box::new(gemini::GeminiAdapter::default()), Box::new(claude::ClaudeAdapter::default())]
306}
307
308pub fn adapter_for(harness: Harness) -> Option<Box<dyn HarnessAdapter>> {
310 adapters().into_iter().find(|a| a.harness() == harness)
311}
312
313pub fn detect(path: &Path) -> Option<Harness> {
316 adapters().iter().find(|a| a.detect(path)).map(|a| a.harness())
317}
318
319pub fn open_transcript(path: &Path, harness: Harness, spans: SpanRetention) -> Option<Box<dyn SessionTracker>> {
322 adapter_for(harness).map(|a| a.open(path, spans))
323}
324
325pub(crate) fn head_lines(path: &Path) -> Vec<serde_json::Value> {
327 use std::io::{BufRead, BufReader};
328 let Ok(f) = std::fs::File::open(path) else { return Vec::new() };
329 BufReader::new(f).lines().map_while(Result::ok).take(5).filter_map(|l| serde_json::from_str(&l).ok()).collect()
330}
331
332pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
335
336pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
340 let s = s.strip_suffix('Z')?;
341 let (date, time) = s.split_once('T')?;
342 let mut d = date.split('-');
343 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
344 let mut t = time.split(':');
345 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
346 let sec_str = t.next()?;
347 let (sec, frac) = match sec_str.split_once('.') {
348 Some((s, f)) => (s.parse::<u64>().ok()?, f),
349 None => (sec_str.parse::<u64>().ok()?, ""),
350 };
351 let nanos: u32 = if frac.is_empty() {
352 0
353 } else {
354 let mut f = frac.to_string();
355 f.truncate(9);
356 while f.len() < 9 {
357 f.push('0');
358 }
359 f.parse().ok()?
360 };
361 let days = days_from_civil(y, mo, da);
362 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
363 if secs < 0 {
364 return None;
365 }
366 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
367}
368
369fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
371 let y = if m <= 2 { y - 1 } else { y };
372 let era = if y >= 0 { y } else { y - 399 } / 400;
373 let yoe = y - era * 400;
374 let mp = (m as i64 + 9) % 12;
375 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
376 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
377 era * 146_097 + doe - 719_468
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383 use std::time::Duration;
384
385 fn at(secs: u64) -> SystemTime {
386 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
387 }
388
389 #[test]
390 fn pairs_spans_by_id_out_of_order() {
391 let mut log = SpanLog::default();
392 log.open("a".into(), "Bash".into(), at(10), false);
393 log.open("b".into(), "Read".into(), at(11), true);
394 log.open("a".into(), "Bash".into(), at(10), false);
396 log.close("b", at(12), false);
398 log.close("a", at(14), true);
399 log.close("zzz", at(15), false);
401 let v = log.to_vec();
402 assert_eq!(v.len(), 2);
403 assert_eq!(v[0].name, "Bash");
404 assert_eq!(v[0].duration_ms, Some(4_000));
405 assert!(v[0].error);
406 assert_eq!(v[1].duration_ms, Some(1_000));
407 assert!(v[1].sidechain);
408 assert!(!v[1].error);
409 }
410
411 #[test]
412 fn keeps_the_newest_spans_and_reports_open_ones() {
413 let mut log = SpanLog::default();
414 for i in 0..(MAX_SPANS + 10) {
415 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
416 log.close(&format!("id{i}"), at(i as u64), false);
417 }
418 assert_eq!(log.len(), MAX_SPANS);
419 assert_eq!(log.iter().next().unwrap().id, "id10");
420 log.open("live".into(), "Bash".into(), at(500), false);
421 let last = log.to_vec().pop().unwrap();
422 assert!(last.is_open());
423 assert_eq!(last.elapsed_ms(at(503)), 3_000);
424 }
425
426 #[test]
427 fn end_at_moves_the_end_of_any_kind_of_span() {
428 let mut log = SpanLog::default();
429 log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
430 assert!(log.open_of_kind(SpanKind::Inference).is_some());
431 assert!(log.open_of_kind(SpanKind::Turn).is_none());
432 log.end_at("inference:1", at(11));
434 log.end_at("inference:1", at(13));
435 log.end_at("nope", at(99));
436 let v = log.to_vec();
437 assert_eq!(v[0].duration_ms, Some(3_000));
438 assert_eq!(v[0].kind, SpanKind::Inference);
439 assert!(log.open_of_kind(SpanKind::Inference).is_none());
440 log.discard_open("inference:1");
442 assert_eq!(log.len(), 1);
443 log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
444 log.discard_open("inference:2");
445 assert_eq!(log.len(), 1);
446 }
447
448 #[test]
449 fn unbounded_log_keeps_everything() {
450 let mut log = SpanLog::unbounded();
451 for i in 0..(MAX_SPANS * 3) {
452 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
453 log.close(&format!("id{i}"), at(i as u64 + 1), false);
454 }
455 assert_eq!(log.len(), MAX_SPANS * 3);
456 assert_eq!(log.iter().next().unwrap().id, "id0");
457 assert_eq!(SpanRetention::default(), SpanRetention::Recent);
458 }
459
460 #[test]
461 fn detects_the_harness_from_the_first_lines() {
462 let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
463 std::fs::create_dir_all(&dir).unwrap();
464 let codex = dir.join("rollout.jsonl");
465 std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
466 let claude = dir.join("s.jsonl");
467 std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
469 let other = dir.join("other.jsonl");
470 std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
471 assert_eq!(detect(&codex), Some(Harness::Codex));
472 assert_eq!(detect(&claude), Some(Harness::Claude));
473 assert_eq!(detect(&other), None);
474 assert_eq!(detect(&dir.join("missing.jsonl")), None);
475 let _ = std::fs::remove_dir_all(&dir);
476 }
477
478 #[test]
479 fn accuses_the_parser_only_with_enough_evidence() {
480 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
482 assert!(!h.fields_unrecognised());
483 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
485 assert!(!h.fields_unrecognised());
486 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
488 assert!(h.fields_unrecognised());
489 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
492 assert!(h.fields_unrecognised());
493 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
495 assert!(!h.fields_unrecognised());
496 assert!(!ParseHealth::default().fields_unrecognised());
498 }
499
500 #[test]
501 fn parses_timestamps() {
502 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
503 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
504 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
505 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
506 assert_eq!(secs.as_secs(), 1_788_419_734);
507 assert_eq!(secs.subsec_millis(), 500);
508 assert!(parse_rfc3339_utc("nope").is_none());
509 }
510}