Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 5 additions & 3 deletions src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&quoted_path, &cmd))
}
Action::NewSession => {
Expand All @@ -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()),
Expand Down Expand Up @@ -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(&quoted_path, &format!("{base_cmd}{flags}"))
}

Expand Down
44 changes: 35 additions & 9 deletions src/model.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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}"),
}
}

Expand Down Expand Up @@ -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'"
);
}
}
3 changes: 2 additions & 1 deletion src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
62 changes: 59 additions & 3 deletions src/scanner/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
6 changes: 4 additions & 2 deletions src/scanner/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
24 changes: 15 additions & 9 deletions src/scanner/cursor_agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,29 @@ fn scan_from(cursor_dir: &Path) -> Result<Vec<Session>, 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))
}
};

Expand Down
51 changes: 35 additions & 16 deletions src/scanner/pi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,20 +103,21 @@ fn parse_session(path: &std::path::Path) -> Option<Session> {

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);

Expand Down Expand Up @@ -289,16 +290,34 @@ 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!(
f,
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) };
Expand All @@ -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"]);
}
}
Loading
Loading