1use chrono::{DateTime, Utc};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::HashSet;
5use std::fs::{self, File};
6use std::io::{BufRead, BufReader};
7use std::path::{Path, PathBuf};
8
9const MAX_HEAD_LINES: usize = 500;
10const MAX_CODEX_SCAN: usize = 400;
11const MAX_TITLE: usize = 200;
12pub const DEFAULT_TRANSCRIPT_LIMIT: usize = 100;
13
14#[derive(Debug, Clone, Serialize, PartialEq)]
15#[serde(rename_all = "camelCase")]
16pub struct AgentTranscript {
17 pub id: String,
18 pub provider: String,
19 pub cwd: String,
20 pub title: String,
21 pub started_at: String,
22 pub updated_at: String,
23}
24
25fn path_key(value: &str) -> String {
26 let mut key = String::new();
27 let mut separated = true;
28 for character in value.chars() {
29 if character.is_ascii_alphanumeric() {
30 key.push(character.to_ascii_lowercase());
31 separated = false;
32 } else if !separated && !key.is_empty() {
33 key.push('-');
34 separated = true;
35 }
36 }
37 if key.ends_with('-') {
38 key.pop();
39 }
40 key
41}
42
43fn title(text: &str) -> Option<String> {
44 let trimmed = text.trim();
45 if trimmed.is_empty()
46 || trimmed.starts_with('<')
47 || trimmed.starts_with("# AGENTS.md instructions for ")
48 {
49 return None;
50 }
51 let collapsed = trimmed.split_whitespace().collect::<Vec<_>>().join(" ");
52 if collapsed.chars().count() <= MAX_TITLE {
53 return Some(collapsed);
54 }
55 let shortened = collapsed.chars().take(MAX_TITLE - 1).collect::<String>();
56 Some(format!("{}…", shortened.trim_end()))
57}
58
59fn content_text(content: &Value) -> String {
60 match content {
61 Value::String(text) => text.clone(),
62 Value::Array(blocks) => blocks
63 .iter()
64 .filter_map(|block| block.get("text").and_then(Value::as_str))
65 .collect(),
66 _ => String::new(),
67 }
68}
69
70fn modified_at(path: &Path) -> Option<String> {
71 let modified = fs::metadata(path).ok()?.modified().ok()?;
72 Some(DateTime::<Utc>::from(modified).to_rfc3339())
73}
74
75fn json_lines(path: &Path) -> impl Iterator<Item = Value> {
76 File::open(path)
77 .ok()
78 .into_iter()
79 .flat_map(|file| BufReader::new(file).lines().take(MAX_HEAD_LINES))
80 .filter_map(Result::ok)
81 .filter_map(|line| serde_json::from_str(&line).ok())
82}
83
84fn read_claude(path: &Path, fallback_id: &str) -> Option<AgentTranscript> {
85 let mut id = None;
86 let mut cwd = None;
87 let mut first_prompt = None;
88 let mut started_at = None;
89 for entry in json_lines(path) {
90 if id.is_none() {
91 id = entry
92 .get("sessionId")
93 .and_then(Value::as_str)
94 .map(str::to_owned);
95 }
96 if cwd.is_none() {
97 cwd = entry.get("cwd").and_then(Value::as_str).map(str::to_owned);
98 }
99 if started_at.is_none() {
100 started_at = entry
101 .get("timestamp")
102 .and_then(Value::as_str)
103 .map(str::to_owned);
104 }
105 if first_prompt.is_none()
106 && entry.get("type").and_then(Value::as_str) == Some("user")
107 && entry.get("isSidechain").and_then(Value::as_bool) != Some(true)
108 {
109 first_prompt = entry
110 .get("message")
111 .and_then(|message| message.get("content"))
112 .and_then(|content| title(&content_text(content)));
113 }
114 if cwd.is_some() && first_prompt.is_some() {
115 break;
116 }
117 }
118 let updated_at = modified_at(path)?;
119 Some(AgentTranscript {
120 id: id.unwrap_or_else(|| fallback_id.to_string()),
121 provider: "claude".to_string(),
122 cwd: cwd?,
123 title: first_prompt?,
124 started_at: started_at.unwrap_or_else(|| updated_at.clone()),
125 updated_at,
126 })
127}
128
129fn is_codex_subagent_thread(payload: &Value) -> bool {
135 payload.get("thread_source").and_then(Value::as_str) == Some("subagent")
136 || payload
137 .get("forked_from_id")
138 .and_then(Value::as_str)
139 .is_some()
140 || payload
141 .get("parent_thread_id")
142 .and_then(Value::as_str)
143 .is_some()
144}
145
146fn read_codex(path: &Path, expected_cwd: Option<&str>) -> Option<AgentTranscript> {
147 let mut id = None;
148 let mut cwd = None;
149 let mut first_prompt = None;
150 let mut started_at = None;
151 for entry in json_lines(path) {
152 let Some(payload) = entry.get("payload") else {
153 continue;
154 };
155 if entry.get("type").and_then(Value::as_str) == Some("session_meta") {
156 if is_codex_subagent_thread(payload) {
157 return None;
158 }
159 id = payload
160 .get("session_id")
161 .or_else(|| payload.get("id"))
162 .and_then(Value::as_str)
163 .map(str::to_owned);
164 cwd = payload
165 .get("cwd")
166 .and_then(Value::as_str)
167 .map(str::to_owned);
168 started_at = payload
169 .get("timestamp")
170 .and_then(Value::as_str)
171 .map(str::to_owned);
172 if expected_cwd.is_some() && cwd.as_deref() != expected_cwd {
173 return None;
174 }
175 } else if first_prompt.is_none()
176 && payload.get("type").and_then(Value::as_str) == Some("message")
177 && payload.get("role").and_then(Value::as_str) == Some("user")
178 {
179 first_prompt = payload
180 .get("content")
181 .and_then(|content| title(&content_text(content)));
182 }
183 if id.is_some() && first_prompt.is_some() {
184 break;
185 }
186 }
187 let updated_at = modified_at(path)?;
188 Some(AgentTranscript {
189 id: id?,
190 provider: "codex".to_string(),
191 cwd: cwd.filter(|value| expected_cwd.map_or(true, |expected| value == expected))?,
192 title: first_prompt?,
193 started_at: started_at.unwrap_or_else(|| updated_at.clone()),
194 updated_at,
195 })
196}
197
198fn dedupe_by_session(transcripts: &mut Vec<AgentTranscript>) {
203 let mut seen = HashSet::new();
204 transcripts.retain(|transcript| seen.insert(transcript.id.clone()));
205}
206
207fn directory_names(path: &Path) -> Vec<String> {
208 fs::read_dir(path)
209 .ok()
210 .into_iter()
211 .flatten()
212 .filter_map(Result::ok)
213 .filter_map(|entry| entry.file_name().into_string().ok())
214 .collect()
215}
216
217fn codex_files(codex_home: &Path) -> Vec<PathBuf> {
218 let root = codex_home.join("sessions");
219 let mut files = Vec::new();
220 let mut years = directory_names(&root);
221 years.sort_by(|a, b| b.cmp(a));
222 for year in years {
223 let year_path = root.join(year);
224 let mut months = directory_names(&year_path);
225 months.sort_by(|a, b| b.cmp(a));
226 for month in months {
227 let month_path = year_path.join(month);
228 let mut days = directory_names(&month_path);
229 days.sort_by(|a, b| b.cmp(a));
230 for day in days {
231 let day_path = month_path.join(day);
232 let mut names = directory_names(&day_path);
233 names.sort_by(|a, b| b.cmp(a));
234 files.extend(
235 names
236 .into_iter()
237 .filter(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
238 .map(|name| day_path.join(name)),
239 );
240 if files.len() >= MAX_CODEX_SCAN {
241 files.truncate(MAX_CODEX_SCAN);
242 return files;
243 }
244 }
245 }
246 }
247 files
248}
249
250pub fn default_transcript_homes() -> (PathBuf, PathBuf) {
256 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
257 let codex = std::env::var_os("CODEX_HOME")
258 .map(PathBuf::from)
259 .unwrap_or_else(|| home.join(".codex"));
260 (home, codex)
261}
262
263pub fn list_agent_transcripts(
264 home: &Path,
265 codex_home: &Path,
266 repo_path: Option<&str>,
267 limit_per_provider: usize,
268) -> Vec<AgentTranscript> {
269 let mut claude = Vec::new();
270 let claude_root = home.join(".claude").join("projects");
271 let expected_key = repo_path.map(path_key);
272 for directory in directory_names(&claude_root).into_iter().filter(|name| {
273 expected_key
274 .as_ref()
275 .map_or(true, |expected| path_key(name) == *expected)
276 }) {
277 let path = claude_root.join(directory);
278 for name in directory_names(&path)
279 .into_iter()
280 .filter(|name| name.ends_with(".jsonl"))
281 {
282 if let Some(transcript) =
283 read_claude(&path.join(&name), name.trim_end_matches(".jsonl"))
284 {
285 if repo_path.map_or(true, |expected| transcript.cwd == expected) {
286 claude.push(transcript);
287 }
288 }
289 }
290 }
291 let mut codex = codex_files(codex_home)
292 .iter()
293 .filter_map(|path| read_codex(path, repo_path))
294 .collect::<Vec<_>>();
295 claude.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
296 codex.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
297 dedupe_by_session(&mut claude);
298 dedupe_by_session(&mut codex);
299 claude.truncate(limit_per_provider);
300 codex.truncate(limit_per_provider);
301 let mut transcripts = claude;
302 transcripts.extend(codex);
303 transcripts.sort_by(|left, right| right.updated_at.cmp(&left.updated_at));
304 transcripts
305}
306
307#[cfg(test)]
308mod tests {
309 use super::list_agent_transcripts;
310 use std::fs;
311
312 #[test]
313 fn lists_claude_and_codex_sessions_for_the_requested_repository() {
314 let home = std::env::temp_dir().join(format!(
315 "nomoreide-agent-transcripts-{}",
316 uuid::Uuid::new_v4()
317 ));
318 let repo = "/tmp/work/repo";
319 let claude_dir = home.join(".claude/projects/-tmp-work-repo");
320 let codex_dir = home.join(".codex/sessions/2026/07/25");
321 fs::create_dir_all(&claude_dir).unwrap();
322 fs::create_dir_all(&codex_dir).unwrap();
323 fs::write(
324 claude_dir.join("dce2b69c-0fb4-4bd3-b456-b2bef4230c81.jsonl"),
325 concat!(
326 "{\"type\":\"mode\",\"sessionId\":\"dce2b69c-0fb4-4bd3-b456-b2bef4230c81\"}\n",
327 "{\"type\":\"user\",\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T01:00:00Z\",",
328 "\"message\":{\"content\":\"Resume the dock\"}}\n"
329 ),
330 )
331 .unwrap();
332 fs::write(
333 codex_dir.join(
334 "rollout-2026-07-25T02-00-00-019f7c82-cb32-73d3-9ffd-7425ddb8dbb4.jsonl",
335 ),
336 concat!(
337 "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"019f7c82-cb32-73d3-9ffd-7425ddb8dbb4\",",
338 "\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:00:00Z\"}}\n",
339 "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
340 "\"content\":[{\"type\":\"input_text\",\"text\":\"Finish conversation history\"}]}}\n"
341 ),
342 )
343 .unwrap();
344
345 let transcripts = list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 30);
346
347 assert_eq!(transcripts.len(), 2);
348 assert!(transcripts
349 .iter()
350 .any(|row| { row.provider == "claude" && row.title == "Resume the dock" }));
351 assert!(transcripts
352 .iter()
353 .any(|row| { row.provider == "codex" && row.title == "Finish conversation history" }));
354 assert_eq!(
355 list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 1).len(),
356 2,
357 );
358 fs::remove_dir_all(home).unwrap();
359 }
360
361 #[test]
362 fn drops_codex_subagent_threads_that_repeat_their_parents_session_id() {
363 let home = std::env::temp_dir().join(format!(
364 "nomoreide-agent-subagents-{}",
365 uuid::Uuid::new_v4()
366 ));
367 let repo = "/tmp/work/repo";
368 let codex_dir = home.join(".codex/sessions/2026/07/25");
369 fs::create_dir_all(&codex_dir).unwrap();
370 fs::write(
371 codex_dir.join("rollout-2026-07-25T02-00-00-parent.jsonl"),
372 concat!(
373 "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"parent\",\"id\":\"parent\",",
374 "\"thread_source\":\"user\",\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:00:00Z\"}}\n",
375 "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
376 "\"content\":[{\"type\":\"input_text\",\"text\":\"the real task\"}]}}\n"
377 ),
378 )
379 .unwrap();
380 fs::write(
382 codex_dir.join("rollout-2026-07-25T02-05-00-child.jsonl"),
383 concat!(
384 "{\"type\":\"session_meta\",\"payload\":{\"session_id\":\"parent\",\"id\":\"child\",",
385 "\"forked_from_id\":\"parent\",\"parent_thread_id\":\"parent\",\"thread_source\":\"subagent\",",
386 "\"cwd\":\"/tmp/work/repo\",\"timestamp\":\"2026-07-25T02:05:00Z\"}}\n",
387 "{\"type\":\"response_item\",\"payload\":{\"type\":\"message\",\"role\":\"user\",",
388 "\"content\":[{\"type\":\"input_text\",\"text\":\"subagent instruction\"}]}}\n"
389 ),
390 )
391 .unwrap();
392
393 let transcripts = list_agent_transcripts(&home, &home.join(".codex"), Some(repo), 30);
394
395 assert_eq!(transcripts.len(), 1);
396 assert_eq!(transcripts[0].id, "parent");
397 assert_eq!(transcripts[0].title, "the real task");
398 fs::remove_dir_all(home).unwrap();
399 }
400}