diff --git a/Cargo.toml b/Cargo.toml index da2e351..ab9acdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,4 +33,6 @@ opt-level = 3 lto = "fat" codegen-units = 1 strip = true -panic = "abort" +# No `panic = "abort"`: scanner::scan_all relies on unwinding to isolate a +# panicking scanner thread (join -> Err) so one malformed session file can't +# take down the whole listing. diff --git a/src/action.rs b/src/action.rs index 6cc02bd..be8016f 100644 --- a/src/action.rs +++ b/src/action.rs @@ -11,7 +11,7 @@ pub fn generate_command( match action { Action::Resume => { - let cmd = session.agent.resume_cmd(&session.session_id); + let cmd = session.agent.resume_cmd(&session.session_id, &shell); Some(shell.cd_and("ed_path, &cmd)) } Action::NewSession => { @@ -30,7 +30,9 @@ pub fn generate_command( pub fn action_preview(session: &Session, action: Action) -> String { match action { - Action::Resume => session.agent.resume_cmd(&session.session_id), + Action::Resume => session + .agent + .resume_cmd(&session.session_id, &CommandShell::from_env()), Action::NewSession => "choose agent CLI...".to_string(), Action::Open => format!("{} .", detect_editor()), Action::Cd => CommandShell::from_env().cd_only(&session.display_path()), @@ -67,7 +69,7 @@ pub fn detect_editor() -> String { pub fn resume_with_flags(session: &Session, flags: &str) -> String { let shell = CommandShell::from_env(); let quoted_path = shell.quote(&session.project_path); - let base_cmd = session.agent.resume_cmd(&session.session_id); + let base_cmd = session.agent.resume_cmd(&session.session_id, &shell); shell.cd_and("ed_path, &format!("{base_cmd}{flags}")) } diff --git a/src/model.rs b/src/model.rs index 5b32737..a3b0d8b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,5 +1,7 @@ use std::fmt; +use crate::shell::CommandShell; + // Serde derives are load-bearing for the session cache: unit variants // serialize as their exact variant names ("ClaudeCode", "Codex", ...), which // is the on-disk format of ~/.cache/agf/sessions.json. @@ -73,18 +75,24 @@ impl Agent { } /// Shell command to resume the most recent session. - pub fn resume_cmd(&self, session_id: &str) -> String { + /// + /// `session_id` is escaped for `shell` rather than wrapped in raw single + /// quotes: session ids come from parsed on-disk files and are not + /// guaranteed to be quote-free, so an unescaped id could break the + /// generated command — or inject shell — once the wrapper `eval`s it. + pub fn resume_cmd(&self, session_id: &str, shell: &CommandShell) -> String { + let id = shell.quote(session_id); match self { - Agent::ClaudeCode => format!("claude --resume '{session_id}'"), - Agent::Codex => format!("codex resume '{session_id}'"), - Agent::OpenCode => format!("opencode -s '{session_id}'"), - Agent::Pi => format!("pi --session '{session_id}'"), + Agent::ClaudeCode => format!("claude --resume {id}"), + Agent::Codex => format!("codex resume {id}"), + Agent::OpenCode => format!("opencode -s {id}"), + Agent::Pi => format!("pi --session {id}"), // Kiro CLI has no per-session resume flag — `--resume` always // reopens the latest session for the cwd, so session_id is unused. Agent::Kiro => "kiro-cli chat --resume".to_string(), - Agent::CursorAgent => format!("cursor-agent --resume '{session_id}'"), - Agent::Gemini => format!("gemini --resume '{session_id}'"), - Agent::Hermes => format!("hermes --resume '{session_id}'"), + Agent::CursorAgent => format!("cursor-agent --resume {id}"), + Agent::Gemini => format!("gemini --resume {id}"), + Agent::Hermes => format!("hermes --resume {id}"), } } @@ -283,8 +291,26 @@ mod tests { #[test] fn pi_resume_command_uses_selected_session_id() { assert_eq!( - Agent::Pi.resume_cmd("019e14f4-c9a5-76dc-b7b6-0613e602a620"), + Agent::Pi.resume_cmd( + "019e14f4-c9a5-76dc-b7b6-0613e602a620", + &crate::shell::CommandShell::Posix + ), "pi --session '019e14f4-c9a5-76dc-b7b6-0613e602a620'" ); } + + #[test] + fn resume_cmd_escapes_session_id() { + // A session id containing a single quote must not break out of the + // quoted argument (broken command) or inject shell. + assert_eq!( + Agent::ClaudeCode.resume_cmd("a'b", &crate::shell::CommandShell::Posix), + r#"claude --resume 'a'\''b'"# + ); + // PowerShell doubles the embedded quote instead of `'\''`. + assert_eq!( + Agent::ClaudeCode.resume_cmd("a'b", &crate::shell::CommandShell::PowerShell), + "claude --resume 'a''b'" + ); + } } diff --git a/src/plugin.rs b/src/plugin.rs index d7053e7..39edcfd 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -85,7 +85,8 @@ impl AgentPlugin for PluginAdapter { } fn resume_cmd(&self, session_id: &str) -> String { - self.0.resume_cmd(session_id) + self.0 + .resume_cmd(session_id, &crate::shell::CommandShell::from_env()) } fn new_session_cmd(&self) -> &str { diff --git a/src/scanner/claude.rs b/src/scanner/claude.rs index d1afe26..5aedee9 100644 --- a/src/scanner/claude.rs +++ b/src/scanner/claude.rs @@ -18,10 +18,17 @@ use crate::scanner::{collapse_whitespace, read_head_tail}; /// in the head slice; /// * `aiTitle` (emitted while the agent is forming project context, within /// the first few hundred lines) fits in the head slice; -/// * `away_summary` recaps (appended on every idle, latest one wins) are -/// reliably in the tail slice — 256 KB ≈ thousands of recap lines. +/// * the latest `away_summary` recap — appended last, so it sits at the very +/// end of the file — is captured by the tail slice. +/// +/// `TAIL_BYTES` is deliberately small. `away_summary` lines are appended +/// chronologically and only the most recent one is displayed, so a few dozen KB +/// of tail reliably contains it. A larger tail mainly forces mid-size +/// transcripts (which fall under `head + tail` and are therefore read in FULL) +/// to be slurped end-to-end — the dominant cold-start scan cost on large +/// `~/.claude/projects` trees (tens of MB read for an off-by-default recap). const HEAD_BYTES: u64 = 16 * 1024; -const TAIL_BYTES: u64 = 256 * 1024; +const TAIL_BYTES: u64 = 32 * 1024; #[derive(Deserialize)] struct ClaudeEntry { @@ -389,4 +396,53 @@ mod tests { fs::create_dir_all(&dir).unwrap(); assert!(list_session_files(&dir).is_empty()); } + + #[test] + fn scan_session_metadata_finds_worktree_in_head_and_latest_recap_in_tail() { + // A transcript larger than HEAD_BYTES + TAIL_BYTES: `worktree` must come + // from the head (cwd on line 1) and the latest `away_summary` recap from + // the tail (recaps are appended last). This locks TAIL_BYTES — shrinking + // it must never drop the recap, which always sits at the file's end. + let claude_dir = make_claude_dir("agf-test-recap-tail"); + let proj = claude_dir.join("projects").join("-home-proj"); + fs::create_dir_all(&proj).unwrap(); + let sid = "recap-big-1"; + let path = proj.join(format!("{sid}.jsonl")); + let mut f = fs::File::create(&path).unwrap(); + + // Head: cwd inside a worktree. + writeln!( + f, + r#"{{"type":"user","cwd":"/home/proj/.claude/worktrees/feature-x"}}"# + ) + .unwrap(); + // Padding to push the file well past HEAD_BYTES + TAIL_BYTES. + let filler = format!(r#"{{"type":"assistant","pad":"{}"}}"#, "x".repeat(2000)); + let target = (HEAD_BYTES + TAIL_BYTES) as usize + 100 * 1024; + let mut written = 0usize; + while written < target { + writeln!(f, "{filler}").unwrap(); + written += filler.len() + 1; + } + // Tail: an older then a newer away_summary — the latest one must win. + writeln!( + f, + r#"{{"type":"system","subtype":"away_summary","timestamp":"2026-05-01T00:00:00.000Z","content":"old recap"}}"# + ) + .unwrap(); + writeln!( + f, + r#"{{"type":"system","subtype":"away_summary","timestamp":"2026-05-02T00:00:00.000Z","content":"latest recap"}}"# + ) + .unwrap(); + + let meta = scan_session_metadata(vec![(sid.to_string(), path)]); + let m = meta + .get(sid) + .expect("metadata should be present for large file"); + assert_eq!(m.worktree.as_deref(), Some("feature-x")); + assert_eq!(m.recap.as_deref(), Some("recap: latest recap")); + + let _ = fs::remove_dir_all(&claude_dir); + } } diff --git a/src/scanner/codex.rs b/src/scanner/codex.rs index 8c9ff8f..9e1c6e5 100644 --- a/src/scanner/codex.rs +++ b/src/scanner/codex.rs @@ -196,8 +196,10 @@ fn scan_sqlite( let project_name = project_name_from_path(&cwd); - // updated_at is Unix seconds — convert to millis - let timestamp = updated_at * 1000; + // updated_at is Unix seconds — convert to millis. Saturate so a + // corrupt/tampered value can't overflow i64 and wrap to a garbage + // (often negative) timestamp that jumps the session to a list extreme. + let timestamp = updated_at.saturating_mul(1000); // Build summaries: prefer history.jsonl, fall back to title/first_msg let session_summaries = if let Some(s) = summaries.get(&session_id) { diff --git a/src/scanner/cursor_agent.rs b/src/scanner/cursor_agent.rs index 58f5998..893f1a8 100644 --- a/src/scanner/cursor_agent.rs +++ b/src/scanner/cursor_agent.rs @@ -136,23 +136,29 @@ fn scan_from(cursor_dir: &Path) -> Result, AgfError> { let meta = store_db_path.as_deref().and_then(read_store_db); + // Last-activity time: the transcript file is appended on every turn, + // so its mtime tracks when the session was last used. Prefer it over + // the store.db `createdAt` (which never advances after creation) so a + // recently-used old session sorts by last use, not creation — matching + // how the other agents' timestamps behave and fixing the "recent + // session buried under old ones" time-sort complaint. + let file_mtime = path + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64); + let (summary, timestamp) = match meta { - Some(m) => (m.name, m.created_at), + Some(m) => (m.name, file_mtime.unwrap_or(m.created_at)), None => { - let mtime = path - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as i64) - .unwrap_or(0); // Prompt extraction only applies to JSONL; .txt format is unknown let prompt = if ext == Some("jsonl") { extract_first_prompt(path) } else { None }; - (prompt, mtime) + (prompt, file_mtime.unwrap_or(0)) } }; diff --git a/src/scanner/pi.rs b/src/scanner/pi.rs index 87a07ba..a5df434 100644 --- a/src/scanner/pi.rs +++ b/src/scanner/pi.rs @@ -103,20 +103,21 @@ fn parse_session(path: &std::path::Path) -> Option { let session_id = header.id?; let cwd = header.cwd?; - let timestamp = header + // pi's session-header timestamp is the CREATION time and never advances as + // the session is used. The transcript file is appended on every turn, so + // its mtime tracks last activity — take the max of the two so a + // recently-used old session sorts by when it was last touched, not created. + let header_ts = header .timestamp .and_then(|t| chrono::DateTime::parse_from_rfc3339(&t).ok()) - .map(|dt| dt.timestamp_millis()) - .unwrap_or_else(|| { - path.metadata() - .and_then(|m| m.modified()) - .map(|t| { - t.duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 - }) - .unwrap_or(0) - }); + .map(|dt| dt.timestamp_millis()); + let file_mtime = path + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as i64); + let timestamp = header_ts.into_iter().chain(file_mtime).max().unwrap_or(0); let project_name = project_name_from_path(&cwd); @@ -289,9 +290,25 @@ mod tests { )); let session_dir = home.join(".pi/agent/sessions/--tmp-project--"); fs::create_dir_all(&session_dir).unwrap(); - for (file, id, ts) in [ - ("old.jsonl", "old-session", "2026-05-01T00:00:00Z"), - ("new.jsonl", "new-session", "2026-05-02T00:00:00Z"), + // `old-session` has the OLDER creation header but the NEWER file mtime + // (it was resumed/used more recently); `new-session` was created later + // but not touched since. Sorting is by last activity (max of header ts + // and file mtime), so old-session must rank ABOVE new-session — proving + // we no longer sort pi sessions by their immutable creation timestamp. + use std::time::{Duration, SystemTime}; + for (file, id, ts, mtime_secs) in [ + ( + "old.jsonl", + "old-session", + "2026-05-01T00:00:00Z", + 1_800_000_000u64, + ), + ( + "new.jsonl", + "new-session", + "2026-05-02T00:00:00Z", + 1_790_000_000u64, + ), ] { let mut f = fs::File::create(session_dir.join(file)).unwrap(); writeln!( @@ -299,6 +316,8 @@ mod tests { r#"{{"type":"session","id":"{id}","timestamp":"{ts}","cwd":"/tmp/project"}}"# ) .unwrap(); + f.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_secs)) + .unwrap(); } // Serialized by the HOME_LOCK guard above. unsafe { std::env::set_var("HOME", &home) }; @@ -314,6 +333,6 @@ mod tests { } let ids: Vec<_> = sessions.iter().map(|s| s.session_id.as_str()).collect(); - assert_eq!(ids, vec!["new-session", "old-session"]); + assert_eq!(ids, vec!["old-session", "new-session"]); } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 38d3e26..8b8e0ac 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -68,7 +68,12 @@ pub struct App { pub scroll_offset: usize, pub viewport_height: usize, pub sort_mode: SortMode, - pub selected_set: HashSet, + /// Multi-select set for bulk delete, keyed by session identity + /// `(agent, session_id)` — NOT by `sessions` Vec index. A background scan + /// can reorder/replace `sessions` between selection and delete (every + /// render frame drains scan results), so an index-keyed set would resolve + /// to the wrong sessions at delete time and destroy the wrong data. + pub selected_set: HashSet<(Agent, String)>, pub summary_offsets: HashMap, pub summary_search_count: usize, pub include_summaries: bool, @@ -373,16 +378,23 @@ impl App { } }) .collect(); - // Sort groups: most recent session first + // Sort groups: most recent session first. Use the MAX timestamp across + // each group's sessions, not `.first()` — `.first()` is only the newest + // when the list is in Time sort; in Name/Agent sort it is not, which + // ordered the groups incorrectly. self.groups.sort_by(|a, b| { let a_ts = a .sessions - .first() - .map_or(0, |&i| self.sessions[i].timestamp); + .iter() + .map(|&i| self.sessions[i].timestamp) + .max() + .unwrap_or(0); let b_ts = b .sessions - .first() - .map_or(0, |&i| self.sessions[i].timestamp); + .iter() + .map(|&i| self.sessions[i].timestamp) + .max() + .unwrap_or(0); b_ts.cmp(&a_ts) }); } @@ -1694,10 +1706,14 @@ fn ui_bulk_delete(ui: &mut slt::Context, app: &mut App) { } if ui.key(' ') { - if let Some(idx) = app.filtered_indices.get(app.selected).copied() - && !app.selected_set.remove(&idx) + if let Some(key) = app + .filtered_indices + .get(app.selected) + .and_then(|&i| app.sessions.get(i)) + .map(|s| (s.agent, s.session_id.clone())) + && !app.selected_set.remove(&key) { - app.selected_set.insert(idx); + app.selected_set.insert(key); } if !app.filtered_indices.is_empty() && app.selected < app.filtered_indices.len() - 1 { app.selected += 1; @@ -1772,20 +1788,25 @@ fn ui_delete_confirm(ui: &mut slt::Context, app: &mut App) { if ui.key_code(slt::KeyCode::Enter) { if app.delete_index == 0 { if is_bulk { - let mut indices: Vec = app.selected_set.drain().collect(); - indices.sort_unstable_by(|a, b| b.cmp(a)); - for idx in indices { - // Only drop the row from the UI when the on-disk delete - // actually succeeded; failed deletes stay visible. - if idx < app.sessions.len() - && crate::delete::delete_session(&app.sessions[idx]).is_ok() - { - let agent = app.sessions[idx].agent; - app.sessions.remove(idx); - decrement_agent_count(&mut app.agent_counts, agent); + // Resolve the selected (agent, session_id) keys to sessions at + // delete time. Keying by identity — not by Vec index captured at + // selection time — is what keeps this correct when a background + // scan reordered `sessions` in between. + let selected: HashSet<(Agent, String)> = app.selected_set.drain().collect(); + let mut deleted: HashSet<(Agent, String)> = HashSet::new(); + for s in &app.sessions { + let key = (s.agent, s.session_id.clone()); + // Only mark the row deleted when the on-disk delete actually + // succeeded; failed deletes stay visible. + if selected.contains(&key) && crate::delete::delete_session(s).is_ok() { + deleted.insert(key); } } - app.selected_set.clear(); + for key in &deleted { + decrement_agent_count(&mut app.agent_counts, key.0); + } + app.sessions + .retain(|s| !deleted.contains(&(s.agent, s.session_id.clone()))); app.update_filter(); } else if let Some(idx) = app.filtered_indices.get(app.selected).copied() { // Only drop the row from the UI when the on-disk delete @@ -1880,7 +1901,11 @@ fn render_bulk_delete_confirm(ui: &mut slt::Context, app: &App) { let mut names: Vec = app .selected_set .iter() - .filter_map(|idx| app.sessions.get(*idx)) + .filter_map(|(agent, id)| { + app.sessions + .iter() + .find(|s| s.agent == *agent && &s.session_id == id) + }) .map(|s| s.project_name.clone()) .collect(); names.sort(); @@ -2300,7 +2325,9 @@ fn render_session_list(ui: &mut slt::Context, app: &App, bulk_mode: bool) { }; if bulk_mode { - let is_checked = app.selected_set.contains(&session_idx); + let is_checked = app + .selected_set + .contains(&(session.agent, session.session_id.clone())); let indicator = match (is_selected, is_checked) { (true, true) => ">[x] ", (true, false) => ">[ ] ",