Skip to main content

claudectl_core/agent/
codex.rs

1//! Codex session discovery, from the rollout transcripts Codex writes.
2//!
3//! ## Why there is no process here
4//!
5//! Claude Code writes `~/.claude/sessions/<pid>.json` per session, so
6//! liveness is exact: the pid either exists or it does not. Codex has no
7//! such file, and — measured on a real machine — no usable process mapping
8//! either. The ChatGPT desktop app runs Codex as **one** long-lived
9//! `codex … app-server` process serving every session, alongside 27 Electron
10//! helpers. A terminal `codex` would be its own process, but nothing
11//! distinguishes which session a given process owns.
12//!
13//! So a Codex session is discovered from its transcript, and the
14//! consequences are honest rather than papered over:
15//!
16//! * There is **no pid**, so `ClaudeSession::pid` is 0 and the session is not
17//!   actionable — see `ClaudeSession::is_actionable`. Sending input to it or
18//!   killing it is not merely unimplemented; with the app there is no process
19//!   that would mean the right thing.
20//! * There is **no cost**. Codex is subscription-billed and rollouts record
21//!   tokens only, so `cost_usd` stays 0 with `usage_metrics_available`
22//!   false — absent, rather than a `$0.00` that would understate a fleet
23//!   total.
24//! * There is **no session-end event**. `task_complete` ends a *turn*, not a
25//!   session, and a session the user closed is byte-identical to one sitting
26//!   idle. Liveness is therefore a recency window, and a crashed session mid
27//!   turn reads as working until that window expires. `CODEX_SESSION_TTL_SECS`
28//!   is the whole of the liveness model, and that is a property of what Codex
29//!   records, not a shortcut.
30//!
31//! ## Layout
32//!
33//! ```text
34//! ~/.codex/sessions/YYYY/MM/DD/rollout-<ISO-ts>-<uuid>.jsonl
35//! ```
36//!
37//! One JSON object per line: `{timestamp, ordinal, type, payload}`. The types
38//! this module reads are `session_meta` (identity and cwd),
39//! `event_msg/token_count` (usage and the context window),
40//! `event_msg/{task_started,task_complete,turn_aborted}` (status), and
41//! `response_item/{function_call,custom_tool_call}` (the pending tool).
42
43use std::fs;
44use std::path::{Path, PathBuf};
45
46use crate::helpers::dirs_home;
47
48/// How long after its last write a rollout still counts as a live session.
49///
50/// Chosen from the 671 rollouts on a development machine: 0 files touched in
51/// the last hour, 1 in four hours, 25 in a week. The distribution is bimodal
52/// — a session is either being written to right now or was abandoned hours
53/// ago — so anything from a few minutes to a few hours lists the same
54/// sessions. 30 minutes is the span in which "the turn finished and I am
55/// still at my desk" is plausible; past it, the file is a transcript rather
56/// than a session.
57///
58/// Raising this costs precision in exactly one way: a Codex session that
59/// crashed mid-turn keeps reading as working until the window expires,
60/// because Codex records no session-end event to distinguish it.
61pub const CODEX_SESSION_TTL_SECS: u64 = 30 * 60;
62
63/// The lifecycle state the transcript last recorded.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum Lifecycle {
66    /// A turn is in flight: `task_started` with no matching terminal event.
67    Working,
68    /// The last turn finished (`task_complete`) or was abandoned
69    /// (`turn_aborted`). The *session* may still be open — Codex records no
70    /// difference.
71    Idle,
72    /// No lifecycle event at all. A session that opened and was closed before
73    /// the first turn.
74    #[default]
75    Unknown,
76}
77
78/// What one rollout transcript says about its session.
79#[derive(Debug, Clone, Default)]
80pub struct RolloutSummary {
81    /// Codex's own session id, from `session_meta`.
82    pub session_id: String,
83    /// Working directory the session was started in.
84    pub cwd: String,
85    /// Epoch seconds of the first event.
86    pub started_at_secs: u64,
87    /// Epoch seconds of the last event carrying a timestamp.
88    pub last_event_secs: u64,
89    pub input_tokens: u64,
90    pub cached_input_tokens: u64,
91    pub output_tokens: u64,
92    /// `total_tokens` as Codex reports it — what the context window is
93    /// measured against.
94    pub total_tokens: u64,
95    /// The model's context window, when the transcript says.
96    pub context_window: Option<u64>,
97    pub model: Option<String>,
98    pub lifecycle: Lifecycle,
99    /// Tool calls seen, for the activity signal.
100    pub tool_calls: u32,
101    /// The most recent tool call's name, already canonicalized into the
102    /// cross-agent vocabulary (#522) so it compares with a Claude session's.
103    pub last_tool: Option<String>,
104    /// The transcript this came from.
105    pub path: PathBuf,
106}
107
108/// `~/.codex/sessions`.
109pub fn sessions_root() -> PathBuf {
110    super::home_for(super::vocab::CODEX)
111        .unwrap_or_else(|| dirs_home().join(".codex"))
112        .join("sessions")
113}
114
115/// Every rollout whose last write is within `ttl_secs` of `now_secs`, parsed.
116///
117/// Walks only `YYYY/MM/DD` depth, so an unrelated file dropped in
118/// `~/.codex/sessions` costs one `read_dir` rather than a recursive sweep.
119/// Returns newest-first.
120pub fn scan(now_secs: u64, ttl_secs: u64) -> Vec<RolloutSummary> {
121    let mut out = Vec::new();
122    for path in recent_rollout_paths(&sessions_root(), now_secs, ttl_secs) {
123        match fs::read_to_string(&path) {
124            Ok(content) => {
125                if let Some(mut summary) = parse_rollout(&content) {
126                    summary.path = path;
127                    out.push(summary);
128                }
129            }
130            Err(e) => crate::logger::log(
131                "DEBUG",
132                &format!("codex rollout read failed: {}: {e}", path.display()),
133            ),
134        }
135    }
136    out.sort_by_key(|s| std::cmp::Reverse(s.last_event_secs));
137    out
138}
139
140/// Rollout paths under `root` modified within the window, newest first.
141fn recent_rollout_paths(root: &Path, now_secs: u64, ttl_secs: u64) -> Vec<PathBuf> {
142    let mut found: Vec<(PathBuf, u64)> = Vec::new();
143    // sessions/<year>/<month>/<day>/*.jsonl — three levels, no deeper.
144    for year in subdirs(root) {
145        for month in subdirs(&year) {
146            for day in subdirs(&month) {
147                let Ok(entries) = fs::read_dir(&day) else {
148                    continue;
149                };
150                for entry in entries.flatten() {
151                    let path = entry.path();
152                    if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
153                        continue;
154                    }
155                    let Some(mtime) = mtime_secs(&entry) else {
156                        continue;
157                    };
158                    if now_secs.saturating_sub(mtime) <= ttl_secs {
159                        found.push((path, mtime));
160                    }
161                }
162            }
163        }
164    }
165    found.sort_by_key(|(_, mtime)| std::cmp::Reverse(*mtime));
166    found.into_iter().map(|(p, _)| p).collect()
167}
168
169fn subdirs(dir: &Path) -> Vec<PathBuf> {
170    let Ok(entries) = fs::read_dir(dir) else {
171        return Vec::new();
172    };
173    entries
174        .flatten()
175        .map(|e| e.path())
176        .filter(|p| p.is_dir())
177        .collect()
178}
179
180fn mtime_secs(entry: &fs::DirEntry) -> Option<u64> {
181    entry
182        .metadata()
183        .ok()?
184        .modified()
185        .ok()?
186        .duration_since(std::time::UNIX_EPOCH)
187        .ok()
188        .map(|d| d.as_secs())
189}
190
191/// Parse a rollout transcript. Pure, so it is testable without a filesystem.
192///
193/// `None` when no `session_meta` was found — a file that is not a rollout, or
194/// one truncated before its first line landed.
195///
196/// A line that fails to parse is skipped rather than failing the file: a
197/// transcript being appended to right now routinely ends mid-line.
198pub fn parse_rollout(content: &str) -> Option<RolloutSummary> {
199    let mut s = RolloutSummary::default();
200    let mut saw_meta = false;
201
202    for line in content.lines() {
203        let line = line.trim();
204        if line.is_empty() {
205            continue;
206        }
207        let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
208            continue;
209        };
210
211        if let Some(secs) = event
212            .get("timestamp")
213            .and_then(|v| v.as_str())
214            .and_then(crate::history::parse_timestamp_epoch)
215        {
216            if s.started_at_secs == 0 {
217                s.started_at_secs = secs;
218            }
219            s.last_event_secs = s.last_event_secs.max(secs);
220        }
221
222        let kind = event.get("type").and_then(|v| v.as_str()).unwrap_or("");
223        let payload = event.get("payload");
224        let payload_kind = payload
225            .and_then(|p| p.get("type"))
226            .and_then(|v| v.as_str())
227            .unwrap_or("");
228
229        match (kind, payload_kind) {
230            ("session_meta", _) => {
231                saw_meta = true;
232                if let Some(p) = payload {
233                    // `session_id` is the thread; `id` is this rollout. Prefer
234                    // the thread so a resumed session keeps one identity.
235                    if let Some(id) = p
236                        .get("session_id")
237                        .or_else(|| p.get("id"))
238                        .and_then(|v| v.as_str())
239                    {
240                        s.session_id = id.to_string();
241                    }
242                    if let Some(cwd) = p.get("cwd").and_then(|v| v.as_str()) {
243                        s.cwd = cwd.to_string();
244                    }
245                }
246            }
247            ("event_msg", "token_count") => {
248                if let Some(info) = payload.and_then(|p| p.get("info")) {
249                    if let Some(total) = info.get("total_token_usage") {
250                        s.input_tokens = u64_at(total, "input_tokens");
251                        s.cached_input_tokens = u64_at(total, "cached_input_tokens");
252                        s.output_tokens = u64_at(total, "output_tokens");
253                        s.total_tokens = u64_at(total, "total_tokens");
254                    }
255                    if let Some(window) = info.get("model_context_window").and_then(|v| v.as_u64())
256                    {
257                        s.context_window = Some(window);
258                    }
259                }
260            }
261            ("event_msg", "task_started") => s.lifecycle = Lifecycle::Working,
262            ("event_msg", "task_complete" | "turn_aborted") => s.lifecycle = Lifecycle::Idle,
263            ("turn_context", _) => {
264                if let Some(model) = payload
265                    .and_then(|p| p.get("model"))
266                    .and_then(|v| v.as_str())
267                {
268                    s.model = Some(model.to_string());
269                }
270            }
271            ("response_item", "function_call" | "custom_tool_call") => {
272                s.tool_calls = s.tool_calls.saturating_add(1);
273                if let Some(p) = payload {
274                    if let Some(name) = p.get("name").and_then(|v| v.as_str()) {
275                        let namespace = p.get("namespace").and_then(|v| v.as_str());
276                        let arg = p
277                            .get("input")
278                            .or_else(|| p.get("arguments"))
279                            .and_then(|v| v.as_str());
280                        s.last_tool = Some(super::vocab::canonical_tool(
281                            super::vocab::CODEX,
282                            namespace,
283                            name,
284                            arg,
285                        ));
286                    }
287                }
288            }
289            _ => {}
290        }
291    }
292
293    saw_meta.then_some(s)
294}
295
296fn u64_at(v: &serde_json::Value, key: &str) -> u64 {
297    v.get(key).and_then(|x| x.as_u64()).unwrap_or(0)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    /// Shaped from a real transcript (§9.1 of docs/cross-agent-hive.md).
305    fn rollout_fixture() -> String {
306        [
307            r#"{"timestamp":"2026-10-11T19:08:37.776Z","ordinal":0,"type":"session_meta","payload":{"session_id":"01a12c5d-36b7-7ec3-bead-f78afff85501","id":"01a12c5d-f5ba-7783-96fa-69029d38aec6","cwd":"/Users/dev/project","runtime_workspace_roots":["/Users/dev"]}}"#,
308            r#"{"timestamp":"2026-10-11T19:08:38.000Z","ordinal":1,"type":"turn_context","payload":{"model":"gpt-6-astra"}}"#,
309            r#"{"timestamp":"2026-10-11T19:08:39.000Z","ordinal":2,"type":"event_msg","payload":{"type":"task_started"}}"#,
310            r#"{"timestamp":"2026-10-11T19:08:40.000Z","ordinal":3,"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}","call_id":"c1"}}"#,
311            r#"{"timestamp":"2026-10-11T19:08:41.000Z","ordinal":4,"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":33434,"cached_input_tokens":22912,"output_tokens":215,"total_tokens":33649},"model_context_window":272000}}}"#,
312            r#"{"timestamp":"2026-10-11T19:08:42.000Z","ordinal":5,"type":"event_msg","payload":{"type":"task_complete"}}"#,
313        ]
314        .join("\n")
315    }
316
317    #[test]
318    fn parses_identity_and_cwd() {
319        let s = parse_rollout(&rollout_fixture()).expect("fixture should parse");
320        assert_eq!(s.session_id, "01a12c5d-36b7-7ec3-bead-f78afff85501");
321        assert_eq!(s.cwd, "/Users/dev/project");
322    }
323
324    /// `session_id` is the thread and `id` is this rollout. Preferring the
325    /// thread keeps one identity across a resume; preferring `id` would make
326    /// a resumed session look like a new one.
327    #[test]
328    fn prefers_the_thread_id_over_the_rollout_id() {
329        let line = r#"{"type":"session_meta","payload":{"session_id":"thread","id":"rollout","cwd":"/x"}}"#;
330        let s = parse_rollout(line).unwrap();
331        assert_eq!(s.session_id, "thread");
332    }
333
334    /// An older rollout may carry only `id`.
335    #[test]
336    fn falls_back_to_the_rollout_id() {
337        let line = r#"{"type":"session_meta","payload":{"id":"rollout-only","cwd":"/x"}}"#;
338        let s = parse_rollout(line).unwrap();
339        assert_eq!(s.session_id, "rollout-only");
340    }
341
342    #[test]
343    fn parses_usage_and_the_context_window() {
344        let s = parse_rollout(&rollout_fixture()).unwrap();
345        assert_eq!(s.input_tokens, 33434);
346        assert_eq!(s.cached_input_tokens, 22912);
347        assert_eq!(s.output_tokens, 215);
348        assert_eq!(s.total_tokens, 33649);
349        assert_eq!(
350            s.context_window,
351            Some(272_000),
352            "model_context_window is present, so context pressure needs no new signal"
353        );
354    }
355
356    #[test]
357    fn parses_model_from_turn_context() {
358        let s = parse_rollout(&rollout_fixture()).unwrap();
359        assert_eq!(s.model.as_deref(), Some("gpt-6-astra"));
360    }
361
362    /// The last lifecycle event wins: this fixture ends on `task_complete`.
363    #[test]
364    fn a_finished_turn_reads_as_idle() {
365        let s = parse_rollout(&rollout_fixture()).unwrap();
366        assert_eq!(s.lifecycle, Lifecycle::Idle);
367    }
368
369    /// `task_started` with no terminal event is a turn in flight.
370    #[test]
371    fn an_unfinished_turn_reads_as_working() {
372        let lines = [
373            r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
374            r#"{"type":"event_msg","payload":{"type":"task_started"}}"#,
375        ]
376        .join("\n");
377        assert_eq!(parse_rollout(&lines).unwrap().lifecycle, Lifecycle::Working);
378    }
379
380    /// `turn_aborted` is a finished turn too — the user interrupted it.
381    #[test]
382    fn an_aborted_turn_reads_as_idle() {
383        let lines = [
384            r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
385            r#"{"type":"event_msg","payload":{"type":"task_started"}}"#,
386            r#"{"type":"event_msg","payload":{"type":"turn_aborted"}}"#,
387        ]
388        .join("\n");
389        assert_eq!(parse_rollout(&lines).unwrap().lifecycle, Lifecycle::Idle);
390    }
391
392    #[test]
393    fn a_session_with_no_turn_is_unknown() {
394        let line = r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#;
395        assert_eq!(parse_rollout(line).unwrap().lifecycle, Lifecycle::Unknown);
396    }
397
398    /// The pending tool is recorded in the canonical vocabulary (#522), so it
399    /// compares with a Claude session's `pending_tool_name`.
400    #[test]
401    fn the_last_tool_is_canonicalized() {
402        let s = parse_rollout(&rollout_fixture()).unwrap();
403        assert_eq!(
404            s.last_tool.as_deref(),
405            Some("Bash"),
406            "exec_command must surface as Bash, not as its Codex spelling"
407        );
408        assert_eq!(s.tool_calls, 1);
409    }
410
411    /// An `apply_patch` needs its envelope to tell Write from Edit, and the
412    /// envelope is in `input` for a custom_tool_call.
413    #[test]
414    fn apply_patch_uses_its_envelope() {
415        let lines = [
416            r#"{"type":"session_meta","payload":{"session_id":"s","cwd":"/x"}}"#,
417            r#"{"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","input":"*** Begin Patch\n*** Add File: new.rs\n+x"}}"#,
418        ]
419        .join("\n");
420        assert_eq!(
421            parse_rollout(&lines).unwrap().last_tool.as_deref(),
422            Some("Write")
423        );
424    }
425
426    #[test]
427    fn timestamps_bracket_the_session() {
428        let s = parse_rollout(&rollout_fixture()).unwrap();
429        assert!(s.started_at_secs > 0);
430        assert!(
431            s.last_event_secs >= s.started_at_secs,
432            "last event must not precede the first"
433        );
434    }
435
436    /// A transcript being appended to right now routinely ends mid-line, so a
437    /// trailing partial line must not lose the whole file.
438    #[test]
439    fn a_truncated_trailing_line_is_skipped() {
440        let content = format!("{}\n{{\"timestamp\":\"2026-10", rollout_fixture());
441        let s = parse_rollout(&content).expect("a partial last line must not fail the file");
442        assert_eq!(s.session_id, "01a12c5d-36b7-7ec3-bead-f78afff85501");
443        assert_eq!(s.total_tokens, 33649);
444    }
445
446    /// Without `session_meta` this is not a rollout.
447    #[test]
448    fn a_file_without_session_meta_is_not_a_session() {
449        assert!(
450            parse_rollout(r#"{"type":"event_msg","payload":{"type":"task_started"}}"#).is_none()
451        );
452        assert!(parse_rollout("").is_none());
453        assert!(parse_rollout("not json at all").is_none());
454    }
455
456    #[test]
457    fn sessions_root_is_under_the_codex_home() {
458        let root = sessions_root();
459        assert!(root.ends_with("sessions"));
460        assert!(root.to_string_lossy().contains(".codex"));
461    }
462
463    // ── Scanning ──────────────────────────────────────────────────────────
464
465    #[test]
466    fn recent_paths_respect_the_ttl_window() {
467        use std::io::Write;
468        let dir = tempfile::tempdir().unwrap();
469        let day = dir.path().join("2026").join("10").join("11");
470        fs::create_dir_all(&day).unwrap();
471
472        let rollout = day.join("rollout-a.jsonl");
473        let mut f = fs::File::create(&rollout).unwrap();
474        writeln!(f, "{}", rollout_fixture()).unwrap();
475        // Not a rollout; must be ignored on extension alone.
476        fs::write(day.join("notes.txt"), "x").unwrap();
477
478        let mtime = mtime_of(&rollout);
479        let found = recent_rollout_paths(dir.path(), mtime, 600);
480        assert_eq!(found.len(), 1, "only the .jsonl should be picked up");
481        assert_eq!(found[0], rollout);
482
483        // Same file, asked about long after its write.
484        let stale = recent_rollout_paths(dir.path(), mtime + 10_000, 600);
485        assert!(
486            stale.is_empty(),
487            "past the window a rollout is a transcript, not a session"
488        );
489    }
490
491    #[test]
492    fn scanning_a_missing_root_is_empty_not_an_error() {
493        let dir = tempfile::tempdir().unwrap();
494        assert!(recent_rollout_paths(&dir.path().join("nope"), 0, 600).is_empty());
495    }
496
497    fn mtime_of(path: &Path) -> u64 {
498        fs::metadata(path)
499            .unwrap()
500            .modified()
501            .unwrap()
502            .duration_since(std::time::UNIX_EPOCH)
503            .unwrap()
504            .as_secs()
505    }
506}