Skip to main content

codex_helper_core/
sessions.rs

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/// Summary information for a Codex conversation session.
25#[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    /// RFC3339 timestamp string for the most recent assistant message, if available.
40    pub last_response_at: Option<String>,
41    /// Number of user turns (from `event_msg` user_message).
42    pub user_turns: usize,
43    /// Number of assistant messages (from `response_item` message role=assistant).
44    pub assistant_turns: usize,
45    /// Conversation rounds (best-effort; currently `min(user_turns, assistant_turns)`).
46    pub rounds: usize,
47    pub first_user_message: Option<String>,
48    pub source: SessionSummarySource,
49    pub sort_hint_ms: Option<u64>,
50}
51
52/// Basic metadata for a Codex session (best-effort parsed from JSONL).
53#[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/// A single transcript message extracted from a Codex session JSONL.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct SessionTranscriptMessage {
65    pub timestamp: Option<String>,
66    pub role: String,
67    pub text: String,
68}
69
70/// Minimal data for printing `project_root session_id` style lists.
71#[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
124/// Find recent user-facing Codex sessions across all projects. Results are ordered newest-first by updated_at.
125pub 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
170/// Find recent user-facing Codex sessions whose cwd matches a given directory
171/// (or one of its ancestors/descendants). Results are ordered newest-first by updated_at.
172pub 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
224/// Search Codex sessions for user messages containing the given substring.
225/// Matching is case-insensitive and only considers the first user message per session.
226pub 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
280/// Convenience wrapper that uses the current working directory as the root for session matching.
281pub 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
286/// Convenience wrapper to search sessions under the current working directory.
287pub 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
295/// List recent user-facing Codex sessions across all projects, filtered by session file mtime.
296///
297/// This is optimized for "resume" workflows: it avoids counting turns/timestamps and only reads the
298/// `session_meta` header for sessions that pass the recency filter.
299pub 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
537/// Find a Codex session's cwd by its session id (UUID suffix in rollout filename).
538///
539/// This is best-effort and scans session files from newest to oldest until it finds a match.
540pub 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
592/// Best-effort: locate a Codex session JSONL file by session id.
593///
594/// We first try to match the UUID suffix in the `rollout-...-<uuid>.jsonl` filename (fast path),
595/// then fall back to scanning session_meta records to match `payload.id`.
596pub 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
669/// Read the `session_meta` record from a Codex session JSONL file (best-effort).
670pub 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 in bytes at header-read time.
732    file_size: u64,
733    /// File modified time in milliseconds since epoch (used for cheap recency sorting).
734    mtime_ms: u64,
735    /// Best-effort: timestamp of the most recent JSONL record (from the file tail; only computed for displayed rows).
736    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    // Use file mtime for cheap recency ordering; this correctly surfaces sessions that were resumed
949    // (older filename timestamp but recently appended to).
950    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        // Iterate lines from the end.
1188        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        // Keep the partial first line for the next iteration.
1230        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        // Sort by timestamp desc, then UUID desc.
1329        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    // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
1340    let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1341
1342    // Timestamp format is stable and has a fixed width: "YYYY-MM-DDThh-mm-ss" (19 chars).
1343    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;