You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

499 lines
15 KiB
Rust

//! 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<String>,
/// Absolute path to the `.jsonl` file.
pub file_path: PathBuf,
/// Last-modified time of the file.
pub modified_at: DateTime<Utc>,
}
/// 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<String>,
/// Absolute path to the agent's `.jsonl` file.
pub file_path: PathBuf,
/// Last-modified time of the file.
pub modified_at: DateTime<Utc>,
}
/// Resolve the `~/.claude/projects/` directory path.
///
/// Uses `HOME` environment variable to expand `~`.
fn claude_projects_dir() -> Option<PathBuf> {
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<String> {
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::<RawEntry>(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<Vec<SessionRef>> {
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::<Utc>::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
/// <project-dir>/
/// <session-uuid>.jsonl
/// <session-uuid>/ ← directory named after the UUID (no extension)
/// subagents/
/// agent-<id>.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<Vec<AgentRef>> {
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<Vec<AgentRef>> {
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 <session-uuid>/ 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<Vec<AgentRef>> {
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::<Utc>::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<String> {
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::<RawEntry>(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<String> {
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::<Utc>::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::<Utc>::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());
}
}