//! Discovery of Claude Code session and sub-agent JSONL files on disk. use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use crate::models::session::RawEntry; /// Reference to a discovered session file on disk. #[derive(Debug, Clone)] pub struct SessionRef { /// The session UUID (filename stem, without `.jsonl`). pub session_id: String, /// The project path recovered from the JSONL `cwd` field. /// `None` if the file could not be parsed to find a `cwd`. pub project_path: Option, /// Absolute path to the `.jsonl` file. pub file_path: PathBuf, /// Last-modified time of the file. pub modified_at: DateTime, } /// Reference to a discovered sub-agent JSONL file. #[derive(Debug, Clone)] pub struct AgentRef { /// The agent's UUID (from filename: `agent-{uuid}.jsonl`). pub agent_id: String, /// The parent session UUID (read from first line of the agent JSONL). pub session_id: String, /// Agent type, from `agent-{id}.meta.json` if present. pub agent_type: Option, /// Absolute path to the agent's `.jsonl` file. pub file_path: PathBuf, /// Last-modified time of the file. pub modified_at: DateTime, } /// Resolve the `~/.claude/projects/` directory path. /// /// Uses `HOME` environment variable to expand `~`. fn claude_projects_dir() -> Option { let home = std::env::var("HOME").ok()?; Some(PathBuf::from(home).join(".claude").join("projects")) } /// Scan the first few lines of a session file to find a `cwd` field. /// /// Reads up to 20 non-empty lines looking for any entry whose top-level `cwd` /// field is set. The `cwd` field is written by Claude Code on `system`-type /// entries; it may not appear on the very first line if the session file starts /// with a user or assistant message. /// /// Returns `None` if the file cannot be read or no `cwd` is found within the /// first 20 lines. fn read_cwd_from_first_line(path: &Path) -> Option { const MAX_LINES: usize = 20; let file = std::fs::File::open(path).ok()?; let reader = BufReader::new(file); let mut checked = 0usize; for line in reader.lines() { let line = line.ok()?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } checked += 1; if let Ok(entry) = serde_json::from_str::(trimmed) && entry.cwd.is_some() { return entry.cwd; } if checked >= MAX_LINES { break; } } None } /// Discover all session JSONL files under `~/.claude/projects/`. /// /// Walks one level deep: for each project subdirectory, collects `*.jsonl` files /// while skipping `subagents/` subdirectories. For each file the session ID is /// extracted from the filename stem and the project path from the first parseable /// JSONL line's `cwd` field. /// /// Files that cannot be read or stat'd are skipped with a warning to `stderr`. pub fn discover_sessions() -> crate::error::Result> { let projects_dir = match claude_projects_dir() { Some(d) => d, None => { eprintln!("claudbg: could not determine HOME directory"); return Ok(vec![]); } }; if !projects_dir.exists() { return Ok(vec![]); } let mut sessions = Vec::new(); let project_entries = match std::fs::read_dir(&projects_dir) { Ok(e) => e, Err(err) => { eprintln!( "claudbg: could not read {}: {}", projects_dir.display(), err ); return Ok(vec![]); } }; for proj_entry in project_entries { let proj_entry = match proj_entry { Ok(e) => e, Err(err) => { eprintln!("claudbg: error reading project dir entry: {err}"); continue; } }; let proj_path = proj_entry.path(); if !proj_path.is_dir() { continue; } let file_entries = match std::fs::read_dir(&proj_path) { Ok(e) => e, Err(err) => { eprintln!("claudbg: could not read {}: {}", proj_path.display(), err); continue; } }; for file_entry in file_entries { let file_entry = match file_entry { Ok(e) => e, Err(err) => { eprintln!("claudbg: error reading file entry: {err}"); continue; } }; let file_path = file_entry.path(); // Skip subdirectories (e.g. subagents/). if file_path.is_dir() { continue; } // Only process .jsonl files. if file_path.extension().and_then(|e| e.to_str()) != Some("jsonl") { continue; } // Extract session_id from filename stem. let session_id = match file_path.file_stem().and_then(|s| s.to_str()) { Some(s) => s.to_string(), None => { eprintln!( "claudbg: could not extract session ID from {}", file_path.display() ); continue; } }; // Get last-modified time. let modified_at = match file_entry.metadata() { Ok(meta) => match meta.modified() { Ok(t) => DateTime::::from(t), Err(err) => { eprintln!( "claudbg: could not read mtime for {}: {}", file_path.display(), err ); continue; } }, Err(err) => { eprintln!("claudbg: could not stat {}: {}", file_path.display(), err); continue; } }; // Try to extract cwd from first JSONL line. let project_path = read_cwd_from_first_line(&file_path); sessions.push(SessionRef { session_id, project_path, file_path, modified_at, }); } } Ok(sessions) } /// Discover all sub-agent runs for a session given the session's JSONL file path. /// /// The actual disk layout is: /// ```text /// / /// .jsonl /// / ← directory named after the UUID (no extension) /// subagents/ /// agent-.jsonl /// ``` /// So we derive the sibling directory from the file stem and look inside it. pub fn discover_agents_for_session(session_file: &Path) -> crate::error::Result> { let parent = match session_file.parent() { Some(p) => p, None => return Ok(vec![]), }; let stem = match session_file.file_stem() { Some(s) => s, None => return Ok(vec![]), }; let session_dir = parent.join(stem); let subagents_dir = session_dir.join("subagents"); collect_agents_in_dir(&subagents_dir, None) } /// Discover all sub-agent runs across all sessions. /// /// Walks `~/.claude/projects/` and collects `agent-*.jsonl` files from every /// `subagents/` subdirectory found. pub fn discover_all_agents() -> crate::error::Result> { let projects_dir = match claude_projects_dir() { Some(d) => d, None => { eprintln!("claudbg: could not determine HOME directory"); return Ok(vec![]); } }; if !projects_dir.exists() { return Ok(vec![]); } let mut agents = Vec::new(); let project_entries = match std::fs::read_dir(&projects_dir) { Ok(e) => e, Err(err) => { eprintln!( "claudbg: could not read {}: {}", projects_dir.display(), err ); return Ok(vec![]); } }; for proj_entry in project_entries { let proj_entry = match proj_entry { Ok(e) => e, Err(err) => { eprintln!("claudbg: error reading project dir entry: {err}"); continue; } }; let proj_path = proj_entry.path(); if !proj_path.is_dir() { continue; } // Each project dir may contain zero or more / subdirectories. // Each of those may have a `subagents/` directory inside it. let session_dir_entries = match std::fs::read_dir(&proj_path) { Ok(e) => e, Err(err) => { eprintln!("claudbg: could not read {}: {}", proj_path.display(), err); continue; } }; for session_dir_entry in session_dir_entries { let session_dir_entry = match session_dir_entry { Ok(e) => e, Err(err) => { eprintln!("claudbg: error reading session dir entry: {err}"); continue; } }; let session_dir_path = session_dir_entry.path(); if !session_dir_path.is_dir() { continue; } let subagents_dir = session_dir_path.join("subagents"); match collect_agents_in_dir(&subagents_dir, None) { Ok(mut found) => agents.append(&mut found), Err(err) => { eprintln!( "claudbg: error collecting agents in {}: {}", subagents_dir.display(), err ); } } } } Ok(agents) } /// Collect all `agent-*.jsonl` files from a single `subagents/` directory. /// /// `filter_session_id` optionally restricts results to agents whose first JSONL /// line's `session_id` field matches the given value. fn collect_agents_in_dir( subagents_dir: &Path, filter_session_id: Option<&str>, ) -> crate::error::Result> { if !subagents_dir.exists() { return Ok(vec![]); } let mut agents = Vec::new(); let entries = match std::fs::read_dir(subagents_dir) { Ok(e) => e, Err(err) => { eprintln!( "claudbg: could not read {}: {}", subagents_dir.display(), err ); return Ok(vec![]); } }; for entry in entries { let entry = match entry { Ok(e) => e, Err(err) => { eprintln!("claudbg: error reading subagent entry: {err}"); continue; } }; let file_path = entry.path(); if file_path.is_dir() { continue; } // Only process agent-*.jsonl files. let file_name = match file_path.file_name().and_then(|n| n.to_str()) { Some(n) => n.to_string(), None => continue, }; if !file_name.starts_with("agent-") || !file_name.ends_with(".jsonl") { continue; } // Extract agent_id: strip "agent-" prefix and ".jsonl" suffix. let agent_id = file_name .strip_prefix("agent-") .and_then(|s| s.strip_suffix(".jsonl")) .unwrap_or(&file_name) .to_string(); // Get last-modified time. let modified_at = match entry.metadata() { Ok(meta) => match meta.modified() { Ok(t) => DateTime::::from(t), Err(err) => { eprintln!( "claudbg: could not read mtime for {}: {}", file_path.display(), err ); continue; } }, Err(err) => { eprintln!("claudbg: could not stat {}: {}", file_path.display(), err); continue; } }; // Read session_id from first line of the agent file. let session_id = read_session_id_from_first_line(&file_path).unwrap_or_default(); // Filter by session_id if requested. if let Some(filter) = filter_session_id && session_id != filter { continue; } // Try to read agent_type from meta.json. let meta_path = subagents_dir.join(format!("agent-{agent_id}.meta.json")); let agent_type = read_agent_type_from_meta(&meta_path); agents.push(AgentRef { agent_id, session_id, agent_type, file_path, modified_at, }); } Ok(agents) } /// Read the `session_id` from the first non-empty line of an agent JSONL file. fn read_session_id_from_first_line(path: &Path) -> Option { let file = std::fs::File::open(path).ok()?; let reader = BufReader::new(file); for line in reader.lines() { let line = line.ok()?; let trimmed = line.trim(); if trimmed.is_empty() { continue; } if let Ok(entry) = serde_json::from_str::(trimmed) && entry.session_id.is_some() { return entry.session_id; } break; } None } /// Try to read `agent_type` from an `agent-{id}.meta.json` file. /// /// Expects a JSON object with an `"agent_type"` string field. fn read_agent_type_from_meta(meta_path: &Path) -> Option { let content = std::fs::read_to_string(meta_path).ok()?; let value: serde_json::Value = serde_json::from_str(&content).ok()?; value .get("agent_type") .and_then(|v| v.as_str()) .map(|s| s.to_string()) } #[cfg(test)] mod tests { use super::*; /// `SessionRef` implements `Debug` and `Clone`. #[test] fn session_ref_debug_clone() { let sr = SessionRef { session_id: "abc123".to_string(), project_path: Some("/home/user/project".to_string()), file_path: PathBuf::from("/tmp/abc123.jsonl"), modified_at: DateTime::::from(std::time::SystemTime::UNIX_EPOCH), }; let cloned = sr.clone(); assert_eq!(cloned.session_id, sr.session_id); // Ensure Debug works without panic. let _ = format!("{sr:?}"); } /// `AgentRef` implements `Debug` and `Clone`. #[test] fn agent_ref_debug_clone() { let ar = AgentRef { agent_id: "def456".to_string(), session_id: "abc123".to_string(), agent_type: Some("TaskAgent".to_string()), file_path: PathBuf::from("/tmp/agent-def456.jsonl"), modified_at: DateTime::::from(std::time::SystemTime::UNIX_EPOCH), }; let cloned = ar.clone(); assert_eq!(cloned.agent_id, ar.agent_id); let _ = format!("{ar:?}"); } /// `discover_sessions()` does not panic and returns `Ok` even if /// `~/.claude/projects/` does not exist. #[test] fn discover_sessions_does_not_panic() { let result = discover_sessions(); // Should always succeed (empty or non-empty). assert!(result.is_ok()); } /// `discover_all_agents()` does not panic and returns `Ok`. #[test] fn discover_all_agents_does_not_panic() { let result = discover_all_agents(); assert!(result.is_ok()); } }