1pub mod claude;
7pub mod codex;
8
9use crate::model::{Activity, CostBreakdown, Harness, SpanKind, TokenUsage, ToolSpan};
10use std::collections::VecDeque;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct ParseHealth {
24 pub billable_messages: u64,
26 pub usage_records: u64,
29 pub empty_usage_records: u64,
32}
33
34impl ParseHealth {
35 const MIN_EVIDENCE: u64 = 3;
38
39 pub fn fields_unrecognised(&self) -> bool {
46 self.billable_messages >= Self::MIN_EVIDENCE && self.usage_records == self.empty_usage_records
47 }
48}
49
50#[derive(Debug, Clone, Default)]
51pub struct SessionSummary {
52 pub harness: Option<Harness>,
53 pub session_id: Option<String>,
54 pub cwd: Option<PathBuf>,
55 pub model: Option<String>,
56 pub harness_version: Option<String>,
57 pub usage: TokenUsage,
58 pub cost_usd: f64,
59 pub cost_breakdown: CostBreakdown,
61 pub unpriced_tokens: u64,
62 pub turns: u64,
63 pub subagent_turns: u64,
64 pub tool_calls: u64,
65 pub web_searches: u64,
67 pub spans: SpanLog,
68 pub health: ParseHealth,
69 pub activity: Activity,
70 pub started_at: Option<SystemTime>,
71 pub last_activity: Option<SystemTime>,
72}
73
74pub const MAX_SPANS: usize = 256;
82
83#[derive(Debug, Clone)]
90pub struct SpanLog {
91 spans: VecDeque<ToolSpan>,
92 cap: usize,
93}
94
95impl Default for SpanLog {
96 fn default() -> Self {
97 SpanLog { spans: VecDeque::new(), cap: MAX_SPANS }
98 }
99}
100
101impl SpanLog {
102 pub fn unbounded() -> Self {
106 SpanLog { spans: VecDeque::new(), cap: usize::MAX }
107 }
108
109 pub fn open(&mut self, id: String, name: String, at: SystemTime, sidechain: bool) {
112 self.open_kind(id, name, at, sidechain, SpanKind::Tool);
113 }
114
115 pub fn open_kind(&mut self, id: String, name: String, at: SystemTime, sidechain: bool, kind: SpanKind) {
117 if id.is_empty() || self.spans.iter().any(|s| s.is_open() && s.id == id) {
118 return;
119 }
120 if self.spans.len() >= self.cap {
121 self.spans.pop_front();
122 }
123 self.spans.push_back(ToolSpan { id, name, started_at: at, duration_ms: None, sidechain, error: false, kind });
124 }
125
126 pub fn end_at(&mut self, id: &str, at: SystemTime) {
130 let Some(s) = self.spans.iter_mut().rev().find(|s| s.id == id) else { return };
131 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
132 }
133
134 pub fn open_of_kind(&self, kind: SpanKind) -> Option<&ToolSpan> {
136 self.spans.iter().rev().find(|s| s.is_open() && s.kind == kind)
137 }
138
139 pub fn discard_open(&mut self, id: &str) {
143 if let Some(i) = self.spans.iter().rposition(|s| s.is_open() && s.id == id) {
144 self.spans.remove(i);
145 }
146 }
147
148 pub fn close(&mut self, id: &str, at: SystemTime, error: bool) {
151 let Some(s) = self.spans.iter_mut().rev().find(|s| s.is_open() && s.id == id) else { return };
152 s.duration_ms = Some(at.duration_since(s.started_at).map(|d| d.as_millis() as u64).unwrap_or(0));
153 s.error = error;
154 }
155
156 pub fn len(&self) -> usize {
157 self.spans.len()
158 }
159
160 pub fn is_empty(&self) -> bool {
161 self.spans.is_empty()
162 }
163
164 pub fn iter(&self) -> impl DoubleEndedIterator<Item = &ToolSpan> + ExactSizeIterator {
167 self.spans.iter()
168 }
169
170 pub fn to_vec(&self) -> Vec<ToolSpan> {
171 self.spans.iter().cloned().collect()
172 }
173
174 pub fn merged<'a>(logs: impl IntoIterator<Item = &'a SpanLog>, cap: usize) -> SpanLog {
178 let mut spans: Vec<ToolSpan> = logs.into_iter().flat_map(|l| l.spans.iter().cloned()).collect();
179 spans.sort_by_key(|s| s.started_at);
180 if spans.len() > cap {
181 spans.drain(..spans.len() - cap);
182 }
183 SpanLog { spans: spans.into(), cap }
184 }
185
186 pub fn cap(&self) -> usize {
187 self.cap
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
193pub enum SpanRetention {
194 #[default]
196 Recent,
197 All,
201}
202
203impl SpanRetention {
204 pub(crate) fn log(self) -> SpanLog {
205 match self {
206 SpanRetention::Recent => SpanLog::default(),
207 SpanRetention::All => SpanLog::unbounded(),
208 }
209 }
210}
211
212pub trait SessionTracker {
213 fn refresh(&mut self) -> anyhow::Result<bool>;
216 fn summary(&self) -> &SessionSummary;
217 fn path(&self) -> &Path;
218
219 fn refresh_all(&mut self) -> anyhow::Result<()> {
223 while self.refresh()? {}
224 Ok(())
225 }
226}
227
228pub fn detect(path: &Path) -> Option<Harness> {
232 use std::io::{BufRead, BufReader};
233 let f = std::fs::File::open(path).ok()?;
234 for line in BufReader::new(f).lines().map_while(Result::ok).take(5) {
235 let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
236 if v.get("type").and_then(serde_json::Value::as_str) == Some("session_meta") {
237 return Some(Harness::Codex);
238 }
239 if v.get("sessionId").is_some() || v.get("parentUuid").is_some() {
240 return Some(Harness::Claude);
241 }
242 }
243 None
244}
245
246pub fn open_transcript(path: &Path, harness: Harness, spans: SpanRetention) -> Box<dyn SessionTracker> {
248 match harness {
249 Harness::Codex => Box::new(codex::CodexTranscript::new(path).with_spans(spans)),
250 _ => Box::new(claude::ClaudeTranscript::new(path).with_spans(spans)),
251 }
252}
253
254pub const REFRESH_BUDGET_BYTES: usize = 8 * 1024 * 1024;
257
258pub fn parse_rfc3339_utc(s: &str) -> Option<SystemTime> {
262 let s = s.strip_suffix('Z')?;
263 let (date, time) = s.split_once('T')?;
264 let mut d = date.split('-');
265 let (y, mo, da) = (d.next()?.parse::<i64>().ok()?, d.next()?.parse::<u32>().ok()?, d.next()?.parse::<u32>().ok()?);
266 let mut t = time.split(':');
267 let (h, mi) = (t.next()?.parse::<u64>().ok()?, t.next()?.parse::<u64>().ok()?);
268 let sec_str = t.next()?;
269 let (sec, frac) = match sec_str.split_once('.') {
270 Some((s, f)) => (s.parse::<u64>().ok()?, f),
271 None => (sec_str.parse::<u64>().ok()?, ""),
272 };
273 let nanos: u32 = if frac.is_empty() {
274 0
275 } else {
276 let mut f = frac.to_string();
277 f.truncate(9);
278 while f.len() < 9 {
279 f.push('0');
280 }
281 f.parse().ok()?
282 };
283 let days = days_from_civil(y, mo, da);
284 let secs = days * 86_400 + (h * 3600 + mi * 60 + sec) as i64;
285 if secs < 0 {
286 return None;
287 }
288 Some(SystemTime::UNIX_EPOCH + std::time::Duration::new(secs as u64, nanos))
289}
290
291fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
293 let y = if m <= 2 { y - 1 } else { y };
294 let era = if y >= 0 { y } else { y - 399 } / 400;
295 let yoe = y - era * 400;
296 let mp = (m as i64 + 9) % 12;
297 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
298 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
299 era * 146_097 + doe - 719_468
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::time::Duration;
306
307 fn at(secs: u64) -> SystemTime {
308 SystemTime::UNIX_EPOCH + Duration::from_secs(secs)
309 }
310
311 #[test]
312 fn pairs_spans_by_id_out_of_order() {
313 let mut log = SpanLog::default();
314 log.open("a".into(), "Bash".into(), at(10), false);
315 log.open("b".into(), "Read".into(), at(11), true);
316 log.open("a".into(), "Bash".into(), at(10), false);
318 log.close("b", at(12), false);
320 log.close("a", at(14), true);
321 log.close("zzz", at(15), false);
323 let v = log.to_vec();
324 assert_eq!(v.len(), 2);
325 assert_eq!(v[0].name, "Bash");
326 assert_eq!(v[0].duration_ms, Some(4_000));
327 assert!(v[0].error);
328 assert_eq!(v[1].duration_ms, Some(1_000));
329 assert!(v[1].sidechain);
330 assert!(!v[1].error);
331 }
332
333 #[test]
334 fn keeps_the_newest_spans_and_reports_open_ones() {
335 let mut log = SpanLog::default();
336 for i in 0..(MAX_SPANS + 10) {
337 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
338 log.close(&format!("id{i}"), at(i as u64), false);
339 }
340 assert_eq!(log.len(), MAX_SPANS);
341 assert_eq!(log.iter().next().unwrap().id, "id10");
342 log.open("live".into(), "Bash".into(), at(500), false);
343 let last = log.to_vec().pop().unwrap();
344 assert!(last.is_open());
345 assert_eq!(last.elapsed_ms(at(503)), 3_000);
346 }
347
348 #[test]
349 fn end_at_moves_the_end_of_any_kind_of_span() {
350 let mut log = SpanLog::default();
351 log.open_kind("inference:1".into(), "inference".into(), at(10), false, SpanKind::Inference);
352 assert!(log.open_of_kind(SpanKind::Inference).is_some());
353 assert!(log.open_of_kind(SpanKind::Turn).is_none());
354 log.end_at("inference:1", at(11));
356 log.end_at("inference:1", at(13));
357 log.end_at("nope", at(99));
358 let v = log.to_vec();
359 assert_eq!(v[0].duration_ms, Some(3_000));
360 assert_eq!(v[0].kind, SpanKind::Inference);
361 assert!(log.open_of_kind(SpanKind::Inference).is_none());
362 log.discard_open("inference:1");
364 assert_eq!(log.len(), 1);
365 log.open_kind("inference:2".into(), "inference".into(), at(20), false, SpanKind::Inference);
366 log.discard_open("inference:2");
367 assert_eq!(log.len(), 1);
368 }
369
370 #[test]
371 fn unbounded_log_keeps_everything() {
372 let mut log = SpanLog::unbounded();
373 for i in 0..(MAX_SPANS * 3) {
374 log.open(format!("id{i}"), "T".into(), at(i as u64), false);
375 log.close(&format!("id{i}"), at(i as u64 + 1), false);
376 }
377 assert_eq!(log.len(), MAX_SPANS * 3);
378 assert_eq!(log.iter().next().unwrap().id, "id0");
379 assert_eq!(SpanRetention::default(), SpanRetention::Recent);
380 }
381
382 #[test]
383 fn detects_the_harness_from_the_first_lines() {
384 let dir = std::env::temp_dir().join(format!("agent-top-detect-{}", std::process::id()));
385 std::fs::create_dir_all(&dir).unwrap();
386 let codex = dir.join("rollout.jsonl");
387 std::fs::write(&codex, "{\"type\":\"session_meta\",\"payload\":{\"id\":\"x\"}}\n").unwrap();
388 let claude = dir.join("s.jsonl");
389 std::fs::write(&claude, "{\"type\":\"summary\",\"leafUuid\":\"u\"}\n{\"type\":\"user\",\"sessionId\":\"abc\"}\n").unwrap();
391 let other = dir.join("other.jsonl");
392 std::fs::write(&other, "{\"hello\":1}\nnot json\n").unwrap();
393 assert_eq!(detect(&codex), Some(Harness::Codex));
394 assert_eq!(detect(&claude), Some(Harness::Claude));
395 assert_eq!(detect(&other), None);
396 assert_eq!(detect(&dir.join("missing.jsonl")), None);
397 let _ = std::fs::remove_dir_all(&dir);
398 }
399
400 #[test]
401 fn accuses_the_parser_only_with_enough_evidence() {
402 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 0 };
404 assert!(!h.fields_unrecognised());
405 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 39 };
407 assert!(!h.fields_unrecognised());
408 let h = ParseHealth { billable_messages: 40, usage_records: 40, empty_usage_records: 40 };
410 assert!(h.fields_unrecognised());
411 let h = ParseHealth { billable_messages: 40, usage_records: 0, empty_usage_records: 0 };
414 assert!(h.fields_unrecognised());
415 let h = ParseHealth { billable_messages: 2, usage_records: 0, empty_usage_records: 0 };
417 assert!(!h.fields_unrecognised());
418 assert!(!ParseHealth::default().fields_unrecognised());
420 }
421
422 #[test]
423 fn parses_timestamps() {
424 let t = parse_rfc3339_utc("1970-01-02T00:00:00.000Z").unwrap();
425 assert_eq!(t, SystemTime::UNIX_EPOCH + Duration::from_secs(86_400));
426 let t = parse_rfc3339_utc("2026-09-03T07:15:34.5Z").unwrap();
427 let secs = t.duration_since(SystemTime::UNIX_EPOCH).unwrap();
428 assert_eq!(secs.as_secs(), 1_788_419_734);
429 assert_eq!(secs.subsec_millis(), 500);
430 assert!(parse_rfc3339_utc("nope").is_none());
431 }
432}