1use super::{
34 AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SpanLog, SpanRetention,
35 mcp_server_of, parse_rfc3339_utc,
36};
37use crate::jsonl::TailReader;
38use crate::model::{Activity, Attribution, CostBreakdown, Harness, ProcNode, SpanKind, TokenUsage};
39use crate::pricing::{self, Table};
40use crate::process::{RawProc, session_id_from_args};
41use serde::Deserialize;
42use serde_json::Value;
43use std::collections::{BTreeMap, HashMap, HashSet};
44use std::path::{Path, PathBuf};
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46
47pub fn home() -> Option<PathBuf> {
48 std::env::var_os("HOME").map(PathBuf::from)
49}
50
51pub fn claude_dir() -> Option<PathBuf> {
52 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
53 return Some(PathBuf::from(d));
54 }
55 home().map(|h| h.join(".claude"))
56}
57
58pub fn sessions_dir() -> Option<PathBuf> {
59 claude_dir().map(|d| d.join("sessions"))
60}
61
62pub fn projects_dir() -> Option<PathBuf> {
63 claude_dir().map(|d| d.join("projects"))
64}
65
66pub fn encode_project_path(p: &Path) -> String {
69 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
70}
71
72pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
73 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
74}
75
76#[derive(Debug, Clone, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct PidSession {
80 pub pid: u32,
81 pub session_id: String,
82 pub cwd: PathBuf,
83 #[serde(default)]
84 pub name: Option<String>,
85 #[serde(default)]
86 pub status: Option<String>,
87 #[serde(default)]
88 pub version: Option<String>,
89 #[serde(default)]
90 pub kind: Option<String>,
91 #[serde(default)]
92 pub entrypoint: Option<String>,
93 #[serde(default)]
94 pub started_at: Option<u64>,
95 #[serde(default)]
96 pub updated_at: Option<u64>,
97}
98
99impl PidSession {
100 pub fn started(&self) -> Option<SystemTime> {
101 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
102 }
103}
104
105pub fn read_pid_sessions() -> Vec<PidSession> {
108 let Some(dir) = sessions_dir() else { return Vec::new() };
109 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
110 let mut out = Vec::new();
111 for e in rd.flatten() {
112 let p = e.path();
113 if p.extension().and_then(|x| x.to_str()) != Some("json") {
114 continue;
115 }
116 if let Ok(s) = std::fs::read_to_string(&p)
117 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
118 {
119 out.push(ps);
120 }
121 }
122 out
123}
124
125pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
127 let Some(dir) = projects_dir() else { return Vec::new() };
128 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
129 let mut out = Vec::new();
130 for proj in projects.flatten() {
131 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
132 for f in files.flatten() {
133 let p = f.path();
134 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
135 continue;
136 }
137 if let Ok(md) = f.metadata()
138 && md.modified().map(|m| m >= since).unwrap_or(false)
139 {
140 out.push(p);
141 }
142 }
143 }
144 out
145}
146
147pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
150 let dir = projects_dir()?.join(encode_project_path(cwd));
151 let rd = std::fs::read_dir(&dir).ok()?;
152 let slack = Duration::from_secs(15);
153 let mut best: Option<(Duration, PathBuf)> = None;
154 for f in rd.flatten() {
155 let p = f.path();
156 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
157 continue;
158 }
159 let Ok(md) = f.metadata() else { continue };
162 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
163 if created + slack < proc_start {
164 continue;
165 }
166 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
167 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
168 best = Some((gap, p));
169 }
170 }
171 best.map(|(_, p)| p)
172}
173
174pub fn is_subagent_transcript(p: &Path) -> bool {
179 p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
180}
181
182#[derive(Default)]
185pub struct ClaudeAdapter {
186 recent: Vec<PathBuf>,
187 registry: HashMap<u32, PidSession>,
188}
189
190impl HarnessAdapter for ClaudeAdapter {
191 fn harness(&self) -> Harness {
192 Harness::Claude
193 }
194
195 fn rescan(&mut self, since: SystemTime) {
196 self.recent = recent_transcripts(since);
197 }
198
199 fn prepare(&mut self, _roots: &[&ProcNode]) {
202 self.registry = read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
203 }
204
205 fn hints(&self, pid: u32) -> Option<RegistryHints> {
206 self.registry.get(&pid).map(|r| RegistryHints {
207 name: r.name.clone(),
208 session_id: Some(r.session_id.clone()),
209 cwd: Some(r.cwd.clone()),
210 version: r.version.clone(),
211 status: r.status.clone(),
212 })
213 }
214
215 fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
216 if let Some(reg) = self.registry.get(&root.pid)
217 && let Some(p) = transcript_path(®.cwd, ®.session_id)
218 {
219 return (vec![p], Attribution::HarnessRegistry);
220 }
221 if let (Some(raw), Some(cwd)) = (raw, ctx.cwd)
222 && let Some(id) = session_id_from_args(&raw.cmd)
223 && let Some(p) = transcript_path(cwd, &id)
224 && p.exists()
225 {
226 return (vec![p], Attribution::CommandLine);
227 }
228 if let Some(cwd) = ctx.cwd
229 && let Some(p) = guess_transcript(cwd, ctx.proc_start)
230 && !ctx.attached.contains(&p)
231 {
232 return (vec![p], Attribution::CwdHeuristic);
233 }
234 (Vec::new(), Attribution::None)
235 }
236
237 fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
238 self.recent.iter().filter(|p| !attached.contains(*p) && !is_subagent_transcript(p)).cloned().collect()
239 }
240
241 fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
242 Box::new(ClaudeTranscript::new(path).with_spans(spans))
243 }
244
245 fn detect(&self, path: &Path) -> bool {
248 super::head_lines(path)
249 .iter()
250 .any(|v| (v.get("sessionId").is_some() || v.get("parentUuid").is_some()) && v.get("projectHash").is_none())
251 }
252
253 fn transcripts(&self) -> Vec<(String, PathBuf)> {
255 recent_transcripts(SystemTime::UNIX_EPOCH)
256 .into_iter()
257 .filter(|p| !is_subagent_transcript(p))
258 .map(|p| (p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), p))
259 .collect()
260 }
261}
262
263pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
267 let stem = transcript.file_stem()?;
268 Some(transcript.with_file_name(stem).join("subagents"))
269}
270
271struct Parser {
274 reader: TailReader,
275 summary: SessionSummary,
276 last_msg_id: Option<String>,
278 last_contrib: Contrib,
279 inference: Option<String>,
282 turn: Option<String>,
283 inferences: u64,
284 turns: u64,
285 prev_ts: Option<SystemTime>,
289 inference_ended_by: Option<String>,
294 turn_ended_by: Option<String>,
295 pending_mcp: HashMap<String, String>,
298}
299
300#[derive(Debug, Clone, Copy, Default)]
303struct Contrib {
304 usage: TokenUsage,
305 cost: CostBreakdown,
306 unpriced: u64,
307 searches: u64,
308}
309
310impl Parser {
311 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
312 Parser {
313 reader: TailReader::new(path),
314 summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
315 last_msg_id: None,
316 last_contrib: Contrib::default(),
317 inference: None,
318 turn: None,
319 inferences: 0,
320 turns: 0,
321 prev_ts: None,
322 inference_ended_by: None,
323 turn_ended_by: None,
324 pending_mcp: HashMap::new(),
325 }
326 }
327
328 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
330 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
331 for l in &lines {
332 self.ingest(l, prices);
333 }
334 Ok((lines.len(), more))
335 }
336
337 fn ingest(&mut self, line: &str, prices: &Table) {
338 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
339 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
340 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
341 if self.summary.started_at.is_none() {
342 self.summary.started_at = Some(ts);
343 }
344 self.summary.last_activity = Some(ts);
345 }
346 if self.summary.session_id.is_none() {
347 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
348 }
349 if self.summary.cwd.is_none() {
350 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
351 }
352 if self.summary.harness_version.is_none() {
353 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
354 }
355 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
356 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
357 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
358 match kind {
359 "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
360 "user" if !is_meta => {
361 self.summary.activity = Activity::Working;
363 if let Some(ts) = ts {
364 let answered = self.close_spans(&v, ts);
365 if !answered {
366 self.begin_turn(ts, sidechain);
367 }
368 self.begin_inference(ts, sidechain);
369 }
370 }
371 "user" => {
376 if let Some(ts) = ts {
377 self.begin_inference(ts, sidechain);
378 }
379 }
380 _ => {}
381 }
382 if ts.is_some() {
383 self.prev_ts = ts;
384 }
385 }
386
387 fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
391 if let Some(id) = self.turn.take()
392 && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
393 {
394 let ended = self.prev_ts.unwrap_or(ts).min(ts);
395 self.summary.spans.end_at(&id, ended);
396 }
397 self.turns += 1;
398 let id = format!("turn:{}", self.turns);
399 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
400 self.turn = Some(id);
401 self.turn_ended_by = None;
402 }
403
404 fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
409 if let Some(id) = self.inference.take() {
410 self.summary.spans.discard_open(&id);
411 }
412 self.inferences += 1;
413 let id = format!("inference:{}", self.inferences);
414 self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
415 self.inference = Some(id);
416 self.inference_ended_by = None;
417 }
418
419 fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
423 match (ended_by.as_deref(), id) {
424 (None, Some(id)) => {
425 *ended_by = Some(id.to_string());
426 true
427 }
428 (None, None) => true,
429 (Some(e), Some(id)) => e == id,
430 (Some(_), None) => false,
431 }
432 }
433
434 fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
438 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
439 let mut answered = false;
440 for b in content {
441 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
442 continue;
443 }
444 answered = true;
445 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
446 let error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false);
447 self.summary.spans.close(id, ts, error);
448 if let Some(server) = self.pending_mcp.remove(id)
449 && error
450 {
451 self.summary.mcp.entry(server).or_default().errors += 1;
452 }
453 }
454 answered
455 }
456
457 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
458 let Some(msg) = v.get("message") else { return };
459 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
460 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
461 if !model.is_empty() && model != "<synthetic>" {
462 self.summary.model = Some(model.to_string());
463 }
464 if let Some(content) = msg.get("content").and_then(Value::as_array) {
465 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
466 for b in calls {
467 self.summary.tool_calls += 1;
468 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
469 if let Some(server) = mcp_server_of(name) {
470 let u = self.summary.mcp.entry(server.to_string()).or_default();
471 u.calls += 1;
472 u.last_call = u.last_call.max(ts);
473 if let Some(id) = b.get("id").and_then(Value::as_str) {
474 self.pending_mcp.insert(id.to_string(), server.to_string());
475 }
476 }
477 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
478 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
479 }
480 }
481 }
482 let synthetic = model == "<synthetic>";
487 if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
488 if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
489 self.summary.spans.end_at(span, ts);
490 } else {
491 self.inference = None;
492 }
493 }
494 match msg.get("stop_reason").and_then(Value::as_str) {
495 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
496 self.summary.activity = Activity::Waiting;
497 if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
501 if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
502 self.summary.spans.end_at(span, ts);
503 } else {
504 self.turn = None;
505 }
506 }
507 }
508 _ => self.summary.activity = Activity::Working,
509 }
510
511 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
514 self.summary.health.billable_messages += 1;
515 }
516 let usage = match msg.get("usage") {
517 Some(u) => {
518 let parsed = parse_usage(u);
519 self.summary.health.usage_records += 1;
520 if parsed.total() == 0 {
521 self.summary.health.empty_usage_records += 1;
522 }
523 parsed
524 }
525 None => TokenUsage::default(),
526 };
527 let price = prices.lookup(model);
528 let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
531 let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
532 cost.web_search = prices.web_search_cost(searches);
533 let unpriced = if price.is_none() { usage.total() } else { 0 };
534
535 let same_message = id.is_some() && id == self.last_msg_id;
536 if same_message {
537 let c = self.last_contrib;
539 self.summary.usage.sub(&c.usage);
540 self.summary.cost_usd -= c.cost.total();
541 self.summary.cost_breakdown.sub(&c.cost);
542 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
543 self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
544 } else {
545 self.summary.turns += 1;
546 if sidechain {
547 self.summary.subagent_turns += 1;
548 }
549 }
550 self.summary.usage.add(&usage);
551 self.summary.cost_usd += cost.total();
552 self.summary.cost_breakdown.add(&cost);
553 self.summary.unpriced_tokens += unpriced;
554 self.summary.web_searches += searches;
555 self.last_msg_id = id;
556 self.last_contrib = Contrib { usage, cost, unpriced, searches };
557 }
558}
559
560pub struct ClaudeTranscript {
571 main: Parser,
572 subagents: BTreeMap<PathBuf, Parser>,
574 prices: &'static Table,
575 retention: SpanRetention,
576 summary: SessionSummary,
579}
580
581impl ClaudeTranscript {
582 pub fn new(path: impl Into<PathBuf>) -> Self {
583 let retention = SpanRetention::Recent;
584 ClaudeTranscript {
585 main: Parser::new(path, retention),
586 subagents: BTreeMap::new(),
587 prices: pricing::table(),
588 retention,
589 summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
590 }
591 }
592
593 pub fn with_prices(mut self, prices: &'static Table) -> Self {
596 self.prices = prices;
597 self
598 }
599
600 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
602 self.retention = retention;
603 self.main.summary.spans = retention.log();
604 for p in self.subagents.values_mut() {
605 p.summary.spans = retention.log();
606 }
607 self
608 }
609
610 pub fn set_registry_hints(&mut self, ps: &PidSession) {
611 let s = &mut self.main.summary;
612 s.session_id.get_or_insert_with(|| ps.session_id.clone());
613 s.cwd.get_or_insert_with(|| ps.cwd.clone());
614 if ps.version.is_some() {
615 s.harness_version = ps.version.clone();
616 }
617 if s.started_at.is_none() {
618 s.started_at = ps.started();
619 }
620 self.fold();
621 }
622
623 fn discover_subagents(&mut self) {
627 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
628 let Ok(rd) = std::fs::read_dir(&dir) else { return };
629 for e in rd.flatten() {
630 let p = e.path();
631 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
632 continue;
633 }
634 let parser = Parser::new(&p, self.retention);
635 self.subagents.insert(p, parser);
636 }
637 }
638
639 fn fold(&mut self) {
640 let mut s = self.main.summary.clone();
641 for c in self.subagents.values() {
642 let t = &c.summary;
643 s.usage.add(&t.usage);
644 s.cost_usd += t.cost_usd;
645 s.cost_breakdown.add(&t.cost_breakdown);
646 s.unpriced_tokens += t.unpriced_tokens;
647 s.turns += t.turns;
648 s.subagent_turns += t.subagent_turns;
649 s.tool_calls += t.tool_calls;
650 s.web_searches += t.web_searches;
651 s.health.billable_messages += t.health.billable_messages;
652 s.health.usage_records += t.health.usage_records;
653 s.health.empty_usage_records += t.health.empty_usage_records;
654 s.last_activity = s.last_activity.max(t.last_activity);
655 for (server, u) in &t.mcp {
656 s.mcp.entry(server.clone()).or_default().add(u);
657 }
658 }
659 if !self.subagents.is_empty() {
660 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
661 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
662 }
663 self.summary = s;
664 }
665}
666
667fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
668 matches!((a, b), (Some(x), Some(y)) if x == y)
669}
670
671fn parse_usage(u: &Value) -> TokenUsage {
672 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
673 let cache_write_total = g("cache_creation_input_tokens");
674 let (w1h, w5m) = match u.get("cache_creation") {
675 Some(cc) => (
676 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
677 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
678 ),
679 None => (0, 0),
680 };
681 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
683 TokenUsage {
684 input: g("input_tokens"),
685 cache_write_5m: w5m,
686 cache_write_1h: w1h,
687 cache_read: g("cache_read_input_tokens"),
688 output: g("output_tokens"),
689 }
690}
691
692impl SessionTracker for ClaudeTranscript {
693 fn refresh(&mut self) -> anyhow::Result<bool> {
694 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
695 self.discover_subagents();
696 for c in self.subagents.values_mut() {
697 if let Ok((n, m)) = c.refresh(self.prices) {
699 ingested += n;
700 more |= m;
701 }
702 }
703 if ingested > 0 || self.summary.session_id.is_none() {
704 self.fold();
705 }
706 Ok(more)
707 }
708
709 fn summary(&self) -> &SessionSummary {
710 &self.summary
711 }
712
713 fn path(&self) -> &Path {
714 self.main.reader.path()
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use std::io::Write;
722
723 #[test]
724 fn encodes_paths_like_claude_code() {
725 assert_eq!(
726 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
727 "-Users-atlas-Documents-orbital-forge-agent-top"
728 );
729 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
730 }
731
732 #[test]
733 fn dedupes_usage_by_message_id_and_tracks_state() {
734 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
735 std::fs::create_dir_all(&dir).unwrap();
736 let path = dir.join("s.jsonl");
737 let mut f = std::fs::File::create(&path).unwrap();
738 let usage = r#"{"input_tokens":2,"cache_creation_input_tokens":100,"cache_read_input_tokens":1000,"output_tokens":50,"cache_creation":{"ephemeral_1h_input_tokens":100,"ephemeral_5m_input_tokens":0}}"#;
739 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
740 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"text","text":"x"}}],"usage":{usage}}}}}"#).unwrap();
741 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:02.000Z","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","name":"Bash"}}],"usage":{usage}}}}}"#).unwrap();
742 let mut t = ClaudeTranscript::new(&path);
743 t.refresh().unwrap();
744 let s = t.summary();
745 assert_eq!(s.turns, 1);
746 assert_eq!(s.tool_calls, 1);
747 assert_eq!(s.usage.total(), 1152);
748 assert_eq!(s.activity, Activity::Working);
749 assert_eq!(s.session_id.as_deref(), Some("abc"));
750 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
752 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","message":{{"id":"msg_2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
753 t.refresh().unwrap();
754 assert_eq!(t.summary().turns, 2);
755 assert_eq!(t.summary().activity, Activity::Waiting);
756 let _ = std::fs::remove_dir_all(&dir);
757 }
758
759 #[test]
760 fn counts_calls_per_mcp_server_and_their_errors() {
761 let dir = std::env::temp_dir().join(format!("agent-top-claude-mcp-{}", std::process::id()));
762 std::fs::create_dir_all(&dir).unwrap();
763 let path = dir.join("s.jsonl");
764 let mut f = std::fs::File::create(&path).unwrap();
765 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","message":{{"id":"m1","model":"claude-sonnet-5","content":[{{"type":"tool_use","id":"t1","name":"mcp__filesystem__read_file"}},{{"type":"tool_use","id":"t2","name":"mcp__chrome-devtools__take_screenshot"}},{{"type":"tool_use","id":"t3","name":"Bash"}}]}}}}"#).unwrap();
766 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:02.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}},{{"type":"tool_result","tool_use_id":"t2","is_error":true}},{{"type":"tool_result","tool_use_id":"t3","is_error":true}}]}}}}"#).unwrap();
767 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:05.000Z","message":{{"id":"m2","model":"claude-sonnet-5","content":[{{"type":"tool_use","id":"t4","name":"mcp__filesystem__list_directory"}}]}}}}"#).unwrap();
768 let mut t = ClaudeTranscript::new(&path);
769 t.refresh().unwrap();
770 let s = t.summary();
771 assert_eq!(s.tool_calls, 4, "MCP calls are tool calls too");
772 assert_eq!(s.mcp.len(), 2, "Bash is not a server");
773 let fs = &s.mcp["filesystem"];
774 assert_eq!((fs.calls, fs.errors), (2, 0));
775 assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-03T07:00:05.000Z"));
776 let cd = &s.mcp["chrome-devtools"];
777 assert_eq!((cd.calls, cd.errors), (1, 1), "the failed result is charged to its server, not to Bash's");
778 let _ = std::fs::remove_dir_all(&dir);
779 }
780
781 #[test]
782 fn reconstructs_turns_inferences_and_web_searches() {
783 let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
784 std::fs::create_dir_all(&dir).unwrap();
785 let path = dir.join("s.jsonl");
786 let mut f = std::fs::File::create(&path).unwrap();
787 let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
788 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
790 .unwrap();
791 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:02.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"thinking"}}],"usage":{usage}}}}}"#).unwrap();
792 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:04.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"t1","name":"Bash"}}],"usage":{usage}}}}}"#).unwrap();
793 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
795 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:09.000Z","message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
796 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
797 t.refresh().unwrap();
798 let s = t.summary();
799 assert_eq!(s.web_searches, 2);
801 assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
803 let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
804 let turns = by_kind(SpanKind::Turn);
805 assert_eq!(turns.len(), 1);
806 assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
807 let inf = by_kind(SpanKind::Inference);
808 assert_eq!(inf.len(), 2);
809 assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
810 assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
811 assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
812 let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
814 assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
815 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
817 t.refresh().unwrap();
818 let turns = by_kind_of(t.summary(), SpanKind::Turn);
819 assert_eq!(turns.len(), 2);
820 assert_eq!(turns[0].duration_ms, Some(9_000));
821 assert!(turns[1].is_open());
822 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:01:05.000Z","message":{{"id":"m3","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"t2","name":"Bash"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
827 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
828 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
829 .unwrap();
830 t.refresh().unwrap();
831 let turns = by_kind_of(t.summary(), SpanKind::Turn);
832 assert_eq!(turns.len(), 3);
833 assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
834 assert!(turns[2].is_open());
835 let inf = by_kind_of(t.summary(), SpanKind::Inference);
836 assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
837 assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
838 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-04T07:00:02.000Z","message":{{"id":"m4","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
842 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
843 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-04T09:00:03.000Z","message":{{"id":"m5","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
844 t.refresh().unwrap();
845 let turns = by_kind_of(t.summary(), SpanKind::Turn);
846 assert_eq!(turns.len(), 3, "a meta line is not a prompt");
847 assert_eq!(turns[2].duration_ms, Some(2_000));
848 let inf = by_kind_of(t.summary(), SpanKind::Inference);
849 let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
850 assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
851 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
854 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-07T09:00:00.000Z","message":{{"id":"synthetic-1","model":"<synthetic>","stop_reason":"stop_sequence","content":[{{"type":"text"}}],"usage":{{"input_tokens":0,"output_tokens":0}}}}}}"#).unwrap();
855 t.refresh().unwrap();
856 let inf = by_kind_of(t.summary(), SpanKind::Inference);
857 assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
858 assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
859 let _ = std::fs::remove_dir_all(&dir);
860 }
861
862 fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
863 s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
864 }
865
866 #[test]
867 fn folds_subagent_transcripts_into_the_parent() {
868 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
869 let _ = std::fs::remove_dir_all(&dir);
870 std::fs::create_dir_all(&dir).unwrap();
871 let path = dir.join("s.jsonl");
872 let mut f = std::fs::File::create(&path).unwrap();
873 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_agent","name":"Agent"}}],"usage":{{"input_tokens":100,"output_tokens":10}}}}}}"#).unwrap();
875 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
876 t.refresh().unwrap();
877 assert_eq!(t.summary().usage.total(), 110);
878 assert_eq!(t.summary().subagent_turns, 0);
879 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
881
882 let sub = subagents_dir(&path).unwrap();
884 std::fs::create_dir_all(&sub).unwrap();
885 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
886 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
887 writeln!(g, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"id":"s1","model":"claude-opus-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_sub","name":"Grep"}}],"usage":{{"input_tokens":1000,"output_tokens":100}}}}}}"#).unwrap();
888 writeln!(g, r#"{{"type":"user","timestamp":"2026-09-03T07:00:03.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_sub"}}]}}}}"#).unwrap();
889 t.refresh().unwrap();
890 let s = t.summary();
891 assert_eq!(s.usage.total(), 1210);
892 assert_eq!(s.turns, 2);
893 assert_eq!(s.subagent_turns, 1);
894 assert_eq!(s.tool_calls, 2);
895 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
897 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
898 assert_eq!(s.session_id.as_deref(), Some("abc"));
899 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
900 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
901 let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
902 assert_eq!(spans.len(), 2);
903 assert_eq!(spans[0].name, "Agent");
904 assert!(spans[0].is_open());
905 assert_eq!(spans[1].name, "Grep");
906 assert!(spans[1].sidechain);
907 assert_eq!(spans[1].duration_ms, Some(2_000));
908 let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
911 assert_eq!(inf.len(), 1);
912 assert!(inf[0].sidechain);
913 assert!(inf[0].is_open());
914
915 writeln!(g, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:04.000Z","sessionId":"abc","isSidechain":true,"agentId":"a1","message":{{"id":"s2","model":"claude-opus-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#).unwrap();
917 t.refresh().unwrap();
918 assert_eq!(t.summary().usage.total(), 1212);
919 assert_eq!(t.summary().subagent_turns, 2);
920 let _ = std::fs::remove_dir_all(&dir);
921 }
922
923 #[test]
924 fn builds_spans_from_tool_use_and_tool_result() {
925 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
926 std::fs::create_dir_all(&dir).unwrap();
927 let path = dir.join("s.jsonl");
928 let mut f = std::fs::File::create(&path).unwrap();
929 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:00.000Z","message":{{"id":"m1","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_a","name":"Bash"}},{{"type":"tool_use","id":"toolu_b","name":"Read"}}],"usage":{{"input_tokens":1}}}}}}"#).unwrap();
930 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:02.500Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"toolu_b","is_error":true}},{{"type":"tool_result","tool_use_id":"toolu_a","is_error":false}}]}},"toolUseResult":{{}}}}"#).unwrap();
932 writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","isSidechain":true,"message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"tool_use","content":[{{"type":"tool_use","id":"toolu_c","name":"Grep"}}],"usage":{{"input_tokens":1}}}}}}"#).unwrap();
934 let mut t = ClaudeTranscript::new(&path);
935 t.refresh().unwrap();
936 let all = t.summary().spans.to_vec();
937 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
941 assert_eq!(inf.len(), 1);
942 assert_eq!(inf[0].name, "inference");
943 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
944 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
945 assert_eq!(spans.len(), 3);
946 assert_eq!(spans[0].name, "Bash");
947 assert_eq!(spans[0].duration_ms, Some(2_500));
948 assert!(!spans[0].error);
949 assert_eq!(spans[1].name, "Read");
950 assert_eq!(spans[1].duration_ms, Some(2_500));
951 assert!(spans[1].error);
952 assert!(spans[2].is_open());
953 assert!(spans[2].sidechain);
954 assert_eq!(t.summary().tool_calls, 3);
955 let _ = std::fs::remove_dir_all(&dir);
956 }
957}