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
78pub fn infer_project_root_from_cwd(cwd: &str) -> String {
79    let path = std::path::PathBuf::from(cwd);
80    if !path.is_absolute() {
81        return cwd.to_string();
82    }
83
84    let canonical = std::fs::canonicalize(&path).unwrap_or(path);
85    let mut cur = canonical.clone();
86    loop {
87        if cur.join(".git").exists() {
88            return cur.to_string_lossy().to_string();
89        }
90        if !cur.pop() {
91            break;
92        }
93    }
94    canonical.to_string_lossy().to_string()
95}
96
97const MAX_SCAN_FILES: usize = 10_000;
98const HEAD_SCAN_LINES: usize = 512;
99const IO_CHUNK_SIZE: usize = 64 * 1024;
100const TAIL_SCAN_MAX_BYTES: usize = 1024 * 1024;
101const SESSION_IO_CONCURRENCY: usize = 8;
102
103const MAX_SCAN_FILES_RECENT: usize = 200_000;
104
105/// Find recent user-facing Codex sessions across all projects. Results are ordered newest-first by updated_at.
106pub async fn find_codex_sessions(limit: usize) -> Result<Vec<SessionSummary>> {
107    let root = codex_sessions_dir();
108    find_codex_sessions_in_dir(&root, limit).await
109}
110
111async fn find_codex_sessions_in_dir(
112    sessions_dir: &Path,
113    limit: usize,
114) -> Result<Vec<SessionSummary>> {
115    if limit == 0 {
116        return Ok(Vec::new());
117    }
118    if !sessions_dir.exists() {
119        return Ok(Vec::new());
120    }
121
122    let mut headers: Vec<SessionHeader> = Vec::new();
123    let mut scanned_files: usize = 0;
124
125    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
126
127    'outer: for (_year, year_path) in year_dirs {
128        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
129        for (_month, month_path) in month_dirs {
130            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
131            for (_day, day_path) in day_dirs {
132                let day_files = collect_rollout_files_sorted(&day_path).await?;
133                for path in day_files {
134                    if scanned_files >= MAX_SCAN_FILES {
135                        break 'outer;
136                    }
137                    scanned_files += 1;
138
139                    let header_opt = read_session_header_without_cwd_match(&path).await?;
140                    if let Some(header) = header_opt {
141                        headers.push(header);
142                    }
143                }
144            }
145        }
146    }
147
148    select_and_expand_headers(Vec::new(), headers, limit).await
149}
150
151/// Find recent user-facing Codex sessions whose cwd matches a given directory
152/// (or one of its ancestors/descendants). Results are ordered newest-first by updated_at.
153pub async fn find_codex_sessions_for_dir(
154    root_dir: &Path,
155    limit: usize,
156) -> Result<Vec<SessionSummary>> {
157    let sessions_dir = codex_sessions_dir();
158    find_codex_sessions_for_dir_in_sessions_dir(&sessions_dir, root_dir, limit).await
159}
160
161async fn find_codex_sessions_for_dir_in_sessions_dir(
162    sessions_dir: &Path,
163    root_dir: &Path,
164    limit: usize,
165) -> Result<Vec<SessionSummary>> {
166    if !sessions_dir.exists() {
167        return Ok(Vec::new());
168    }
169
170    let mut matched: Vec<SessionHeader> = Vec::new();
171    let mut scanned_files: usize = 0;
172    let mut cwd_matcher = SessionCwdMatcher::new(root_dir);
173
174    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
175
176    'outer: for (_year, year_path) in year_dirs {
177        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
178        for (_month, month_path) in month_dirs {
179            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
180            for (_day, day_path) in day_dirs {
181                let day_files = collect_rollout_files_sorted(&day_path).await?;
182                for path in day_files {
183                    if scanned_files >= MAX_SCAN_FILES {
184                        break 'outer;
185                    }
186                    scanned_files += 1;
187
188                    let header_opt =
189                        read_session_header_with_cwd_matcher(&path, Some(&mut cwd_matcher)).await?;
190                    let Some(header) = header_opt else {
191                        continue;
192                    };
193
194                    if header.is_cwd_match {
195                        matched.push(header);
196                    }
197                }
198            }
199        }
200    }
201
202    select_and_expand_headers(matched, Vec::new(), limit).await
203}
204
205/// Search Codex sessions for user messages containing the given substring.
206/// Matching is case-insensitive and only considers the first user message per session.
207pub async fn search_codex_sessions_for_dir(
208    root_dir: &Path,
209    query: &str,
210    limit: usize,
211) -> Result<Vec<SessionSummary>> {
212    let needle = query.to_lowercase();
213
214    let root = codex_sessions_dir();
215    if !root.exists() {
216        return Ok(Vec::new());
217    }
218
219    let mut matched: Vec<SessionHeader> = Vec::new();
220    let mut scanned_files: usize = 0;
221    let mut cwd_matcher = SessionCwdMatcher::new(root_dir);
222
223    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
224
225    'outer: for (_year, year_path) in year_dirs {
226        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
227        for (_month, month_path) in month_dirs {
228            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
229            for (_day, day_path) in day_dirs {
230                let day_files = collect_rollout_files_sorted(&day_path).await?;
231                for path in day_files {
232                    if scanned_files >= MAX_SCAN_FILES {
233                        break 'outer;
234                    }
235                    scanned_files += 1;
236
237                    let header_opt =
238                        read_session_header_with_cwd_matcher(&path, Some(&mut cwd_matcher)).await?;
239                    let Some(header) = header_opt else {
240                        continue;
241                    };
242                    if !header
243                        .first_user_message
244                        .to_lowercase()
245                        .contains(needle.as_str())
246                    {
247                        continue;
248                    }
249
250                    if header.is_cwd_match {
251                        matched.push(header);
252                    }
253                }
254            }
255        }
256    }
257
258    select_and_expand_headers(matched, Vec::new(), limit).await
259}
260
261/// Convenience wrapper that uses the current working directory as the root for session matching.
262pub async fn find_codex_sessions_for_current_dir(limit: usize) -> Result<Vec<SessionSummary>> {
263    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
264    find_codex_sessions_for_dir(&cwd, limit).await
265}
266
267/// Convenience wrapper to search sessions under the current working directory.
268pub async fn search_codex_sessions_for_current_dir(
269    query: &str,
270    limit: usize,
271) -> Result<Vec<SessionSummary>> {
272    let cwd = std::env::current_dir().context("failed to resolve current directory")?;
273    search_codex_sessions_for_dir(&cwd, query, limit).await
274}
275
276/// List recent user-facing Codex sessions across all projects, filtered by session file mtime.
277///
278/// This is optimized for "resume" workflows: it avoids counting turns/timestamps and only reads the
279/// `session_meta` header for sessions that pass the recency filter.
280pub async fn find_recent_codex_sessions(
281    since: Duration,
282    limit: usize,
283) -> Result<Vec<RecentSession>> {
284    let root = codex_sessions_dir();
285    find_recent_codex_sessions_in_dir(&root, since, limit).await
286}
287
288async fn find_recent_codex_sessions_in_dir(
289    sessions_dir: &Path,
290    since: Duration,
291    limit: usize,
292) -> Result<Vec<RecentSession>> {
293    if limit == 0 {
294        return Ok(Vec::new());
295    }
296    if since.is_zero() {
297        return Ok(Vec::new());
298    }
299    if !sessions_dir.exists() {
300        return Ok(Vec::new());
301    }
302
303    let now_ms = SystemTime::now()
304        .duration_since(UNIX_EPOCH)
305        .unwrap_or_default()
306        .as_millis()
307        .min(u64::MAX as u128) as u64;
308    let since_ms = since.as_millis().min(u64::MAX as u128) as u64;
309    let threshold_ms = now_ms.saturating_sub(since_ms);
310
311    let mut out: Vec<RecentSession> = Vec::new();
312    let mut scanned_files: usize = 0;
313
314    let year_dirs = collect_dirs_desc(sessions_dir, |s| s.parse::<u32>().ok()).await?;
315    'outer: for (_year, year_path) in year_dirs {
316        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
317        for (_month, month_path) in month_dirs {
318            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
319            for (_day, day_path) in day_dirs {
320                let day_files = collect_rollout_files_sorted(&day_path).await?;
321                for path in day_files {
322                    if scanned_files >= MAX_SCAN_FILES_RECENT {
323                        break 'outer;
324                    }
325                    scanned_files += 1;
326
327                    let meta = match fs::metadata(&path).await {
328                        Ok(m) => m,
329                        Err(_) => continue,
330                    };
331                    let mtime_ms = meta
332                        .modified()
333                        .ok()
334                        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
335                        .map(|d| d.as_millis().min(u64::MAX as u128) as u64)
336                        .unwrap_or(0);
337                    if mtime_ms < threshold_ms {
338                        continue;
339                    }
340
341                    let file_id = path
342                        .file_name()
343                        .and_then(|s| s.to_str())
344                        .and_then(parse_timestamp_and_uuid)
345                        .map(|(_, uuid)| uuid);
346
347                    let meta = read_codex_session_meta(&path).await?;
348                    if meta.as_ref().is_some_and(|meta| meta.is_subagent) {
349                        continue;
350                    }
351                    let (id, cwd) = if let Some(meta) = meta {
352                        (meta.id, meta.cwd)
353                    } else if let Some(id) = file_id {
354                        (id, None)
355                    } else {
356                        continue;
357                    };
358
359                    out.push(RecentSession { id, cwd, mtime_ms });
360                }
361            }
362        }
363    }
364
365    out.sort_by_key(|item| Reverse((item.mtime_ms, item.id.clone())));
366    out.truncate(limit);
367    Ok(out)
368}
369
370/// Find a Codex session's cwd by its session id (UUID suffix in rollout filename).
371///
372/// This is best-effort and scans session files from newest to oldest until it finds a match.
373pub async fn find_codex_session_cwd_by_id(session_id: &str) -> Result<Option<String>> {
374    let root = codex_sessions_dir();
375    if !root.exists() {
376        return Ok(None);
377    }
378
379    let year_dirs = collect_dirs_desc(&root, |s| s.parse::<u32>().ok()).await?;
380    for (_year, year_path) in year_dirs {
381        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
382        for (_month, month_path) in month_dirs {
383            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
384            for (_day, day_path) in day_dirs {
385                let day_files = collect_rollout_files_sorted(&day_path).await?;
386                for path in day_files {
387                    let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
388                        continue;
389                    };
390                    let Some((_ts, uuid)) = parse_timestamp_and_uuid(name) else {
391                        continue;
392                    };
393                    if uuid != session_id {
394                        continue;
395                    }
396
397                    let file = fs::File::open(&path)
398                        .await
399                        .with_context(|| format!("failed to open session file {:?}", path))?;
400                    let reader = BufReader::new(file);
401                    let mut lines = reader.lines();
402                    while let Some(line) = lines.next_line().await? {
403                        let line = line.trim();
404                        if line.is_empty() {
405                            continue;
406                        }
407                        let value: Value = match serde_json::from_str(line) {
408                            Ok(v) => v,
409                            Err(_) => continue,
410                        };
411                        if let Some(meta) = parse_session_meta(&value) {
412                            return Ok(meta.cwd);
413                        }
414                    }
415
416                    return Ok(None);
417                }
418            }
419        }
420    }
421
422    Ok(None)
423}
424
425/// Best-effort: locate a Codex session JSONL file by session id.
426///
427/// We first try to match the UUID suffix in the `rollout-...-<uuid>.jsonl` filename (fast path),
428/// then fall back to scanning session_meta records to match `payload.id`.
429pub async fn find_codex_session_file_by_id(session_id: &str) -> Result<Option<PathBuf>> {
430    Ok(find_codex_session_files_by_ids(&[session_id.to_string()])
431        .await?
432        .remove(session_id))
433}
434
435pub async fn find_codex_session_files_by_ids(
436    session_ids: &[String],
437) -> Result<HashMap<String, PathBuf>> {
438    find_codex_session_files_by_ids_in_dir(&codex_sessions_dir(), session_ids).await
439}
440
441async fn find_codex_session_files_by_ids_in_dir(
442    root: &Path,
443    session_ids: &[String],
444) -> Result<HashMap<String, PathBuf>> {
445    if !root.exists() || session_ids.is_empty() {
446        return Ok(HashMap::new());
447    }
448
449    let mut remaining = session_ids
450        .iter()
451        .map(|sid| sid.trim())
452        .filter(|sid| !sid.is_empty())
453        .map(ToOwned::to_owned)
454        .collect::<std::collections::HashSet<_>>();
455    if remaining.is_empty() {
456        return Ok(HashMap::new());
457    }
458
459    let mut found = HashMap::new();
460    let mut scanned_files: usize = 0;
461    let year_dirs = collect_dirs_desc(root, |s| s.parse::<u32>().ok()).await?;
462
463    'outer: for (_year, year_path) in year_dirs {
464        let month_dirs = collect_dirs_desc(&year_path, |s| s.parse::<u8>().ok()).await?;
465        for (_month, month_path) in month_dirs {
466            let day_dirs = collect_dirs_desc(&month_path, |s| s.parse::<u8>().ok()).await?;
467            for (_day, day_path) in day_dirs {
468                let day_files = collect_rollout_files_sorted(&day_path).await?;
469                for path in day_files {
470                    if scanned_files >= MAX_SCAN_FILES || remaining.is_empty() {
471                        break 'outer;
472                    }
473                    scanned_files += 1;
474
475                    if let Some(name) = path.file_name().and_then(|s| s.to_str())
476                        && let Some((_ts, uuid)) = parse_timestamp_and_uuid(name)
477                        && remaining.remove(&uuid)
478                    {
479                        found.insert(uuid.to_string(), path.clone());
480                        if remaining.is_empty() {
481                            break 'outer;
482                        }
483                        continue;
484                    }
485
486                    if let Some(meta) = read_codex_session_meta(&path).await?
487                        && remaining.remove(meta.id.as_str())
488                    {
489                        found.insert(meta.id, path);
490                        if remaining.is_empty() {
491                            break 'outer;
492                        }
493                    }
494                }
495            }
496        }
497    }
498
499    Ok(found)
500}
501
502/// Read the `session_meta` record from a Codex session JSONL file (best-effort).
503pub async fn read_codex_session_meta(path: &Path) -> Result<Option<SessionMeta>> {
504    let file = fs::File::open(path)
505        .await
506        .with_context(|| format!("failed to open session file {:?}", path))?;
507    let reader = BufReader::new(file);
508    let mut lines = reader.lines();
509
510    let mut lines_scanned = 0usize;
511    while let Some(line) = lines.next_line().await? {
512        let trimmed = line.trim();
513        if trimmed.is_empty() {
514            continue;
515        }
516        lines_scanned += 1;
517        if lines_scanned > HEAD_SCAN_LINES {
518            break;
519        }
520
521        let value: Value = match serde_json::from_str(trimmed) {
522            Ok(v) => v,
523            Err(_) => continue,
524        };
525
526        if let Some(meta) = parse_session_meta(&value) {
527            return Ok(Some(SessionMeta {
528                id: meta.id,
529                cwd: meta.cwd,
530                created_at: meta.created_at,
531                is_subagent: meta.is_subagent,
532            }));
533        }
534    }
535
536    Ok(None)
537}
538
539#[cfg(test)]
540async fn summarize_session_for_current_dir(
541    path: &Path,
542    cwd: &Path,
543) -> Result<Option<SessionSummary>> {
544    let header_opt = read_session_header(path, cwd).await?;
545    let Some(header) = header_opt else {
546        return Ok(None);
547    };
548    Ok(Some(expand_header_to_summary_uncached(header).await?))
549}
550
551struct SessionMetaInfo {
552    id: String,
553    cwd: Option<String>,
554    created_at: Option<String>,
555    is_subagent: bool,
556}
557
558#[derive(Debug, Clone)]
559struct SessionHeader {
560    id: String,
561    path: PathBuf,
562    cwd: Option<String>,
563    created_at: Option<String>,
564    /// File size in bytes at header-read time.
565    file_size: u64,
566    /// File modified time in milliseconds since epoch (used for cheap recency sorting).
567    mtime_ms: u64,
568    /// Best-effort: timestamp of the most recent JSONL record (from the file tail; only computed for displayed rows).
569    updated_hint: Option<String>,
570    first_user_message: String,
571    is_cwd_match: bool,
572}
573
574fn parse_session_meta(value: &Value) -> Option<SessionMetaInfo> {
575    let obj = value.as_object()?;
576    let type_str = obj.get("type")?.as_str()?;
577    if type_str != "session_meta" {
578        return None;
579    }
580
581    let payload = obj.get("payload")?.as_object()?;
582    let id = payload.get("id").and_then(|v| v.as_str())?.to_string();
583    let cwd = payload
584        .get("cwd")
585        .and_then(|v| v.as_str())
586        .map(|s| s.to_string());
587    let created_at = payload
588        .get("timestamp")
589        .and_then(|v| v.as_str())
590        .map(|s| s.to_string())
591        .or_else(|| {
592            obj.get("timestamp")
593                .and_then(|v| v.as_str())
594                .map(|s| s.to_string())
595        });
596
597    Some(SessionMetaInfo {
598        id,
599        cwd,
600        created_at,
601        is_subagent: session_meta_payload_is_subagent(payload),
602    })
603}
604
605fn session_meta_payload_is_subagent(payload: &serde_json::Map<String, Value>) -> bool {
606    payload
607        .get("thread_source")
608        .and_then(|v| v.as_str())
609        .is_some_and(is_subagent_source_name)
610        || payload.get("source").is_some_and(value_is_subagent_source)
611        || payload
612            .get("agent_path")
613            .and_then(|v| v.as_str())
614            .is_some_and(|s| !s.trim().is_empty())
615}
616
617fn value_is_subagent_source(value: &Value) -> bool {
618    match value {
619        Value::String(source) => is_subagent_source_name(source),
620        Value::Object(map) => {
621            map.keys().any(|key| is_subagent_source_name(key))
622                || map
623                    .get("kind")
624                    .and_then(|v| v.as_str())
625                    .is_some_and(is_subagent_source_name)
626                || map
627                    .get("type")
628                    .and_then(|v| v.as_str())
629                    .is_some_and(is_subagent_source_name)
630        }
631        Value::Array(items) => items.iter().any(value_is_subagent_source),
632        _ => false,
633    }
634}
635
636fn is_subagent_source_name(source: &str) -> bool {
637    let normalized = source
638        .trim()
639        .chars()
640        .filter(|ch| *ch != '-' && *ch != '_')
641        .flat_map(char::to_lowercase)
642        .collect::<String>();
643    normalized.starts_with("subagent")
644}
645
646fn user_message_text(value: &Value) -> Option<&str> {
647    let obj = value.as_object()?;
648    let type_str = obj.get("type")?.as_str()?;
649    if type_str != "event_msg" {
650        return None;
651    }
652    let payload = obj.get("payload")?.as_object()?;
653    let payload_type = payload.get("type")?.as_str()?;
654    if payload_type != "user_message" {
655        return None;
656    }
657    payload.get("message").and_then(|v| v.as_str())
658}
659
660fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
661    if needle.is_empty() {
662        return true;
663    }
664    if haystack.len() < needle.len() {
665        return false;
666    }
667    haystack.windows(needle.len()).any(|w| w == needle)
668}
669
670#[cfg(test)]
671async fn read_session_header(path: &Path, cwd: &Path) -> Result<Option<SessionHeader>> {
672    let mut cwd_matcher = SessionCwdMatcher::new(cwd);
673    read_session_header_with_cwd_matcher(path, Some(&mut cwd_matcher)).await
674}
675
676async fn read_session_header_without_cwd_match(path: &Path) -> Result<Option<SessionHeader>> {
677    read_session_header_with_cwd_matcher(path, None).await
678}
679
680async fn read_session_header_with_cwd_matcher(
681    path: &Path,
682    cwd_matcher: Option<&mut SessionCwdMatcher>,
683) -> Result<Option<SessionHeader>> {
684    let meta = fs::metadata(path)
685        .await
686        .with_context(|| format!("failed to stat session file {:?}", path))?;
687    let file_size = meta.len();
688    let mtime_ms = meta
689        .modified()
690        .ok()
691        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
692        .map(|d| d.as_millis() as u64)
693        .unwrap_or(0);
694
695    let file = fs::File::open(path)
696        .await
697        .with_context(|| format!("failed to open session file {:?}", path))?;
698    let reader = BufReader::new(file);
699    let mut lines = reader.lines();
700
701    let mut session_id: Option<String> = None;
702    let mut cwd_str: Option<String> = None;
703    let mut created_at: Option<String> = None;
704    let mut first_user_message: Option<String> = None;
705
706    let mut lines_scanned = 0usize;
707    while let Some(line) = lines.next_line().await? {
708        let trimmed = line.trim();
709        if trimmed.is_empty() {
710            continue;
711        }
712        lines_scanned += 1;
713        if lines_scanned > HEAD_SCAN_LINES {
714            break;
715        }
716        let value: Value = match serde_json::from_str(trimmed) {
717            Ok(v) => v,
718            Err(_) => continue,
719        };
720
721        if session_id.is_none()
722            && let Some(meta) = parse_session_meta(&value)
723        {
724            if meta.is_subagent {
725                return Ok(None);
726            }
727            session_id = Some(meta.id);
728            cwd_str = meta.cwd;
729            created_at = meta.created_at;
730        }
731
732        if first_user_message.is_none()
733            && let Some(msg) = user_message_text(&value)
734        {
735            first_user_message = Some(msg.to_string());
736        }
737
738        if session_id.is_some() && first_user_message.is_some() {
739            break;
740        }
741    }
742
743    let Some(id) = session_id else {
744        return Ok(None);
745    };
746    let Some(first_user_message) = first_user_message else {
747        return Ok(None);
748    };
749
750    let cwd_value = cwd_str.clone();
751    let is_cwd_match =
752        if let (Some(session_cwd), Some(matcher)) = (cwd_value.as_deref(), cwd_matcher) {
753            matcher.matches(session_cwd)
754        } else {
755            false
756        };
757
758    Ok(Some(SessionHeader {
759        id,
760        path: path.to_path_buf(),
761        cwd: cwd_value,
762        created_at,
763        file_size,
764        mtime_ms,
765        updated_hint: None,
766        first_user_message,
767        is_cwd_match,
768    }))
769}
770
771async fn select_and_expand_headers(
772    matched: Vec<SessionHeader>,
773    others: Vec<SessionHeader>,
774    limit: usize,
775) -> Result<Vec<SessionSummary>> {
776    if limit == 0 {
777        return Ok(Vec::new());
778    }
779
780    let mut chosen = if !matched.is_empty() { matched } else { others };
781    // Use file mtime for cheap recency ordering; this correctly surfaces sessions that were resumed
782    // (older filename timestamp but recently appended to).
783    chosen.sort_by_key(|item| Reverse(item.mtime_ms));
784    if chosen.len() > limit {
785        chosen.truncate(limit);
786    }
787
788    let cache = Arc::new(Mutex::new(SessionStatsCache::load_default().await));
789    let mut out: Vec<SessionSummary> = Vec::with_capacity(chosen.len().min(limit));
790    let mut stream = stream::iter(chosen)
791        .map(|header| {
792            let cache = Arc::clone(&cache);
793            async move { expand_header_to_summary_cached(cache, header).await }
794        })
795        .buffer_unordered(SESSION_IO_CONCURRENCY);
796
797    while let Some(summary) = stream.next().await {
798        out.push(summary?);
799    }
800
801    drop(stream);
802    let mut cache = Arc::try_unwrap(cache)
803        .map_err(|_| anyhow!("session stats cache still has active workers"))?
804        .into_inner()
805        .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
806    cache.save_if_dirty().await?;
807
808    sort_by_updated_desc(&mut out);
809    out.truncate(limit);
810    Ok(out)
811}
812
813fn build_summary_from_stats(
814    header: SessionHeader,
815    user_turns: usize,
816    assistant_turns: usize,
817    last_response_at: Option<String>,
818) -> SessionSummary {
819    let rounds = user_turns.min(assistant_turns);
820    let updated_at = last_response_at
821        .clone()
822        .or_else(|| header.updated_hint.clone())
823        .or_else(|| header.created_at.clone());
824
825    SessionSummary {
826        id: header.id,
827        path: header.path,
828        cwd: header.cwd,
829        created_at: header.created_at,
830        updated_at,
831        last_response_at,
832        user_turns,
833        assistant_turns,
834        rounds,
835        first_user_message: Some(header.first_user_message),
836        source: SessionSummarySource::LocalFile,
837        sort_hint_ms: None,
838    }
839}
840
841async fn expand_header_to_summary_cached(
842    cache: Arc<Mutex<SessionStatsCache>>,
843    mut header: SessionHeader,
844) -> Result<SessionSummary> {
845    let path = header.path.clone();
846    let key = path.to_string_lossy().to_string();
847    let size = header.file_size;
848    let mtime_ms = header.mtime_ms;
849
850    let cached = {
851        let cache = cache
852            .lock()
853            .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
854        cache.lookup(&key, mtime_ms, size)
855    };
856
857    let stats = if let Some(stats) = cached {
858        if stats.last_response_at.is_none() && header.updated_hint.is_none() {
859            header.updated_hint = read_last_timestamp_from_tail(&path)
860                .await?
861                .or_else(|| header.created_at.clone());
862        }
863        stats
864    } else {
865        let (counts, tail) = tokio::join!(
866            count_turns_in_file(&path),
867            read_tail_timestamps(&path, true)
868        );
869        let (user_turns, assistant_turns) = counts?;
870        let tail = tail?;
871        header.updated_hint = tail.last_record_at.or_else(|| header.created_at.clone());
872
873        let stats = SessionStatsSnapshot {
874            user_turns,
875            assistant_turns,
876            last_response_at: tail.last_assistant_at,
877        };
878        {
879            let mut cache = cache
880                .lock()
881                .map_err(|_| anyhow!("session stats cache lock poisoned"))?;
882            cache.insert(key, mtime_ms, size, &stats);
883        }
884        stats
885    };
886
887    Ok(build_summary_from_stats(
888        header,
889        stats.user_turns,
890        stats.assistant_turns,
891        stats.last_response_at,
892    ))
893}
894
895#[cfg(test)]
896async fn expand_header_to_summary_uncached(header: SessionHeader) -> Result<SessionSummary> {
897    let (user_turns, assistant_turns) = count_turns_in_file(&header.path).await?;
898    let last_response_at = read_last_assistant_timestamp_from_tail(&header.path).await?;
899    Ok(build_summary_from_stats(
900        header,
901        user_turns,
902        assistant_turns,
903        last_response_at,
904    ))
905}
906
907async fn count_turns_in_file(path: &Path) -> Result<(usize, usize)> {
908    const USER_TURN_NEEDLE: &[u8] = br#""payload":{"type":"user_message""#;
909    const ASSISTANT_TURN_NEEDLE: &[u8] = br#""role":"assistant""#;
910
911    let mut file = fs::File::open(path)
912        .await
913        .with_context(|| format!("failed to open session file {:?}", path))?;
914
915    let mut buf = vec![0u8; IO_CHUNK_SIZE];
916    let mut user_carry: Vec<u8> = Vec::new();
917    let mut assistant_carry: Vec<u8> = Vec::new();
918    let mut user_total = 0usize;
919    let mut assistant_total = 0usize;
920    let mut user_window: Vec<u8> = Vec::with_capacity(IO_CHUNK_SIZE + USER_TURN_NEEDLE.len());
921    let mut assistant_window: Vec<u8> =
922        Vec::with_capacity(IO_CHUNK_SIZE + ASSISTANT_TURN_NEEDLE.len());
923
924    loop {
925        let n = file.read(&mut buf).await?;
926        if n == 0 {
927            break;
928        }
929
930        user_window.clear();
931        user_window.extend_from_slice(&user_carry);
932        user_window.extend_from_slice(&buf[..n]);
933        user_total = user_total.saturating_add(count_subslice(&user_window, USER_TURN_NEEDLE));
934
935        assistant_window.clear();
936        assistant_window.extend_from_slice(&assistant_carry);
937        assistant_window.extend_from_slice(&buf[..n]);
938        assistant_total = assistant_total
939            .saturating_add(count_subslice(&assistant_window, ASSISTANT_TURN_NEEDLE));
940
941        let user_keep = USER_TURN_NEEDLE.len().saturating_sub(1);
942        user_carry = if user_keep > 0 && user_window.len() >= user_keep {
943            user_window[user_window.len() - user_keep..].to_vec()
944        } else {
945            Vec::new()
946        };
947
948        let assistant_keep = ASSISTANT_TURN_NEEDLE.len().saturating_sub(1);
949        assistant_carry = if assistant_keep > 0 && assistant_window.len() >= assistant_keep {
950            assistant_window[assistant_window.len() - assistant_keep..].to_vec()
951        } else {
952            Vec::new()
953        };
954    }
955
956    Ok((user_total, assistant_total))
957}
958
959fn count_subslice(haystack: &[u8], needle: &[u8]) -> usize {
960    if needle.is_empty() {
961        return 0;
962    }
963    if haystack.len() < needle.len() {
964        return 0;
965    }
966    haystack
967        .windows(needle.len())
968        .filter(|w| *w == needle)
969        .count()
970}
971
972#[derive(Debug, Default)]
973struct TailTimestamps {
974    last_record_at: Option<String>,
975    last_assistant_at: Option<String>,
976}
977
978async fn read_last_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
979    Ok(read_tail_timestamps(path, false).await?.last_record_at)
980}
981
982#[cfg(test)]
983async fn read_last_assistant_timestamp_from_tail(path: &Path) -> Result<Option<String>> {
984    Ok(read_tail_timestamps(path, true).await?.last_assistant_at)
985}
986
987async fn read_tail_timestamps(path: &Path, include_assistant: bool) -> Result<TailTimestamps> {
988    const ASSISTANT_ROLE_NEEDLE: &[u8] = br#""role":"assistant""#;
989
990    let mut file = fs::File::open(path)
991        .await
992        .with_context(|| format!("failed to open session file {:?}", path))?;
993    let meta = file
994        .metadata()
995        .await
996        .with_context(|| format!("failed to stat session file {:?}", path))?;
997    let mut pos = meta.len();
998    if pos == 0 {
999        return Ok(TailTimestamps::default());
1000    }
1001
1002    let mut scanned = 0usize;
1003    let mut carry: Vec<u8> = Vec::new();
1004    let chunk_size = IO_CHUNK_SIZE as u64;
1005    let mut found = TailTimestamps::default();
1006
1007    while pos > 0 && scanned < TAIL_SCAN_MAX_BYTES {
1008        let start = pos.saturating_sub(chunk_size);
1009        let size = (pos - start) as usize;
1010        file.seek(std::io::SeekFrom::Start(start)).await?;
1011
1012        let mut chunk = vec![0u8; size];
1013        file.read_exact(&mut chunk).await?;
1014        scanned = scanned.saturating_add(size);
1015
1016        if !carry.is_empty() {
1017            chunk.extend_from_slice(&carry);
1018        }
1019
1020        // Iterate lines from the end.
1021        let mut end = chunk.len();
1022        while end > 0 {
1023            let mut begin = end;
1024            while begin > 0 && chunk[begin - 1] != b'\n' {
1025                begin -= 1;
1026            }
1027            let line = chunk[begin..end].trim_ascii();
1028            end = begin.saturating_sub(1);
1029
1030            if line.is_empty() {
1031                continue;
1032            }
1033
1034            let wants_record = found.last_record_at.is_none();
1035            let wants_assistant = include_assistant
1036                && found.last_assistant_at.is_none()
1037                && contains_bytes(line, ASSISTANT_ROLE_NEEDLE);
1038            if !wants_record && !wants_assistant {
1039                continue;
1040            }
1041
1042            let value: Value = match serde_json::from_slice(line) {
1043                Ok(v) => v,
1044                Err(_) => continue,
1045            };
1046            if let Some(ts) = value.get("timestamp").and_then(|v| v.as_str()) {
1047                let ts = ts.to_string();
1048                if wants_record {
1049                    found.last_record_at = Some(ts.clone());
1050                }
1051                if wants_assistant {
1052                    found.last_assistant_at = Some(ts);
1053                }
1054                if found.last_record_at.is_some()
1055                    && (!include_assistant || found.last_assistant_at.is_some())
1056                {
1057                    return Ok(found);
1058                }
1059            }
1060        }
1061
1062        // Keep the partial first line for the next iteration.
1063        if let Some(first_nl) = chunk.iter().position(|b| *b == b'\n') {
1064            carry = chunk[..first_nl].to_vec();
1065        } else {
1066            carry = chunk;
1067        }
1068
1069        pos = start;
1070    }
1071
1072    Ok(found)
1073}
1074
1075struct SessionCwdMatcher {
1076    current: PathBuf,
1077    cache: HashMap<String, bool>,
1078}
1079
1080impl SessionCwdMatcher {
1081    fn new(current_dir: &Path) -> Self {
1082        Self {
1083            current: std::fs::canonicalize(current_dir)
1084                .unwrap_or_else(|_| current_dir.to_path_buf()),
1085            cache: HashMap::new(),
1086        }
1087    }
1088
1089    fn matches(&mut self, session_cwd: &str) -> bool {
1090        if let Some(matched) = self.cache.get(session_cwd) {
1091            return *matched;
1092        }
1093
1094        let session_path = PathBuf::from(session_cwd);
1095        let matched = if session_path.is_absolute() {
1096            let cwd = std::fs::canonicalize(&session_path).unwrap_or(session_path);
1097            self.current == cwd || self.current.starts_with(&cwd) || cwd.starts_with(&self.current)
1098        } else {
1099            false
1100        };
1101
1102        self.cache.insert(session_cwd.to_string(), matched);
1103        matched
1104    }
1105}
1106
1107#[cfg(test)]
1108fn path_matches_current_dir(session_cwd: &str, current_dir: &Path) -> bool {
1109    let mut matcher = SessionCwdMatcher::new(current_dir);
1110    matcher.matches(session_cwd)
1111}
1112
1113async fn collect_dirs_desc<T, F>(parent: &Path, parse: F) -> std::io::Result<Vec<(T, PathBuf)>>
1114where
1115    T: Ord + Copy,
1116    F: Fn(&str) -> Option<T>,
1117{
1118    let mut dir = fs::read_dir(parent).await?;
1119    let mut vec: Vec<(T, PathBuf)> = Vec::new();
1120    while let Some(entry) = dir.next_entry().await? {
1121        if entry
1122            .file_type()
1123            .await
1124            .map(|ft| ft.is_dir())
1125            .unwrap_or(false)
1126            && let Some(s) = entry.file_name().to_str()
1127            && let Some(v) = parse(s)
1128        {
1129            vec.push((v, entry.path()));
1130        }
1131    }
1132    vec.sort_by_key(|(v, _)| Reverse(*v));
1133    Ok(vec)
1134}
1135
1136async fn collect_rollout_files_sorted(parent: &Path) -> std::io::Result<Vec<PathBuf>> {
1137    let mut dir = fs::read_dir(parent).await?;
1138    let mut records: Vec<(String, String, PathBuf)> = Vec::new();
1139
1140    while let Some(entry) = dir.next_entry().await? {
1141        if entry
1142            .file_type()
1143            .await
1144            .map(|ft| ft.is_file())
1145            .unwrap_or(false)
1146        {
1147            let name_os = entry.file_name();
1148            let Some(name) = name_os.to_str() else {
1149                continue;
1150            };
1151            if !name.starts_with("rollout-") || !name.ends_with(".jsonl") {
1152                continue;
1153            }
1154            if let Some((ts, uuid)) = parse_timestamp_and_uuid(name) {
1155                records.push((ts, uuid, entry.path()));
1156            }
1157        }
1158    }
1159
1160    records.sort_by(|a, b| {
1161        // Sort by timestamp desc, then UUID desc.
1162        match b.0.cmp(&a.0) {
1163            Ordering::Equal => b.1.cmp(&a.1),
1164            other => other,
1165        }
1166    });
1167
1168    Ok(records.into_iter().map(|(_, _, path)| path).collect())
1169}
1170
1171fn parse_timestamp_and_uuid(name: &str) -> Option<(String, String)> {
1172    // Expected: rollout-YYYY-MM-DDThh-mm-ss-<uuid>.jsonl
1173    let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?;
1174
1175    // Timestamp format is stable and has a fixed width: "YYYY-MM-DDThh-mm-ss" (19 chars).
1176    const TS_LEN: usize = 19;
1177    if core.len() <= TS_LEN + 1 {
1178        return None;
1179    }
1180    let (ts, rest) = core.split_at(TS_LEN);
1181    let uuid = rest.strip_prefix('-')?;
1182    if uuid.is_empty() {
1183        return None;
1184    }
1185    Some((ts.to_string(), uuid.to_string()))
1186}
1187
1188fn sort_by_updated_desc(vec: &mut [SessionSummary]) {
1189    vec.sort_by(|a, b| {
1190        let ta = a.updated_at.as_deref();
1191        let tb = b.updated_at.as_deref();
1192        match (ta, tb) {
1193            (Some(ta), Some(tb)) => tb.cmp(ta),
1194            (Some(_), None) => Ordering::Less,
1195            (None, Some(_)) => Ordering::Greater,
1196            (None, None) => Ordering::Equal,
1197        }
1198    });
1199}
1200
1201#[cfg(test)]
1202mod tests;