Skip to main content

agent_top_core/harness/
claude.rs

1//! Claude Code: `~/.claude/sessions/<pid>.json` registry and
2//! `~/.claude/projects/<encoded-cwd>/<session>.jsonl` transcripts.
3//!
4//! Format notes (verified on Claude Code 2.1.259, 2026-09-03):
5//! * One API response is written as several lines, one per content block,
6//!   every line carrying the same `message.id` and the same `message.usage`.
7//!   Usage must be counted once per id.
8//! * `usage.cache_creation.ephemeral_1h_input_tokens` /
9//!   `ephemeral_5m_input_tokens` split cache writes by TTL, which have
10//!   different prices.
11//! * Subagents (Claude Code 2.1.233 and later): each Agent-tool call gets its
12//!   own transcript at `<project>/<session>/subagents/agent-<id>.jsonl`, every
13//!   line carrying the parent's `sessionId`, `isSidechain: true` and an
14//!   `agentId`, with `agent-<id>.meta.json` beside it naming the agent type
15//!   and the spawning `toolUseId`. The parent transcript no longer carries any
16//!   sidechain lines itself. Claude Code's own cost display includes those
17//!   files, so `ClaudeTranscript` tails and folds them in.
18//! * A `tool_use` block in an assistant message and the `tool_result` block
19//!   that answers it carry the same id in `id` / `tool_use_id`, and their
20//!   lines carry the timestamps that bracket the call. That pairing is the
21//!   trace: verified 240/240 on a real session.
22//! * `usage.server_tool_use.web_search_requests` counts server-side web
23//!   searches, billed per search on top of tokens; `web_fetch_requests` sits
24//!   beside it and is free. Deduped per message id like the rest of usage.
25//! * Turns and inferences are reconstructed from line order: a `user` line
26//!   with no `tool_result` block is a prompt and starts a turn; any non-meta
27//!   `user` line starts an inference; each `assistant` line extends that
28//!   inference to its own timestamp, and one with an end-of-turn
29//!   `stop_reason` ends the turn there too.
30//! * The registry file has `status: "busy" | "idle"`, which is the harness's
31//!   own opinion of its state and beats any transcript heuristic.
32//! * A compaction writes a `system` line with `subtype: "compact_boundary"`
33//!   and `compactMetadata.{preTokens,postTokens}`, then the summary as a
34//!   `user` line with `isCompactSummary: true`. The boundary resets the
35//!   context ledger exactly, so no halving heuristic is needed here.
36//! * Context by source: each `tool_result` is filed under its `tool_use`'s
37//!   name (the MCP server for `mcp__` tools) and sized by the growth of the
38//!   next message's prompt. See `ContextLedger`.
39//! * Claude Code's own cost (verified on 2.1.278 to 2.1.286, 2026-10-01): a
40//!   `type: "cost-state"` line, written twice in a row when a session exits
41//!   (after its last turn, before any resume), with `totalCostUSD`,
42//!   `hasUnknownModelCost` and a `modelUsage` map of tokens and `costUSD` per
43//!   model. It carries no timestamp. The total is cumulative across resumes
44//!   and includes the subagents, whose own transcripts have no such line. It also counts requests no transcript
45//!   records (Haiku calls, and 4 to 57% more cache reads on 2026-10-01's
46//!   sample), so it is shown beside our figure as `HarnessCost`, never
47//!   reconciled with it.
48
49use super::{
50    AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, RegistryHints, SessionSummary, SessionTracker, SpanLog, SpanRetention,
51    mcp_server_of, parse_rfc3339_utc,
52};
53use crate::jsonl::TailReader;
54use crate::model::{Activity, Attribution, ContextOrigin, CostBreakdown, Harness, HarnessCost, ProcNode, SpanKind, TokenUsage};
55use crate::pricing::{self, Table};
56use crate::process::{RawProc, session_id_from_args};
57use serde::Deserialize;
58use serde_json::Value;
59use std::collections::{BTreeMap, HashMap, HashSet};
60use std::path::{Path, PathBuf};
61use std::time::{Duration, SystemTime, UNIX_EPOCH};
62
63pub fn home() -> Option<PathBuf> {
64    std::env::var_os("HOME").map(PathBuf::from)
65}
66
67pub fn claude_dir() -> Option<PathBuf> {
68    if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
69        return Some(PathBuf::from(d));
70    }
71    home().map(|h| h.join(".claude"))
72}
73
74pub fn sessions_dir() -> Option<PathBuf> {
75    claude_dir().map(|d| d.join("sessions"))
76}
77
78pub fn projects_dir() -> Option<PathBuf> {
79    claude_dir().map(|d| d.join("projects"))
80}
81
82/// Claude Code's project directory name: every character that is not
83/// ASCII alphanumeric becomes `-`, so `/Users/a/x.y` is `-Users-a-x-y`.
84pub fn encode_project_path(p: &Path) -> String {
85    p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
86}
87
88pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
89    projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
90}
91
92/// One `~/.claude/sessions/<pid>.json`.
93#[derive(Debug, Clone, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct PidSession {
96    pub pid: u32,
97    pub session_id: String,
98    pub cwd: PathBuf,
99    #[serde(default)]
100    pub name: Option<String>,
101    #[serde(default)]
102    pub status: Option<String>,
103    #[serde(default)]
104    pub version: Option<String>,
105    #[serde(default)]
106    pub kind: Option<String>,
107    #[serde(default)]
108    pub entrypoint: Option<String>,
109    #[serde(default)]
110    pub started_at: Option<u64>,
111    #[serde(default)]
112    pub updated_at: Option<u64>,
113}
114
115impl PidSession {
116    pub fn started(&self) -> Option<SystemTime> {
117        self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
118    }
119}
120
121/// Read every registry file. Stale files for dead pids are returned too; the
122/// caller reconciles against the process table.
123pub fn read_pid_sessions() -> Vec<PidSession> {
124    let Some(dir) = sessions_dir() else { return Vec::new() };
125    let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
126    let mut out = Vec::new();
127    for e in rd.flatten() {
128        let p = e.path();
129        if p.extension().and_then(|x| x.to_str()) != Some("json") {
130            continue;
131        }
132        if let Ok(s) = std::fs::read_to_string(&p)
133            && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
134        {
135            out.push(ps);
136        }
137    }
138    out
139}
140
141/// Transcripts modified after `since`, across all projects.
142pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
143    let Some(dir) = projects_dir() else { return Vec::new() };
144    let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
145    let mut out = Vec::new();
146    for proj in projects.flatten() {
147        let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
148        for f in files.flatten() {
149            let p = f.path();
150            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
151                continue;
152            }
153            if let Ok(md) = f.metadata()
154                && md.modified().map(|m| m >= since).unwrap_or(false)
155            {
156                out.push(p);
157            }
158        }
159    }
160    out
161}
162
163/// Fallback attribution when the registry has no entry: the transcript in
164/// the cwd's project directory created closest after the process start.
165pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
166    let dir = projects_dir()?.join(encode_project_path(cwd));
167    let rd = std::fs::read_dir(&dir).ok()?;
168    let slack = Duration::from_secs(15);
169    let mut best: Option<(Duration, PathBuf)> = None;
170    for f in rd.flatten() {
171        let p = f.path();
172        if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
173            continue;
174        }
175        // A file we cannot stat is skipped, not fatal: one unreadable
176        // transcript must not abandon attribution for the whole directory.
177        let Ok(md) = f.metadata() else { continue };
178        let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
179        if created + slack < proc_start {
180            continue;
181        }
182        let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
183        if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
184            best = Some((gap, p));
185        }
186    }
187    best.map(|(_, p)| p)
188}
189
190/// Older Claude Code versions stored subagent transcripts as `agent-<id>.jsonl`
191/// next to the parent session; they are not sessions of their own. Current
192/// versions nest them under `<session>/subagents/`, where the directory walk
193/// does not look, and `ClaudeTranscript` folds them into the parent.
194pub fn is_subagent_transcript(p: &Path) -> bool {
195    p.file_name().and_then(|f| f.to_str()).map(|f| f.starts_with("agent-")).unwrap_or(false)
196}
197
198/// The Claude Code adapter: the registry first, then the command line, then
199/// the cwd heuristic. See the module notes for the layout it reads.
200#[derive(Default)]
201pub struct ClaudeAdapter {
202    recent: Vec<PathBuf>,
203    registry: HashMap<u32, PidSession>,
204}
205
206impl HarnessAdapter for ClaudeAdapter {
207    fn harness(&self) -> Harness {
208        Harness::Claude
209    }
210
211    fn rescan(&mut self, since: SystemTime) {
212        self.recent = recent_transcripts(since);
213    }
214
215    /// The registry is re-read every pass: it is a handful of small files and
216    /// its `status` is what the state column shows.
217    fn prepare(&mut self, _roots: &[&ProcNode], _by_pid: &HashMap<u32, &RawProc>) {
218        self.registry = read_pid_sessions().into_iter().map(|s| (s.pid, s)).collect();
219    }
220
221    fn hints(&self, pid: u32) -> Option<RegistryHints> {
222        self.registry.get(&pid).map(|r| RegistryHints {
223            name: r.name.clone(),
224            session_id: Some(r.session_id.clone()),
225            cwd: Some(r.cwd.clone()),
226            version: r.version.clone(),
227            status: r.status.clone(),
228        })
229    }
230
231    fn attribute(&self, root: &ProcNode, raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
232        if let Some(reg) = self.registry.get(&root.pid)
233            && let Some(p) = transcript_path(&reg.cwd, &reg.session_id)
234        {
235            return (vec![p], Attribution::HarnessRegistry);
236        }
237        if let (Some(raw), Some(cwd)) = (raw, ctx.cwd)
238            && let Some(id) = session_id_from_args(&raw.cmd)
239            && let Some(p) = transcript_path(cwd, &id)
240            && p.exists()
241        {
242            return (vec![p], Attribution::CommandLine);
243        }
244        if let Some(cwd) = ctx.cwd
245            && let Some(p) = guess_transcript(cwd, ctx.proc_start)
246            && !ctx.attached.contains(&p)
247        {
248            return (vec![p], Attribution::CwdHeuristic);
249        }
250        (Vec::new(), Attribution::None)
251    }
252
253    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
254        self.recent.iter().filter(|p| !attached.contains(*p) && !is_subagent_transcript(p)).cloned().collect()
255    }
256
257    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
258        Box::new(ClaudeTranscript::new(path).with_spans(spans))
259    }
260
261    /// Every line carries `sessionId`; so does Gemini's metadata line, which
262    /// has a `projectHash` beside it that no Claude Code line has.
263    fn detect(&self, path: &Path) -> bool {
264        super::head_lines(path)
265            .iter()
266            .any(|v| (v.get("sessionId").is_some() || v.get("parentUuid").is_some()) && v.get("projectHash").is_none())
267    }
268
269    /// The session id is the file stem.
270    fn transcripts(&self) -> Vec<(String, PathBuf)> {
271        recent_transcripts(SystemTime::UNIX_EPOCH)
272            .into_iter()
273            .filter(|p| !is_subagent_transcript(p))
274            .map(|p| (p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(), p))
275            .collect()
276    }
277}
278
279/// Where Claude Code keeps a session's subagent transcripts: one
280/// `agent-<id>.jsonl` per Agent-tool call, next to an `agent-<id>.meta.json`
281/// naming the agent type and the `toolUseId` that spawned it.
282pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
283    let stem = transcript.file_stem()?;
284    Some(transcript.with_file_name(stem).join("subagents"))
285}
286
287/// One JSONL file being tailed into a `SessionSummary`: the main transcript,
288/// or one subagent's.
289struct Parser {
290    reader: TailReader,
291    summary: SessionSummary,
292    /// Dedupe state: the last API message id seen and what it contributed.
293    last_msg_id: Option<String>,
294    last_contrib: Contrib,
295    /// The inference and turn spans currently being extended, by id, and the
296    /// counters that name them.
297    inference: Option<String>,
298    turn: Option<String>,
299    inferences: u64,
300    turns: u64,
301    /// Timestamp of the previous line, so a turn abandoned mid-way can be
302    /// ended where activity actually stopped rather than at the next prompt,
303    /// which may be days later.
304    prev_ts: Option<SystemTime>,
305    /// The message ids that first ended the current inference and turn. Only
306    /// further blocks of that same message may move the end; a different
307    /// message is a different reply, and must not stretch a span that was
308    /// already over, which a reply to a slash command hours later would.
309    inference_ended_by: Option<String>,
310    turn_ended_by: Option<String>,
311    /// Tool calls awaiting their result, by call id: the result names only
312    /// the id, and the name is what the context ledger files it under and,
313    /// for an MCP tool, what its `is_error` is charged to.
314    pending_tools: HashMap<String, String>,
315}
316
317/// What one API message added to the summary, so the next line of the same
318/// message can replace it.
319#[derive(Debug, Clone, Copy, Default)]
320struct Contrib {
321    usage: TokenUsage,
322    cost: CostBreakdown,
323    unpriced: u64,
324    searches: u64,
325}
326
327impl Parser {
328    fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
329        Parser {
330            reader: TailReader::new(path),
331            summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, spans: spans.log(), ..Default::default() },
332            last_msg_id: None,
333            last_contrib: Contrib::default(),
334            inference: None,
335            turn: None,
336            inferences: 0,
337            turns: 0,
338            prev_ts: None,
339            inference_ended_by: None,
340            turn_ended_by: None,
341            pending_tools: HashMap::new(),
342        }
343    }
344
345    /// Returns how many lines were ingested and whether more are waiting.
346    fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
347        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
348        for l in &lines {
349            self.ingest(l, prices);
350        }
351        Ok((lines.len(), more))
352    }
353
354    fn ingest(&mut self, line: &str, prices: &Table) {
355        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
356        let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
357        if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
358            if self.summary.started_at.is_none() {
359                self.summary.started_at = Some(ts);
360            }
361            self.summary.last_activity = Some(ts);
362        }
363        if self.summary.session_id.is_none() {
364            self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
365        }
366        if self.summary.cwd.is_none() {
367            self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
368        }
369        if self.summary.harness_version.is_none() {
370            self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
371        }
372        let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
373        let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
374        let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
375        match kind {
376            "assistant" => {
377                self.ingest_assistant(&v, sidechain, ts, prices);
378                if let Some(h) = &mut self.summary.harness_cost {
379                    h.current = false;
380                }
381            }
382            "user" if !is_meta => {
383                // Either a prompt or a tool_result: in both cases the model owes a response.
384                self.summary.activity = Activity::Working;
385                if let Some(ts) = ts {
386                    let answered = self.close_spans(&v, ts);
387                    if !answered {
388                        self.begin_turn(ts, sidechain);
389                    }
390                    self.begin_inference(ts, sidechain);
391                }
392            }
393            // A meta line (a slash command's output, an injected caveat) is
394            // not a prompt and says nothing about state, but the model may
395            // reply to it, and that reply is an inference. If it never comes,
396            // the next submission drops the span.
397            "user" => {
398                if let Some(ts) = ts {
399                    self.begin_inference(ts, sidechain);
400                }
401            }
402            "system" if v.get("subtype").and_then(Value::as_str) == Some("compact_boundary") => {
403                self.summary.context.compacted();
404            }
405            "cost-state" => {
406                if let Some(usd) = v.get("totalCostUSD").and_then(Value::as_f64) {
407                    self.summary.harness_cost = Some(HarnessCost {
408                        usd,
409                        lower_bound: v.get("hasUnknownModelCost").and_then(Value::as_bool).unwrap_or(false),
410                        as_of: self.summary.last_activity,
411                        current: true,
412                    });
413                }
414            }
415            _ => {}
416        }
417        if ts.is_some() {
418            self.prev_ts = ts;
419        }
420    }
421
422    /// A human prompt starts a turn. A previous turn the model never ended
423    /// (the user interrupted it, or closed the session) is ended where the
424    /// last line before this prompt was written, not at the prompt itself.
425    fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
426        if let Some(id) = self.turn.take()
427            && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
428        {
429            let ended = self.prev_ts.unwrap_or(ts).min(ts);
430            self.summary.spans.end_at(&id, ended);
431        }
432        self.turns += 1;
433        let id = format!("turn:{}", self.turns);
434        self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
435        self.turn = Some(id);
436        self.turn_ended_by = None;
437    }
438
439    /// Anything submitted to the model starts an inference: the span grows
440    /// with each block of the reply and ends at the last one. A submission
441    /// that got no reply before the next one (a message queued mid-turn, an
442    /// interrupted request) was not an inference and is dropped.
443    fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
444        if let Some(id) = self.inference.take() {
445            self.summary.spans.discard_open(&id);
446        }
447        self.inferences += 1;
448        let id = format!("inference:{}", self.inferences);
449        self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
450        self.inference = Some(id);
451        self.inference_ended_by = None;
452    }
453
454    /// Whether a block of message `id` may move the end of a span that
455    /// `ended_by` records as first ended by some message. The first ending
456    /// message claims the span; any other message is a different reply.
457    fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
458        match (ended_by.as_deref(), id) {
459            (None, Some(id)) => {
460                *ended_by = Some(id.to_string());
461                true
462            }
463            (None, None) => true,
464            (Some(e), Some(id)) => e == id,
465            (Some(_), None) => false,
466        }
467    }
468
469    /// A user line answering tool calls: every `tool_result` block closes a
470    /// span. Returns whether the line answered any, which is what separates a
471    /// tool result from a fresh prompt.
472    fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
473        let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
474        let mut answered = false;
475        for b in content {
476            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
477                continue;
478            }
479            answered = true;
480            let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
481            let error = b.get("is_error").and_then(Value::as_bool).unwrap_or(false);
482            self.summary.spans.close(id, ts, error);
483            let name = self.pending_tools.remove(id).unwrap_or_else(|| "tool".into());
484            match mcp_server_of(&name) {
485                Some(server) => {
486                    if error {
487                        self.summary.mcp.entry(server.to_string()).or_default().errors += 1;
488                    }
489                    self.summary.context.result(id, ContextOrigin::Mcp, server);
490                }
491                None => self.summary.context.result(id, ContextOrigin::Tool, &name),
492            }
493        }
494        answered
495    }
496
497    fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
498        let Some(msg) = v.get("message") else { return };
499        let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
500        let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
501        if !model.is_empty() && model != "<synthetic>" {
502            self.summary.model = Some(model.to_string());
503        }
504        if let Some(content) = msg.get("content").and_then(Value::as_array) {
505            let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
506            for b in calls {
507                self.summary.tool_calls += 1;
508                let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
509                if let Some(server) = mcp_server_of(name) {
510                    let u = self.summary.mcp.entry(server.to_string()).or_default();
511                    u.calls += 1;
512                    u.last_call = u.last_call.max(ts);
513                }
514                if let Some(id) = b.get("id").and_then(Value::as_str) {
515                    self.pending_tools.insert(id.to_string(), name.to_string());
516                }
517                if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
518                    self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
519                }
520            }
521        }
522        // Every block of the reply extends the inference to where it landed.
523        // A `<synthetic>` message is written by the harness, not the model
524        // (a resume notice, say, days after the last real line), so it ends
525        // nothing.
526        let synthetic = model == "<synthetic>";
527        if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
528            if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
529                self.summary.spans.end_at(span, ts);
530            } else {
531                self.inference = None;
532            }
533        }
534        match msg.get("stop_reason").and_then(Value::as_str) {
535            Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
536                self.summary.activity = Activity::Waiting;
537                // The turn ends with the reply's last block; each block of the
538                // ending message moves the end, since all of them carry the
539                // stop reason.
540                if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
541                    if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
542                        self.summary.spans.end_at(span, ts);
543                    } else {
544                        self.turn = None;
545                    }
546                }
547            }
548            _ => self.summary.activity = Activity::Working,
549        }
550
551        // Health is judged on the record being present but unreadable, which is
552        // what a renamed field looks like from in here.
553        if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
554            self.summary.health.billable_messages += 1;
555        }
556        let usage = match msg.get("usage") {
557            Some(u) => {
558                let parsed = parse_usage(u);
559                self.summary.health.usage_records += 1;
560                if parsed.total() == 0 {
561                    self.summary.health.empty_usage_records += 1;
562                }
563                parsed
564            }
565            None => TokenUsage::default(),
566        };
567        let price = prices.lookup(model);
568        // Web searches are billed per search on top of the tokens; the usage
569        // record carries the count. Web fetches are in the same record and free.
570        let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
571        let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
572        cost.web_search = prices.web_search_cost(searches);
573        let unpriced = if price.is_none() { usage.total() } else { 0 };
574
575        let same_message = id.is_some() && id == self.last_msg_id;
576        if same_message {
577            // Replace the previous contribution from this id with the latest one.
578            let c = self.last_contrib;
579            self.summary.usage.sub(&c.usage);
580            self.summary.cost_usd -= c.cost.total();
581            self.summary.cost_breakdown.sub(&c.cost);
582            self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
583            self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
584        } else {
585            self.summary.turns += 1;
586            if sidechain {
587                self.summary.subagent_turns += 1;
588            }
589            // Every line of a message repeats its usage; the first one sizes
590            // whatever was submitted since the previous message.
591            self.summary.context.response(&usage, &cost);
592        }
593        self.summary.usage.add(&usage);
594        self.summary.cost_usd += cost.total();
595        self.summary.cost_breakdown.add(&cost);
596        self.summary.unpriced_tokens += unpriced;
597        self.summary.web_searches += searches;
598        self.last_msg_id = id;
599        self.last_contrib = Contrib { usage, cost, unpriced, searches };
600    }
601}
602
603/// A Claude Code session: the main transcript plus every subagent transcript
604/// under its `subagents/` directory, folded into one summary.
605///
606/// Claude Code bills a subagent's API calls to the session that spawned it
607/// and shows them in its own cost display, but writes them to a separate
608/// file, so a session that used the Agent tool reads low if only the main
609/// transcript is counted. Each subagent file is tailed like the main one and
610/// its tokens, cost, turns, tool calls and spans are added to the parent's.
611/// A subagent may run a different model from its parent; each line is priced
612/// by the model it names, so that is handled without special casing.
613pub struct ClaudeTranscript {
614    main: Parser,
615    /// Keyed by path, so a directory listing adds each subagent once.
616    subagents: BTreeMap<PathBuf, Parser>,
617    prices: &'static Table,
618    retention: SpanRetention,
619    /// The fold of `main` and `subagents`, rebuilt whenever any of them read
620    /// a line. Cheap: a clone of the main summary and a merge of the span logs.
621    summary: SessionSummary,
622}
623
624impl ClaudeTranscript {
625    pub fn new(path: impl Into<PathBuf>) -> Self {
626        let retention = SpanRetention::Recent;
627        ClaudeTranscript {
628            main: Parser::new(path, retention),
629            subagents: BTreeMap::new(),
630            prices: pricing::table(),
631            retention,
632            summary: SessionSummary { harness: Some(Harness::Claude), folds_child_usage: true, ..Default::default() },
633        }
634    }
635
636    /// Price with this table instead of the process-wide one. Lets a test
637    /// assert a cost without the developer's own price file changing it.
638    pub fn with_prices(mut self, prices: &'static Table) -> Self {
639        self.prices = prices;
640        self
641    }
642
643    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
644    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
645        self.retention = retention;
646        self.main.summary.spans = retention.log();
647        for p in self.subagents.values_mut() {
648            p.summary.spans = retention.log();
649        }
650        self
651    }
652
653    pub fn set_registry_hints(&mut self, ps: &PidSession) {
654        let s = &mut self.main.summary;
655        s.session_id.get_or_insert_with(|| ps.session_id.clone());
656        s.cwd.get_or_insert_with(|| ps.cwd.clone());
657        if ps.version.is_some() {
658            s.harness_version = ps.version.clone();
659        }
660        if s.started_at.is_none() {
661            s.started_at = ps.started();
662        }
663        self.fold();
664    }
665
666    /// Pick up subagent transcripts that appeared since the last look. One
667    /// directory listing per refresh; the directory is small and usually
668    /// absent, so this is a single failed `open` for most sessions.
669    fn discover_subagents(&mut self) {
670        let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
671        let Ok(rd) = std::fs::read_dir(&dir) else { return };
672        for e in rd.flatten() {
673            let p = e.path();
674            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
675                continue;
676            }
677            let parser = Parser::new(&p, self.retention);
678            self.subagents.insert(p, parser);
679        }
680    }
681
682    fn fold(&mut self) {
683        let mut s = self.main.summary.clone();
684        for c in self.subagents.values() {
685            let t = &c.summary;
686            s.usage.add(&t.usage);
687            s.cost_usd += t.cost_usd;
688            s.cost_breakdown.add(&t.cost_breakdown);
689            s.unpriced_tokens += t.unpriced_tokens;
690            s.turns += t.turns;
691            s.subagent_turns += t.subagent_turns;
692            s.tool_calls += t.tool_calls;
693            s.web_searches += t.web_searches;
694            s.health.billable_messages += t.health.billable_messages;
695            s.health.usage_records += t.health.usage_records;
696            s.health.empty_usage_records += t.health.empty_usage_records;
697            s.last_activity = s.last_activity.max(t.last_activity);
698            for (server, u) in &t.mcp {
699                s.mcp.entry(server.clone()).or_default().add(u);
700            }
701            s.context.merge(&t.context);
702            // A subagent that wrote after the record ran after that exit.
703            if let Some(h) = &mut s.harness_cost
704                && t.last_activity > h.as_of
705                && t.health.usage_records > 0
706            {
707                h.current = false;
708            }
709        }
710        if !self.subagents.is_empty() {
711            let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
712            s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
713        }
714        self.summary = s;
715    }
716}
717
718fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
719    matches!((a, b), (Some(x), Some(y)) if x == y)
720}
721
722fn parse_usage(u: &Value) -> TokenUsage {
723    let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
724    let cache_write_total = g("cache_creation_input_tokens");
725    let (w1h, w5m) = match u.get("cache_creation") {
726        Some(cc) => (
727            cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
728            cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
729        ),
730        None => (0, 0),
731    };
732    // Older transcripts have only the total; treat it as 5-minute writes.
733    let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
734    TokenUsage {
735        input: g("input_tokens"),
736        cache_write_5m: w5m,
737        cache_write_1h: w1h,
738        // Claude Code records the TTL, so nothing is ever unsplit here.
739        cache_write_unsplit: 0,
740        cache_read: g("cache_read_input_tokens"),
741        output: g("output_tokens"),
742    }
743}
744
745impl SessionTracker for ClaudeTranscript {
746    fn refresh(&mut self) -> anyhow::Result<bool> {
747        let (mut ingested, mut more) = self.main.refresh(self.prices)?;
748        self.discover_subagents();
749        for c in self.subagents.values_mut() {
750            // One unreadable subagent file must not take the session with it.
751            if let Ok((n, m)) = c.refresh(self.prices) {
752                ingested += n;
753                more |= m;
754            }
755        }
756        if ingested > 0 || self.summary.session_id.is_none() {
757            self.fold();
758        }
759        Ok(more)
760    }
761
762    fn summary(&self) -> &SessionSummary {
763        &self.summary
764    }
765
766    fn path(&self) -> &Path {
767        self.main.reader.path()
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use std::io::Write;
775
776    #[test]
777    fn encodes_paths_like_claude_code() {
778        assert_eq!(
779            encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
780            "-Users-atlas-Documents-orbital-forge-agent-top"
781        );
782        assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
783    }
784
785    #[test]
786    fn dedupes_usage_by_message_id_and_tracks_state() {
787        let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
788        std::fs::create_dir_all(&dir).unwrap();
789        let path = dir.join("s.jsonl");
790        let mut f = std::fs::File::create(&path).unwrap();
791        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}}"#;
792        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
793        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();
794        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();
795        let mut t = ClaudeTranscript::new(&path);
796        t.refresh().unwrap();
797        let s = t.summary();
798        assert_eq!(s.turns, 1);
799        assert_eq!(s.tool_calls, 1);
800        assert_eq!(s.usage.total(), 1152);
801        assert_eq!(s.activity, Activity::Working);
802        assert_eq!(s.session_id.as_deref(), Some("abc"));
803        // sonnet-5: 2*2 + 100*4 + 1000*0.2 + 50*10 = 4 + 400 + 200 + 500 = 1104 micro-dollars
804        assert!((s.cost_usd - 0.001104).abs() < 1e-9);
805        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();
806        t.refresh().unwrap();
807        assert_eq!(t.summary().turns, 2);
808        assert_eq!(t.summary().activity, Activity::Waiting);
809        let _ = std::fs::remove_dir_all(&dir);
810    }
811
812    #[test]
813    fn keeps_claude_codes_own_cost_beside_ours_and_never_in_it() {
814        let dir = std::env::temp_dir().join(format!("agent-top-claude-cost-{}", std::process::id()));
815        std::fs::create_dir_all(&dir).unwrap();
816        let path = dir.join("s.jsonl");
817        let mut f = std::fs::File::create(&path).unwrap();
818        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-10-01T07:00:01.000Z","sessionId":"abc","message":{{"id":"msg_1","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1000000,"output_tokens":0}}}}}}"#).unwrap();
819        let mut t = ClaudeTranscript::new(&path);
820        t.refresh().unwrap();
821        assert_eq!(t.summary().harness_cost, None, "no record, no figure");
822
823        // Written twice in a row, with no timestamp of its own.
824        let state = r#"{"type":"cost-state","sessionId":"abc","totalCostUSD":2.5,"hasUnknownModelCost":false,"modelUsage":{}}"#;
825        writeln!(f, "{state}\n{state}").unwrap();
826        t.refresh().unwrap();
827        let s = t.summary();
828        let h = s.harness_cost.expect("read");
829        assert_eq!(h.usd, 2.5);
830        assert!(!h.lower_bound);
831        assert!(h.current, "nothing has run since it was written");
832        assert_eq!(h.as_of, parse_rfc3339_utc("2026-10-01T07:00:01.000Z"), "dated by the line before it");
833        assert!((s.cost_usd - 2.0).abs() < 1e-9, "our figure is still the transcript at list price");
834
835        // The newest record wins, and an unpriced model makes it a floor.
836        writeln!(f, r#"{{"type":"cost-state","totalCostUSD":3.75,"hasUnknownModelCost":true}}"#).unwrap();
837        t.refresh().unwrap();
838        let h = t.summary().harness_cost.unwrap();
839        assert_eq!((h.usd, h.lower_bound), (3.75, true));
840
841        // A subagent that finished before the record is inside it.
842        let subs = subagents_dir(&path).unwrap();
843        std::fs::create_dir_all(&subs).unwrap();
844        let reply = |ts: &str| {
845            format!(
846                r#"{{"type":"assistant","timestamp":"{ts}","isSidechain":true,"message":{{"id":"m_{ts}","model":"claude-sonnet-5","stop_reason":"end_turn","content":[],"usage":{{"input_tokens":1,"output_tokens":1}}}}}}"#
847            )
848        };
849        std::fs::write(subs.join("agent-a.jsonl"), reply("2026-10-01T06:59:00.000Z") + "\n").unwrap();
850        t.refresh().unwrap();
851        assert!(t.summary().harness_cost.unwrap().current);
852
853        // One that ran after it is not, and neither is the resumed session's next reply.
854        std::fs::write(subs.join("agent-b.jsonl"), reply("2026-10-01T09:00:00.000Z") + "\n").unwrap();
855        t.refresh().unwrap();
856        assert!(!t.summary().harness_cost.unwrap().current, "the subagent ran after the exit");
857        std::fs::remove_file(subs.join("agent-b.jsonl")).unwrap();
858        let mut t = ClaudeTranscript::new(&path);
859        t.refresh().unwrap();
860        assert!(t.summary().harness_cost.unwrap().current);
861        writeln!(f, "{}", reply("2026-10-01T09:30:00.000Z").replace(r#""isSidechain":true,"#, "")).unwrap();
862        t.refresh().unwrap();
863        let h = t.summary().harness_cost.unwrap();
864        assert!(!h.current, "a resumed session's usage since is not in it");
865        assert_eq!(h.usd, 3.75, "and the figure itself is unchanged until the next exit");
866        let _ = std::fs::remove_dir_all(&dir);
867    }
868
869    #[test]
870    fn counts_calls_per_mcp_server_and_their_errors() {
871        let dir = std::env::temp_dir().join(format!("agent-top-claude-mcp-{}", std::process::id()));
872        std::fs::create_dir_all(&dir).unwrap();
873        let path = dir.join("s.jsonl");
874        let mut f = std::fs::File::create(&path).unwrap();
875        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();
876        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();
877        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();
878        let mut t = ClaudeTranscript::new(&path);
879        t.refresh().unwrap();
880        let s = t.summary();
881        assert_eq!(s.tool_calls, 4, "MCP calls are tool calls too");
882        assert_eq!(s.mcp.len(), 2, "Bash is not a server");
883        let fs = &s.mcp["filesystem"];
884        assert_eq!((fs.calls, fs.errors), (2, 0));
885        assert_eq!(fs.last_call, parse_rfc3339_utc("2026-09-03T07:00:05.000Z"));
886        let cd = &s.mcp["chrome-devtools"];
887        assert_eq!((cd.calls, cd.errors), (1, 1), "the failed result is charged to its server, not to Bash's");
888        let _ = std::fs::remove_dir_all(&dir);
889    }
890
891    #[test]
892    fn files_context_growth_under_each_tool_and_resets_at_a_compaction() {
893        let dir = std::env::temp_dir().join(format!("agent-top-claude-context-{}", std::process::id()));
894        std::fs::create_dir_all(&dir).unwrap();
895        let path = dir.join("s.jsonl");
896        let mut f = std::fs::File::create(&path).unwrap();
897        let usage = |read: u64, out: u64| format!(r#"{{"cache_read_input_tokens":{read},"output_tokens":{out}}}"#);
898        // System prompt and ask: 10k, all "other".
899        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"go"}}}}"#).unwrap();
900        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":"Read"}},{{"type":"tool_use","id":"t2","name":"mcp__fs__read_text_file"}}],"usage":{}}}}}"#, usage(10_000, 100)).unwrap();
901        // The same message on a second line must not be a second response.
902        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:01.500Z","message":{{"id":"m1","model":"claude-sonnet-5","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(10_000, 100)).unwrap();
903        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"}}]}}}}"#).unwrap();
904        // 14_100 = 10_000 + the 100-token reply + 4_000 of results, 2_000 each.
905        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:00:03.000Z","message":{{"id":"m2","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(14_100, 50)).unwrap();
906        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
907        t.refresh().unwrap();
908        let by_name = |s: &SessionSummary| -> std::collections::HashMap<String, crate::model::ContextSource> {
909            s.context.sources().into_iter().map(|c| (c.name.clone(), c)).collect()
910        };
911        let c = by_name(t.summary());
912        assert_eq!(c["Read"].tokens, 2_000);
913        assert_eq!((c["fs"].tokens, c["fs"].origin, c["fs"].calls), (2_000, ContextOrigin::Mcp, 1));
914        assert_eq!(c["other"].tokens, 10_100);
915        // sonnet-5 cache read is $0.20/M: other read twice (10_000 + 10_100),
916        // each result once.
917        assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["Read"].cost_usd);
918        assert!((c["other"].cost_usd - 20_100.0 * 0.2 / 1e6).abs() < 1e-12, "{}", c["other"].cost_usd);
919        let prompt_cost = t.summary().cost_breakdown.cache_read;
920        let attributed: f64 = c.values().map(|x| x.cost_usd).sum();
921        assert!((attributed - prompt_cost).abs() < 1e-12, "sources sum to the prompt-side cost");
922
923        // Compaction: the boundary line, then a response whose whole prompt
924        // is the summary. Read and fs stop being charged.
925        writeln!(f, r#"{{"type":"system","subtype":"compact_boundary","timestamp":"2026-09-03T07:10:00.000Z","compactMetadata":{{"trigger":"auto","preTokens":14100,"postTokens":3000}}}}"#).unwrap();
926        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:10:01.000Z","isCompactSummary":true,"message":{{"role":"user","content":"summary"}}}}"#).unwrap();
927        writeln!(f, r#"{{"type":"assistant","timestamp":"2026-09-03T07:10:05.000Z","message":{{"id":"m3","model":"claude-sonnet-5","stop_reason":"end_turn","content":[{{"type":"text"}}],"usage":{}}}}}"#, usage(3_000, 10)).unwrap();
928        t.refresh().unwrap();
929        let c = by_name(t.summary());
930        assert_eq!(c["other"].tokens, 13_100);
931        assert!((c["Read"].cost_usd - 2_000.0 * 0.2 / 1e6).abs() < 1e-12, "not charged after the compaction");
932        let _ = std::fs::remove_dir_all(&dir);
933    }
934
935    #[test]
936    fn reconstructs_turns_inferences_and_web_searches() {
937        let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
938        std::fs::create_dir_all(&dir).unwrap();
939        let path = dir.join("s.jsonl");
940        let mut f = std::fs::File::create(&path).unwrap();
941        let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
942        // Prompt at :00; the reply streams as two lines (:02 thinking, :04 tool_use) of one message.
943        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
944            .unwrap();
945        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();
946        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();
947        // Tool result at :05; final reply at :09 ends the turn.
948        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
949        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();
950        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
951        t.refresh().unwrap();
952        let s = t.summary();
953        // Two searches on one message id, counted once despite two lines; fetches are free.
954        assert_eq!(s.web_searches, 2);
955        // sonnet-5: 100*2 + 10*10 = 300 micro-dollars, plus 2 searches at $10/1000, plus 1*2 + 1*10.
956        assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
957        let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
958        let turns = by_kind(SpanKind::Turn);
959        assert_eq!(turns.len(), 1);
960        assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
961        let inf = by_kind(SpanKind::Inference);
962        assert_eq!(inf.len(), 2);
963        assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
964        assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
965        assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
966        // Spans are in transcript order: turn, inference, tool, inference.
967        let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
968        assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
969        // A second prompt starts turn 2 and, since turn 1 already ended, leaves it alone.
970        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
971        t.refresh().unwrap();
972        let turns = by_kind_of(t.summary(), SpanKind::Turn);
973        assert_eq!(turns.len(), 2);
974        assert_eq!(turns[0].duration_ms, Some(9_000));
975        assert!(turns[1].is_open());
976        // The model starts a tool call at :01:05, the user interrupts, and the
977        // next prompt comes a day later. Turn 2 ends at the last activity,
978        // :01:05, not at the next prompt, and the reply-less inference opened
979        // by the interruption line is dropped rather than left open.
980        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();
981        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
982        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
983            .unwrap();
984        t.refresh().unwrap();
985        let turns = by_kind_of(t.summary(), SpanKind::Turn);
986        assert_eq!(turns.len(), 3);
987        assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
988        assert!(turns[2].is_open());
989        let inf = by_kind_of(t.summary(), SpanKind::Inference);
990        assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
991        assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
992        // Turn 3 ends at :00:02. Two hours later a slash command writes a meta
993        // user line and the model replies with end_turn. That reply is its own
994        // inference, and it must not stretch turn 3 or its inference.
995        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();
996        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
997        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();
998        t.refresh().unwrap();
999        let turns = by_kind_of(t.summary(), SpanKind::Turn);
1000        assert_eq!(turns.len(), 3, "a meta line is not a prompt");
1001        assert_eq!(turns[2].duration_ms, Some(2_000));
1002        let inf = by_kind_of(t.summary(), SpanKind::Inference);
1003        let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
1004        assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
1005        // Three days later the harness writes a synthetic notice parented to a
1006        // meta line. It is not the model and ends nothing.
1007        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
1008        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();
1009        t.refresh().unwrap();
1010        let inf = by_kind_of(t.summary(), SpanKind::Inference);
1011        assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
1012        assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
1013        let _ = std::fs::remove_dir_all(&dir);
1014    }
1015
1016    fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
1017        s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
1018    }
1019
1020    #[test]
1021    fn folds_subagent_transcripts_into_the_parent() {
1022        let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
1023        let _ = std::fs::remove_dir_all(&dir);
1024        std::fs::create_dir_all(&dir).unwrap();
1025        let path = dir.join("s.jsonl");
1026        let mut f = std::fs::File::create(&path).unwrap();
1027        // The parent spawns an Agent-tool call at 07:00:00, which is still running.
1028        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();
1029        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
1030        t.refresh().unwrap();
1031        assert_eq!(t.summary().usage.total(), 110);
1032        assert_eq!(t.summary().subagent_turns, 0);
1033        // sonnet-5: 100*2 + 10*10 = 300 micro-dollars
1034        assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
1035
1036        // A subagent transcript appears, on a different model, with its own tool call.
1037        let sub = subagents_dir(&path).unwrap();
1038        std::fs::create_dir_all(&sub).unwrap();
1039        let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
1040        std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
1041        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();
1042        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();
1043        t.refresh().unwrap();
1044        let s = t.summary();
1045        assert_eq!(s.usage.total(), 1210);
1046        assert_eq!(s.turns, 2);
1047        assert_eq!(s.subagent_turns, 1);
1048        assert!(s.folds_child_usage, "a child's usage is inside this summary, so the share is worth breaking out");
1049        assert_eq!(s.tool_calls, 2);
1050        // opus-5: 1000*5 + 100*25 = 7500 micro-dollars, on top of the parent's 300
1051        assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
1052        assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
1053        assert_eq!(s.session_id.as_deref(), Some("abc"));
1054        let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1055        assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
1056        let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
1057        assert_eq!(spans.len(), 2);
1058        assert_eq!(spans[0].name, "Agent");
1059        assert!(spans[0].is_open());
1060        assert_eq!(spans[1].name, "Grep");
1061        assert!(spans[1].sidechain);
1062        assert_eq!(spans[1].duration_ms, Some(2_000));
1063        // The subagent's prompt-less transcript still yields an inference span
1064        // (its tool result was submitted at 07:00:03 and nothing came back yet).
1065        let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1066        assert_eq!(inf.len(), 1);
1067        assert!(inf[0].sidechain);
1068        assert!(inf[0].is_open());
1069
1070        // The subagent keeps writing; only the new lines are read.
1071        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();
1072        t.refresh().unwrap();
1073        assert_eq!(t.summary().usage.total(), 1212);
1074        assert_eq!(t.summary().subagent_turns, 2);
1075        let _ = std::fs::remove_dir_all(&dir);
1076    }
1077
1078    #[test]
1079    fn builds_spans_from_tool_use_and_tool_result() {
1080        let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
1081        std::fs::create_dir_all(&dir).unwrap();
1082        let path = dir.join("s.jsonl");
1083        let mut f = std::fs::File::create(&path).unwrap();
1084        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();
1085        // Results arrive on one line, in the other order, one of them failed.
1086        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();
1087        // A subagent call that has not come back yet.
1088        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();
1089        let mut t = ClaudeTranscript::new(&path);
1090        t.refresh().unwrap();
1091        let all = t.summary().spans.to_vec();
1092        // The tool results at 07:00:02.5 started an inference that the
1093        // sidechain line at 07:00:03 did not end (it is a different file's
1094        // business in real life; here it shows the span is still open).
1095        let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
1096        assert_eq!(inf.len(), 1);
1097        assert_eq!(inf[0].name, "inference");
1098        assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
1099        let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
1100        assert_eq!(spans.len(), 3);
1101        assert_eq!(spans[0].name, "Bash");
1102        assert_eq!(spans[0].duration_ms, Some(2_500));
1103        assert!(!spans[0].error);
1104        assert_eq!(spans[1].name, "Read");
1105        assert_eq!(spans[1].duration_ms, Some(2_500));
1106        assert!(spans[1].error);
1107        assert!(spans[2].is_open());
1108        assert!(spans[2].sidechain);
1109        assert_eq!(t.summary().tool_calls, 3);
1110        let _ = std::fs::remove_dir_all(&dir);
1111    }
1112}