Skip to content

Commit fe4b68c

Browse files
subiniumclaude
andauthored
fix(codex): read sessions from SQLite DB + fix agf resume cmd output (#13)
- Codex scanner now reads from state_*.sqlite (threads table) as primary source, covering CLI, desktop app (vscode), and exec sessions. Falls back to JSONL walkdir if SQLite is unavailable. - agf resume subcommand now writes to AGF_CMD_FILE so the shell wrapper can eval the command instead of printing it as text. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 450e38c commit fe4b68c

4 files changed

Lines changed: 374 additions & 391 deletions

File tree

‎Cargo.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "agf"
3-
version = "0.7.0"
3+
version = "0.7.1"
44
edition = "2021"
55
description = "AI Agent Session Finder TUI — unified launcher for Claude Code, Codex, and OpenCode sessions"
66
license = "MIT"
@@ -10,7 +10,7 @@ categories = ["command-line-utilities"]
1010
readme = "README.md"
1111

1212
[dependencies]
13-
superlighttui = "0.16"
13+
superlighttui = "0.15"
1414
serde = { version = "1", features = ["derive"] }
1515
serde_json = "1"
1616
rusqlite = { version = "0.32", features = ["bundled"] }

‎src/main.rs‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,6 @@ enum Commands {
4242
}
4343

4444
fn main() -> anyhow::Result<()> {
45-
// Handle --version / -V / version before clap (args_conflicts_with_subcommands blocks it)
46-
if let Some(arg) = std::env::args().nth(1) {
47-
if arg == "--version" || arg == "-V" || arg == "version" {
48-
eprintln!("agf {}", env!("CARGO_PKG_VERSION"));
49-
return Ok(());
50-
}
51-
}
52-
5345
let cli = Cli::parse();
5446

5547
match cli.command {

‎src/scanner/codex.rs‎

Lines changed: 166 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,50 +2,181 @@ use std::collections::HashMap;
22
use std::fs::File;
33
use std::io::{BufRead, BufReader};
44

5-
use serde::Deserialize;
6-
use walkdir::WalkDir;
5+
use rusqlite::Connection;
76

87
use crate::error::AgfError;
98
use crate::model::{Agent, Session};
109

11-
#[derive(Deserialize)]
12-
struct SessionMeta {
13-
#[serde(rename = "type")]
14-
entry_type: Option<String>,
15-
payload: Option<SessionPayload>,
10+
pub fn scan() -> Result<Vec<Session>, AgfError> {
11+
let codex_dir = crate::config::codex_dir()?;
12+
13+
// Collect summaries from history.jsonl (keyed by session_id, newest-first)
14+
let summaries = read_history_summaries(&codex_dir);
15+
16+
// Primary: read from SQLite (state_*.sqlite)
17+
let mut sessions = scan_sqlite(&codex_dir, &summaries);
18+
19+
// Fallback: if SQLite found nothing, try JSONL walkdir
20+
if sessions.is_empty() {
21+
sessions = scan_jsonl(&codex_dir, &summaries);
22+
}
23+
24+
sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
25+
Ok(sessions)
1626
}
1727

18-
#[derive(Deserialize)]
19-
struct SessionPayload {
20-
id: Option<String>,
21-
cwd: Option<String>,
22-
timestamp: Option<String>,
23-
git: Option<GitInfo>,
28+
/// Read sessions from Codex SQLite database (state_*.sqlite).
29+
/// This is the primary source — covers CLI, desktop app (vscode), and exec sessions.
30+
fn scan_sqlite(
31+
codex_dir: &std::path::Path,
32+
summaries: &HashMap<String, Vec<String>>,
33+
) -> Vec<Session> {
34+
// Find the latest state_*.sqlite file
35+
let db_path = match find_state_db(codex_dir) {
36+
Some(p) => p,
37+
None => return Vec::new(),
38+
};
39+
40+
let conn =
41+
match Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) {
42+
Ok(c) => c,
43+
Err(_) => return Vec::new(),
44+
};
45+
46+
let mut stmt = match conn.prepare(
47+
"SELECT id, cwd, title, updated_at, git_branch, first_user_message
48+
FROM threads
49+
WHERE archived = 0 AND cwd != ''
50+
ORDER BY updated_at DESC",
51+
) {
52+
Ok(s) => s,
53+
Err(_) => return Vec::new(),
54+
};
55+
56+
let rows = match stmt.query_map([], |row| {
57+
Ok((
58+
row.get::<_, String>(0)?,
59+
row.get::<_, String>(1)?,
60+
row.get::<_, String>(2).unwrap_or_default(),
61+
row.get::<_, i64>(3).unwrap_or(0),
62+
row.get::<_, Option<String>>(4)?,
63+
row.get::<_, String>(5).unwrap_or_default(),
64+
))
65+
}) {
66+
Ok(r) => r,
67+
Err(_) => return Vec::new(),
68+
};
69+
70+
let mut sessions = Vec::new();
71+
for row in rows.flatten() {
72+
let (session_id, cwd, title, updated_at, git_branch, first_msg) = row;
73+
74+
if session_id.is_empty() || cwd.is_empty() {
75+
continue;
76+
}
77+
78+
let project_name = std::path::Path::new(&cwd)
79+
.file_name()
80+
.and_then(|n| n.to_str())
81+
.unwrap_or("unknown")
82+
.to_string();
83+
84+
// updated_at is Unix seconds — convert to millis
85+
let timestamp = updated_at * 1000;
86+
87+
// Build summaries: prefer history.jsonl, fall back to title/first_msg
88+
let session_summaries = if let Some(s) = summaries.get(&session_id) {
89+
s.clone()
90+
} else {
91+
// Use first line of title (can be very long), or first_user_message
92+
let summary =
93+
first_line_truncated(&title, 200).or_else(|| first_line_truncated(&first_msg, 200));
94+
match summary {
95+
Some(s) => vec![s],
96+
None => Vec::new(),
97+
}
98+
};
99+
100+
sessions.push(Session {
101+
agent: Agent::Codex,
102+
session_id,
103+
project_name,
104+
project_path: cwd,
105+
summaries: session_summaries,
106+
timestamp,
107+
git_branch,
108+
worktree: None,
109+
});
110+
}
111+
112+
sessions
24113
}
25114

26-
#[derive(Deserialize)]
27-
struct GitInfo {
28-
branch: Option<String>,
115+
/// Find the latest state_*.sqlite file in the codex directory.
116+
fn find_state_db(codex_dir: &std::path::Path) -> Option<std::path::PathBuf> {
117+
let entries = std::fs::read_dir(codex_dir).ok()?;
118+
let mut candidates: Vec<std::path::PathBuf> = entries
119+
.filter_map(|e| e.ok())
120+
.map(|e| e.path())
121+
.filter(|p| {
122+
p.file_name()
123+
.and_then(|n| n.to_str())
124+
.map(|n| n.starts_with("state_") && n.ends_with(".sqlite"))
125+
.unwrap_or(false)
126+
})
127+
.collect();
128+
129+
// Sort descending by name so state_5 > state_4 etc.
130+
candidates.sort_by(|a, b| b.cmp(a));
131+
candidates.into_iter().next()
29132
}
30133

31-
#[derive(Deserialize)]
32-
struct HistoryEntry {
33-
session_id: Option<String>,
34-
ts: Option<f64>,
35-
text: Option<String>,
134+
/// Extract first line of text, truncated to max_len chars. Returns None if empty.
135+
fn first_line_truncated(s: &str, max_len: usize) -> Option<String> {
136+
let line = s.lines().next().unwrap_or("").trim();
137+
if line.is_empty() {
138+
return None;
139+
}
140+
if line.len() > max_len {
141+
Some(format!("{}…", &line[..max_len]))
142+
} else {
143+
Some(line.to_string())
144+
}
36145
}
37146

38-
pub fn scan() -> Result<Vec<Session>, AgfError> {
39-
let codex_dir = crate::config::codex_dir()?;
40-
let sessions_dir = codex_dir.join("sessions");
147+
/// Fallback: scan JSONL session files via walkdir (legacy format).
148+
fn scan_jsonl(
149+
codex_dir: &std::path::Path,
150+
summaries: &HashMap<String, Vec<String>>,
151+
) -> Vec<Session> {
152+
use serde::Deserialize;
153+
use walkdir::WalkDir;
41154

42-
// Collect summaries from history.jsonl (keyed by session_id, newest-first)
43-
let summaries = read_history_summaries(&codex_dir);
155+
#[derive(Deserialize)]
156+
struct SessionMeta {
157+
#[serde(rename = "type")]
158+
entry_type: Option<String>,
159+
payload: Option<SessionPayload>,
160+
}
44161

162+
#[derive(Deserialize)]
163+
struct SessionPayload {
164+
id: Option<String>,
165+
cwd: Option<String>,
166+
timestamp: Option<String>,
167+
git: Option<GitInfo>,
168+
}
169+
170+
#[derive(Deserialize)]
171+
struct GitInfo {
172+
branch: Option<String>,
173+
}
174+
175+
let sessions_dir = codex_dir.join("sessions");
45176
let mut sessions = Vec::new();
46177

47178
if !sessions_dir.exists() {
48-
return Ok(sessions);
179+
return sessions;
49180
}
50181

51182
for entry in WalkDir::new(&sessions_dir)
@@ -57,7 +188,6 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
57188
continue;
58189
}
59190

60-
// Only the first line contains session_meta — no need to read the whole file.
61191
let first_line = match read_first_line(path) {
62192
Some(line) => line,
63193
None => continue,
@@ -101,7 +231,6 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
101231
.unwrap_or(0);
102232

103233
let git_branch = payload.git.and_then(|g| g.branch);
104-
105234
let session_summaries = summaries.get(&session_id).cloned().unwrap_or_default();
106235

107236
sessions.push(Session {
@@ -116,8 +245,7 @@ pub fn scan() -> Result<Vec<Session>, AgfError> {
116245
});
117246
}
118247

119-
sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
120-
Ok(sessions)
248+
sessions
121249
}
122250

123251
/// Read only the first non-empty line of a file without loading the rest.
@@ -137,6 +265,13 @@ fn read_first_line(path: &std::path::Path) -> Option<String> {
137265
}
138266
}
139267

268+
#[derive(serde::Deserialize)]
269+
struct HistoryEntry {
270+
session_id: Option<String>,
271+
ts: Option<f64>,
272+
text: Option<String>,
273+
}
274+
140275
fn read_history_summaries(codex_dir: &std::path::Path) -> HashMap<String, Vec<String>> {
141276
let path = codex_dir.join("history.jsonl");
142277
let mut summaries: HashMap<String, Vec<(f64, String)>> = HashMap::new();

0 commit comments

Comments
 (0)