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
33use super::{REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanLog, SpanRetention, parse_rfc3339_utc};
34use crate::jsonl::TailReader;
35use crate::model::{Activity, CostBreakdown, Harness, SpanKind, TokenUsage};
36use crate::pricing::{self, Table};
37use serde::Deserialize;
38use serde_json::Value;
39use std::collections::BTreeMap;
40use std::path::{Path, PathBuf};
41use std::time::{Duration, SystemTime, UNIX_EPOCH};
42
43pub fn home() -> Option<PathBuf> {
44    std::env::var_os("HOME").map(PathBuf::from)
45}
46
47pub fn claude_dir() -> Option<PathBuf> {
48    if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
49        return Some(PathBuf::from(d));
50    }
51    home().map(|h| h.join(".claude"))
52}
53
54pub fn sessions_dir() -> Option<PathBuf> {
55    claude_dir().map(|d| d.join("sessions"))
56}
57
58pub fn projects_dir() -> Option<PathBuf> {
59    claude_dir().map(|d| d.join("projects"))
60}
61
62/// Claude Code's project directory name: every character that is not
63/// ASCII alphanumeric becomes `-`, so `/Users/a/x.y` is `-Users-a-x-y`.
64pub fn encode_project_path(p: &Path) -> String {
65    p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
66}
67
68pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
69    projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
70}
71
72/// One `~/.claude/sessions/<pid>.json`.
73#[derive(Debug, Clone, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct PidSession {
76    pub pid: u32,
77    pub session_id: String,
78    pub cwd: PathBuf,
79    #[serde(default)]
80    pub name: Option<String>,
81    #[serde(default)]
82    pub status: Option<String>,
83    #[serde(default)]
84    pub version: Option<String>,
85    #[serde(default)]
86    pub kind: Option<String>,
87    #[serde(default)]
88    pub entrypoint: Option<String>,
89    #[serde(default)]
90    pub started_at: Option<u64>,
91    #[serde(default)]
92    pub updated_at: Option<u64>,
93}
94
95impl PidSession {
96    pub fn started(&self) -> Option<SystemTime> {
97        self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
98    }
99}
100
101/// Read every registry file. Stale files for dead pids are returned too; the
102/// caller reconciles against the process table.
103pub fn read_pid_sessions() -> Vec<PidSession> {
104    let Some(dir) = sessions_dir() else { return Vec::new() };
105    let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
106    let mut out = Vec::new();
107    for e in rd.flatten() {
108        let p = e.path();
109        if p.extension().and_then(|x| x.to_str()) != Some("json") {
110            continue;
111        }
112        if let Ok(s) = std::fs::read_to_string(&p)
113            && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
114        {
115            out.push(ps);
116        }
117    }
118    out
119}
120
121/// Transcripts modified after `since`, across all projects.
122pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
123    let Some(dir) = projects_dir() else { return Vec::new() };
124    let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
125    let mut out = Vec::new();
126    for proj in projects.flatten() {
127        let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
128        for f in files.flatten() {
129            let p = f.path();
130            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
131                continue;
132            }
133            if let Ok(md) = f.metadata()
134                && md.modified().map(|m| m >= since).unwrap_or(false)
135            {
136                out.push(p);
137            }
138        }
139    }
140    out
141}
142
143/// Fallback attribution when the registry has no entry: the transcript in
144/// the cwd's project directory created closest after the process start.
145pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
146    let dir = projects_dir()?.join(encode_project_path(cwd));
147    let rd = std::fs::read_dir(&dir).ok()?;
148    let slack = Duration::from_secs(15);
149    let mut best: Option<(Duration, PathBuf)> = None;
150    for f in rd.flatten() {
151        let p = f.path();
152        if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
153            continue;
154        }
155        // A file we cannot stat is skipped, not fatal: one unreadable
156        // transcript must not abandon attribution for the whole directory.
157        let Ok(md) = f.metadata() else { continue };
158        let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
159        if created + slack < proc_start {
160            continue;
161        }
162        let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
163        if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
164            best = Some((gap, p));
165        }
166    }
167    best.map(|(_, p)| p)
168}
169
170/// Where Claude Code keeps a session's subagent transcripts: one
171/// `agent-<id>.jsonl` per Agent-tool call, next to an `agent-<id>.meta.json`
172/// naming the agent type and the `toolUseId` that spawned it.
173pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
174    let stem = transcript.file_stem()?;
175    Some(transcript.with_file_name(stem).join("subagents"))
176}
177
178/// One JSONL file being tailed into a `SessionSummary`: the main transcript,
179/// or one subagent's.
180struct Parser {
181    reader: TailReader,
182    summary: SessionSummary,
183    /// Dedupe state: the last API message id seen and what it contributed.
184    last_msg_id: Option<String>,
185    last_contrib: Contrib,
186    /// The inference and turn spans currently being extended, by id, and the
187    /// counters that name them.
188    inference: Option<String>,
189    turn: Option<String>,
190    inferences: u64,
191    turns: u64,
192    /// Timestamp of the previous line, so a turn abandoned mid-way can be
193    /// ended where activity actually stopped rather than at the next prompt,
194    /// which may be days later.
195    prev_ts: Option<SystemTime>,
196    /// The message ids that first ended the current inference and turn. Only
197    /// further blocks of that same message may move the end; a different
198    /// message is a different reply, and must not stretch a span that was
199    /// already over, which a reply to a slash command hours later would.
200    inference_ended_by: Option<String>,
201    turn_ended_by: Option<String>,
202}
203
204/// What one API message added to the summary, so the next line of the same
205/// message can replace it.
206#[derive(Debug, Clone, Copy, Default)]
207struct Contrib {
208    usage: TokenUsage,
209    cost: CostBreakdown,
210    unpriced: u64,
211    searches: u64,
212}
213
214impl Parser {
215    fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
216        Parser {
217            reader: TailReader::new(path),
218            summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
219            last_msg_id: None,
220            last_contrib: Contrib::default(),
221            inference: None,
222            turn: None,
223            inferences: 0,
224            turns: 0,
225            prev_ts: None,
226            inference_ended_by: None,
227            turn_ended_by: None,
228        }
229    }
230
231    /// Returns how many lines were ingested and whether more are waiting.
232    fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
233        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
234        for l in &lines {
235            self.ingest(l, prices);
236        }
237        Ok((lines.len(), more))
238    }
239
240    fn ingest(&mut self, line: &str, prices: &Table) {
241        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
242        let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
243        if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
244            if self.summary.started_at.is_none() {
245                self.summary.started_at = Some(ts);
246            }
247            self.summary.last_activity = Some(ts);
248        }
249        if self.summary.session_id.is_none() {
250            self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
251        }
252        if self.summary.cwd.is_none() {
253            self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
254        }
255        if self.summary.harness_version.is_none() {
256            self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
257        }
258        let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
259        let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
260        let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
261        match kind {
262            "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
263            "user" if !is_meta => {
264                // Either a prompt or a tool_result: in both cases the model owes a response.
265                self.summary.activity = Activity::Working;
266                if let Some(ts) = ts {
267                    let answered = self.close_spans(&v, ts);
268                    if !answered {
269                        self.begin_turn(ts, sidechain);
270                    }
271                    self.begin_inference(ts, sidechain);
272                }
273            }
274            // A meta line (a slash command's output, an injected caveat) is
275            // not a prompt and says nothing about state, but the model may
276            // reply to it, and that reply is an inference. If it never comes,
277            // the next submission drops the span.
278            "user" => {
279                if let Some(ts) = ts {
280                    self.begin_inference(ts, sidechain);
281                }
282            }
283            _ => {}
284        }
285        if ts.is_some() {
286            self.prev_ts = ts;
287        }
288    }
289
290    /// A human prompt starts a turn. A previous turn the model never ended
291    /// (the user interrupted it, or closed the session) is ended where the
292    /// last line before this prompt was written, not at the prompt itself.
293    fn begin_turn(&mut self, ts: SystemTime, sidechain: bool) {
294        if let Some(id) = self.turn.take()
295            && self.summary.spans.open_of_kind(SpanKind::Turn).is_some_and(|s| s.id == id)
296        {
297            let ended = self.prev_ts.unwrap_or(ts).min(ts);
298            self.summary.spans.end_at(&id, ended);
299        }
300        self.turns += 1;
301        let id = format!("turn:{}", self.turns);
302        self.summary.spans.open_kind(id.clone(), "turn".into(), ts, sidechain, SpanKind::Turn);
303        self.turn = Some(id);
304        self.turn_ended_by = None;
305    }
306
307    /// Anything submitted to the model starts an inference: the span grows
308    /// with each block of the reply and ends at the last one. A submission
309    /// that got no reply before the next one (a message queued mid-turn, an
310    /// interrupted request) was not an inference and is dropped.
311    fn begin_inference(&mut self, ts: SystemTime, sidechain: bool) {
312        if let Some(id) = self.inference.take() {
313            self.summary.spans.discard_open(&id);
314        }
315        self.inferences += 1;
316        let id = format!("inference:{}", self.inferences);
317        self.summary.spans.open_kind(id.clone(), "inference".into(), ts, sidechain, SpanKind::Inference);
318        self.inference = Some(id);
319        self.inference_ended_by = None;
320    }
321
322    /// Whether a block of message `id` may move the end of a span that
323    /// `ended_by` records as first ended by some message. The first ending
324    /// message claims the span; any other message is a different reply.
325    fn may_extend(ended_by: &mut Option<String>, id: Option<&str>) -> bool {
326        match (ended_by.as_deref(), id) {
327            (None, Some(id)) => {
328                *ended_by = Some(id.to_string());
329                true
330            }
331            (None, None) => true,
332            (Some(e), Some(id)) => e == id,
333            (Some(_), None) => false,
334        }
335    }
336
337    /// A user line answering tool calls: every `tool_result` block closes a
338    /// span. Returns whether the line answered any, which is what separates a
339    /// tool result from a fresh prompt.
340    fn close_spans(&mut self, v: &Value, ts: SystemTime) -> bool {
341        let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return false };
342        let mut answered = false;
343        for b in content {
344            if b.get("type").and_then(Value::as_str) != Some("tool_result") {
345                continue;
346            }
347            answered = true;
348            let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
349            self.summary.spans.close(id, ts, b.get("is_error").and_then(Value::as_bool).unwrap_or(false));
350        }
351        answered
352    }
353
354    fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
355        let Some(msg) = v.get("message") else { return };
356        let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
357        let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
358        if !model.is_empty() && model != "<synthetic>" {
359            self.summary.model = Some(model.to_string());
360        }
361        if let Some(content) = msg.get("content").and_then(Value::as_array) {
362            let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
363            for b in calls {
364                self.summary.tool_calls += 1;
365                if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
366                    let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
367                    self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
368                }
369            }
370        }
371        // Every block of the reply extends the inference to where it landed.
372        // A `<synthetic>` message is written by the harness, not the model
373        // (a resume notice, say, days after the last real line), so it ends
374        // nothing.
375        let synthetic = model == "<synthetic>";
376        if let (Some(ts), Some(span), false) = (ts, self.inference.as_deref(), synthetic) {
377            if Self::may_extend(&mut self.inference_ended_by, id.as_deref()) {
378                self.summary.spans.end_at(span, ts);
379            } else {
380                self.inference = None;
381            }
382        }
383        match msg.get("stop_reason").and_then(Value::as_str) {
384            Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
385                self.summary.activity = Activity::Waiting;
386                // The turn ends with the reply's last block; each block of the
387                // ending message moves the end, since all of them carry the
388                // stop reason.
389                if let (Some(ts), Some(span), false) = (ts, self.turn.as_deref(), synthetic) {
390                    if Self::may_extend(&mut self.turn_ended_by, id.as_deref()) {
391                        self.summary.spans.end_at(span, ts);
392                    } else {
393                        self.turn = None;
394                    }
395                }
396            }
397            _ => self.summary.activity = Activity::Working,
398        }
399
400        // Health is judged on the record being present but unreadable, which is
401        // what a renamed field looks like from in here.
402        if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
403            self.summary.health.billable_messages += 1;
404        }
405        let usage = match msg.get("usage") {
406            Some(u) => {
407                let parsed = parse_usage(u);
408                self.summary.health.usage_records += 1;
409                if parsed.total() == 0 {
410                    self.summary.health.empty_usage_records += 1;
411                }
412                parsed
413            }
414            None => TokenUsage::default(),
415        };
416        let price = prices.lookup(model);
417        // Web searches are billed per search on top of the tokens; the usage
418        // record carries the count. Web fetches are in the same record and free.
419        let searches = msg.pointer("/usage/server_tool_use/web_search_requests").and_then(Value::as_u64).unwrap_or(0);
420        let mut cost = price.map(|p| p.breakdown(&usage)).unwrap_or_default();
421        cost.web_search = prices.web_search_cost(searches);
422        let unpriced = if price.is_none() { usage.total() } else { 0 };
423
424        let same_message = id.is_some() && id == self.last_msg_id;
425        if same_message {
426            // Replace the previous contribution from this id with the latest one.
427            let c = self.last_contrib;
428            self.summary.usage.sub(&c.usage);
429            self.summary.cost_usd -= c.cost.total();
430            self.summary.cost_breakdown.sub(&c.cost);
431            self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(c.unpriced);
432            self.summary.web_searches = self.summary.web_searches.saturating_sub(c.searches);
433        } else {
434            self.summary.turns += 1;
435            if sidechain {
436                self.summary.subagent_turns += 1;
437            }
438        }
439        self.summary.usage.add(&usage);
440        self.summary.cost_usd += cost.total();
441        self.summary.cost_breakdown.add(&cost);
442        self.summary.unpriced_tokens += unpriced;
443        self.summary.web_searches += searches;
444        self.last_msg_id = id;
445        self.last_contrib = Contrib { usage, cost, unpriced, searches };
446    }
447}
448
449/// A Claude Code session: the main transcript plus every subagent transcript
450/// under its `subagents/` directory, folded into one summary.
451///
452/// Claude Code bills a subagent's API calls to the session that spawned it
453/// and shows them in its own cost display, but writes them to a separate
454/// file, so a session that used the Agent tool reads low if only the main
455/// transcript is counted. Each subagent file is tailed like the main one and
456/// its tokens, cost, turns, tool calls and spans are added to the parent's.
457/// A subagent may run a different model from its parent; each line is priced
458/// by the model it names, so that is handled without special casing.
459pub struct ClaudeTranscript {
460    main: Parser,
461    /// Keyed by path, so a directory listing adds each subagent once.
462    subagents: BTreeMap<PathBuf, Parser>,
463    prices: &'static Table,
464    retention: SpanRetention,
465    /// The fold of `main` and `subagents`, rebuilt whenever any of them read
466    /// a line. Cheap: a clone of the main summary and a merge of the span logs.
467    summary: SessionSummary,
468}
469
470impl ClaudeTranscript {
471    pub fn new(path: impl Into<PathBuf>) -> Self {
472        let retention = SpanRetention::Recent;
473        ClaudeTranscript {
474            main: Parser::new(path, retention),
475            subagents: BTreeMap::new(),
476            prices: pricing::table(),
477            retention,
478            summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
479        }
480    }
481
482    /// Price with this table instead of the process-wide one. Lets a test
483    /// assert a cost without the developer's own price file changing it.
484    pub fn with_prices(mut self, prices: &'static Table) -> Self {
485        self.prices = prices;
486        self
487    }
488
489    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
490    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
491        self.retention = retention;
492        self.main.summary.spans = retention.log();
493        for p in self.subagents.values_mut() {
494            p.summary.spans = retention.log();
495        }
496        self
497    }
498
499    pub fn set_registry_hints(&mut self, ps: &PidSession) {
500        let s = &mut self.main.summary;
501        s.session_id.get_or_insert_with(|| ps.session_id.clone());
502        s.cwd.get_or_insert_with(|| ps.cwd.clone());
503        if ps.version.is_some() {
504            s.harness_version = ps.version.clone();
505        }
506        if s.started_at.is_none() {
507            s.started_at = ps.started();
508        }
509        self.fold();
510    }
511
512    /// Pick up subagent transcripts that appeared since the last look. One
513    /// directory listing per refresh; the directory is small and usually
514    /// absent, so this is a single failed `open` for most sessions.
515    fn discover_subagents(&mut self) {
516        let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
517        let Ok(rd) = std::fs::read_dir(&dir) else { return };
518        for e in rd.flatten() {
519            let p = e.path();
520            if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
521                continue;
522            }
523            let parser = Parser::new(&p, self.retention);
524            self.subagents.insert(p, parser);
525        }
526    }
527
528    fn fold(&mut self) {
529        let mut s = self.main.summary.clone();
530        for c in self.subagents.values() {
531            let t = &c.summary;
532            s.usage.add(&t.usage);
533            s.cost_usd += t.cost_usd;
534            s.cost_breakdown.add(&t.cost_breakdown);
535            s.unpriced_tokens += t.unpriced_tokens;
536            s.turns += t.turns;
537            s.subagent_turns += t.subagent_turns;
538            s.tool_calls += t.tool_calls;
539            s.web_searches += t.web_searches;
540            s.health.billable_messages += t.health.billable_messages;
541            s.health.usage_records += t.health.usage_records;
542            s.health.empty_usage_records += t.health.empty_usage_records;
543            s.last_activity = s.last_activity.max(t.last_activity);
544        }
545        if !self.subagents.is_empty() {
546            let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
547            s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
548        }
549        self.summary = s;
550    }
551}
552
553fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
554    matches!((a, b), (Some(x), Some(y)) if x == y)
555}
556
557fn parse_usage(u: &Value) -> TokenUsage {
558    let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
559    let cache_write_total = g("cache_creation_input_tokens");
560    let (w1h, w5m) = match u.get("cache_creation") {
561        Some(cc) => (
562            cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
563            cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
564        ),
565        None => (0, 0),
566    };
567    // Older transcripts have only the total; treat it as 5-minute writes.
568    let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
569    TokenUsage {
570        input: g("input_tokens"),
571        cache_write_5m: w5m,
572        cache_write_1h: w1h,
573        cache_read: g("cache_read_input_tokens"),
574        output: g("output_tokens"),
575    }
576}
577
578impl SessionTracker for ClaudeTranscript {
579    fn refresh(&mut self) -> anyhow::Result<bool> {
580        let (mut ingested, mut more) = self.main.refresh(self.prices)?;
581        self.discover_subagents();
582        for c in self.subagents.values_mut() {
583            // One unreadable subagent file must not take the session with it.
584            if let Ok((n, m)) = c.refresh(self.prices) {
585                ingested += n;
586                more |= m;
587            }
588        }
589        if ingested > 0 || self.summary.session_id.is_none() {
590            self.fold();
591        }
592        Ok(more)
593    }
594
595    fn summary(&self) -> &SessionSummary {
596        &self.summary
597    }
598
599    fn path(&self) -> &Path {
600        self.main.reader.path()
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use std::io::Write;
608
609    #[test]
610    fn encodes_paths_like_claude_code() {
611        assert_eq!(
612            encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
613            "-Users-atlas-Documents-orbital-forge-agent-top"
614        );
615        assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
616    }
617
618    #[test]
619    fn dedupes_usage_by_message_id_and_tracks_state() {
620        let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
621        std::fs::create_dir_all(&dir).unwrap();
622        let path = dir.join("s.jsonl");
623        let mut f = std::fs::File::create(&path).unwrap();
624        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}}"#;
625        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
626        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();
627        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();
628        let mut t = ClaudeTranscript::new(&path);
629        t.refresh().unwrap();
630        let s = t.summary();
631        assert_eq!(s.turns, 1);
632        assert_eq!(s.tool_calls, 1);
633        assert_eq!(s.usage.total(), 1152);
634        assert_eq!(s.activity, Activity::Working);
635        assert_eq!(s.session_id.as_deref(), Some("abc"));
636        // sonnet-5: 2*2 + 100*4 + 1000*0.2 + 50*10 = 4 + 400 + 200 + 500 = 1104 micro-dollars
637        assert!((s.cost_usd - 0.001104).abs() < 1e-9);
638        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();
639        t.refresh().unwrap();
640        assert_eq!(t.summary().turns, 2);
641        assert_eq!(t.summary().activity, Activity::Waiting);
642        let _ = std::fs::remove_dir_all(&dir);
643    }
644
645    #[test]
646    fn reconstructs_turns_inferences_and_web_searches() {
647        let dir = std::env::temp_dir().join(format!("agent-top-claude-turns-{}", std::process::id()));
648        std::fs::create_dir_all(&dir).unwrap();
649        let path = dir.join("s.jsonl");
650        let mut f = std::fs::File::create(&path).unwrap();
651        let usage = r#"{"input_tokens":100,"output_tokens":10,"server_tool_use":{"web_search_requests":2,"web_fetch_requests":5}}"#;
652        // Prompt at :00; the reply streams as two lines (:02 thinking, :04 tool_use) of one message.
653        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","message":{{"role":"user","content":"look it up"}}}}"#)
654            .unwrap();
655        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();
656        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();
657        // Tool result at :05; final reply at :09 ends the turn.
658        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:05.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t1"}}]}}}}"#).unwrap();
659        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();
660        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
661        t.refresh().unwrap();
662        let s = t.summary();
663        // Two searches on one message id, counted once despite two lines; fetches are free.
664        assert_eq!(s.web_searches, 2);
665        // sonnet-5: 100*2 + 10*10 = 300 micro-dollars, plus 2 searches at $10/1000, plus 1*2 + 1*10.
666        assert!((s.cost_usd - (0.000300 + 0.02 + 0.000012)).abs() < 1e-9, "{}", s.cost_usd);
667        let by_kind = |k: SpanKind| s.spans.iter().filter(|sp| sp.kind == k).cloned().collect::<Vec<_>>();
668        let turns = by_kind(SpanKind::Turn);
669        assert_eq!(turns.len(), 1);
670        assert_eq!(turns[0].duration_ms, Some(9_000), "prompt at :00, reply ended at :09");
671        let inf = by_kind(SpanKind::Inference);
672        assert_eq!(inf.len(), 2);
673        assert_eq!(inf[0].duration_ms, Some(4_000), "prompt at :00, last block of the reply at :04");
674        assert_eq!(inf[1].duration_ms, Some(4_000), "tool result at :05, reply at :09");
675        assert_eq!(by_kind(SpanKind::Tool)[0].duration_ms, Some(1_000));
676        // Spans are in transcript order: turn, inference, tool, inference.
677        let kinds: Vec<_> = s.spans.iter().map(|sp| sp.kind).collect();
678        assert_eq!(kinds, vec![SpanKind::Turn, SpanKind::Inference, SpanKind::Tool, SpanKind::Inference]);
679        // A second prompt starts turn 2 and, since turn 1 already ended, leaves it alone.
680        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:00.000Z","message":{{"role":"user","content":"thanks"}}}}"#).unwrap();
681        t.refresh().unwrap();
682        let turns = by_kind_of(t.summary(), SpanKind::Turn);
683        assert_eq!(turns.len(), 2);
684        assert_eq!(turns[0].duration_ms, Some(9_000));
685        assert!(turns[1].is_open());
686        // The model starts a tool call at :01:05, the user interrupts, and the
687        // next prompt comes a day later. Turn 2 ends at the last activity,
688        // :01:05, not at the next prompt, and the reply-less inference opened
689        // by the interruption line is dropped rather than left open.
690        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();
691        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:01:06.000Z","message":{{"role":"user","content":[{{"type":"tool_result","tool_use_id":"t2"}}]}}}}"#).unwrap();
692        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T07:00:00.000Z","message":{{"role":"user","content":"next day"}}}}"#)
693            .unwrap();
694        t.refresh().unwrap();
695        let turns = by_kind_of(t.summary(), SpanKind::Turn);
696        assert_eq!(turns.len(), 3);
697        assert_eq!(turns[1].duration_ms, Some(6_000), "turn 2: :01:00 to the interrupted tool result at :01:06");
698        assert!(turns[2].is_open());
699        let inf = by_kind_of(t.summary(), SpanKind::Inference);
700        assert_eq!(inf.iter().filter(|s| s.is_open()).count(), 1, "only the newest inference is open");
701        assert_eq!(inf.last().unwrap().started_at, turns[2].started_at);
702        // Turn 3 ends at :00:02. Two hours later a slash command writes a meta
703        // user line and the model replies with end_turn. That reply is its own
704        // inference, and it must not stretch turn 3 or its inference.
705        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();
706        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:00:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
707        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();
708        t.refresh().unwrap();
709        let turns = by_kind_of(t.summary(), SpanKind::Turn);
710        assert_eq!(turns.len(), 3, "a meta line is not a prompt");
711        assert_eq!(turns[2].duration_ms, Some(2_000));
712        let inf = by_kind_of(t.summary(), SpanKind::Inference);
713        let last_two: Vec<_> = inf.iter().rev().take(2).map(|s| s.duration_ms).collect();
714        assert_eq!(last_two, vec![Some(3_000), Some(2_000)], "the command's reply is its own 3 s inference");
715        // Three days later the harness writes a synthetic notice parented to a
716        // meta line. It is not the model and ends nothing.
717        writeln!(f, r#"{{"type":"user","timestamp":"2026-09-04T09:30:00.000Z","isMeta":true,"message":{{"role":"user","content":"<local-command-stdout>"}}}}"#).unwrap();
718        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();
719        t.refresh().unwrap();
720        let inf = by_kind_of(t.summary(), SpanKind::Inference);
721        assert!(inf.last().unwrap().is_open(), "the meta line's inference has no real reply yet");
722        assert_eq!(by_kind_of(t.summary(), SpanKind::Turn)[2].duration_ms, Some(2_000));
723        let _ = std::fs::remove_dir_all(&dir);
724    }
725
726    fn by_kind_of(s: &SessionSummary, k: SpanKind) -> Vec<crate::model::ToolSpan> {
727        s.spans.iter().filter(|sp| sp.kind == k).cloned().collect()
728    }
729
730    #[test]
731    fn folds_subagent_transcripts_into_the_parent() {
732        let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
733        let _ = std::fs::remove_dir_all(&dir);
734        std::fs::create_dir_all(&dir).unwrap();
735        let path = dir.join("s.jsonl");
736        let mut f = std::fs::File::create(&path).unwrap();
737        // The parent spawns an Agent-tool call at 07:00:00, which is still running.
738        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();
739        let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
740        t.refresh().unwrap();
741        assert_eq!(t.summary().usage.total(), 110);
742        assert_eq!(t.summary().subagent_turns, 0);
743        // sonnet-5: 100*2 + 10*10 = 300 micro-dollars
744        assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
745
746        // A subagent transcript appears, on a different model, with its own tool call.
747        let sub = subagents_dir(&path).unwrap();
748        std::fs::create_dir_all(&sub).unwrap();
749        let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
750        std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
751        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();
752        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();
753        t.refresh().unwrap();
754        let s = t.summary();
755        assert_eq!(s.usage.total(), 1210);
756        assert_eq!(s.turns, 2);
757        assert_eq!(s.subagent_turns, 1);
758        assert_eq!(s.tool_calls, 2);
759        // opus-5: 1000*5 + 100*25 = 7500 micro-dollars, on top of the parent's 300
760        assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
761        assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
762        assert_eq!(s.session_id.as_deref(), Some("abc"));
763        let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
764        assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
765        let spans: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
766        assert_eq!(spans.len(), 2);
767        assert_eq!(spans[0].name, "Agent");
768        assert!(spans[0].is_open());
769        assert_eq!(spans[1].name, "Grep");
770        assert!(spans[1].sidechain);
771        assert_eq!(spans[1].duration_ms, Some(2_000));
772        // The subagent's prompt-less transcript still yields an inference span
773        // (its tool result was submitted at 07:00:03 and nothing came back yet).
774        let inf: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
775        assert_eq!(inf.len(), 1);
776        assert!(inf[0].sidechain);
777        assert!(inf[0].is_open());
778
779        // The subagent keeps writing; only the new lines are read.
780        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();
781        t.refresh().unwrap();
782        assert_eq!(t.summary().usage.total(), 1212);
783        assert_eq!(t.summary().subagent_turns, 2);
784        let _ = std::fs::remove_dir_all(&dir);
785    }
786
787    #[test]
788    fn builds_spans_from_tool_use_and_tool_result() {
789        let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
790        std::fs::create_dir_all(&dir).unwrap();
791        let path = dir.join("s.jsonl");
792        let mut f = std::fs::File::create(&path).unwrap();
793        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();
794        // Results arrive on one line, in the other order, one of them failed.
795        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();
796        // A subagent call that has not come back yet.
797        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();
798        let mut t = ClaudeTranscript::new(&path);
799        t.refresh().unwrap();
800        let all = t.summary().spans.to_vec();
801        // The tool results at 07:00:02.5 started an inference that the
802        // sidechain line at 07:00:03 did not end (it is a different file's
803        // business in real life; here it shows the span is still open).
804        let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
805        assert_eq!(inf.len(), 1);
806        assert_eq!(inf[0].name, "inference");
807        assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no prompt line, so no turn");
808        let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).cloned().collect();
809        assert_eq!(spans.len(), 3);
810        assert_eq!(spans[0].name, "Bash");
811        assert_eq!(spans[0].duration_ms, Some(2_500));
812        assert!(!spans[0].error);
813        assert_eq!(spans[1].name, "Read");
814        assert_eq!(spans[1].duration_ms, Some(2_500));
815        assert!(spans[1].error);
816        assert!(spans[2].is_open());
817        assert!(spans[2].sidechain);
818        assert_eq!(t.summary().tool_calls, 3);
819        let _ = std::fs::remove_dir_all(&dir);
820    }
821}