Skip to main content

agent_sessions/
discover.rs

1use crate::{Agent, DiscoverError, DiscoverOperation, FileKind, Roots};
2use std::{
3    fs, io,
4    path::{Path, PathBuf},
5    time::SystemTime,
6};
7
8#[derive(Debug, Clone, Default)]
9pub struct DiscoverFilter {
10    pub agents: Vec<Agent>,
11    pub include_subagents: bool,
12    pub modified_after: Option<SystemTime>,
13}
14#[derive(Debug, Clone)]
15pub struct SessionFile {
16    pub agent: Agent,
17    pub path: PathBuf,
18    pub kind: FileKind,
19    pub size: u64,
20    pub modified: SystemTime,
21}
22#[derive(Debug, Default)]
23pub struct Discovery {
24    pub files: Vec<SessionFile>,
25    pub errors: Vec<DiscoverError>,
26}
27
28impl SessionFile {
29    /// Explicit host selection for hooks and nonstandard roots.
30    pub fn inspect(agent: Agent, path: impl Into<PathBuf>) -> io::Result<Self> {
31        let path = path.into();
32        let meta = fs::symlink_metadata(&path)?;
33        if !meta.is_file() {
34            return Err(io::Error::new(
35                io::ErrorKind::InvalidInput,
36                "session path is not a regular file",
37            ));
38        }
39        let kind = if path.components().any(|p| p.as_os_str() == "subagents") {
40            FileKind::Subagent
41        } else {
42            FileKind::Main
43        };
44        Ok(Self {
45            agent,
46            path,
47            kind,
48            size: meta.len(),
49            modified: meta.modified()?,
50        })
51    }
52}
53
54fn scan_roots(roots: &Roots) -> Vec<(Agent, PathBuf)> {
55    let mut result = Vec::new();
56    if let Some(root) = &roots.claude {
57        result.push((Agent::ClaudeCode, root.join("projects")));
58    }
59    if let Some(root) = &roots.codex {
60        for subdir in ["sessions", "archived_sessions"] {
61            result.push((Agent::Codex, root.join(subdir)));
62        }
63    }
64    result
65}
66
67/// Classify a known path lexically under configured roots, preserving IO errors.
68/// Paths containing parent components are rejected rather than escaping a root.
69pub fn classify(path: &Path, roots: &Roots) -> io::Result<Option<SessionFile>> {
70    if path
71        .components()
72        .any(|c| matches!(c, std::path::Component::ParentDir))
73    {
74        return Err(io::Error::new(
75            io::ErrorKind::InvalidInput,
76            "parent path component",
77        ));
78    }
79    if path.extension().is_none_or(|e| e != "jsonl") {
80        return Ok(None);
81    }
82    for (agent, root) in scan_roots(roots) {
83        if path.starts_with(root) {
84            return SessionFile::inspect(agent, path).map(Some);
85        }
86    }
87    Ok(None)
88}
89
90pub fn discover(roots: &Roots, filter: &DiscoverFilter) -> Discovery {
91    let mut result = Discovery::default();
92    for (agent, root) in scan_roots(roots) {
93        scan_directory(agent, &root, filter, true, &mut result);
94    }
95    sort_files(&mut result);
96    result
97}
98
99/// Scan an explicitly selected directory recursively. Unlike configured default
100/// roots, a missing directory is reported in `errors`. Symlinks are not followed.
101/// Subagent exclusion applies to descendant directories named `subagents`;
102/// an explicitly selected root remains eligible regardless of its ancestors.
103pub fn discover_directory(agent: Agent, root: &Path, filter: &DiscoverFilter) -> Discovery {
104    let mut result = Discovery::default();
105    scan_directory(agent, root, filter, false, &mut result);
106    sort_files(&mut result);
107    result
108}
109
110fn scan_directory(
111    agent: Agent,
112    root: &Path,
113    filter: &DiscoverFilter,
114    optional: bool,
115    result: &mut Discovery,
116) {
117    if !filter.agents.is_empty() && !filter.agents.contains(&agent) {
118        return;
119    }
120    let skip_tagged_subagents = optional && !filter.include_subagents;
121    let mut pending = vec![(root.to_path_buf(), optional)];
122    while let Some((dir, optional)) = pending.pop() {
123        let metadata = match fs::symlink_metadata(&dir) {
124            Ok(m) => m,
125            Err(e) if optional && e.kind() == io::ErrorKind::NotFound => continue,
126            Err(source) => {
127                result.errors.push(DiscoverError {
128                    agent,
129                    path: dir,
130                    source,
131                    operation: DiscoverOperation::DirectoryMetadata,
132                });
133                continue;
134            }
135        };
136        if metadata.is_symlink() {
137            continue;
138        }
139        let entries = match fs::read_dir(&dir) {
140            Ok(entries) => entries,
141            Err(source) => {
142                result.errors.push(DiscoverError {
143                    agent,
144                    path: dir,
145                    source,
146                    operation: DiscoverOperation::ReadDirectory,
147                });
148                continue;
149            }
150        };
151        for entry in entries {
152            let entry = match entry {
153                Ok(e) => e,
154                Err(source) => {
155                    result.errors.push(DiscoverError {
156                        agent,
157                        path: dir.clone(),
158                        source,
159                        operation: DiscoverOperation::ReadEntry,
160                    });
161                    continue;
162                }
163            };
164            let path = entry.path();
165            let ty = match entry.file_type() {
166                Ok(ty) => ty,
167                Err(source) => {
168                    result.errors.push(DiscoverError {
169                        agent,
170                        path,
171                        source,
172                        operation: DiscoverOperation::FileType,
173                    });
174                    continue;
175                }
176            };
177            if ty.is_dir() {
178                if filter.include_subagents || entry.file_name() != "subagents" {
179                    pending.push((path, false));
180                }
181            } else if ty.is_file() && is_session_path(agent, &path) {
182                match SessionFile::inspect(agent, &path) {
183                    Ok(file)
184                        if filter.modified_after.is_none_or(|t| file.modified >= t)
185                            && (!skip_tagged_subagents || file.kind == FileKind::Main) =>
186                    {
187                        result.files.push(file)
188                    }
189                    Ok(_) => {}
190                    Err(source) => result.errors.push(DiscoverError {
191                        agent,
192                        path,
193                        source,
194                        operation: DiscoverOperation::InspectFile,
195                    }),
196                }
197            }
198        }
199    }
200}
201
202fn is_session_path(agent: Agent, path: &Path) -> bool {
203    let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
204    let ext = path.extension().and_then(|v| v.to_str()).unwrap_or("");
205    match agent {
206        Agent::ClaudeCode | Agent::Codex | Agent::Pi | Agent::CodeBuddy | Agent::IFlow => {
207            ext == "jsonl"
208        }
209        Agent::QwenCode => {
210            ext == "jsonl"
211                && path
212                    .parent()
213                    .and_then(Path::file_name)
214                    .is_some_and(|v| v == "chats")
215        }
216        Agent::GeminiCli => name.starts_with("session-") && matches!(ext, "json" | "jsonl"),
217        Agent::KimiCli => name == "wire.jsonl",
218        Agent::CopilotCli => name == "events.jsonl",
219        Agent::Cline | Agent::RooCode => name == "api_conversation_history.json",
220        Agent::OpenCode => matches!(ext, "json" | "db"),
221        Agent::Goose => matches!(ext, "json" | "db"),
222        Agent::Continue => ext == "json" && name != "sessions.json",
223        Agent::Cursor => name == "state.vscdb",
224        Agent::Grok => name == "updates.jsonl",
225        Agent::ClineCli => name.ends_with(".messages.json"),
226        Agent::Hermes => name == "state.db" || (name.starts_with("session_") && ext == "json"),
227        Agent::WorkBuddy => {
228            ext == "jsonl"
229                && !path
230                    .components()
231                    .any(|p| matches!(p.as_os_str().to_str(), Some("audit-log" | "logs")))
232        }
233        Agent::Qoder => ext == "jsonl" && !path.components().any(|p| p.as_os_str() == "logs"),
234        Agent::ZCode => name == "db.sqlite",
235        Agent::GrokBot => ext == "blob" && grokbot_transcript_name(path),
236        Agent::CursorCli => name == "store.db",
237        Agent::Zed => name == "threads.db" || ext == "json",
238        Agent::Warp => name == "warp.sqlite",
239        Agent::Antigravity => name.ends_with(".trajectory.json"),
240    }
241}
242fn sort_files(result: &mut Discovery) {
243    result.files.sort_by(|a, b| a.path.cmp(&b.path));
244    result
245        .files
246        .dedup_by(|a, b| a.path == b.path && a.agent == b.agent);
247}
248
249// Grok Bot filenames are the unpadded RFC 4648 base32 persistence keys.
250// Select transcript replicas without opening unrelated account/settings blobs.
251fn grokbot_transcript_name(path: &Path) -> bool {
252    let Some(stem) = path.file_stem().and_then(|v| v.to_str()) else {
253        return false;
254    };
255    let mut bits = 0_u32;
256    let mut count = 0_u32;
257    let mut bytes = Vec::new();
258    for byte in stem.bytes() {
259        let n = match byte.to_ascii_uppercase() {
260            b'A'..=b'Z' => byte.to_ascii_uppercase() - b'A',
261            b'2'..=b'7' => byte - b'2' + 26,
262            _ => return false,
263        };
264        bits = (bits << 5) | u32::from(n);
265        count += 5;
266        if count >= 8 {
267            count -= 8;
268            bytes.push((bits >> count) as u8);
269            bits &= (1 << count) - 1;
270        }
271    }
272    String::from_utf8(bytes).is_ok_and(|v| v.contains(".transcript.replicas."))
273}