Skip to main content

devboy_core/agents/
codex.rs

1//! OpenAI Codex CLI detector.
2//!
3//! Codex stores prompts cross-session in a single rolling file:
4//! `~/.codex/history.jsonl` — `{"session_id":"019a…","ts":1763549853,"text":"…"}`.
5//!
6//! Sessions = number of unique `session_id` values.
7//! last_used = max `ts` (epoch seconds).
8
9use std::collections::HashSet;
10use std::io::{BufRead, BufReader};
11use std::path::Path;
12
13use super::{AgentDetector, AgentSnapshot, InstallStatus};
14
15const ID: &str = "codex";
16const DISPLAY_NAME: &str = "Codex CLI";
17const MAX_LINES: usize = 200_000;
18
19pub struct CodexDetector;
20
21impl AgentDetector for CodexDetector {
22    fn id(&self) -> &'static str {
23        ID
24    }
25    fn display_name(&self) -> &'static str {
26        DISPLAY_NAME
27    }
28
29    fn detect(&self, home: &Path) -> AgentSnapshot {
30        let codex_dir = home.join(".codex");
31        let history = codex_dir.join("history.jsonl");
32        let paths_checked = vec![codex_dir.clone(), history.clone()];
33
34        let dir_present = codex_dir.is_dir();
35        let binary_present = which::which("codex").is_ok();
36
37        let status = if dir_present || binary_present {
38            InstallStatus::Yes
39        } else {
40            InstallStatus::No
41        };
42        if status == InstallStatus::No {
43            return empty(paths_checked);
44        }
45
46        let (sessions, last_used) = parse_history(&history);
47
48        AgentSnapshot {
49            id: ID,
50            display_name: DISPLAY_NAME,
51            status,
52            sessions: (sessions > 0).then_some(sessions),
53            last_used,
54            score: 0.0,
55            paths_checked,
56        }
57    }
58}
59
60fn parse_history(history: &Path) -> (u64, Option<chrono::DateTime<chrono::Utc>>) {
61    let Ok(file) = std::fs::File::open(history) else {
62        return (0, None);
63    };
64    let reader = BufReader::new(file);
65    let mut sessions: HashSet<String> = HashSet::new();
66    let mut last_ts: i64 = 0;
67    for line in reader.lines().map_while(Result::ok).take(MAX_LINES) {
68        let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
69            continue;
70        };
71        if let Some(sid) = value.get("session_id").and_then(|v| v.as_str()) {
72            sessions.insert(sid.to_string());
73        }
74        if let Some(ts) = value.get("ts").and_then(|v| v.as_i64())
75            && ts > last_ts
76        {
77            last_ts = ts;
78        }
79    }
80    // `from_timestamp` returns `None` for out-of-range epoch seconds; in
81    // that case treat the field as missing rather than silently reporting
82    // 1970-01-01 as the last-used time.
83    let last_used = if last_ts > 0 {
84        chrono::DateTime::<chrono::Utc>::from_timestamp(last_ts, 0)
85    } else {
86        None
87    };
88    (sessions.len() as u64, last_used)
89}
90
91fn empty(paths_checked: Vec<std::path::PathBuf>) -> AgentSnapshot {
92    AgentSnapshot {
93        id: ID,
94        display_name: DISPLAY_NAME,
95        status: InstallStatus::No,
96        sessions: None,
97        last_used: None,
98        score: 0.0,
99        paths_checked,
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::fs;
107    use tempfile::tempdir;
108
109    #[test]
110    fn counts_unique_session_ids() {
111        let home = tempdir().unwrap();
112        let codex = home.path().join(".codex");
113        fs::create_dir_all(&codex).unwrap();
114        let history = codex.join("history.jsonl");
115        let body = "\
116{\"session_id\":\"AAA\",\"ts\":1700000000,\"text\":\"hi\"}
117{\"session_id\":\"AAA\",\"ts\":1700000010,\"text\":\"again\"}
118{\"session_id\":\"BBB\",\"ts\":1700000020,\"text\":\"third\"}
119{\"session_id\":\"CCC\",\"ts\":1700000030,\"text\":\"fourth\"}
120";
121        fs::write(&history, body).unwrap();
122        let snap = CodexDetector.detect(home.path());
123        assert_eq!(snap.status, InstallStatus::Yes);
124        assert_eq!(snap.sessions, Some(3));
125        assert_eq!(snap.last_used.unwrap().timestamp(), 1700000030);
126    }
127
128    #[test]
129    fn no_codex_dir_means_not_installed() {
130        let home = tempdir().unwrap();
131        let snap = CodexDetector.detect(home.path());
132        if which::which("codex").is_err() {
133            assert_eq!(snap.status, InstallStatus::No);
134            assert!(snap.sessions.is_none());
135            assert!(snap.last_used.is_none());
136        }
137    }
138
139    #[test]
140    fn empty_history_file_means_zero_sessions() {
141        let home = tempdir().unwrap();
142        let codex = home.path().join(".codex");
143        fs::create_dir_all(&codex).unwrap();
144        fs::write(codex.join("history.jsonl"), b"").unwrap();
145        let snap = CodexDetector.detect(home.path());
146        assert_eq!(snap.status, InstallStatus::Yes);
147        assert!(snap.sessions.is_none());
148        assert!(snap.last_used.is_none());
149    }
150
151    #[test]
152    fn malformed_json_lines_are_skipped() {
153        let home = tempdir().unwrap();
154        let codex = home.path().join(".codex");
155        fs::create_dir_all(&codex).unwrap();
156        let body = "\
157not json at all
158{\"session_id\":\"X\",\"ts\":1700000000,\"text\":\"valid\"}
159{this is also broken}
160";
161        fs::write(codex.join("history.jsonl"), body).unwrap();
162        let snap = CodexDetector.detect(home.path());
163        assert_eq!(snap.sessions, Some(1));
164        assert_eq!(snap.last_used.unwrap().timestamp(), 1700000000);
165    }
166
167    #[test]
168    fn missing_history_file_yields_no_session_data_but_install_yes() {
169        let home = tempdir().unwrap();
170        let codex = home.path().join(".codex");
171        fs::create_dir_all(&codex).unwrap();
172        // No history.jsonl present.
173        let snap = CodexDetector.detect(home.path());
174        assert_eq!(snap.status, InstallStatus::Yes);
175        assert!(snap.sessions.is_none());
176        assert!(snap.last_used.is_none());
177    }
178}