1use super::{REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanLog, SpanRetention, parse_rfc3339_utc};
26use crate::jsonl::TailReader;
27use crate::model::{Activity, Harness, TokenUsage};
28use crate::pricing::{self, Table};
29use serde::Deserialize;
30use serde_json::Value;
31use std::collections::BTreeMap;
32use std::path::{Path, PathBuf};
33use std::time::{Duration, SystemTime, UNIX_EPOCH};
34
35pub fn home() -> Option<PathBuf> {
36 std::env::var_os("HOME").map(PathBuf::from)
37}
38
39pub fn claude_dir() -> Option<PathBuf> {
40 if let Some(d) = std::env::var_os("CLAUDE_CONFIG_DIR") {
41 return Some(PathBuf::from(d));
42 }
43 home().map(|h| h.join(".claude"))
44}
45
46pub fn sessions_dir() -> Option<PathBuf> {
47 claude_dir().map(|d| d.join("sessions"))
48}
49
50pub fn projects_dir() -> Option<PathBuf> {
51 claude_dir().map(|d| d.join("projects"))
52}
53
54pub fn encode_project_path(p: &Path) -> String {
57 p.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect()
58}
59
60pub fn transcript_path(cwd: &Path, session_id: &str) -> Option<PathBuf> {
61 projects_dir().map(|d| d.join(encode_project_path(cwd)).join(format!("{session_id}.jsonl")))
62}
63
64#[derive(Debug, Clone, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PidSession {
68 pub pid: u32,
69 pub session_id: String,
70 pub cwd: PathBuf,
71 #[serde(default)]
72 pub name: Option<String>,
73 #[serde(default)]
74 pub status: Option<String>,
75 #[serde(default)]
76 pub version: Option<String>,
77 #[serde(default)]
78 pub kind: Option<String>,
79 #[serde(default)]
80 pub entrypoint: Option<String>,
81 #[serde(default)]
82 pub started_at: Option<u64>,
83 #[serde(default)]
84 pub updated_at: Option<u64>,
85}
86
87impl PidSession {
88 pub fn started(&self) -> Option<SystemTime> {
89 self.started_at.map(|ms| UNIX_EPOCH + Duration::from_millis(ms))
90 }
91}
92
93pub fn read_pid_sessions() -> Vec<PidSession> {
96 let Some(dir) = sessions_dir() else { return Vec::new() };
97 let Ok(rd) = std::fs::read_dir(&dir) else { return Vec::new() };
98 let mut out = Vec::new();
99 for e in rd.flatten() {
100 let p = e.path();
101 if p.extension().and_then(|x| x.to_str()) != Some("json") {
102 continue;
103 }
104 if let Ok(s) = std::fs::read_to_string(&p)
105 && let Ok(ps) = serde_json::from_str::<PidSession>(&s)
106 {
107 out.push(ps);
108 }
109 }
110 out
111}
112
113pub fn recent_transcripts(since: SystemTime) -> Vec<PathBuf> {
115 let Some(dir) = projects_dir() else { return Vec::new() };
116 let Ok(projects) = std::fs::read_dir(&dir) else { return Vec::new() };
117 let mut out = Vec::new();
118 for proj in projects.flatten() {
119 let Ok(files) = std::fs::read_dir(proj.path()) else { continue };
120 for f in files.flatten() {
121 let p = f.path();
122 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
123 continue;
124 }
125 if let Ok(md) = f.metadata()
126 && md.modified().map(|m| m >= since).unwrap_or(false)
127 {
128 out.push(p);
129 }
130 }
131 }
132 out
133}
134
135pub fn guess_transcript(cwd: &Path, proc_start: SystemTime) -> Option<PathBuf> {
138 let dir = projects_dir()?.join(encode_project_path(cwd));
139 let rd = std::fs::read_dir(&dir).ok()?;
140 let slack = Duration::from_secs(15);
141 let mut best: Option<(Duration, PathBuf)> = None;
142 for f in rd.flatten() {
143 let p = f.path();
144 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
145 continue;
146 }
147 let Ok(md) = f.metadata() else { continue };
150 let Ok(created) = md.created().or_else(|_| md.modified()) else { continue };
151 if created + slack < proc_start {
152 continue;
153 }
154 let gap = created.duration_since(proc_start).unwrap_or(Duration::ZERO);
155 if best.as_ref().map(|(g, _)| gap < *g).unwrap_or(true) {
156 best = Some((gap, p));
157 }
158 }
159 best.map(|(_, p)| p)
160}
161
162pub fn subagents_dir(transcript: &Path) -> Option<PathBuf> {
166 let stem = transcript.file_stem()?;
167 Some(transcript.with_file_name(stem).join("subagents"))
168}
169
170struct Parser {
173 reader: TailReader,
174 summary: SessionSummary,
175 last_msg_id: Option<String>,
177 last_contrib: (TokenUsage, f64, u64),
178}
179
180impl Parser {
181 fn new(path: impl Into<PathBuf>, spans: SpanRetention) -> Self {
182 Parser {
183 reader: TailReader::new(path),
184 summary: SessionSummary { harness: Some(Harness::Claude), spans: spans.log(), ..Default::default() },
185 last_msg_id: None,
186 last_contrib: (TokenUsage::default(), 0.0, 0),
187 }
188 }
189
190 fn refresh(&mut self, prices: &Table) -> anyhow::Result<(usize, bool)> {
192 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
193 for l in &lines {
194 self.ingest(l, prices);
195 }
196 Ok((lines.len(), more))
197 }
198
199 fn ingest(&mut self, line: &str, prices: &Table) {
200 let Ok(v) = serde_json::from_str::<Value>(line) else { return };
201 let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
202 if let Some(ts) = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc) {
203 if self.summary.started_at.is_none() {
204 self.summary.started_at = Some(ts);
205 }
206 self.summary.last_activity = Some(ts);
207 }
208 if self.summary.session_id.is_none() {
209 self.summary.session_id = v.get("sessionId").and_then(Value::as_str).map(str::to_string);
210 }
211 if self.summary.cwd.is_none() {
212 self.summary.cwd = v.get("cwd").and_then(Value::as_str).map(PathBuf::from);
213 }
214 if self.summary.harness_version.is_none() {
215 self.summary.harness_version = v.get("version").and_then(Value::as_str).map(str::to_string);
216 }
217 let sidechain = v.get("isSidechain").and_then(Value::as_bool).unwrap_or(false);
218 let is_meta = v.get("isMeta").and_then(Value::as_bool).unwrap_or(false);
219 let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
220 match kind {
221 "assistant" => self.ingest_assistant(&v, sidechain, ts, prices),
222 "user" if !is_meta => {
223 self.summary.activity = Activity::Working;
225 if let Some(ts) = ts {
226 self.close_spans(&v, ts);
227 }
228 }
229 _ => {}
230 }
231 }
232
233 fn close_spans(&mut self, v: &Value, ts: SystemTime) {
235 let Some(content) = v.pointer("/message/content").and_then(Value::as_array) else { return };
236 for b in content {
237 if b.get("type").and_then(Value::as_str) != Some("tool_result") {
238 continue;
239 }
240 let Some(id) = b.get("tool_use_id").and_then(Value::as_str) else { continue };
241 self.summary.spans.close(id, ts, b.get("is_error").and_then(Value::as_bool).unwrap_or(false));
242 }
243 }
244
245 fn ingest_assistant(&mut self, v: &Value, sidechain: bool, ts: Option<SystemTime>, prices: &Table) {
246 let Some(msg) = v.get("message") else { return };
247 let id = msg.get("id").and_then(Value::as_str).map(str::to_string);
248 let model = msg.get("model").and_then(Value::as_str).unwrap_or("");
249 if !model.is_empty() && model != "<synthetic>" {
250 self.summary.model = Some(model.to_string());
251 }
252 if let Some(content) = msg.get("content").and_then(Value::as_array) {
253 let calls = content.iter().filter(|b| b.get("type").and_then(Value::as_str) == Some("tool_use"));
254 for b in calls {
255 self.summary.tool_calls += 1;
256 if let (Some(ts), Some(id)) = (ts, b.get("id").and_then(Value::as_str)) {
257 let name = b.get("name").and_then(Value::as_str).unwrap_or("tool");
258 self.summary.spans.open(id.to_string(), name.to_string(), ts, sidechain);
259 }
260 }
261 }
262 match msg.get("stop_reason").and_then(Value::as_str) {
263 Some("end_turn") | Some("stop_sequence") | Some("max_tokens") | Some("refusal") => {
264 self.summary.activity = Activity::Waiting;
265 }
266 _ => self.summary.activity = Activity::Working,
267 }
268
269 if !same_message_id(id.as_deref(), self.last_msg_id.as_deref()) {
272 self.summary.health.billable_messages += 1;
273 }
274 let usage = match msg.get("usage") {
275 Some(u) => {
276 let parsed = parse_usage(u);
277 self.summary.health.usage_records += 1;
278 if parsed.total() == 0 {
279 self.summary.health.empty_usage_records += 1;
280 }
281 parsed
282 }
283 None => TokenUsage::default(),
284 };
285 let price = prices.lookup(model);
286 let cost = price.map(|p| p.cost(&usage)).unwrap_or(0.0);
287 let unpriced = if price.is_none() { usage.total() } else { 0 };
288
289 let same_message = id.is_some() && id == self.last_msg_id;
290 if same_message {
291 let (u, c, un) = self.last_contrib;
293 self.summary.usage.sub(&u);
294 self.summary.cost_usd -= c;
295 self.summary.unpriced_tokens = self.summary.unpriced_tokens.saturating_sub(un);
296 } else {
297 self.summary.turns += 1;
298 if sidechain {
299 self.summary.subagent_turns += 1;
300 }
301 }
302 self.summary.usage.add(&usage);
303 self.summary.cost_usd += cost;
304 self.summary.unpriced_tokens += unpriced;
305 self.last_msg_id = id;
306 self.last_contrib = (usage, cost, unpriced);
307 }
308}
309
310pub struct ClaudeTranscript {
321 main: Parser,
322 subagents: BTreeMap<PathBuf, Parser>,
324 prices: &'static Table,
325 retention: SpanRetention,
326 summary: SessionSummary,
329}
330
331impl ClaudeTranscript {
332 pub fn new(path: impl Into<PathBuf>) -> Self {
333 let retention = SpanRetention::Recent;
334 ClaudeTranscript {
335 main: Parser::new(path, retention),
336 subagents: BTreeMap::new(),
337 prices: pricing::table(),
338 retention,
339 summary: SessionSummary { harness: Some(Harness::Claude), ..Default::default() },
340 }
341 }
342
343 pub fn with_prices(mut self, prices: &'static Table) -> Self {
346 self.prices = prices;
347 self
348 }
349
350 pub fn with_spans(mut self, retention: SpanRetention) -> Self {
352 self.retention = retention;
353 self.main.summary.spans = retention.log();
354 for p in self.subagents.values_mut() {
355 p.summary.spans = retention.log();
356 }
357 self
358 }
359
360 pub fn set_registry_hints(&mut self, ps: &PidSession) {
361 let s = &mut self.main.summary;
362 s.session_id.get_or_insert_with(|| ps.session_id.clone());
363 s.cwd.get_or_insert_with(|| ps.cwd.clone());
364 if ps.version.is_some() {
365 s.harness_version = ps.version.clone();
366 }
367 if s.started_at.is_none() {
368 s.started_at = ps.started();
369 }
370 self.fold();
371 }
372
373 fn discover_subagents(&mut self) {
377 let Some(dir) = subagents_dir(self.main.reader.path()) else { return };
378 let Ok(rd) = std::fs::read_dir(&dir) else { return };
379 for e in rd.flatten() {
380 let p = e.path();
381 if p.extension().and_then(|x| x.to_str()) != Some("jsonl") || self.subagents.contains_key(&p) {
382 continue;
383 }
384 let parser = Parser::new(&p, self.retention);
385 self.subagents.insert(p, parser);
386 }
387 }
388
389 fn fold(&mut self) {
390 let mut s = self.main.summary.clone();
391 for c in self.subagents.values() {
392 let t = &c.summary;
393 s.usage.add(&t.usage);
394 s.cost_usd += t.cost_usd;
395 s.unpriced_tokens += t.unpriced_tokens;
396 s.turns += t.turns;
397 s.subagent_turns += t.subagent_turns;
398 s.tool_calls += t.tool_calls;
399 s.health.billable_messages += t.health.billable_messages;
400 s.health.usage_records += t.health.usage_records;
401 s.health.empty_usage_records += t.health.empty_usage_records;
402 s.last_activity = s.last_activity.max(t.last_activity);
403 }
404 if !self.subagents.is_empty() {
405 let logs = std::iter::once(&self.main.summary.spans).chain(self.subagents.values().map(|c| &c.summary.spans));
406 s.spans = SpanLog::merged(logs, self.main.summary.spans.cap());
407 }
408 self.summary = s;
409 }
410}
411
412fn same_message_id(a: Option<&str>, b: Option<&str>) -> bool {
413 matches!((a, b), (Some(x), Some(y)) if x == y)
414}
415
416fn parse_usage(u: &Value) -> TokenUsage {
417 let g = |k: &str| u.get(k).and_then(Value::as_u64).unwrap_or(0);
418 let cache_write_total = g("cache_creation_input_tokens");
419 let (w1h, w5m) = match u.get("cache_creation") {
420 Some(cc) => (
421 cc.get("ephemeral_1h_input_tokens").and_then(Value::as_u64).unwrap_or(0),
422 cc.get("ephemeral_5m_input_tokens").and_then(Value::as_u64).unwrap_or(0),
423 ),
424 None => (0, 0),
425 };
426 let (w1h, w5m) = if w1h + w5m == 0 { (0, cache_write_total) } else { (w1h, w5m) };
428 TokenUsage {
429 input: g("input_tokens"),
430 cache_write_5m: w5m,
431 cache_write_1h: w1h,
432 cache_read: g("cache_read_input_tokens"),
433 output: g("output_tokens"),
434 }
435}
436
437impl SessionTracker for ClaudeTranscript {
438 fn refresh(&mut self) -> anyhow::Result<bool> {
439 let (mut ingested, mut more) = self.main.refresh(self.prices)?;
440 self.discover_subagents();
441 for c in self.subagents.values_mut() {
442 if let Ok((n, m)) = c.refresh(self.prices) {
444 ingested += n;
445 more |= m;
446 }
447 }
448 if ingested > 0 || self.summary.session_id.is_none() {
449 self.fold();
450 }
451 Ok(more)
452 }
453
454 fn summary(&self) -> &SessionSummary {
455 &self.summary
456 }
457
458 fn path(&self) -> &Path {
459 self.main.reader.path()
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use std::io::Write;
467
468 #[test]
469 fn encodes_paths_like_claude_code() {
470 assert_eq!(
471 encode_project_path(Path::new("/Users/atlas/Documents/orbital/forge/agent-top")),
472 "-Users-atlas-Documents-orbital-forge-agent-top"
473 );
474 assert_eq!(encode_project_path(Path::new("/tmp/a.b_c")), "-tmp-a-b-c");
475 }
476
477 #[test]
478 fn dedupes_usage_by_message_id_and_tracks_state() {
479 let dir = std::env::temp_dir().join(format!("agent-top-claude-{}", std::process::id()));
480 std::fs::create_dir_all(&dir).unwrap();
481 let path = dir.join("s.jsonl");
482 let mut f = std::fs::File::create(&path).unwrap();
483 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}}"#;
484 writeln!(f, r#"{{"type":"user","timestamp":"2026-09-03T07:00:00.000Z","sessionId":"abc","cwd":"/tmp/p","message":{{"role":"user","content":"hi"}}}}"#).unwrap();
485 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();
486 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();
487 let mut t = ClaudeTranscript::new(&path);
488 t.refresh().unwrap();
489 let s = t.summary();
490 assert_eq!(s.turns, 1);
491 assert_eq!(s.tool_calls, 1);
492 assert_eq!(s.usage.total(), 1152);
493 assert_eq!(s.activity, Activity::Working);
494 assert_eq!(s.session_id.as_deref(), Some("abc"));
495 assert!((s.cost_usd - 0.001104).abs() < 1e-9);
497 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();
498 t.refresh().unwrap();
499 assert_eq!(t.summary().turns, 2);
500 assert_eq!(t.summary().activity, Activity::Waiting);
501 let _ = std::fs::remove_dir_all(&dir);
502 }
503
504 #[test]
505 fn folds_subagent_transcripts_into_the_parent() {
506 let dir = std::env::temp_dir().join(format!("agent-top-claude-sub-{}", std::process::id()));
507 let _ = std::fs::remove_dir_all(&dir);
508 std::fs::create_dir_all(&dir).unwrap();
509 let path = dir.join("s.jsonl");
510 let mut f = std::fs::File::create(&path).unwrap();
511 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();
513 let mut t = ClaudeTranscript::new(&path).with_prices(pricing::builtin_table());
514 t.refresh().unwrap();
515 assert_eq!(t.summary().usage.total(), 110);
516 assert_eq!(t.summary().subagent_turns, 0);
517 assert!((t.summary().cost_usd - 0.000300).abs() < 1e-9);
519
520 let sub = subagents_dir(&path).unwrap();
522 std::fs::create_dir_all(&sub).unwrap();
523 let mut g = std::fs::File::create(sub.join("agent-a1.jsonl")).unwrap();
524 std::fs::write(sub.join("agent-a1.meta.json"), r#"{"agentType":"Explore"}"#).unwrap();
525 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();
526 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();
527 t.refresh().unwrap();
528 let s = t.summary();
529 assert_eq!(s.usage.total(), 1210);
530 assert_eq!(s.turns, 2);
531 assert_eq!(s.subagent_turns, 1);
532 assert_eq!(s.tool_calls, 2);
533 assert!((s.cost_usd - 0.007800).abs() < 1e-9, "{}", s.cost_usd);
535 assert_eq!(s.model.as_deref(), Some("claude-sonnet-5"), "the row's model is the parent's");
536 assert_eq!(s.session_id.as_deref(), Some("abc"));
537 let last = s.last_activity.unwrap().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
538 assert_eq!(last % 60, 3, "last activity is the subagent's, which wrote most recently");
539 let spans = s.spans.to_vec();
540 assert_eq!(spans.len(), 2);
541 assert_eq!(spans[0].name, "Agent");
542 assert!(spans[0].is_open());
543 assert_eq!(spans[1].name, "Grep");
544 assert!(spans[1].sidechain);
545 assert_eq!(spans[1].duration_ms, Some(2_000));
546
547 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();
549 t.refresh().unwrap();
550 assert_eq!(t.summary().usage.total(), 1212);
551 assert_eq!(t.summary().subagent_turns, 2);
552 let _ = std::fs::remove_dir_all(&dir);
553 }
554
555 #[test]
556 fn builds_spans_from_tool_use_and_tool_result() {
557 let dir = std::env::temp_dir().join(format!("agent-top-claude-spans-{}", std::process::id()));
558 std::fs::create_dir_all(&dir).unwrap();
559 let path = dir.join("s.jsonl");
560 let mut f = std::fs::File::create(&path).unwrap();
561 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();
562 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();
564 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();
566 let mut t = ClaudeTranscript::new(&path);
567 t.refresh().unwrap();
568 let spans = t.summary().spans.to_vec();
569 assert_eq!(spans.len(), 3);
570 assert_eq!(spans[0].name, "Bash");
571 assert_eq!(spans[0].duration_ms, Some(2_500));
572 assert!(!spans[0].error);
573 assert_eq!(spans[1].name, "Read");
574 assert_eq!(spans[1].duration_ms, Some(2_500));
575 assert!(spans[1].error);
576 assert!(spans[2].is_open());
577 assert!(spans[2].sidechain);
578 assert_eq!(t.summary().tool_calls, 3);
579 let _ = std::fs::remove_dir_all(&dir);
580 }
581}