1use super::*;
2
3pub fn locate_codex_session(
5 home: &Path,
6 selection: &CodexSessionSelection,
7) -> Result<LocatedCodexSession> {
8 let mut listed = list_codex_sessions(home)?;
9 if matches!(selection, CodexSessionSelection::Latest) {
12 listed.retain(|session| !session.natively_archived);
13 }
14 if let CodexSessionSelection::NativeSessionId(session_id) = selection
15 && !listed
16 .iter()
17 .any(|session| session.native_session_id == *session_id)
18 {
19 return locate_unindexed_codex_session(home, session_id);
20 }
21 select_jsonl_session(listed, selection, "Codex")
22}
23
24pub(super) fn locate_unindexed_codex_session(
25 home: &Path,
26 session_id: &str,
27) -> Result<LocatedCodexSession> {
28 validate_id("Codex session", session_id)?;
29 let mut requested = BTreeMap::new();
30 requested.insert(session_id.to_owned(), session_id.to_owned());
31 let mut candidates = Vec::new();
32 let root = home.join("sessions");
33 if root.is_dir() {
34 collect_codex_candidate_paths(&root, &requested, &mut candidates)?;
35 }
36 let titles = codex_native_titles(home)?;
37 let mut matches = Vec::new();
38 for candidate in candidates {
39 let Some(metadata) = codex_session_metadata(&candidate.path)? else {
40 continue;
41 };
42 if metadata.id == session_id {
43 matches.push(LocatedCodexSession {
44 natively_archived: false,
45 title: titles
46 .get(session_id)
47 .cloned()
48 .unwrap_or_else(|| session_id.to_owned()),
49 native_session_id: metadata.id,
50 jsonl_path: candidate.path,
51 modified_at: candidate.modified_at,
52 cwd: metadata.cwd,
53 git_branch: metadata.git_branch,
54 size_bytes: candidate.size_bytes,
55 history_mode: metadata.history_mode,
56 });
57 }
58 }
59 select_jsonl_session(
60 matches,
61 &CodexSessionSelection::NativeSessionId(session_id.to_owned()),
62 "Codex",
63 )
64}
65
66pub fn list_codex_sessions(home: &Path) -> Result<Vec<LocatedCodexSession>> {
68 let mut sessions = Vec::new();
69 scan_codex_sessions(home, |progress| {
70 if let Some(session) = progress.session {
71 sessions.push(session);
72 }
73 })?;
74 Ok(sessions)
75}
76
77pub fn scan_codex_sessions(
79 home: &Path,
80 mut report: impl FnMut(SessionScanProgress<LocatedCodexSession>),
81) -> Result<()> {
82 if let Some(sessions) = codex_indexed_sessions(home)? {
83 let total = sessions.len();
84 report(SessionScanProgress {
85 scanned: 0,
86 total,
87 session: None,
88 });
89 for (index, session) in sessions.into_iter().enumerate() {
90 report(SessionScanProgress {
91 scanned: index + 1,
92 total,
93 session: Some(session),
94 });
95 }
96 return Ok(());
97 }
98
99 let titles = codex_native_titles(home)?;
103 let mut candidates = Vec::new();
104 let root = home.join("sessions");
105 if root.is_dir() {
106 collect_codex_candidate_paths(&root, &titles, &mut candidates)?;
107 }
108 candidates.sort_by(|left, right| {
109 right
110 .modified_at
111 .cmp(&left.modified_at)
112 .then_with(|| right.path.cmp(&left.path))
113 });
114 let total = candidates.len();
115 report(SessionScanProgress {
116 scanned: 0,
117 total,
118 session: None,
119 });
120 for (index, candidate) in candidates.into_iter().enumerate() {
121 let session = codex_session_metadata(&candidate.path)?.map(|metadata| {
122 let session_id = metadata.id;
123 LocatedCodexSession {
124 natively_archived: false,
125 title: titles
126 .get(&session_id)
127 .cloned()
128 .unwrap_or_else(|| session_id.clone()),
129 native_session_id: session_id,
130 jsonl_path: candidate.path,
131 modified_at: candidate.modified_at,
132 cwd: metadata.cwd,
133 git_branch: metadata.git_branch,
134 size_bytes: candidate.size_bytes,
135 history_mode: metadata.history_mode,
136 }
137 });
138 report(SessionScanProgress {
139 scanned: index + 1,
140 total,
141 session,
142 });
143 }
144 Ok(())
145}
146
147pub(super) fn codex_indexed_sessions(home: &Path) -> Result<Option<Vec<LocatedCodexSession>>> {
148 let database = home.join("state_5.sqlite");
149 if !database.is_file() {
150 return Ok(None);
151 }
152 let connection = rusqlite::Connection::open_with_flags(
153 database,
154 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
155 )?;
156 let has_history_mode = connection
157 .prepare("SELECT history_mode FROM threads LIMIT 0")
158 .is_ok();
159 let history_mode_column = if has_history_mode {
160 "history_mode"
161 } else {
162 "'legacy'"
163 };
164 let query = format!(
168 "SELECT id, rollout_path, updated_at, COALESCE(NULLIF(name, ''), NULLIF(title, ''), id), cwd, \
169 COALESCE(NULLIF(git_branch, ''), 'HEAD'), {history_mode_column}, archived \
170 FROM threads \
171 WHERE source IN ('cli', 'vscode') \
172 AND preview <> '' \
173 AND rollout_path IS NOT NULL \
174 ORDER BY updated_at DESC, id DESC"
175 );
176 let Ok(mut statement) = connection.prepare(&query) else {
177 return Ok(None);
178 };
179 let rows = statement.query_map([], |row| {
180 Ok((
181 row.get::<_, String>(0)?,
182 row.get::<_, String>(1)?,
183 row.get::<_, i64>(2)?,
184 row.get::<_, String>(3)?,
185 row.get::<_, String>(4)?,
186 row.get::<_, String>(5)?,
187 row.get::<_, String>(6)?,
188 row.get::<_, bool>(7)?,
189 ))
190 })?;
191 let mut sessions = Vec::new();
192 for row in rows {
193 let (session_id, path, updated_at, title, cwd, git_branch, history_mode, natively_archived) =
194 row?;
195 let path = PathBuf::from(path);
196 if validate_id("Codex session", &session_id).is_err() || updated_at.is_negative() {
197 continue;
198 }
199 let Ok(metadata) = fs::symlink_metadata(&path) else {
200 continue;
201 };
202 if metadata.file_type().is_symlink() || !metadata.is_file() {
203 continue;
204 }
205 sessions.push(LocatedCodexSession {
206 native_session_id: session_id.clone(),
207 jsonl_path: path,
208 modified_at: SystemTime::UNIX_EPOCH + Duration::from_secs(updated_at as u64),
209 title: normalize_session_title(&title).unwrap_or(session_id),
210 cwd: PathBuf::from(cwd),
211 git_branch,
212 size_bytes: metadata.len(),
213 history_mode: parse_codex_history_mode(&history_mode)?,
214 natively_archived,
215 });
216 }
217 Ok(Some(sessions))
218}
219
220pub(super) fn collect_codex_candidate_paths(
221 root: &Path,
222 native_titles: &BTreeMap<String, String>,
223 candidates: &mut Vec<FileScanCandidate>,
224) -> Result<()> {
225 for entry in fs::read_dir(root)? {
226 let entry = entry?;
227 let path = entry.path();
228 let metadata = fs::symlink_metadata(&path)?;
229 if metadata.file_type().is_symlink() {
230 continue;
231 }
232 if metadata.is_dir() {
233 collect_codex_candidate_paths(&path, native_titles, candidates)?;
234 continue;
235 }
236 if !metadata.is_file() || path.extension().and_then(|value| value.to_str()) != Some("jsonl")
237 {
238 continue;
239 }
240 if let Some(session_id) = codex_rollout_id_from_path(&path)
241 && !native_titles.contains_key(session_id)
242 {
243 continue;
244 }
245 candidates.push(FileScanCandidate {
246 path,
247 modified_at: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
248 size_bytes: metadata.len(),
249 });
250 }
251 Ok(())
252}
253
254pub(super) fn codex_rollout_id_from_path(path: &Path) -> Option<&str> {
255 let stem = path.file_stem()?.to_str()?;
256 let id = stem.get(stem.len().checked_sub(36)?..)?;
257 (id.as_bytes().get(8) == Some(&b'-')
258 && id.as_bytes().get(13) == Some(&b'-')
259 && id.as_bytes().get(18) == Some(&b'-')
260 && id.as_bytes().get(23) == Some(&b'-'))
261 .then_some(id)
262}
263
264pub(super) fn codex_session_metadata(path: &Path) -> Result<Option<CodexSessionMetadata>> {
265 let file =
266 fs::File::open(path).with_context(|| format!("open Codex session {}", path.display()))?;
267 let mut reader = BufReader::new(file);
268 let mut line = String::new();
269 for _ in 0..8 {
270 line.clear();
271 if reader.read_line(&mut line)? == 0 {
272 break;
273 }
274 let record: Value = serde_json::from_str(&line)
275 .with_context(|| format!("parse Codex session {}", path.display()))?;
276 if record.get("type").and_then(Value::as_str) != Some("session_meta") {
277 continue;
278 }
279 if !codex_source_is_interactive(record.pointer("/payload/source")) {
280 return Ok(None);
281 }
282 if record
285 .pointer("/payload/ephemeral")
286 .and_then(Value::as_bool)
287 == Some(true)
288 {
289 return Ok(None);
290 }
291 let id = record
295 .pointer("/payload/id")
296 .or_else(|| record.pointer("/payload/session_id"))
297 .and_then(Value::as_str)
298 .filter(|id| !id.is_empty())
299 .map(ToOwned::to_owned);
300 if let Some(id) = id {
301 validate_id("Codex session", &id)?;
302 let cwd = record
303 .pointer("/payload/cwd")
304 .and_then(Value::as_str)
305 .filter(|cwd| !cwd.trim().is_empty())
306 .map(PathBuf::from)
307 .unwrap_or_default();
308 let git_branch = record
309 .pointer("/payload/git/branch")
310 .and_then(Value::as_str)
311 .filter(|branch| !branch.trim().is_empty())
312 .unwrap_or("HEAD")
313 .to_owned();
314 let history_mode = record
315 .pointer("/payload/history_mode")
316 .and_then(Value::as_str)
317 .map(parse_codex_history_mode)
318 .transpose()?
319 .unwrap_or(CodexHistoryMode::Legacy);
320 return Ok(Some(CodexSessionMetadata {
321 id,
322 cwd,
323 git_branch,
324 history_mode,
325 }));
326 }
327 }
328 Ok(None)
329}
330
331pub(super) fn parse_codex_history_mode(value: &str) -> Result<CodexHistoryMode> {
332 match value {
333 "legacy" => Ok(CodexHistoryMode::Legacy),
334 "paginated" => Ok(CodexHistoryMode::Paginated),
335 other => bail!("unsupported Codex history mode {other:?}"),
336 }
337}
338
339pub(super) fn codex_source_is_interactive(source: Option<&Value>) -> bool {
340 match source {
341 None => true,
343 Some(Value::String(source)) => matches!(source.as_str(), "cli" | "vscode"),
344 Some(_) => false,
347 }
348}
349
350pub(super) fn codex_native_titles(home: &Path) -> Result<BTreeMap<String, String>> {
351 let mut titles = BTreeMap::new();
352 let history = home.join("history.jsonl");
356 if history.is_file() {
357 for line in BufReader::new(fs::File::open(&history)?).lines() {
358 let record: Value = serde_json::from_str(&line?)?;
359 if let (Some(session_id), Some(text)) = (
360 record.get("session_id").and_then(Value::as_str),
361 record.get("text").and_then(Value::as_str),
362 ) && !text.trim().is_empty()
363 {
364 titles
365 .entry(session_id.to_owned())
366 .or_insert_with(|| session_id.to_owned());
367 }
368 }
369 }
370 let index = home.join("session_index.jsonl");
371 if index.is_file() {
372 for line in BufReader::new(fs::File::open(&index)?).lines() {
373 let record: Value = serde_json::from_str(&line?)?;
374 if let (Some(session_id), Some(title)) = (
375 record.get("id").and_then(Value::as_str),
376 record.get("thread_name").and_then(Value::as_str),
377 ) && let Some(title) = normalize_session_title(title)
378 {
379 titles.insert(session_id.to_owned(), title);
380 }
381 }
382 }
383 Ok(titles)
384}