Skip to main content

agent_top_core/harness/
codex.rs

1//! OpenAI Codex CLI: `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl`.
2//!
3//! Format notes (verified on Codex CLI 0.149, 2026-09-03):
4//! * The first line is `session_meta` with `payload.cwd`, `payload.id`,
5//!   `payload.cli_version` and `payload.originator`.
6//! * `event_msg` / `token_count` carries `info.total_token_usage`, which is
7//!   cumulative for the session; `info` is null on rate-limit-only events.
8//!   `input_tokens` includes `cached_input_tokens`.
9//! * `task_started` / `task_complete` / `turn_aborted` bracket a turn.
10//! * `response_item` with `payload.type` `function_call` or
11//!   `custom_tool_call` is one tool call; the matching `*_output` item
12//!   carries the same `payload.call_id`, and the two lines' timestamps
13//!   bracket the call. That pairing is the trace.
14//! * `task_started` and `task_complete` bracket a turn span. An inference
15//!   span runs from a user `message` item or a `*_output` item to the next
16//!   thing the model produced: a call, a `reasoning` item, a
17//!   `web_search_call`, or an assistant `message`.
18//! * `response_item` `web_search_call` is one server-side web search.
19//!
20//! Codex model prices are not in the static table, so cost is reported as
21//! unpriced tokens.
22
23use super::{REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanRetention, parse_rfc3339_utc};
24use crate::jsonl::TailReader;
25use crate::model::{Activity, Harness, SpanKind, TokenUsage};
26use crate::pricing::{self, Table};
27use serde_json::Value;
28use std::path::{Path, PathBuf};
29use std::time::SystemTime;
30
31pub fn codex_dir() -> Option<PathBuf> {
32    if let Some(d) = std::env::var_os("CODEX_HOME") {
33        return Some(PathBuf::from(d));
34    }
35    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
36}
37
38pub fn sessions_dir() -> Option<PathBuf> {
39    codex_dir().map(|d| d.join("sessions"))
40}
41
42/// Rollout files modified after `since`. Walks `YYYY/MM/DD` and prunes by
43/// directory mtime so the walk stays cheap on a long history.
44/// Rollouts the process has open: the app-server's live threads, or the CLI's
45/// one conversation. `None` when the platform cannot say. Filtered to the
46/// sessions directory so an unrelated file the process holds (a log, a
47/// config) is never mistaken for a thread, and mapped back under the
48/// un-canonicalised sessions directory so the paths compare equal to those
49/// from `recent_rollouts`.
50pub fn rollouts_open_by(pid: u32) -> Option<Vec<PathBuf>> {
51    let root = sessions_dir()?;
52    let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
53    let open = crate::openfiles::open_files(pid)?;
54    Some(
55        open.into_iter()
56            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
57            .filter_map(|p| p.strip_prefix(&canonical).ok().map(|rel| root.join(rel)))
58            .collect(),
59    )
60}
61
62/// Every rollout written since `since`.
63pub fn recent_rollouts(since: SystemTime) -> Vec<PathBuf> {
64    let Some(root) = sessions_dir() else { return Vec::new() };
65    rollouts_under(&root, since)
66}
67
68/// The tree is `YYYY/MM/DD/*.jsonl` and is walked in full, three levels deep,
69/// with only the files filtered by mtime. Pruning directories by their mtime
70/// looked cheaper and was wrong: a directory's mtime moves only when an entry
71/// is created directly inside it, so the year directory is touched once a
72/// month and every rollout written after the first of the month was invisible.
73/// Pruning by name would be wrong too, since a directory's date says when a
74/// thread started, not whether it is still being written to; the app-server
75/// keeps a thread for days. A few hundred directories cost a few milliseconds.
76pub(crate) fn rollouts_under(root: &Path, since: SystemTime) -> Vec<PathBuf> {
77    let mut out = Vec::new();
78    walk(root, 0, since, &mut out);
79    out
80}
81
82fn walk(dir: &Path, depth: usize, since: SystemTime, out: &mut Vec<PathBuf>) {
83    let Ok(rd) = std::fs::read_dir(dir) else { return };
84    for e in rd.flatten() {
85        let p = e.path();
86        let Ok(md) = e.metadata() else { continue };
87        if md.is_dir() {
88            if depth < 3 {
89                walk(&p, depth + 1, since, out);
90            }
91        } else if p.extension().and_then(|x| x.to_str()) == Some("jsonl") && md.modified().map(|m| m >= since).unwrap_or(false) {
92            out.push(p);
93        }
94    }
95}
96
97/// Cheap header read: cwd and start time from the first line only.
98pub fn read_meta(path: &Path) -> Option<(PathBuf, SystemTime)> {
99    use std::io::{BufRead, BufReader};
100    let f = std::fs::File::open(path).ok()?;
101    let mut first = String::new();
102    BufReader::new(f).read_line(&mut first).ok()?;
103    let v: Value = serde_json::from_str(&first).ok()?;
104    if v.get("type").and_then(Value::as_str) != Some("session_meta") {
105        return None;
106    }
107    let cwd = v.pointer("/payload/cwd").and_then(Value::as_str).map(PathBuf::from)?;
108    let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc)?;
109    Some((cwd, ts))
110}
111
112pub struct CodexTranscript {
113    reader: TailReader,
114    prices: &'static Table,
115    summary: SessionSummary,
116    /// Counters naming the turn and inference spans, and the ids of the ones
117    /// currently being extended.
118    turns: u64,
119    inferences: u64,
120    turn: Option<String>,
121    inference: Option<String>,
122}
123
124impl CodexTranscript {
125    pub fn new(path: impl Into<PathBuf>) -> Self {
126        CodexTranscript {
127            reader: TailReader::new(path),
128            prices: pricing::table(),
129            summary: SessionSummary { harness: Some(Harness::Codex), ..Default::default() },
130            turns: 0,
131            inferences: 0,
132            turn: None,
133            inference: None,
134        }
135    }
136
137    /// Something was submitted to the model. One inference at a time: a
138    /// developer message followed by a user message is one submission.
139    fn begin_inference(&mut self, ts: SystemTime) {
140        if self.summary.spans.open_of_kind(SpanKind::Inference).is_some() {
141            return;
142        }
143        // A turn that ended without the model replying (aborted) leaves the
144        // previous inference open; it produced nothing, so it goes.
145        if let Some(id) = self.inference.take() {
146            self.summary.spans.discard_open(&id);
147        }
148        self.inferences += 1;
149        let id = format!("inference:{}", self.inferences);
150        self.summary.spans.open_kind(id.clone(), "inference".into(), ts, false, SpanKind::Inference);
151        self.inference = Some(id);
152    }
153
154    /// The model produced something: the inference in progress ends here.
155    fn end_inference(&mut self, ts: SystemTime) {
156        if let Some(id) = self.inference.take() {
157            self.summary.spans.end_at(&id, ts);
158        }
159    }
160
161    /// See `ClaudeTranscript::with_prices`.
162    pub fn with_prices(mut self, prices: &'static Table) -> Self {
163        self.prices = prices;
164        self
165    }
166
167    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
168    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
169        self.summary.spans = retention.log();
170        self
171    }
172
173    fn ingest(&mut self, line: &str) {
174        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
175        let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
176        if let Some(ts) = ts {
177            if self.summary.started_at.is_none() {
178                self.summary.started_at = Some(ts);
179            }
180            self.summary.last_activity = Some(ts);
181        }
182        let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
183        let payload = v.get("payload");
184        let ptype = payload.and_then(|p| p.get("type")).and_then(Value::as_str).unwrap_or("");
185        match kind {
186            "session_meta" => {
187                if let Some(p) = payload {
188                    self.summary.session_id = p.get("id").or(p.get("session_id")).and_then(Value::as_str).map(str::to_string);
189                    self.summary.cwd = p.get("cwd").and_then(Value::as_str).map(PathBuf::from);
190                    self.summary.harness_version = p.get("cli_version").and_then(Value::as_str).map(str::to_string);
191                }
192            }
193            "turn_context" => {
194                if let Some(m) = payload.and_then(|p| p.get("model")).and_then(Value::as_str) {
195                    self.summary.model = Some(m.to_string());
196                }
197            }
198            "event_msg" => match ptype {
199                "token_count" => {
200                    if let Some(total) = payload.and_then(|p| p.pointer("/info/total_token_usage")) {
201                        let g = |k: &str| total.get(k).and_then(Value::as_u64).unwrap_or(0);
202                        self.summary.health.usage_records += 1;
203                        if g("input_tokens") + g("output_tokens") + g("cached_input_tokens") == 0 {
204                            self.summary.health.empty_usage_records += 1;
205                        }
206                        let cached = g("cached_input_tokens");
207                        let usage = TokenUsage {
208                            input: g("input_tokens").saturating_sub(cached),
209                            cache_read: cached,
210                            output: g("output_tokens"),
211                            ..Default::default()
212                        };
213                        self.summary.usage = usage;
214                        let price = self.summary.model.as_deref().and_then(|m| self.prices.lookup(m));
215                        match price {
216                            Some(p) => {
217                                self.summary.cost_breakdown = p.breakdown(&usage);
218                                self.summary.cost_usd = self.summary.cost_breakdown.total();
219                                self.summary.unpriced_tokens = 0;
220                            }
221                            None => {
222                                self.summary.cost_breakdown = Default::default();
223                                self.summary.cost_usd = 0.0;
224                                self.summary.unpriced_tokens = usage.total();
225                            }
226                        }
227                    }
228                }
229                "task_started" => {
230                    self.summary.activity = Activity::Working;
231                    if let Some(ts) = ts {
232                        self.turns += 1;
233                        let id = format!("turn:{}", self.turns);
234                        self.summary.spans.open_kind(id.clone(), "turn".into(), ts, false, SpanKind::Turn);
235                        self.turn = Some(id);
236                    }
237                }
238                "user_message" => self.summary.activity = Activity::Working,
239                "task_complete" | "turn_aborted" | "error" => {
240                    self.summary.activity = Activity::Waiting;
241                    if let (Some(ts), Some(id)) = (ts, self.turn.take()) {
242                        self.summary.spans.end_at(&id, ts);
243                    }
244                    if let Some(id) = self.inference.take() {
245                        self.summary.spans.discard_open(&id);
246                    }
247                }
248                _ => {}
249            },
250            "response_item" => match ptype {
251                "function_call" | "custom_tool_call" | "local_shell_call" => {
252                    self.summary.tool_calls += 1;
253                    if let (Some(ts), Some(p)) = (ts, payload) {
254                        self.end_inference(ts);
255                        let id = call_id(p);
256                        let name = p.get("name").and_then(Value::as_str).unwrap_or(ptype);
257                        self.summary.spans.open(id, name.to_string(), ts, false);
258                    }
259                }
260                "function_call_output" | "custom_tool_call_output" | "local_shell_call_output" => {
261                    if let (Some(ts), Some(p)) = (ts, payload) {
262                        // Codex reports the result as an opaque string, and
263                        // agent-top does not read tool output, so a failed call
264                        // is not distinguishable from a successful one here.
265                        self.summary.spans.close(&call_id(p), ts, false);
266                        self.begin_inference(ts);
267                    }
268                }
269                // A server-side web search: billed per search by OpenAI, but
270                // at a rate this table does not carry, so counted only.
271                "web_search_call" => {
272                    self.summary.web_searches += 1;
273                    if let Some(ts) = ts {
274                        self.end_inference(ts);
275                    }
276                }
277                "reasoning" => {
278                    if let Some(ts) = ts {
279                        self.end_inference(ts);
280                    }
281                }
282                "message" => match payload.and_then(|p| p.get("role")).and_then(Value::as_str) {
283                    Some("assistant") => {
284                        self.summary.turns += 1;
285                        self.summary.health.billable_messages += 1;
286                        if let Some(ts) = ts {
287                            self.end_inference(ts);
288                        }
289                    }
290                    Some("user") => {
291                        if let Some(ts) = ts {
292                            self.begin_inference(ts);
293                        }
294                    }
295                    _ => {}
296                },
297                _ => {}
298            },
299            _ => {}
300        }
301    }
302}
303
304/// `call_id` on function calls, `id` on the shell-call variants.
305fn call_id(payload: &Value) -> String {
306    payload.get("call_id").or_else(|| payload.get("id")).and_then(Value::as_str).unwrap_or_default().to_string()
307}
308
309impl SessionTracker for CodexTranscript {
310    fn refresh(&mut self) -> anyhow::Result<bool> {
311        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
312        for l in &lines {
313            self.ingest(l);
314        }
315        Ok(more)
316    }
317
318    fn summary(&self) -> &SessionSummary {
319        &self.summary
320    }
321
322    fn path(&self) -> &Path {
323        self.reader.path()
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use std::io::Write;
331    use std::time::Duration;
332
333    /// The bug this guards: the year and month directories were last touched
334    /// when a child directory was created, long before the rollout of
335    /// interest was written.
336    #[test]
337    fn finds_a_fresh_rollout_under_stale_directories() {
338        let root = std::env::temp_dir().join(format!("agent-top-rollouts-{}", std::process::id()));
339        let day = root.join("2026").join("09").join("04");
340        std::fs::create_dir_all(&day).unwrap();
341        let fresh = day.join("rollout-fresh.jsonl");
342        let stale = day.join("rollout-stale.jsonl");
343        std::fs::write(&fresh, "{}\n").unwrap();
344        std::fs::write(&stale, "{}\n").unwrap();
345        let now = SystemTime::now();
346        let long_ago = now - Duration::from_secs(40 * 86_400);
347        std::fs::File::open(&stale).unwrap().set_modified(long_ago).unwrap();
348        for dir in [&root, &root.join("2026"), &root.join("2026").join("09"), &day] {
349            std::fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
350        }
351        let found = rollouts_under(&root, now - Duration::from_secs(1800));
352        assert_eq!(found, vec![fresh], "the fresh file is found through directories nobody has touched in weeks");
353        std::fs::remove_dir_all(&root).unwrap();
354    }
355
356    #[test]
357    fn reads_cumulative_usage_and_state() {
358        let dir = std::env::temp_dir().join(format!("agent-top-codex-{}", std::process::id()));
359        std::fs::create_dir_all(&dir).unwrap();
360        let path = dir.join("rollout.jsonl");
361        let mut f = std::fs::File::create(&path).unwrap();
362        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:20.787Z","type":"session_meta","payload":{{"id":"01a0","cwd":"/tmp/p","cli_version":"0.149.1"}}}}"#).unwrap();
363        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:21.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#).unwrap();
364        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:22.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
365        writeln!(
366            f,
367            r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"shell"}}}}"#
368        )
369        .unwrap();
370        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:24.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":14778,"cached_input_tokens":12672,"output_tokens":241,"total_tokens":15019}}}}}}}}"#).unwrap();
371        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.000Z","type":"event_msg","payload":{{"type":"token_count","info":null}}}}"#)
372            .unwrap();
373        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.500Z","type":"response_item","payload":{{"type":"web_search_call","status":"completed"}}}}"#).unwrap();
374        let mut t = CodexTranscript::new(&path);
375        t.refresh().unwrap();
376        let s = t.summary();
377        assert_eq!(s.session_id.as_deref(), Some("01a0"));
378        assert_eq!(s.model.as_deref(), Some("gpt-5-codex"));
379        assert_eq!(s.usage.input, 14778 - 12672);
380        assert_eq!(s.usage.cache_read, 12672);
381        assert_eq!(s.usage.total(), 15019);
382        assert_eq!(s.unpriced_tokens, 15019);
383        assert_eq!(s.tool_calls, 1);
384        assert_eq!(s.activity, Activity::Working);
385        assert_eq!(read_meta(&path).unwrap().0, PathBuf::from("/tmp/p"));
386        assert_eq!(s.web_searches, 1);
387        let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
388        assert_eq!(tools.len(), 1);
389        assert_eq!(tools[0].name, "shell");
390        assert!(tools[0].is_open(), "no output item yet");
391        let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
392        assert_eq!(turns.len(), 1);
393        assert!(turns[0].is_open(), "task_started with no task_complete");
394        let _ = std::fs::remove_dir_all(&dir);
395    }
396
397    #[test]
398    fn pairs_calls_with_their_outputs() {
399        let dir = std::env::temp_dir().join(format!("agent-top-codex-spans-{}", std::process::id()));
400        std::fs::create_dir_all(&dir).unwrap();
401        let path = dir.join("rollout.jsonl");
402        let mut f = std::fs::File::create(&path).unwrap();
403        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"exec_command"}}}}"#).unwrap();
404        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:23.100Z","type":"response_item","payload":{{"type":"custom_tool_call","call_id":"call_2","name":"apply_patch"}}}}"#).unwrap();
405        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:24.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"call_1","output":"ok"}}}}"#).unwrap();
406        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:26.100Z","type":"response_item","payload":{{"type":"custom_tool_call_output","call_id":"call_2","output":"ok"}}}}"#).unwrap();
407        // The model answers the outputs 1.5 s after the last one, then the turn completes.
408        writeln!(
409            f,
410            r#"{{"timestamp":"2026-08-28T08:53:27.600Z","type":"response_item","payload":{{"type":"message","role":"assistant"}}}}"#
411        )
412        .unwrap();
413        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:27.700Z","type":"event_msg","payload":{{"type":"task_complete"}}}}"#).unwrap();
414        let mut t = CodexTranscript::new(&path);
415        t.refresh().unwrap();
416        let all = t.summary().spans.to_vec();
417        let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
418        assert_eq!(spans.len(), 2);
419        assert_eq!(spans[0].name, "exec_command");
420        assert_eq!(spans[0].duration_ms, Some(1_000));
421        assert_eq!(spans[1].name, "apply_patch");
422        assert_eq!(spans[1].duration_ms, Some(3_000));
423        assert_eq!(t.summary().tool_calls, 2);
424        // One inference: opened by the first output at :24, not re-opened by the
425        // second at :26.1, ended by the assistant message at :27.6.
426        let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
427        assert_eq!(inf.len(), 1);
428        assert_eq!(inf[0].duration_ms, Some(3_600));
429        assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no task_started in this file");
430        let _ = std::fs::remove_dir_all(&dir);
431    }
432}