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
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());
|
|
}
|
|
}
|