1use std::collections::BTreeMap;
42use std::fs::File;
43use std::io::{BufRead, BufReader};
44use std::path::{Path, PathBuf};
45
46use serde::Deserialize;
47
48use crate::adapters::Adapter;
49use crate::error::CoreError;
50use crate::model::{Event, EventKind, Session, SessionRef, SubAgent, ToolCall, Usage};
51
52pub struct ClaudeCode;
54
55fn claude_dir() -> Option<PathBuf> {
59 match std::env::var("CLAUDE_CONFIG_DIR") {
60 Ok(dir) if !dir.trim().is_empty() => Some(PathBuf::from(dir)),
61 _ => directories::BaseDirs::new().map(|b| b.home_dir().join(".claude")),
62 }
63}
64
65impl Adapter for ClaudeCode {
66 fn id(&self) -> &'static str {
67 "claude-code"
68 }
69
70 fn detect(&self) -> bool {
71 claude_dir().is_some_and(|d| d.join("projects").is_dir())
72 }
73
74 fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
75 let root = claude_dir()
76 .ok_or_else(|| anyhow::anyhow!("could not determine the home directory"))?
77 .join("projects");
78 let mut refs = Vec::new();
79 for entry in walkdir::WalkDir::new(&root).follow_links(false) {
80 let entry = match entry {
81 Ok(e) => e,
82 Err(e) => {
83 tracing::debug!("skipping unreadable directory entry: {e}");
84 continue;
85 }
86 };
87 if !entry.file_type().is_file()
88 || entry.path().extension().and_then(|x| x.to_str()) != Some("jsonl")
89 {
90 continue;
91 }
92 let meta = entry.metadata().ok();
93 refs.push(SessionRef {
94 path: entry.path().to_path_buf(),
95 agent: self.id().to_string(),
96 project: project_label(entry.path(), &root),
97 size_bytes: meta.as_ref().map_or(0, |m| m.len()),
98 modified: meta
99 .and_then(|m| m.modified().ok())
100 .and_then(|t| jiff::Timestamp::try_from(t).ok()),
101 });
102 }
103 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
104 Ok(refs)
105 }
106
107 fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
108 let file = File::open(&r.path).map_err(|source| CoreError::Io {
109 path: r.path.clone(),
110 source,
111 })?;
112
113 let mut session = Session {
114 id: session_id_for(&r.path),
115 agent: self.id().to_string(),
116 project: r.project.clone(),
117 model: None,
118 parent_session: parent_session_for(&r.path),
119 started_at: None,
120 ended_at: None,
121 events: Vec::new(),
122 sub_agents: Vec::new(),
123 skipped_lines: 0,
124 };
125 let mut model_counts: BTreeMap<String, u64> = BTreeMap::new();
127
128 for (idx, line) in BufReader::new(file).lines().enumerate() {
129 let line = match line {
130 Ok(l) => l,
131 Err(e) => {
132 session.skipped_lines += 1;
133 tracing::debug!("{}:{}: unreadable line: {e}", r.path.display(), idx + 1);
134 continue;
135 }
136 };
137 if line.trim().is_empty() {
138 continue;
139 }
140 let raw: RawLine = match serde_json::from_str(&line) {
141 Ok(v) => v,
142 Err(e) => {
143 session.skipped_lines += 1;
144 tracing::debug!("{}:{}: unparseable JSON: {e}", r.path.display(), idx + 1);
145 continue;
146 }
147 };
148 match raw.kind.as_deref() {
149 Some("assistant") => push_assistant(raw, &mut session, &mut model_counts),
150 Some("user") => push_user(raw, &mut session),
151 Some("system") => push_system(raw, &mut session),
152 Some("attachment") => push_attachment(raw, &mut session),
156 Some(
162 "summary"
163 | "mode"
164 | "permission-mode"
165 | "last-prompt"
166 | "file-history-snapshot"
167 | "ai-title"
168 | "custom-title"
169 | "queue-operation"
170 | "agent-name"
171 | "queued-message"
172 | "progress",
173 ) => {}
174 other => {
175 session.skipped_lines += 1;
176 tracing::debug!(
177 "{}:{}: unknown line type {:?}",
178 r.path.display(),
179 idx + 1,
180 other
181 );
182 }
183 }
184 }
185
186 session.started_at = session.events.iter().filter_map(|e| e.ts).min();
187 session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
188 session.model = model_counts
189 .into_iter()
190 .max_by_key(|(_, count)| *count)
191 .map(|(model, _)| model);
192 Ok(session)
193 }
194}
195
196fn project_label(path: &Path, root: &Path) -> Option<String> {
198 path.strip_prefix(root)
199 .ok()?
200 .components()
201 .next()
202 .map(|c| c.as_os_str().to_string_lossy().into_owned())
203}
204
205fn session_id_for(path: &Path) -> String {
207 path.file_stem()
208 .map(|s| s.to_string_lossy().into_owned())
209 .unwrap_or_else(|| path.to_string_lossy().into_owned())
210}
211
212fn parent_session_for(path: &Path) -> Option<String> {
214 let comps: Vec<String> = path
215 .components()
216 .map(|c| c.as_os_str().to_string_lossy().into_owned())
217 .collect();
218 let pos = comps.iter().rposition(|c| c == "subagents")?;
219 (pos > 0).then(|| comps[pos - 1].clone())
220}
221
222fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
223 raw.as_deref().and_then(|t| t.parse().ok())
224}
225
226fn snippet(text: &str) -> Option<String> {
228 let first = text.lines().find(|l| !l.trim().is_empty())?;
229 let s: String = first.trim().chars().take(80).collect();
230 (!s.is_empty()).then_some(s)
231}
232
233fn push_assistant(raw: RawLine, session: &mut Session, model_counts: &mut BTreeMap<String, u64>) {
234 let Some(msg) = raw.message else {
235 session.skipped_lines += 1;
236 return;
237 };
238 let ts = parse_ts(&raw.timestamp);
239 let model = msg.model.clone();
240 let synthetic = raw.is_api_error || model.as_deref() == Some("<synthetic>");
241 if let Some(m) = &model {
242 if !synthetic {
243 *model_counts.entry(m.clone()).or_default() += 1;
244 }
245 }
246
247 let mut tool_calls = Vec::new();
248 let mut spawns = Vec::new();
249 let mut content_chars = 0u64;
250 let mut thinking_chars = 0u64;
251 let mut has_thinking = false;
252 let mut summary = None;
253
254 if let Some(blocks) = msg.content.as_array() {
255 for block in blocks {
256 match block.get("type").and_then(|t| t.as_str()) {
257 Some("text") => {
258 let text = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
259 content_chars += text.chars().count() as u64;
260 if summary.is_none() {
261 summary = snippet(text);
262 }
263 }
264 Some("thinking") => {
265 has_thinking = true;
266 let text = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
267 let n = text.chars().count() as u64;
268 content_chars += n;
269 thinking_chars += n;
270 }
271 Some("redacted_thinking") => has_thinking = true,
272 Some("tool_use") => {
273 let id = block
274 .get("id")
275 .and_then(|v| v.as_str())
276 .unwrap_or_default()
277 .to_string();
278 let name = block
279 .get("name")
280 .and_then(|v| v.as_str())
281 .unwrap_or("unknown")
282 .to_string();
283 let input_bytes = block.get("input").map_or(0, |i| i.to_string().len() as u64);
284 content_chars += input_bytes;
285 if name == "Task" || name == "Agent" {
287 spawns.push(SubAgent {
288 tool_call_id: id.clone(),
289 agent_type: block
290 .pointer("/input/subagent_type")
291 .and_then(|v| v.as_str())
292 .map(str::to_string),
293 description: block
294 .pointer("/input/description")
295 .and_then(|v| v.as_str())
296 .map(str::to_string),
297 ts,
298 });
299 }
300 tool_calls.push(ToolCall {
301 server: ToolCall::server_from_name(&name),
302 id,
303 name,
304 input_bytes,
305 });
306 }
307 _ => {}
308 }
309 }
310 } else if let Some(text) = msg.content.as_str() {
311 content_chars += text.chars().count() as u64;
312 summary = snippet(text);
313 }
314
315 let usage = if synthetic {
318 None
319 } else {
320 msg.usage.map(RawUsage::into_usage)
321 };
322
323 session.events.push(Event {
324 kind: EventKind::Assistant,
325 ts,
326 request_id: raw.request_id,
327 model,
328 usage,
329 tool_calls,
330 sidechain: raw.is_sidechain,
331 content_summary: summary,
332 content_chars,
333 thinking_chars,
334 has_thinking,
335 tool_use_id: None,
336 attachment_kind: None,
337 item_count: 0,
338 });
339 for spawn in spawns {
340 session.events.push(Event {
341 kind: EventKind::SubAgentSpawn,
342 ts,
343 request_id: None,
344 model: None,
345 usage: None,
346 tool_calls: Vec::new(),
347 sidechain: raw.is_sidechain,
348 content_summary: spawn.description.clone(),
349 content_chars: 0,
350 thinking_chars: 0,
351 has_thinking: false,
352 tool_use_id: None,
353 attachment_kind: None,
354 item_count: 0,
355 });
356 session.sub_agents.push(spawn);
357 }
358}
359
360fn push_user(raw: RawLine, session: &mut Session) {
361 let ts = parse_ts(&raw.timestamp);
362 let mut kind = if raw.is_compact_summary {
363 EventKind::Compaction } else {
365 EventKind::User
366 };
367 let mut summary = None;
368 let mut content_chars = 0u64;
369 let mut tool_use_id = None;
372
373 match raw.message.as_ref().map(|m| &m.content) {
374 Some(serde_json::Value::String(text)) => {
375 content_chars = text.chars().count() as u64;
376 summary = snippet(text);
377 }
378 Some(value @ serde_json::Value::Array(blocks)) => {
379 let tool_result = blocks
380 .iter()
381 .find(|b| b.get("type").and_then(|t| t.as_str()) == Some("tool_result"));
382 if let Some(tr) = tool_result {
383 kind = EventKind::ToolResult;
384 tool_use_id = tr
385 .get("tool_use_id")
386 .and_then(|v| v.as_str())
387 .map(str::to_string);
388 content_chars = value.to_string().len() as u64;
391 } else {
392 for block in blocks {
393 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
394 content_chars += text.chars().count() as u64;
395 if summary.is_none() {
396 summary = snippet(text);
397 }
398 }
399 }
400 }
401 }
402 _ => {}
403 }
404
405 session.events.push(Event {
406 kind,
407 ts,
408 request_id: None,
409 model: None,
410 usage: None,
411 tool_calls: Vec::new(),
412 sidechain: raw.is_sidechain,
413 content_summary: summary,
414 content_chars,
415 thinking_chars: 0,
416 has_thinking: false,
417 tool_use_id,
418 attachment_kind: None,
419 item_count: 0,
420 });
421}
422
423fn push_system(raw: RawLine, session: &mut Session) {
424 let kind = if raw.subtype.as_deref() == Some("compact_boundary") {
425 EventKind::Compaction } else {
427 EventKind::System
428 };
429 session.events.push(Event {
430 kind,
431 ts: parse_ts(&raw.timestamp),
432 request_id: None,
433 model: None,
434 usage: None,
435 tool_calls: Vec::new(),
436 sidechain: raw.is_sidechain,
437 content_summary: raw.content.as_deref().and_then(snippet),
438 content_chars: raw
439 .content
440 .as_deref()
441 .map_or(0, |c| c.chars().count() as u64),
442 thinking_chars: 0,
443 has_thinking: false,
444 tool_use_id: None,
445 attachment_kind: None,
446 item_count: 0,
447 });
448}
449
450fn push_attachment(raw: RawLine, session: &mut Session) {
461 let Some(att) = raw.attachment else {
462 session.skipped_lines += 1;
464 return;
465 };
466 let item_count = att
468 .added_names
469 .as_ref()
470 .map(|v| v.len() as u64)
471 .or_else(|| att.names.as_ref().map(|v| v.len() as u64))
472 .or(att.skill_count)
473 .unwrap_or(0);
474 let content_chars = att
476 .content
477 .as_deref()
478 .map_or(0, |c| c.chars().count() as u64)
479 + att
480 .added_lines
481 .as_ref()
482 .map_or(0, |v| v.iter().map(|s| s.chars().count() as u64).sum())
483 + att
484 .added_blocks
485 .as_ref()
486 .map_or(0, |b| b.to_string().chars().count() as u64);
487
488 session.events.push(Event {
489 kind: EventKind::Attachment,
490 ts: parse_ts(&raw.timestamp),
491 request_id: None,
492 model: None,
493 usage: None,
494 tool_calls: Vec::new(),
495 sidechain: raw.is_sidechain,
496 content_summary: att.kind.clone(),
497 content_chars,
498 thinking_chars: 0,
499 has_thinking: false,
500 tool_use_id: None,
501 attachment_kind: att.kind,
502 item_count,
503 });
504}
505
506#[derive(Deserialize)]
509struct RawLine {
510 #[serde(rename = "type")]
511 kind: Option<String>,
512 timestamp: Option<String>,
513 #[serde(rename = "requestId")]
514 request_id: Option<String>,
515 #[serde(rename = "isSidechain", default)]
516 is_sidechain: bool,
517 #[serde(rename = "isCompactSummary", default)]
518 is_compact_summary: bool,
519 #[serde(rename = "isApiErrorMessage", default)]
520 is_api_error: bool,
521 subtype: Option<String>,
522 content: Option<String>,
524 message: Option<RawMessage>,
525 attachment: Option<RawAttachment>,
527}
528
529#[derive(Deserialize)]
531struct RawAttachment {
532 #[serde(rename = "type")]
533 kind: Option<String>,
534 #[serde(rename = "addedNames")]
536 added_names: Option<Vec<serde_json::Value>>,
537 names: Option<Vec<serde_json::Value>>,
538 #[serde(rename = "addedLines")]
540 added_lines: Option<Vec<String>>,
541 content: Option<String>,
543 #[serde(rename = "skillCount")]
545 skill_count: Option<u64>,
546 #[serde(rename = "addedBlocks")]
548 added_blocks: Option<serde_json::Value>,
549}
550
551#[derive(Deserialize)]
552struct RawMessage {
553 model: Option<String>,
554 #[serde(default)]
555 content: serde_json::Value,
556 usage: Option<RawUsage>,
557}
558
559#[derive(Deserialize)]
560struct RawUsage {
561 input_tokens: Option<u64>,
562 output_tokens: Option<u64>,
563 cache_creation_input_tokens: Option<u64>,
564 cache_read_input_tokens: Option<u64>,
565 cache_creation: Option<RawCacheCreation>,
566}
567
568#[derive(Deserialize)]
569struct RawCacheCreation {
570 ephemeral_5m_input_tokens: Option<u64>,
571 ephemeral_1h_input_tokens: Option<u64>,
572}
573
574impl RawUsage {
575 fn into_usage(self) -> Usage {
576 Usage {
577 input: self.input_tokens,
578 output: self.output_tokens,
579 cache_creation: self.cache_creation_input_tokens,
580 cache_creation_5m: self
581 .cache_creation
582 .as_ref()
583 .and_then(|c| c.ephemeral_5m_input_tokens),
584 cache_creation_1h: self
585 .cache_creation
586 .as_ref()
587 .and_then(|c| c.ephemeral_1h_input_tokens),
588 cache_read: self.cache_read_input_tokens,
589 thinking: None,
594 }
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn parent_session_is_derived_from_subagents_path() {
604 let p = Path::new("projects/F--demo/1111-2222/subagents/agent-abc.jsonl");
605 assert_eq!(parent_session_for(p).as_deref(), Some("1111-2222"));
606 let normal = Path::new("projects/F--demo/1111-2222.jsonl");
607 assert_eq!(parent_session_for(normal), None);
608 }
609
610 #[test]
611 fn legacy_task_tool_counts_as_spawn() {
612 let line = r#"{"type":"assistant","timestamp":"2026-06-01T10:00:00Z","requestId":"req_t","message":{"role":"assistant","model":"claude-sonnet-4-5","content":[{"type":"tool_use","id":"t1","name":"Task","input":{"description":"d","prompt":"p","subagent_type":"general-purpose"}}],"usage":{"input_tokens":10,"output_tokens":5}}}"#;
615 let raw: RawLine = serde_json::from_str(line).unwrap();
616 let mut session = Session {
617 id: "s".into(),
618 agent: "claude-code".into(),
619 project: None,
620 model: None,
621 parent_session: None,
622 started_at: None,
623 ended_at: None,
624 events: Vec::new(),
625 sub_agents: Vec::new(),
626 skipped_lines: 0,
627 };
628 push_assistant(raw, &mut session, &mut BTreeMap::new());
629 assert_eq!(session.sub_agents.len(), 1);
630 assert_eq!(
631 session.sub_agents[0].agent_type.as_deref(),
632 Some("general-purpose")
633 );
634 assert_eq!(session.events.len(), 2, "assistant event + spawn event");
635 assert_eq!(session.events[1].kind, EventKind::SubAgentSpawn);
636 }
637}