use crate::{Agent, DiscoverError, DiscoverOperation, FileKind, Roots};
use std::{
fs, io,
path::{Path, PathBuf},
time::SystemTime,
};
#[derive(Debug, Clone, Default)]
pub struct DiscoverFilter {
pub agents: Vec<Agent>,
pub include_subagents: bool,
pub modified_after: Option<SystemTime>,
}
#[derive(Debug, Clone)]
pub struct SessionFile {
pub agent: Agent,
pub path: PathBuf,
pub kind: FileKind,
pub size: u64,
pub modified: SystemTime,
}
#[derive(Debug, Default)]
pub struct Discovery {
pub files: Vec<SessionFile>,
pub errors: Vec<DiscoverError>,
}
impl SessionFile {
pub fn inspect(agent: Agent, path: impl Into<PathBuf>) -> io::Result<Self> {
let path = path.into();
let meta = fs::symlink_metadata(&path)?;
if !meta.is_file() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"session path is not a regular file",
));
}
let kind = if path.components().any(|p| p.as_os_str() == "subagents") {
FileKind::Subagent
} else {
FileKind::Main
};
Ok(Self {
agent,
path,
kind,
size: meta.len(),
modified: meta.modified()?,
})
}
}
fn scan_roots(roots: &Roots) -> Vec<(Agent, PathBuf)> {
let mut result = Vec::new();
if let Some(root) = &roots.claude {
result.push((Agent::ClaudeCode, root.join("projects")));
}
if let Some(root) = &roots.codex {
for subdir in ["sessions", "archived_sessions"] {
result.push((Agent::Codex, root.join(subdir)));
}
}
result
}
pub fn classify(path: &Path, roots: &Roots) -> io::Result<Option<SessionFile>> {
if path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"parent path component",
));
}
if path.extension().is_none_or(|e| e != "jsonl") {
return Ok(None);
}
for (agent, root) in scan_roots(roots) {
if path.starts_with(root) {
return SessionFile::inspect(agent, path).map(Some);
}
}
Ok(None)
}
pub fn discover(roots: &Roots, filter: &DiscoverFilter) -> Discovery {
let mut result = Discovery::default();
for (agent, root) in scan_roots(roots) {
scan_directory(agent, &root, filter, true, &mut result);
}
sort_files(&mut result);
result
}
pub fn discover_directory(agent: Agent, root: &Path, filter: &DiscoverFilter) -> Discovery {
let mut result = Discovery::default();
scan_directory(agent, root, filter, false, &mut result);
sort_files(&mut result);
result
}
fn scan_directory(
agent: Agent,
root: &Path,
filter: &DiscoverFilter,
optional: bool,
result: &mut Discovery,
) {
if !filter.agents.is_empty() && !filter.agents.contains(&agent) {
return;
}
let skip_tagged_subagents = optional && !filter.include_subagents;
let mut pending = vec![(root.to_path_buf(), optional)];
while let Some((dir, optional)) = pending.pop() {
let metadata = match fs::symlink_metadata(&dir) {
Ok(m) => m,
Err(e) if optional && e.kind() == io::ErrorKind::NotFound => continue,
Err(source) => {
result.errors.push(DiscoverError {
agent,
path: dir,
source,
operation: DiscoverOperation::DirectoryMetadata,
});
continue;
}
};
if metadata.is_symlink() {
continue;
}
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(source) => {
result.errors.push(DiscoverError {
agent,
path: dir,
source,
operation: DiscoverOperation::ReadDirectory,
});
continue;
}
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(source) => {
result.errors.push(DiscoverError {
agent,
path: dir.clone(),
source,
operation: DiscoverOperation::ReadEntry,
});
continue;
}
};
let path = entry.path();
let ty = match entry.file_type() {
Ok(ty) => ty,
Err(source) => {
result.errors.push(DiscoverError {
agent,
path,
source,
operation: DiscoverOperation::FileType,
});
continue;
}
};
if ty.is_dir() {
if filter.include_subagents || entry.file_name() != "subagents" {
pending.push((path, false));
}
} else if ty.is_file() && is_session_path(agent, &path) {
match SessionFile::inspect(agent, &path) {
Ok(file)
if filter.modified_after.is_none_or(|t| file.modified >= t)
&& (!skip_tagged_subagents || file.kind == FileKind::Main) =>
{
result.files.push(file)
}
Ok(_) => {}
Err(source) => result.errors.push(DiscoverError {
agent,
path,
source,
operation: DiscoverOperation::InspectFile,
}),
}
}
}
}
}
fn is_session_path(agent: Agent, path: &Path) -> bool {
let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
match agent {
Agent::ClaudeCode | Agent::Codex | Agent::Pi | Agent::CodeBuddy | Agent::IFlow => {
ext == "jsonl"
}
Agent::QwenCode => {
ext == "jsonl"
&& path
.parent()
.and_then(Path::file_name)
.is_some_and(|v| v == "chats")
}
Agent::GeminiCli => name.starts_with("session-") && matches!(ext, "json" | "jsonl"),
Agent::KimiCli => name == "wire.jsonl",
Agent::CopilotCli => name == "events.jsonl",
Agent::Cline | Agent::RooCode => name == "api_conversation_history.json",
Agent::OpenCode => matches!(ext, "json" | "db"),
Agent::Goose => matches!(ext, "json" | "db"),
Agent::Continue => ext == "json" && name != "sessions.json",
Agent::Cursor => name == "state.vscdb",
Agent::Grok => name == "updates.jsonl",
Agent::ClineCli => name.ends_with(".messages.json"),
Agent::Hermes => name == "state.db" || (name.starts_with("session_") && ext == "json"),
Agent::WorkBuddy => {
ext == "jsonl"
&& !path
.components()
.any(|p| matches!(p.as_os_str().to_str(), Some("audit-log" | "logs")))
}
Agent::Qoder => ext == "jsonl" && !path.components().any(|p| p.as_os_str() == "logs"),
Agent::ZCode => name == "db.sqlite",
Agent::GrokBot => ext == "blob" && grokbot_transcript_name(path),
Agent::CursorCli => name == "store.db",
Agent::Zed => name == "threads.db" || ext == "json",
Agent::Warp => name == "warp.sqlite",
Agent::Antigravity => name.ends_with(".trajectory.json"),
}
}
fn sort_files(result: &mut Discovery) {
result.files.sort_by(|a, b| a.path.cmp(&b.path));
result
.files
.dedup_by(|a, b| a.path == b.path && a.agent == b.agent);
}
fn grokbot_transcript_name(path: &Path) -> bool {
let Some(stem) = path.file_stem().and_then(|v| v.to_str()) else {
return false;
};
let mut bits = 0_u32;
let mut count = 0_u32;
let mut bytes = Vec::new();
for byte in stem.bytes() {
let n = match byte.to_ascii_uppercase() {
b'A'..=b'Z' => byte.to_ascii_uppercase() - b'A',
b'2'..=b'7' => byte - b'2' + 26,
_ => return false,
};
bits = (bits << 5) | u32::from(n);
count += 5;
if count >= 8 {
count -= 8;
bytes.push((bits >> count) as u8);
bits &= (1 << count) - 1;
}
}
String::from_utf8(bytes).is_ok_and(|v| v.contains(".transcript.replicas."))
}