1use std::cmp::{Ordering, Reverse};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7use anyhow::{Context, Result, anyhow};
8use futures_util::StreamExt;
9use futures_util::stream;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tokio::fs;
13use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader};
14
15use crate::config::codex_sessions_dir;
16use crate::file_replace::write_bytes_file_async;
17
18mod stats_cache;
19mod transcript;
20
21use stats_cache::{SessionStatsCache, SessionStatsSnapshot};
22pub use transcript::{codex_session_transcript_tail_contains_query, read_codex_session_transcript};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum SessionSummarySource {
27 #[default]
28 LocalFile,
29 ObservedOnly,
30}
31
32#[derive(Debug, Clone)]
33pub struct SessionSummary {
34 pub id: String,
35 pub path: PathBuf,
36 pub cwd: Option<String>,
37 pub created_at: Option<String>,
38 pub updated_at: Option<String>,
39 pub last_response_at: Option<String>,
41 pub user_turns: usize,
43 pub assistant_turns: usize,
45 pub rounds: usize,
47 pub first_user_message: Option<String>,
48 pub source: SessionSummarySource,
49 pub sort_hint_ms: Option<u64>,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SessionMeta {
55 pub id: String,
56 pub cwd: Option<String>,
57 pub created_at: Option<String>,
58 #[serde(default)]
59 pub is_subagent: bool,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SessionTranscriptMessage {
65 pub timestamp: Option<String>,
66 pub role: String,
67 pub text: String,
68}
69
70#[derive(Debug, Clone)]
72pub struct RecentSession {
73 pub id: String,
74 pub cwd: Option<String>,
75 pub mtime_ms: u64,
76}
77
78#[cfg(feature = "gui")]
79#[derive(Debug, Clone)]
80pub struct SessionDayDir {
81 pub date: String,
82 pub path: PathBuf,
83}
84
85#[cfg(feature = "gui")]
86#[derive(Debug, Clone)]
87pub struct SessionIndexItem {
88 pub id: String,
89 pub path: PathBuf,
90 pub cwd: Option<String>,
91 pub created_at: Option<String>,
92 pub updated_hint: Option<String>,
93 pub mtime_ms: u64,
94 pub first_user_message: Option<String>,
95}
96
97pub fn infer_project_root_from_cwd(cwd: &str) -> String {
98 let path = std::path::PathBuf::from(cwd);
99 if !path.is_absolute() {
100 return cwd.to_string();
101 }
102
103 let canonical = std::fs::canonicalize(&path).unwrap_or(path);
104 let mut cur = canonical.clone();
105 loop {
106 if cur.join(".git").exists() {
107 return cur.to_string_lossy().to_string();
108 }
109 if !cur.pop() {
110 break;
111 }
112 }
113 canonical.to_string_lossy().to_string()
114}
115
116const MAX_SCAN_FILES: usize = 10_000;
117const HEAD_SCAN_LINES: usize = 512;
118const IO_CHUNK_SIZE: usize = 64 * 1024;
119const TAIL_SCAN_MAX_BYTES: usize = 1024 * 1024;
120const SESSION_IO_CONCURRENCY: usize = 8;
121
122const MAX_SCAN_FILES_RECENT: usize = 200_000;
123
124pub async fn find_codex_sessions(limit: usize) -> Result<Vec<SessionSummary>> {
126 let root = codex_sessions_dir();
127 find_codex_sessions_in_dir(&root, limit).await
128}
129
130async fn find_codex_sessions_in_dir(
131 sessions_dir: &Path,
132 limit: usize,
133) -> Result<Vec<SessionSummary>> {
134 if limit == 0 {
135 return Ok(Vec::new());
136 }
137 if !sessions_dir.exists() {
138 return Ok(Vec::new());
139 }
140
141 let mut headers: Vec<SessionHeader> = Vec::new();
142 let mut scanned_files: usize = 0;
143
144 let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
145
146 'outer: for (_year, year_path) in year_dirs {
147 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
148 for (_month, month_path) in month_dirs {
149 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
150 for (_day, day_path) in day_dirs {
151 let day_files = collect_rollout_files_sorted(&day_path).await?;
152 for path in day_files {
153 if scanned_files >= MAX_SCAN_FILES {
154 break 'outer;
155 }
156 scanned_files += 1;
157
158 let header_opt = read_session_header_without_cwd_match(&path).await?;
159 if let Some(header) = header_opt {
160 headers.push(header);
161 }
162 }
163 }
164 }
165 }
166
167 select_and_expand_headers(Vec::new(), headers, limit).await
168}
169
170pub async fn find_codex_sessions_for_dir(
173 root_dir: &Path,
174 limit: usize,
175) -> Result<Vec<SessionSummary>> {
176 let sessions_dir = codex_sessions_dir();
177 find_codex_sessions_for_dir_in_sessions_dir(&sessions_dir, root_dir, limit).await
178}
179
180async fn find_codex_sessions_for_dir_in_sessions_dir(
181 sessions_dir: &Path,
182 root_dir: &Path,
183 limit: usize,
184) -> Result<Vec<SessionSummary>> {
185 if !sessions_dir.exists() {
186 return Ok(Vec::new());
187 }
188
189 let mut matched: Vec<SessionHeader> = Vec::new();
190 let mut scanned_files: usize = 0;
191 let mut cwd_matcher = SessionCwdMatcher::new(root_dir);
192
193 let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
194
195 'outer: for (_year, year_path) in year_dirs {
196 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
197 for (_month, month_path) in month_dirs {
198 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
199 for (_day, day_path) in day_dirs {
200 let day_files = collect_rollout_files_sorted(&day_path).await?;
201 for path in day_files {
202 if scanned_files >= MAX_SCAN_FILES {
203 break 'outer;
204 }
205 scanned_files += 1;
206
207 let header_opt =
208 read_session_header_with_cwd_matcher(&path, Some(&mut cwd_matcher)).await?;
209 let Some(header) = header_opt else {
210 continue;
211 };
212
213 if header.is_cwd_match {
214 matched.push(header);
215 }
216 }
217 }
218 }
219 }
220
221 select_and_expand_headers(matched, Vec::new(), limit).await
222}
223
224pub async fn search_codex_sessions_for_dir(
227 root_dir: &Path,
228 query: &str,
229 limit: usize,
230) -> Result<Vec<SessionSummary>> {
231 let needle = query.to_lowercase();
232
233 let root = codex_sessions_dir();
234 if !root.exists() {
235 return Ok(Vec::new());
236 }
237
238 let mut matched: Vec<SessionHeader> = Vec::new();
239 let mut scanned_files: usize = 0;
240 let mut cwd_matcher = SessionCwdMatcher::new(root_dir);
241
242 let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
243
244 'outer: for (_year, year_path) in year_dirs {
245 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
246 for (_month, month_path) in month_dirs {
247 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
248 for (_day, day_path) in day_dirs {
249 let day_files = collect_rollout_files_sorted(&day_path).await?;
250 for path in day_files {
251 if scanned_files >= MAX_SCAN_FILES {
252 break 'outer;
253 }
254 scanned_files += 1;
255
256 let header_opt =
257 read_session_header_with_cwd_matcher(&path, Some(&mut cwd_matcher)).await?;
258 let Some(header) = header_opt else {
259 continue;
260 };
261 if !header
262 .first_user_message
263 .to_lowercase()
264 .contains(needle.as_str())
265 {
266 continue;
267 }
268
269 if header.is_cwd_match {
270 matched.push(header);
271 }
272 }
273 }
274 }
275 }
276
277 select_and_expand_headers(matched, Vec::new(), limit).await
278}
279
280pub async fn find_codex_sessions_for_current_dir(limit: usize) -> Result<Vec<SessionSummary>> {
282 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
283 find_codex_sessions_for_dir(&cwd, limit).await
284}
285
286pub async fn search_codex_sessions_for_current_dir(
288 query: &str,
289 limit: usize,
290) -> Result<Vec<SessionSummary>> {
291 let cwd = std::env::current_dir().context("failed to resolve current directory")?;
292 search_codex_sessions_for_dir(&cwd, query, limit).await
293}
294
295pub async fn find_recent_codex_sessions(
300 since: Duration,
301 limit: usize,
302) -> Result<Vec<RecentSession>> {
303 let root = codex_sessions_dir();
304 find_recent_codex_sessions_in_dir(&root, since, limit).await
305}
306
307#[cfg(feature = "gui")]
308pub async fn find_recent_codex_session_summaries(
309 since: Duration,
310 limit: usize,
311) -> Result<Vec<SessionSummary>> {
312 if limit == 0 {
313 return Ok(Vec::new());
314 }
315 let sessions_dir = codex_sessions_dir();
316 if !sessions_dir.exists() {
317 return Ok(Vec::new());
318 }
319
320 let now_ms = SystemTime::now()
321 .duration_since(UNIX_EPOCH)
322 .unwrap_or_default()
323 .as_millis()
324 .min(u64::MAX as u128) as u64;
325 let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
326 let threshold_ms = now_ms.saturating_sub(since_ms);
327
328 let mut headers: Vec<SessionHeader> = Vec::new();
329 let mut scanned_files: usize = 0;
330
331 let year_dirs = collect_dirs_desc(&sessions_dir, |s| s.parse::<u32>().ok()).await?;
332 'outer: for (_year, year_path) in year_dirs {
333 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
334 for (_month, month_path) in month_dirs {
335 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
336 for (_day, day_path) in day_dirs {
337 let day_files = collect_rollout_files_sorted(&day_path).await?;
338 for path in day_files {
339 if scanned_files >= MAX_SCAN_FILES_RECENT {
340 break 'outer;
341 }
342 scanned_files += 1;
343
344 let meta = match fs::metadata(&path).await {
345 Ok(m) => m,
346 Err(_) => continue,
347 };
348 let mtime_ms = meta
349 .modified()
350 .ok()
351 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
352 .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
353 .unwrap_or(0);
354 if mtime_ms < threshold_ms {
355 continue;
356 }
357
358 let header_opt = read_session_header_without_cwd_match(&path).await?;
359 let Some(header) = header_opt else {
360 continue;
361 };
362 headers.push(header);
363 }
364 }
365 }
366 }
367
368 select_and_expand_headers(Vec::new(), headers, limit).await
369}
370
371#[cfg(feature = "gui")]
372pub async fn list_codex_session_day_dirs(limit: usize) -> Result<Vec<SessionDayDir>> {
373 if limit == 0 {
374 return Ok(Vec::new());
375 }
376 let root = codex_sessions_dir();
377 if !root.exists() {
378 return Ok(Vec::new());
379 }
380
381 let mut out: Vec<SessionDayDir> = Vec::new();
382 let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
383 'outer: for (year, year_path) in year_dirs {
384 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
385 for (month, month_path) in month_dirs {
386 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
387 for (day, day_path) in day_dirs {
388 out.push(SessionDayDir {
389 date: format!("{year:04}-{month:02}-{day:02}"),
390 path: day_path,
391 });
392 if out.len() >= limit {
393 break 'outer;
394 }
395 }
396 }
397 }
398 Ok(out)
399}
400
401#[cfg(feature = "gui")]
402pub async fn list_codex_sessions_in_day_dir(
403 day_dir: &Path,
404 limit: usize,
405) -> Result<Vec<SessionIndexItem>> {
406 if limit == 0 {
407 return Ok(Vec::new());
408 }
409 if !day_dir.exists() {
410 return Ok(Vec::new());
411 }
412
413 let day_files = collect_rollout_files_sorted(day_dir).await?;
414 let mut out: Vec<SessionIndexItem> = Vec::new();
415 for chunk in day_files.chunks(SESSION_IO_CONCURRENCY) {
416 let mut stream = stream::iter(chunk.iter().cloned())
417 .map(|path| async move { read_session_index_item(path).await })
418 .buffer_unordered(SESSION_IO_CONCURRENCY);
419
420 while let Some(item) = stream.next().await {
421 if let Some(item) = item? {
422 out.push(item);
423 }
424 }
425 if out.len() >= limit {
426 break;
427 }
428 }
429
430 out.sort_by_key(|item| Reverse(item.mtime_ms));
431 out.truncate(limit);
432 Ok(out)
433}
434
435#[cfg(feature = "gui")]
436async fn read_session_index_item(path: PathBuf) -> Result<Option<SessionIndexItem>> {
437 let header_opt = read_session_header_without_cwd_match(&path).await?;
438 let Some(mut header) = header_opt else {
439 return Ok(None);
440 };
441 header.updated_hint = read_last_timestamp_from_tail(&header.path)
442 .await?
443 .or_else(|| header.created_at.clone());
444 Ok(Some(SessionIndexItem {
445 id: header.id,
446 path: header.path,
447 cwd: header.cwd,
448 created_at: header.created_at,
449 updated_hint: header.updated_hint,
450 mtime_ms: header.mtime_ms,
451 first_user_message: Some(header.first_user_message),
452 }))
453}
454
455async fn find_recent_codex_sessions_in_dir(
456 sessions_dir: &Path,
457 since: Duration,
458 limit: usize,
459) -> Result<Vec<RecentSession>> {
460 if limit == 0 {
461 return Ok(Vec::new());
462 }
463 if since.is_zero() {
464 return Ok(Vec::new());
465 }
466 if !sessions_dir.exists() {
467 return Ok(Vec::new());
468 }
469
470 let now_ms = SystemTime::now()
471 .duration_since(UNIX_EPOCH)
472 .unwrap_or_default()
473 .as_millis()
474 .min(u64::MAX as u128) as u64;
475 let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
476 let threshold_ms = now_ms.saturating_sub(since_ms);
477
478 let mut out: Vec<RecentSession> = Vec::new();
479 let mut scanned_files: usize = 0;
480
481 let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
482 'outer: for (_year, year_path) in year_dirs {
483 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
484 for (_month, month_path) in month_dirs {
485 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
486 for (_day, day_path) in day_dirs {
487 let day_files = collect_rollout_files_sorted(&day_path).await?;
488 for path in day_files {
489 if scanned_files >= MAX_SCAN_FILES_RECENT {
490 break 'outer;
491 }
492 scanned_files += 1;
493
494 let meta = match fs::metadata(&path).await {
495 Ok(m) => m,
496 Err(_) => continue,
497 };
498 let mtime_ms = meta
499 .modified()
500 .ok()
501 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
502 .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
503 .unwrap_or(0);
504 if mtime_ms < threshold_ms {
505 continue;
506 }
507
508 let file_id = path
509 .file_name()
510 .and_then(|s| s.to_str())
511 .and_then(parse_timestamp_and_uuid)
512 .map(|(_, uuid)| uuid);
513
514 let meta = read_codex_session_meta(&path).await?;
515 if meta.as_ref().is_some_and(|meta| meta.is_subagent) {
516 continue;
517 }
518 let (id, cwd) = if let Some(meta) = meta {
519 (meta.id, meta.cwd)
520 } else if let Some(id) = file_id {
521 (id, None)
522 } else {
523 continue;
524 };
525
526 out.push(RecentSession { id, cwd, mtime_ms });
527 }
528 }
529 }
530 }
531
532 out.sort_by_key(|item| Reverse((item.mtime_ms, item.id.clone())));
533 out.truncate(limit);
534 Ok(out)
535}
536
537pub async fn find_codex_session_cwd_by_id(session_id: &str) -> Result<Option<String>> {
541 let root = codex_sessions_dir();
542 if !root.exists() {
543 return Ok(None);
544 }
545
546 let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
547 for (_year, year_path) in year_dirs {
548 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
549 for (_month, month_path) in month_dirs {
550 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
551 for (_day, day_path) in day_dirs {
552 let day_files = collect_rollout_files_sorted(&day_path).await?;
553 for path in day_files {
554 let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
555 continue;
556 };
557 let Some((_ts, uuid)) = parse_timestamp_and_uuid(name) else {
558 continue;
559 };
560 if uuid != session_id {
561 continue;
562 }
563
564 let file = fs::File::open(&path)
565 .await
566 .with_context(|| format!("failed to open session file {:?}", path))?;
567 let reader = BufReader::new(file);
568 let mut lines = reader.lines();
569 while let Some(line) = lines.next_line().await? {
570 let line = line.trim();
571 if line.is_empty() {
572 continue;
573 }
574 let value: Value = match serde_json::from_str(line) {
575 Ok(v) => v,
576 Err(_) => continue,
577 };
578 if let Some(meta) = parse_session_meta(&value) {
579 return Ok(meta.cwd);
580 }
581 }
582
583 return Ok(None);
584 }
585 }
586 }
587 }
588
589 Ok(None)
590}
591
592pub async fn find_codex_session_file_by_id(session_id: &str) -> Result<Option<PathBuf>> {
597 Ok(find_codex_session_files_by_ids(&[session_id.to_string()])
598 .await?
599 .remove(session_id))
600}
601
602pub async fn find_codex_session_files_by_ids(
603 session_ids: &[String],
604) -> Result<HashMap<String, PathBuf>> {
605 find_codex_session_files_by_ids_in_dir(&codex_sessions_dir(), session_ids).await
606}
607
608async fn find_codex_session_files_by_ids_in_dir(
609 root: &Path,
610 session_ids: &[String],
611) -> Result<HashMap<String, PathBuf>> {
612 if !root.exists() || session_ids.is_empty() {
613 return Ok(HashMap::new());
614 }
615
616 let mut remaining = session_ids
617 .iter()
618 .map(|sid| sid.trim())
619 .filter(|sid| !sid.is_empty())
620 .map(ToOwned::to_owned)
621 .collect::<std::collections::HashSet<_>>();
622 if remaining.is_empty() {
623 return Ok(HashMap::new());
624 }
625
626 let mut found = HashMap::new();
627 let mut scanned_files: usize = 0;
628 let year_dirs = collect_dirs_desc(root, |s| s.parse::<u32>().ok()).await?;
629
630 'outer: for (_year, year_path) in year_dirs {
631 let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
632 for (_month, month_path) in month_dirs {
633 let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
634 for (_day, day_path) in day_dirs {
635 let day_files = collect_rollout_files_sorted(&day_path).await?;
636 for path in day_files {
637 if scanned_files >= MAX_SCAN_FILES || remaining.is_empty() {
638 break 'outer;
639 }
640 scanned_files += 1;
641
642 if let Some(name) = path.file_name().and_then(|s| s.to_str())
643 && let Some((_ts, uuid)) = parse_timestamp_and_uuid(name)
644 && remaining.remove(&uuid)
645 {
646 found.insert(uuid.to_string(), path.clone());
647 if remaining.is_empty() {
648 break 'outer;
649 }
650 continue;
651 }
652
653 if let Some(meta) = read_codex_session_meta(&path).await?
654 && remaining.remove(meta.id.as_str())
655 {
656 found.insert(meta.id, path);
657 if remaining.is_empty() {
658 break 'outer;
659 }
660 }
661 }
662 }
663 }
664 }
665
666 Ok(found)
667}
668
669pub async fn read_codex_session_meta(path: &Path) -> Result<Option<SessionMeta>> {
671 let file = fs::File::open(path)
672 .await
673 .with_context(|| format!("failed to open session file {:?}", path))?;
674 let reader = BufReader::new(file);
675 let mut lines = reader.lines();
676
677 let mut lines_scanned = 0usize;
678 while let Some(line) = lines.next_line().await? {
679 let trimmed = line.trim();
680 if trimmed.is_empty() {
681 continue;
682 }
683 lines_scanned += 1;
684 if lines_scanned > HEAD_SCAN_LINES {
685 break;
686 }
687
688 let value: Value = match serde_json::from_str(trimmed) {
689 Ok(v) => v,
690 Err(_) => continue,
691 };
692
693 if let Some(meta) = parse_session_meta(&value) {
694 return Ok(Some(SessionMeta {
695 id: meta.id,
696 cwd: meta.cwd,
697 created_at: meta.created_at,
698 is_subagent: meta.is_subagent,
699 }));
700 }
701 }
702
703 Ok(None)
704}
705
706#[cfg(test)]
707async fn summarize_session_for_current_dir(
708 path: &Path,
709 cwd: &Path,
710) -> Result<Option<SessionSummary>> {
711 let header_opt = read_session_header(path, cwd).await?;
712 let Some(header) = header_opt else {
713 return Ok(None);
714 };
715 Ok(Some(expand_header_to_summary_uncached(header).await?))
716}
717
718struct SessionMetaInfo {
719 id: String,
720 cwd: Option<String>,
721 created_at: Option<String>,
722 is_subagent: bool,
723}
724
725#[derive(Debug, Clone)]
726struct SessionHeader {
727 id: String,
728 path: PathBuf,
729 cwd: Option<String>,
730 created_at: Option<String>,
731 file_size: u64,
733 mtime_ms: u64,
735 updated_hint: Option<String>,
737 first_user_message: String,
738 is_cwd_match: bool,
739}
740
741fn parse_session_meta(value: &Value) -> Option<SessionMetaInfo> {
742 let obj = value.as_object()?;
743 let type_str = obj.get("type")?.as_str()?;
744 if type_str != "session_meta" {
745 return None;
746 }
747
748 let payload = obj.get("payload")?.as_object()?;
749 let id = payload.get("id").and_then(|v| v.as_str())?.to_string();
750 let cwd = payload
751 .get("cwd")
752 .and_then(|v| v.as_str())
753 .map(|s| s.to_string());
754 let created_at = payload
755 .get("timestamp")
756 .and_then(|v| v.as_str())
757 .map(|s| s.to_string())
758 .or_else(|| {
759 obj.get("timestamp")
760 .and_then(|v| v.as_str())
761 .map(|s| s.to_string())
762 });
763
764 Some(SessionMetaInfo {
765 id,
766 cwd,
767 created_at,
768 is_subagent: session_meta_payload_is_subagent(payload),
769 })
770}
771
772fn session_meta_payload_is_subagent(payload: &serde_json::Map<String, Value>) -> bool {
773 payload
774 .get("thread_source")
775 .and_then(|v| v.as_str())
776 .is_some_and(is_subagent_source_name)
777 || payload.get("source").is_some_and(value_is_subagent_source)
778 || payload
779 .get("agent_path")
780 .and_then(|v| v.as_str())
781 .is_some_and(|s| !s.trim().is_empty())
782}
783
784fn value_is_subagent_source(value: &Value) -> bool {
785 match value {
786 Value::String(source) => is_subagent_source_name(source),
787 Value::Object(map) => {
788 map.keys().any(|key| is_subagent_source_name(key))
789 || map
790 .get("kind")
791 .and_then(|v| v.as_str())
792 .is_some_and(is_subagent_source_name)
793 || map
794 .get("type")
795 .and_then(|v| v.as_str())
796 .is_some_and(is_subagent_source_name)
797 }
798 Value::Array(items) => items.iter().any(value_is_subagent_source),
799 _ => false,
800 }
801}
802
803fn is_subagent_source_name(source: &str) -> bool {
804 let normalized = source
805 .trim()
806 .chars()
807 .filter(|ch| *ch != '-' && *ch != '_')
808 .flat_map(char::to_lowercase)
809 .collect::<String>();
810 normalized.starts_with("subagent")
811}
812
813fn user_message_text(value: &Value) -> Option<&str> {
814 let obj = value.as_object()?;
815 let type_str = obj.get("type")?.as_str()?;
816 if type_str != "event_msg" {
817 return None;
818 }
819 let payload = obj.get("payload")?.as_object()?;
820 let payload_type = payload.get("type")?.as_str()?;
821 if payload_type != "user_message" {
822 return None;
823 }
824 payload.get("message").and_then(|v| v.as_str())
825}
826
827fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
828 if needle.is_empty() {
829 return true;
830 }
831 if haystack.len() < needle.len() {
832 return false;
833 }
834 haystack.windows(needle.len()).any(|w| w == needle)
835}
836
837#[cfg(test)]
838async fn read_session_header(path: &Path, cwd: &Path) -> Result<Option<SessionHeader>> {
839 let mut cwd_matcher = SessionCwdMatcher::new(cwd);
840 read_session_header_with_cwd_matcher(path, Some(&mut cwd_matcher)).await
841}
842
843async fn read_session_header_without_cwd_match(path: &Path) -> Result<Option<SessionHeader>> {
844 read_session_header_with_cwd_matcher(path, None).await
845}
846
847async fn read_session_header_with_cwd_matcher(
848 path: &Path,
849 cwd_matcher: Option<&mut SessionCwdMatcher>,
850) -> Result<Option<SessionHeader>> {
851 let meta = fs::metadata(path)
852 .await
853 .with_context(|| format!("failed to stat session file {:?}", path))?;
854 let file_size = meta.len();
855 let mtime_ms = meta
856 .modified()
857 .ok()
858 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
859 .map(|d| d.as_millis() as u64)
860 .unwrap_or(0);
861
862 let file = fs::File::open(path)
863 .await
864 .with_context(|| format!("failed to open session file {:?}", path))?;
865 let reader = BufReader::new(file);
866 let mut lines = reader.lines();
867
868 let mut session_id: Option<String> = None;
869 let mut cwd_str: Option<String> = None;
870 let mut created_at: Option<String> = None;
871 let mut first_user_message: Option<String> = None;
872
873 let mut lines_scanned = 0usize;
874 while let Some(line) = lines.next_line().await? {
875 let trimmed = line.trim();
876 if trimmed.is_empty() {
877 continue;
878 }
879 lines_scanned += 1;
880 if lines_scanned > HEAD_SCAN_LINES {
881 break;
882 }
883 let value: Value = match serde_json::from_str(trimmed) {
884 Ok(v) => v,
885 Err(_) => continue,
886 };
887
888 if session_id.is_none()
889 && let Some(meta) = parse_session_meta(&value)
890 {
891 if meta.is_subagent {
892 return Ok(None);
893 }
894 session_id = Some(meta.id);
895 cwd_str = meta.cwd;
896 created_at = meta.created_at;
897 }
898
899 if first_user_message.is_none()
900 && let Some(msg) = user_message_text(&value)
901 {
902 first_user_message = Some(msg.to_string());
903 }
904
905 if session_id.is_some() && first_user_message.is_some() {
906 break;
907 }
908 }
909
910 let Some(id) = session_id else {
911 return Ok(None);
912 };
913 let Some(first_user_message) = first_user_message else {
914 return Ok(None);
915 };
916
917 let cwd_value = cwd_str.clone();
918 let is_cwd_match =
919 if let (Some(session_cwd), Some(matcher)) = (cwd_value.as_deref(), cwd_matcher) {
920 matcher.matches(session_cwd)
921 } else {
922 false
923 };
924
925 Ok(Some(SessionHeader {
926 id,
927 path: path.to_path_buf(),
928 cwd: cwd_value,
929 created_at,
930 file_size,
931 mtime_ms,
932 updated_hint: None,
933 first_user_message,
934 is_cwd_match,
935 }))
936}
937
938async fn select_and_expand_headers(
939 matched: Vec<SessionHeader>,
940 others: Vec<SessionHeader>,
941 limit: usize,
942) -> Result<Vec<SessionSummary>> {
943 if limit == 0 {
944 return Ok(Vec::new());
945 }
946
947 let mut chosen = if !matched.is_empty() { matched } else { others };
948 chosen.sort_by_key(|item| Reverse(item.mtime_ms));
951 if chosen.len() > limit {
952 chosen.truncate(limit);
953 }
954
955 let cache = Arc::new(Mutex::new(SessionStatsCache::load_default().await));
956 let mut out: Vec<SessionSummary> = Vec::with_capacity(chosen.len().min(limit));
957 let mut stream = stream::iter(chosen)
958 .map(|header| {
959 let cache = Arc::clone(&cache);
960 async move { expand_header_to_summary_cached(cache, header).await }
961 })
962 .buffer_unordered(SESSION_IO_CONCURRENCY);
963
964 while let Some(summary) = stream.next().await {
965 out.push(summary?);
966 }
967
968 drop(stream);
969 let mut cache = Arc::try_unwrap(cache)
970 .map_err(|_| anyhow!("session stats cache still has active workers"))?
971 .into_inner()
972 .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
973 cache.save_if_dirty().await?;
974
975 sort_by_updated_desc(&mut out);
976 out.truncate(limit);
977 Ok(out)
978}
979
980fn build_summary_from_stats(
981 header: SessionHeader,
982 user_turns: usize,
983 assistant_turns: usize,
984 last_response_at: Option<String>,
985) -> SessionSummary {
986 let rounds = user_turns.min(assistant_turns);
987 let updated_at = last_response_at
988 .clone()
989 .or_else(|| header.updated_hint.clone())
990 .or_else(|| header.created_at.clone());
991
992 SessionSummary {
993 id: header.id,
994 path: header.path,
995 cwd: header.cwd,
996 created_at: header.created_at,
997 updated_at,
998 last_response_at,
999 user_turns,
1000 assistant_turns,
1001 rounds,
1002 first_user_message: Some(header.first_user_message),
1003 source: SessionSummarySource::LocalFile,
1004 sort_hint_ms: None,
1005 }
1006}
1007
1008async fn expand_header_to_summary_cached(
1009 cache: Arc<Mutex<SessionStatsCache>>,
1010 mut header: SessionHeader,
1011) -> Result<SessionSummary> {
1012 let path = header.path.clone();
1013 let key = path.to_string_lossy().to_string();
1014 let size = header.file_size;
1015 let mtime_ms = header.mtime_ms;
1016
1017 let cached = {
1018 let cache = cache
1019 .lock()
1020 .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
1021 cache.lookup(&key, mtime_ms, size)
1022 };
1023
1024 let stats = if let Some(stats) = cached {
1025 if stats.last_response_at.is_none() && header.updated_hint.is_none() {
1026 header.updated_hint = read_last_timestamp_from_tail(&path)
1027 .await?
1028 .or_else(|| header.created_at.clone());
1029 }
1030 stats
1031 } else {
1032 let (counts, tail) = tokio::join!(
1033 count_turns_in_file(&path),
1034 read_tail_timestamps(&path, true)
1035 );
1036 let (user_turns, assistant_turns) = counts?;
1037 let tail = tail?;
1038 header.updated_hint = tail.last_record_at.or_else(|| header.created_at.clone());
1039
1040 let stats = SessionStatsSnapshot {
1041 user_turns,
1042 assistant_turns,
1043 last_response_at: tail.last_assistant_at,
1044 };
1045 {
1046 let mut cache = cache
1047 .lock()
1048 .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
1049 cache.insert(key, mtime_ms, size, &stats);
1050 }
1051 stats
1052 };
1053
1054 Ok(build_summary_from_stats(
1055 header,
1056 stats.user_turns,
1057 stats.assistant_turns,
1058 stats.last_response_at,
1059 ))
1060}
1061
1062#[cfg(test)]
1063async fn expand_header_to_summary_uncached(header: SessionHeader) -> Result<SessionSummary> {
1064 let (user_turns, assistant_turns) = count_turns_in_file(&header.path).await?;
1065 let last_response_at = read_last_assistant_timestamp_from_tail(&header.path).await?;
1066 Ok(build_summary_from_stats(
1067 header,
1068 user_turns,
1069 assistant_turns,
1070 last_response_at,
1071 ))
1072}
1073
1074async fn count_turns_in_file(path: &Path) -> Result<(usize, usize)> {
1075 const USER_TURN_NEEDLE: &[u8] = br#""payload":{"type":"user_message""#;
1076 const ASSISTANT_TURN_NEEDLE: &[u8] = br#""role":"assistant""#;
1077
1078 let mut file = fs::File::open(path)
1079 .await
1080 .with_context(|| format!("failed to open session file {:?}", path))?;
1081
1082 let mut buf = vec![0u8; IO_CHUNK_SIZE];
1083 let mut user_carry: Vec<u8> = Vec::new();
1084 let mut assistant_carry: Vec<u8> = Vec::new();
1085 let mut user_total = 0usize;
1086 let mut assistant_total = 0usize;
1087 let mut user_window: Vec<u8> = Vec::with_capacity(IO_CHUNK_SIZE + USER_TURN_NEEDLE.len());
1088 let mut assistant_window: Vec<u8> =
1089 Vec::with_capacity(IO_CHUNK_SIZE + ASSISTANT_TURN_NEEDLE.len());
1090
1091 loop {
1092 let n = file.read(&mut buf).await?;
1093 if n == 0 {
1094 break;
1095 }
1096
1097 user_window.clear();
1098 user_window.extend_from_slice(&user_carry);
1099 user_window.extend_from_slice(&buf[..n]);
1100 user_total = user_total.saturating_add(count_subslice(&user_window, USER_TURN_NEEDLE));
1101
1102 assistant_window.clear();
1103 assistant_window.extend_from_slice(&assistant_carry);
1104 assistant_window.extend_from_slice(&buf[..n]);
1105 assistant_total = assistant_total
1106 .saturating_add(count_subslice(&assistant_window, ASSISTANT_TURN_NEEDLE));
1107
1108 let user_keep = USER_TURN_NEEDLE.len().saturating_sub(1);
1109 user_carry = if user_keep > 0 && user_window.len() >= user_keep {
1110 user_window[user_window.len() - user_keep..].to_vec()
1111 } else {
1112 Vec::new()
1113 };
1114
1115 let assistant_keep = ASSISTANT_TURN_NEEDLE.len().saturating_sub(1);
1116 assistant_carry = if assistant_keep > 0 && assistant_window.len() >= assistant_keep {
1117 assistant_window[assistant_window.len() - assistant_keep..].to_vec()
1118 } else {
1119 Vec::new()
1120 };
1121 }
1122
1123 Ok((user_total, assistant_total))
1124}
1125
1126fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
1127 if needle.is_empty() {
1128 return 0;
1129 }
1130 if haystack.len() < needle.len() {
1131 return 0;
1132 }
1133 haystack
1134 .windows(needle.len())
1135 .filter(|w| *w == needle)
1136 .count()
1137}
1138
1139#[derive(Debug, Default)]
1140struct TailTimestamps {
1141 last_record_at: Option<String>,
1142 last_assistant_at: Option<String>,
1143}
1144
1145async fn read_last_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1146 Ok(read_tail_timestamps(path, false).await?.last_record_at)
1147}
1148
1149#[cfg(test)]
1150async fn read_last_assistant_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
1151 Ok(read_tail_timestamps(path, true).await?.last_assistant_at)
1152}
1153
1154async fn read_tail_timestamps(path: &Path, include_assistant: bool) -> Result<TailTimestamps> {
1155 const ASSISTANT_ROLE_NEEDLE: &[u8] = br#""role":"assistant""#;
1156
1157 let mut file = fs::File::open(path)
1158 .await
1159 .with_context(|| format!("failed to open session file {:?}", path))?;
1160 let meta = file
1161 .metadata()
1162 .await
1163 .with_context(|| format!("failed to stat session file {:?}", path))?;
1164 let mut pos = meta.len();
1165 if pos == 0 {
1166 return Ok(TailTimestamps::default());
1167 }
1168
1169 let mut scanned = 0usize;
1170 let mut carry: Vec<u8> = Vec::new();
1171 let chunk_size = IO_CHUNK_SIZE as u64;
1172 let mut found = TailTimestamps::default();
1173
1174 while pos > 0 && scanned < TAIL_SCAN_MAX_BYTES {
1175 let start = pos.saturating_sub(chunk_size);
1176 let size = (pos - start) as usize;
1177 file.seek(std::io::SeekFrom::Start(start)).await?;
1178
1179 let mut chunk = vec![0u8; size];
1180 file.read_exact(&mut chunk).await?;
1181 scanned = scanned.saturating_add(size);
1182
1183 if !carry.is_empty() {
1184 chunk.extend_from_slice(&carry);
1185 }
1186
1187 let mut end = chunk.len();
1189 while end > 0 {
1190 let mut begin = end;
1191 while begin > 0 && chunk[begin - 1] != b'\n' {
1192 begin -= 1;
1193 }
1194 let line = chunk[begin..end].trim_ascii();
1195 end = begin.saturating_sub(1);
1196
1197 if line.is_empty() {
1198 continue;
1199 }
1200
1201 let wants_record = found.last_record_at.is_none();
1202 let wants_assistant = include_assistant
1203 && found.last_assistant_at.is_none()
1204 && contains_bytes(line, ASSISTANT_ROLE_NEEDLE);
1205 if !wants_record && !wants_assistant {
1206 continue;
1207 }
1208
1209 let value: Value = match serde_json::from_slice(line) {
1210 Ok(v) => v,
1211 Err(_) => continue,
1212 };
1213 if let Some(ts) = value.get("timestamp").and_then(|v| v.as_str()) {
1214 let ts = ts.to_string();
1215 if wants_record {
1216 found.last_record_at = Some(ts.clone());
1217 }
1218 if wants_assistant {
1219 found.last_assistant_at = Some(ts);
1220 }
1221 if found.last_record_at.is_some()
1222 && (!include_assistant || found.last_assistant_at.is_some())
1223 {
1224 return Ok(found);
1225 }
1226 }
1227 }
1228
1229 if let Some(first_nl) = chunk.iter().position(|b| *b == b'\n') {
1231 carry = chunk[..first_nl].to_vec();
1232 } else {
1233 carry = chunk;
1234 }
1235
1236 pos = start;
1237 }
1238
1239 Ok(found)
1240}
1241
1242struct SessionCwdMatcher {
1243 current: PathBuf,
1244 cache: HashMap<String, bool>,
1245}
1246
1247impl SessionCwdMatcher {
1248 fn new(current_dir: &Path) -> Self {
1249 Self {
1250 current: std::fs::canonicalize(current_dir)
1251 .unwrap_or_else(|_| current_dir.to_path_buf()),
1252 cache: HashMap::new(),
1253 }
1254 }
1255
1256 fn matches(&mut self, session_cwd: &str) -> bool {
1257 if let Some(matched) = self.cache.get(session_cwd) {
1258 return *matched;
1259 }
1260
1261 let session_path = PathBuf::from(session_cwd);
1262 let matched = if session_path.is_absolute() {
1263 let cwd = std::fs::canonicalize(&session_path).unwrap_or(session_path);
1264 self.current == cwd || self.current.starts_with(&cwd) || cwd.starts_with(&self.current)
1265 } else {
1266 false
1267 };
1268
1269 self.cache.insert(session_cwd.to_string(), matched);
1270 matched
1271 }
1272}
1273
1274#[cfg(test)]
1275fn path_matches_current_dir(session_cwd: &str, current_dir: &Path) -> bool {
1276 let mut matcher = SessionCwdMatcher::new(current_dir);
1277 matcher.matches(session_cwd)
1278}
1279
1280async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> std::io::Result<Vec<(T, PathBuf)>>
1281where
1282 T: Ord + Copy,
1283 F: Fn(&str) -> Option<T>,
1284{
1285 let mut dir = fs::read_dir(parent).await?;
1286 let mut vec: Vec<(T, PathBuf)> = Vec::new();
1287 while let Some(entry) = dir.next_entry().await? {
1288 if entry
1289 .file_type()
1290 .await
1291 .map(|ft| ft.is_dir())
1292 .unwrap_or(false)
1293 && let Some(s) = entry.file_name().to_str()
1294 && let Some(v) = parse(s)
1295 {
1296 vec.push((v, entry.path()));
1297 }
1298 }
1299 vec.sort_by_key(|(v, _)| Reverse(*v));
1300 Ok(vec)
1301}
1302
1303async fn collect_rollout_files_sorted(parent: &Path) -> std::io::Result<Vec<PathBuf>> {
1304 let mut dir = fs::read_dir(parent).await?;
1305 let mut records: Vec<(String, String, PathBuf)> = Vec::new();
1306
1307 while let Some(entry) = dir.next_entry().await? {
1308 if entry
1309 .file_type()
1310 .await
1311 .map(|ft| ft.is_file())
1312 .unwrap_or(false)
1313 {
1314 let name_os = entry.file_name();
1315 let Some(name) = name_os.to_str() else {
1316 continue;
1317 };
1318 if !name.starts_with("rollout-") || !name.ends_with(".jsonl") {
1319 continue;
1320 }
1321 if let Some((ts, uuid)) = parse_timestamp_and_uuid(name) {
1322 records.push((ts, uuid, entry.path()));
1323 }
1324 }
1325 }
1326
1327 records.sort_by(|a, b| {
1328 match b.0.cmp(&a.0) {
1330 Ordering::Equal => b.1.cmp(&a.1),
1331 other => other,
1332 }
1333 });
1334
1335 Ok(records.into_iter().map(|(_, _, path)| path).collect())
1336}
1337
1338fn parse_timestamp_and_uuid(name: &str) -> Option<(String, String)> {
1339 let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1341
1342 const TS_LEN: usize = 19;
1344 if core.len() <= TS_LEN + 1 {
1345 return None;
1346 }
1347 let (ts, rest) = core.split_at(TS_LEN);
1348 let uuid = rest.strip_prefix('-')?;
1349 if uuid.is_empty() {
1350 return None;
1351 }
1352 Some((ts.to_string(), uuid.to_string()))
1353}
1354
1355fn sort_by_updated_desc(vec: &mut [SessionSummary]) {
1356 vec.sort_by(|a, b| {
1357 let ta = a.updated_at.as_deref();
1358 let tb = b.updated_at.as_deref();
1359 match (ta, tb) {
1360 (Some(ta), Some(tb)) => tb.cmp(ta),
1361 (Some(_), None) => Ordering::Less,
1362 (None, Some(_)) => Ordering::Greater,
1363 (None, None) => Ordering::Equal,
1364 }
1365 });
1366}
1367
1368#[cfg(test)]
1369mod tests;