use crate::paths::Paths;
use std::path::{Path, PathBuf};
pub struct NativeSession {
pub tool: &'static str,
pub id: String,
pub title: String,
pub cwd: Option<PathBuf>,
pub started: i64,
}
fn mtime_secs(p: &Path) -> i64 {
std::fs::metadata(p)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn looks_like_uuid(s: &str) -> bool {
s.len() == 36
&& s.chars().enumerate().all(|(i, c)| match i {
8 | 13 | 18 | 23 => c == '-',
_ => c.is_ascii_hexdigit(),
})
}
fn head_lines(p: &Path) -> Vec<String> {
use std::io::Read;
let Ok(mut f) = std::fs::File::open(p) else {
return Vec::new();
};
let mut buf = vec![0u8; 64 * 1024];
let n = f.read(&mut buf).unwrap_or(0);
buf.truncate(n);
String::from_utf8_lossy(&buf)
.lines()
.map(|l| l.to_string())
.collect()
}
fn first_text(v: &serde_json::Value) -> Option<String> {
if let Some(s) = v.as_str() {
return Some(s.to_string());
}
if let Some(arr) = v.as_array() {
for item in arr {
if let Some(t) = item["text"].as_str() {
return Some(t.to_string());
}
}
}
None
}
fn tidy_title(t: &str) -> String {
let one = t.split_whitespace().collect::<Vec<_>>().join(" ");
let head: String = one.chars().take(60).collect();
if head.is_empty() {
"(no prompt)".into()
} else {
head
}
}
fn claude_head(p: &Path) -> (String, Option<PathBuf>) {
let mut title = None;
let mut cwd = None;
for line in head_lines(p) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
if cwd.is_none() {
if let Some(c) = v["cwd"].as_str() {
let pb = PathBuf::from(c);
if pb.is_dir() {
cwd = Some(pb); }
}
}
if title.is_none() && v["type"] == "user" && !v["isMeta"].as_bool().unwrap_or(false) {
if let Some(t) = first_text(&v["message"]["content"]) {
let meta = t.starts_with("<command-")
|| t.starts_with("<local-command-")
|| t.starts_with("<system-")
|| t.starts_with("<task-notification");
if !meta {
title = Some(tidy_title(&t));
}
}
}
if title.is_some() && cwd.is_some() {
break;
}
}
(title.unwrap_or_else(|| "(no prompt)".into()), cwd)
}
fn codex_head(p: &Path) -> (String, Option<PathBuf>) {
let mut title = None;
let mut cwd = None;
for line in head_lines(p) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let payload = &v["payload"];
if cwd.is_none() {
if let Some(c) = payload["cwd"].as_str() {
let pb = PathBuf::from(c);
if pb.is_dir() {
cwd = Some(pb);
}
}
}
if title.is_none() && payload["role"] == "user" {
if let Some(t) = first_text(&payload["content"]) {
title = Some(tidy_title(&t));
}
}
if title.is_some() && cwd.is_some() {
break;
}
}
(title.unwrap_or_else(|| "(no prompt)".into()), cwd)
}
fn walk_jsonl(dir: &Path, out: &mut Vec<PathBuf>) {
if let Ok(rd) = std::fs::read_dir(dir) {
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk_jsonl(&p, out);
} else if p.extension().is_some_and(|x| x == "jsonl") {
out.push(p);
}
}
}
}
pub fn recent(paths: &Paths, n: usize) -> Vec<NativeSession> {
let mut files: Vec<(i64, &'static str, PathBuf)> = Vec::new();
let mut claude = Vec::new();
walk_jsonl(&paths.claude_projects(), &mut claude);
for p in claude {
if p.to_string_lossy().contains("/subagents/") {
continue;
}
files.push((mtime_secs(&p), "claude-code", p));
}
let mut codex = Vec::new();
walk_jsonl(&paths.codex_sessions(), &mut codex);
for p in codex {
files.push((mtime_secs(&p), "codex", p));
}
files.sort_by_key(|(t, _, _)| std::cmp::Reverse(*t));
files.truncate(n.max(16)); let mut out = Vec::new();
for (started, tool, path) in files {
let stem = match path.file_stem() {
Some(s) => s.to_string_lossy().into_owned(),
None => continue,
};
let id = match tool {
"claude-code" => {
if !looks_like_uuid(&stem) {
continue;
}
stem
}
_ => {
let Some(tail) = stem.len().checked_sub(36).and_then(|i| stem.get(i..)) else {
continue;
};
if !looks_like_uuid(tail) {
continue;
}
tail.to_string()
}
};
let (title, cwd) = match tool {
"claude-code" => claude_head(&path),
_ => codex_head(&path),
};
out.push(NativeSession {
tool,
id,
title,
cwd,
started,
});
if out.len() >= n {
break;
}
}
out
}
pub fn exec_resume(s: &NativeSession) -> anyhow::Error {
use std::os::unix::process::CommandExt;
let mut cmd = match s.tool {
"claude-code" => {
let mut c = std::process::Command::new("claude");
c.args(["--resume", &s.id]);
c
}
_ => {
let mut c = std::process::Command::new("codex");
c.args(["resume", &s.id]);
c
}
};
if let Some(d) = &s.cwd {
cmd.current_dir(d);
}
let err = cmd.exec();
anyhow::anyhow!("could not resume via {}: {err}", s.tool)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_claude_and_codex_sessions_from_disk() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::rooted(dir.path());
let proj = dir.path().join(".claude/projects/-home-dev-api");
std::fs::create_dir_all(&proj).unwrap();
let cwd = dir.path().join("realdir");
std::fs::create_dir_all(&cwd).unwrap();
std::fs::write(
proj.join("0a000000-0000-4000-8000-000000000001.jsonl"),
format!(
"{}\n{}\n",
serde_json::json!({"type":"user","isMeta":true,"cwd":cwd.to_str().unwrap(),
"message":{"content":"<boilerplate>"}}),
serde_json::json!({"type":"user",
"message":{"content":[{"type":"text","text":"fix the login redirect loop please"}]}}),
),
)
.unwrap();
let cx = dir.path().join(".codex/sessions/2026/07/07");
std::fs::create_dir_all(&cx).unwrap();
std::fs::write(
cx.join("rollout-2026-07-07T10-00-00-0a000000-0000-4000-8000-00000000000b.jsonl"),
format!(
"{}\n",
serde_json::json!({"payload":{"role":"user","cwd":"/no/such/dir",
"content":[{"type":"input_text","text":"tighten the retry budget"}]}}),
),
)
.unwrap();
let got = recent(&paths, 10);
assert_eq!(got.len(), 2);
let claude = got.iter().find(|s| s.tool == "claude-code").unwrap();
assert!(claude.title.contains("login redirect"), "{}", claude.title);
assert_eq!(
claude.cwd.as_deref(),
Some(cwd.as_path()),
"real dir trusted"
);
let codex = got.iter().find(|s| s.tool == "codex").unwrap();
assert!(codex.title.contains("retry budget"));
assert_eq!(codex.cwd, None, "nonexistent cwd is NOT trusted");
assert_eq!(codex.id, "0a000000-0000-4000-8000-00000000000b");
}
}