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//! * `info.last_token_usage` beside the cumulative record is the one
20//!   response's usage. The first `token_count` of a turn repeats the
21//!   previous turn's last one, so a record identical to the one before it
22//!   is a snapshot, not a response. Context by source is sized from these:
23//!   each `*_output` item is filed under its call's name, re-filed under
24//!   the MCP server when the `mcp_tool_call_end` for that call id follows
25//!   (it comes after the output), and sized by the next response. Codex
26//!   writes no compaction marker that was seen, so the ledger's halving
27//!   rule stands in. See `ContextLedger`.
28//!
29//! Codex model prices are not in the static table, so cost is reported as
30//! unpriced tokens.
31
32use super::{AttributeContext, HarnessAdapter, REFRESH_BUDGET_BYTES, SessionSummary, SessionTracker, SpanRetention, parse_rfc3339_utc};
33use crate::jsonl::TailReader;
34use crate::model::{Activity, Attribution, ContextOrigin, Harness, ProcNode, SpanKind, TokenUsage};
35use crate::pricing::{self, Table};
36use crate::process::RawProc;
37use serde_json::Value;
38use std::collections::{HashMap, HashSet};
39use std::path::{Path, PathBuf};
40use std::time::{Duration, SystemTime};
41
42pub fn codex_dir() -> Option<PathBuf> {
43    if let Some(d) = std::env::var_os("CODEX_HOME") {
44        return Some(PathBuf::from(d));
45    }
46    std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".codex"))
47}
48
49pub fn sessions_dir() -> Option<PathBuf> {
50    codex_dir().map(|d| d.join("sessions"))
51}
52
53/// Rollout files modified after `since`. Walks `YYYY/MM/DD` and prunes by
54/// directory mtime so the walk stays cheap on a long history.
55/// Rollouts the process has open: the app-server's live threads, or the CLI's
56/// one conversation. `None` when the platform cannot say. Filtered to the
57/// sessions directory so an unrelated file the process holds (a log, a
58/// config) is never mistaken for a thread, and mapped back under the
59/// un-canonicalised sessions directory so the paths compare equal to those
60/// from `recent_rollouts`.
61pub fn rollouts_open_by(pid: u32) -> Option<Vec<PathBuf>> {
62    let root = sessions_dir()?;
63    let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
64    let open = crate::openfiles::open_files(pid)?;
65    Some(
66        open.into_iter()
67            .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("jsonl"))
68            .filter_map(|p| p.strip_prefix(&canonical).ok().map(|rel| root.join(rel)))
69            .collect(),
70    )
71}
72
73/// Every rollout written since `since`.
74pub fn recent_rollouts(since: SystemTime) -> Vec<PathBuf> {
75    let Some(root) = sessions_dir() else { return Vec::new() };
76    rollouts_under(&root, since)
77}
78
79/// The tree is `YYYY/MM/DD/*.jsonl` and is walked in full, three levels deep,
80/// with only the files filtered by mtime. Pruning directories by their mtime
81/// looked cheaper and was wrong: a directory's mtime moves only when an entry
82/// is created directly inside it, so the year directory is touched once a
83/// month and every rollout written after the first of the month was invisible.
84/// Pruning by name would be wrong too, since a directory's date says when a
85/// thread started, not whether it is still being written to; the app-server
86/// keeps a thread for days. A few hundred directories cost a few milliseconds.
87pub(crate) fn rollouts_under(root: &Path, since: SystemTime) -> Vec<PathBuf> {
88    let mut out = Vec::new();
89    walk(root, 0, since, &mut out);
90    out
91}
92
93fn walk(dir: &Path, depth: usize, since: SystemTime, out: &mut Vec<PathBuf>) {
94    let Ok(rd) = std::fs::read_dir(dir) else { return };
95    for e in rd.flatten() {
96        let p = e.path();
97        let Ok(md) = e.metadata() else { continue };
98        if md.is_dir() {
99            if depth < 3 {
100                walk(&p, depth + 1, since, out);
101            }
102        } else if p.extension().and_then(|x| x.to_str()) == Some("jsonl") && md.modified().map(|m| m >= since).unwrap_or(false) {
103            out.push(p);
104        }
105    }
106}
107
108/// Cheap header read: cwd and start time from the first line only.
109pub fn read_meta(path: &Path) -> Option<(PathBuf, SystemTime)> {
110    use std::io::{BufRead, BufReader};
111    let f = std::fs::File::open(path).ok()?;
112    let mut first = String::new();
113    BufReader::new(f).read_line(&mut first).ok()?;
114    let v: Value = serde_json::from_str(&first).ok()?;
115    if v.get("type").and_then(Value::as_str) != Some("session_meta") {
116        return None;
117    }
118    let cwd = v.pointer("/payload/cwd").and_then(Value::as_str).map(PathBuf::from)?;
119    let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc)?;
120    Some((cwd, ts))
121}
122
123/// The Codex adapter: a process is matched to the rollouts it holds open,
124/// and only where the platform cannot say to the cwd and activity heuristics.
125/// See DEC-006.
126#[derive(Default)]
127pub struct CodexAdapter {
128    /// Recent rollouts with the cwd and start time from their header.
129    recent: Vec<(PathBuf, PathBuf, SystemTime)>,
130    /// Which rollouts each Codex process has open, gathered before any
131    /// attribution so that no process's fallback can claim a thread another
132    /// process is demonstrably writing. `None` when the platform cannot say.
133    held: HashMap<u32, Option<Vec<PathBuf>>>,
134    all_held: HashSet<PathBuf>,
135}
136
137impl HarnessAdapter for CodexAdapter {
138    fn harness(&self) -> Harness {
139        Harness::Codex
140    }
141
142    fn rescan(&mut self, since: SystemTime) {
143        self.recent = recent_rollouts(since).into_iter().filter_map(|p| read_meta(&p).map(|(cwd, ts)| (p, cwd, ts))).collect();
144    }
145
146    fn prepare(&mut self, roots: &[&ProcNode]) {
147        self.held = roots.iter().map(|r| (r.pid, rollouts_open_by(r.pid))).collect();
148        self.all_held = self.held.values().flatten().flatten().cloned().collect();
149    }
150
151    fn attribute(&self, root: &ProcNode, _raw: Option<&RawProc>, ctx: &AttributeContext) -> (Vec<PathBuf>, Attribution) {
152        let mine: Option<Vec<PathBuf>> =
153            self.held.get(&root.pid).and_then(|h| h.as_ref()).map(|h| h.iter().filter(|p| !ctx.attached.contains(*p)).cloned().collect());
154        let taken: HashSet<PathBuf> = ctx.attached.union(&self.all_held).cloned().collect();
155        attribute(ctx.cwd, ctx.proc_start, mine.as_deref(), &self.recent, &taken, ctx.now, ctx.activity_timeout)
156    }
157
158    fn unowned(&self, attached: &HashSet<PathBuf>) -> Vec<PathBuf> {
159        self.recent.iter().map(|(p, _, _)| p).filter(|p| !attached.contains(*p)).cloned().collect()
160    }
161
162    fn open(&self, path: &Path, spans: SpanRetention) -> Box<dyn SessionTracker> {
163        Box::new(CodexTranscript::new(path).with_spans(spans))
164    }
165
166    /// Every rollout opens with a `session_meta` record.
167    fn detect(&self, path: &Path) -> bool {
168        super::head_lines(path).iter().any(|v| v.get("type").and_then(Value::as_str) == Some("session_meta"))
169    }
170
171    fn transcripts(&self) -> Vec<(String, PathBuf)> {
172        recent_rollouts(SystemTime::UNIX_EPOCH).into_iter().map(|p| (rollout_id(&p), p)).collect()
173    }
174}
175
176/// The id in `rollout-2026-05-14T21-37-50-<id>`: what follows the fixed-width
177/// timestamp. A file named some other way is matched on its whole stem.
178pub fn rollout_id(p: &Path) -> String {
179    const TS_LEN: usize = "2026-05-14T21-37-50-".len();
180    let stem = p.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
181    stem.strip_prefix("rollout-").and_then(|s| s.get(TS_LEN..)).map(str::to_string).unwrap_or(stem)
182}
183
184/// Codex conversations belonging to one process, newest activity first.
185///
186/// `held` are the rollouts the process has open, which is not a guess: Codex
187/// opens a thread's rollout when the thread starts and closes it when the
188/// thread ends. When the platform can say (`Some`), that list is the answer,
189/// an empty one included: a process holding no rollout is hosting no thread,
190/// and a rollout nobody holds is a finished conversation for the stopped
191/// list. The heuristics below are for when it cannot (`None`).
192///
193/// A `codex` CLI runs one conversation from the directory it was started in, so
194/// a cwd match finds it. The VS Code app-server is a different shape: one
195/// long-lived process, running from `/`, hosting any number of conversations
196/// over its life. Returning a single rollout for it collapses every one of
197/// those into one row and attributes whichever happened to be newest, so this
198/// returns all of them that are currently live and lets the caller give each
199/// its own row.
200///
201/// A rollout in `taken` is skipped: one already claimed by another process,
202/// or one some process has open, so that two Codex processes cannot both
203/// show the same conversation and an older app-server cannot collect the
204/// threads of a newer one.
205pub(crate) fn attribute(
206    cwd: Option<&Path>,
207    proc_start: SystemTime,
208    held: Option<&[PathBuf]>,
209    recent: &[(PathBuf, PathBuf, SystemTime)],
210    taken: &HashSet<PathBuf>,
211    now: SystemTime,
212    activity_timeout: Duration,
213) -> (Vec<PathBuf>, Attribution) {
214    if let Some(held) = held {
215        let mut mine = held.to_vec();
216        mine.sort_by_key(|p| std::cmp::Reverse(written_at(p)));
217        mine.truncate(MAX_THREADS);
218        let attribution = if mine.is_empty() { Attribution::None } else { Attribution::OpenFile };
219        return (mine, attribution);
220    }
221
222    let slack = Duration::from_secs(60);
223    let started_after = |ts: &SystemTime| *ts + slack >= proc_start;
224    let candidates = || recent.iter().filter(|(p, _, ts)| started_after(ts) && !taken.contains(p));
225
226    // The CLI case: the conversation runs where the process runs.
227    if let Some(cwd) = cwd {
228        let mut matched: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates().filter(|(_, c, _)| c == cwd).collect();
229        if !matched.is_empty() {
230            matched.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
231            return (matched.into_iter().map(|(p, _, _)| p.clone()).collect(), Attribution::CwdHeuristic);
232        }
233    }
234
235    // The app-server case: no cwd to match on, so take the conversations that
236    // are actually being written to. A rollout nobody has touched in a while is
237    // a finished conversation, not a thread of this process.
238    let mut live: Vec<&(PathBuf, PathBuf, SystemTime)> = candidates()
239        .filter(|(p, _, _)| written_at(p).map(|w| now.duration_since(w).unwrap_or_default() <= activity_timeout).unwrap_or(false))
240        .collect();
241    live.sort_by_key(|(p, _, _)| std::cmp::Reverse(written_at(p)));
242    live.truncate(MAX_THREADS);
243    let attribution = if live.is_empty() { Attribution::None } else { Attribution::CwdHeuristic };
244    (live.into_iter().map(|(p, _, _)| p.clone()).collect(), attribution)
245}
246
247/// One process is not plausibly running more conversations than this at once,
248/// and an unbounded fan-out would let a stale directory fill the table.
249const MAX_THREADS: usize = 12;
250
251fn written_at(p: &Path) -> Option<SystemTime> {
252    std::fs::metadata(p).and_then(|m| m.modified()).ok()
253}
254
255pub struct CodexTranscript {
256    reader: TailReader,
257    prices: &'static Table,
258    summary: SessionSummary,
259    /// Counters naming the turn and inference spans, and the ids of the ones
260    /// currently being extended.
261    turns: u64,
262    inferences: u64,
263    turn: Option<String>,
264    inference: Option<String>,
265    /// Tool calls awaiting their output, by call id, so the output can be
266    /// filed under the call's name.
267    pending_tools: HashMap<String, String>,
268    /// The last per-response usage seen, to skip the repeated snapshot.
269    last_response: Option<TokenUsage>,
270}
271
272impl CodexTranscript {
273    pub fn new(path: impl Into<PathBuf>) -> Self {
274        CodexTranscript {
275            reader: TailReader::new(path),
276            prices: pricing::table(),
277            summary: SessionSummary { harness: Some(Harness::Codex), ..Default::default() },
278            turns: 0,
279            inferences: 0,
280            turn: None,
281            inference: None,
282            pending_tools: HashMap::new(),
283            last_response: None,
284        }
285    }
286
287    /// Something was submitted to the model. One inference at a time: a
288    /// developer message followed by a user message is one submission.
289    fn begin_inference(&mut self, ts: SystemTime) {
290        if self.summary.spans.open_of_kind(SpanKind::Inference).is_some() {
291            return;
292        }
293        // A turn that ended without the model replying (aborted) leaves the
294        // previous inference open; it produced nothing, so it goes.
295        if let Some(id) = self.inference.take() {
296            self.summary.spans.discard_open(&id);
297        }
298        self.inferences += 1;
299        let id = format!("inference:{}", self.inferences);
300        self.summary.spans.open_kind(id.clone(), "inference".into(), ts, false, SpanKind::Inference);
301        self.inference = Some(id);
302    }
303
304    /// The model produced something: the inference in progress ends here.
305    fn end_inference(&mut self, ts: SystemTime) {
306        if let Some(id) = self.inference.take() {
307            self.summary.spans.end_at(&id, ts);
308        }
309    }
310
311    /// See `ClaudeTranscript::with_prices`.
312    pub fn with_prices(mut self, prices: &'static Table) -> Self {
313        self.prices = prices;
314        self
315    }
316
317    /// Keep every span instead of the newest `MAX_SPANS`. See `SpanRetention`.
318    pub fn with_spans(mut self, retention: SpanRetention) -> Self {
319        self.summary.spans = retention.log();
320        self
321    }
322
323    fn ingest(&mut self, line: &str) {
324        let Ok(v) = serde_json::from_str::<Value>(line) else { return };
325        let ts = v.get("timestamp").and_then(Value::as_str).and_then(parse_rfc3339_utc);
326        if let Some(ts) = ts {
327            if self.summary.started_at.is_none() {
328                self.summary.started_at = Some(ts);
329            }
330            self.summary.last_activity = Some(ts);
331        }
332        let kind = v.get("type").and_then(Value::as_str).unwrap_or("");
333        let payload = v.get("payload");
334        let ptype = payload.and_then(|p| p.get("type")).and_then(Value::as_str).unwrap_or("");
335        match kind {
336            "session_meta" => {
337                if let Some(p) = payload {
338                    self.summary.session_id = p.get("id").or(p.get("session_id")).and_then(Value::as_str).map(str::to_string);
339                    self.summary.cwd = p.get("cwd").and_then(Value::as_str).map(PathBuf::from);
340                    self.summary.harness_version = p.get("cli_version").and_then(Value::as_str).map(str::to_string);
341                }
342            }
343            "turn_context" => {
344                if let Some(m) = payload.and_then(|p| p.get("model")).and_then(Value::as_str) {
345                    self.summary.model = Some(m.to_string());
346                }
347            }
348            "event_msg" => match ptype {
349                "token_count" => {
350                    if let Some(total) = payload.and_then(|p| p.pointer("/info/total_token_usage")) {
351                        let g = |k: &str| total.get(k).and_then(Value::as_u64).unwrap_or(0);
352                        self.summary.health.usage_records += 1;
353                        if g("input_tokens") + g("output_tokens") + g("cached_input_tokens") == 0 {
354                            self.summary.health.empty_usage_records += 1;
355                        }
356                        let cached = g("cached_input_tokens");
357                        let usage = TokenUsage {
358                            input: g("input_tokens").saturating_sub(cached),
359                            cache_read: cached,
360                            output: g("output_tokens"),
361                            ..Default::default()
362                        };
363                        self.summary.usage = usage;
364                        let price = self.summary.model.as_deref().and_then(|m| self.prices.lookup(m));
365                        match price {
366                            Some(p) => {
367                                self.summary.cost_breakdown = p.breakdown(&usage);
368                                self.summary.cost_usd = self.summary.cost_breakdown.total();
369                                self.summary.unpriced_tokens = 0;
370                            }
371                            None => {
372                                self.summary.cost_breakdown = Default::default();
373                                self.summary.cost_usd = 0.0;
374                                self.summary.unpriced_tokens = usage.total();
375                            }
376                        }
377                    }
378                    if let Some(last) = payload.and_then(|p| p.pointer("/info/last_token_usage")) {
379                        let g = |k: &str| last.get(k).and_then(Value::as_u64).unwrap_or(0);
380                        let cached = g("cached_input_tokens");
381                        let usage = TokenUsage {
382                            input: g("input_tokens").saturating_sub(cached),
383                            cache_read: cached,
384                            output: g("output_tokens"),
385                            ..Default::default()
386                        };
387                        if self.last_response != Some(usage) {
388                            let cost = self
389                                .summary
390                                .model
391                                .as_deref()
392                                .and_then(|m| self.prices.lookup(m))
393                                .map(|p| p.breakdown(&usage))
394                                .unwrap_or_default();
395                            self.summary.context.response(&usage, &cost);
396                            self.last_response = Some(usage);
397                        }
398                    }
399                    // The rate-limit snapshot rides on every token_count; the
400                    // latest one is the current state.
401                    if let Some(rl) = payload.and_then(|p| p.get("rate_limits")).filter(|v| v.is_object()) {
402                        self.summary.rate_limit = Some(parse_rate_limits(rl));
403                    }
404                }
405                "task_started" => {
406                    self.summary.activity = Activity::Working;
407                    if let Some(ts) = ts {
408                        self.turns += 1;
409                        let id = format!("turn:{}", self.turns);
410                        self.summary.spans.open_kind(id.clone(), "turn".into(), ts, false, SpanKind::Turn);
411                        self.turn = Some(id);
412                    }
413                }
414                "user_message" => self.summary.activity = Activity::Working,
415                // An MCP tool call. Codex records the call as a `response_item`
416                // `function_call` too, which the block below counts as a tool
417                // call and turns into a span; this line is the only one that
418                // names the server, so it feeds the per-server map and nothing
419                // else, to avoid double counting. `mcp_tool_call_begin` carries
420                // the same `invocation`; the pair brackets the call, but the
421                // `end` alone is enough for a count and is the one always
422                // present in the versions seen.
423                "mcp_tool_call_end" => {
424                    if let Some(inv) = payload.and_then(|p| p.get("invocation"))
425                        && let Some(server) = inv.get("server").and_then(Value::as_str).filter(|s| !s.is_empty())
426                    {
427                        let error = payload
428                            .and_then(|p| p.get("result"))
429                            .and_then(Value::as_object)
430                            .map(|r| !r.contains_key("Ok"))
431                            .unwrap_or(false);
432                        let u = self.summary.mcp.entry(server.to_string()).or_default();
433                        u.calls += 1;
434                        u.errors += u64::from(error);
435                        u.last_call = u.last_call.max(ts);
436                        self.summary.context.retag(&payload.map(call_id).unwrap_or_default(), ContextOrigin::Mcp, server);
437                    }
438                }
439                "task_complete" | "turn_aborted" | "error" => {
440                    self.summary.activity = Activity::Waiting;
441                    if let (Some(ts), Some(id)) = (ts, self.turn.take()) {
442                        self.summary.spans.end_at(&id, ts);
443                    }
444                    if let Some(id) = self.inference.take() {
445                        self.summary.spans.discard_open(&id);
446                    }
447                }
448                _ => {}
449            },
450            "response_item" => match ptype {
451                "function_call" | "custom_tool_call" | "local_shell_call" => {
452                    self.summary.tool_calls += 1;
453                    if let (Some(ts), Some(p)) = (ts, payload) {
454                        self.end_inference(ts);
455                        let id = call_id(p);
456                        let name = p.get("name").and_then(Value::as_str).unwrap_or(ptype);
457                        self.pending_tools.insert(id.clone(), name.to_string());
458                        self.summary.spans.open(id, name.to_string(), ts, false);
459                    }
460                }
461                "function_call_output" | "custom_tool_call_output" | "local_shell_call_output" => {
462                    if let (Some(ts), Some(p)) = (ts, payload) {
463                        // Codex reports the result as an opaque string, and
464                        // agent-top does not read tool output, so a failed call
465                        // is not distinguishable from a successful one here.
466                        let id = call_id(p);
467                        self.summary.spans.close(&id, ts, false);
468                        let name = self.pending_tools.remove(&id).unwrap_or_else(|| "tool".into());
469                        self.summary.context.result(&id, ContextOrigin::Tool, &name);
470                        self.begin_inference(ts);
471                    }
472                }
473                // A server-side web search: billed per search by OpenAI, but
474                // at a rate this table does not carry, so counted only.
475                "web_search_call" => {
476                    self.summary.web_searches += 1;
477                    if let Some(ts) = ts {
478                        self.end_inference(ts);
479                    }
480                }
481                "reasoning" => {
482                    if let Some(ts) = ts {
483                        self.end_inference(ts);
484                    }
485                }
486                "message" => match payload.and_then(|p| p.get("role")).and_then(Value::as_str) {
487                    Some("assistant") => {
488                        self.summary.turns += 1;
489                        self.summary.health.billable_messages += 1;
490                        if let Some(ts) = ts {
491                            self.end_inference(ts);
492                        }
493                    }
494                    Some("user") => {
495                        if let Some(ts) = ts {
496                            self.begin_inference(ts);
497                        }
498                    }
499                    _ => {}
500                },
501                _ => {}
502            },
503            _ => {}
504        }
505    }
506}
507
508/// Codex's `rate_limits`: a short window (`primary`) and a long one
509/// (`secondary`), each a used-percent, a window length and a reset time in
510/// epoch seconds, plus the plan and whether the limit is currently hit.
511fn parse_rate_limits(v: &Value) -> crate::model::RateLimit {
512    use crate::model::{RateLimit, RateWindow};
513    let window = |w: Option<&Value>| -> Option<RateWindow> {
514        let w = w?;
515        Some(RateWindow {
516            used_percent: w.get("used_percent").and_then(Value::as_f64).unwrap_or(0.0),
517            window_minutes: w.get("window_minutes").and_then(Value::as_u64).unwrap_or(0),
518            resets_at: w
519                .get("resets_at")
520                .and_then(Value::as_i64)
521                .filter(|s| *s > 0)
522                .map(|s| std::time::UNIX_EPOCH + std::time::Duration::from_secs(s as u64)),
523        })
524    };
525    RateLimit {
526        primary: window(v.get("primary")),
527        secondary: window(v.get("secondary")),
528        plan: v.get("plan_type").and_then(Value::as_str).map(str::to_string),
529        reached: v.get("rate_limit_reached_type").map(|x| !x.is_null()).unwrap_or(false),
530    }
531}
532
533/// `call_id` on function calls, `id` on the shell-call variants.
534fn call_id(payload: &Value) -> String {
535    payload.get("call_id").or_else(|| payload.get("id")).and_then(Value::as_str).unwrap_or_default().to_string()
536}
537
538impl SessionTracker for CodexTranscript {
539    fn refresh(&mut self) -> anyhow::Result<bool> {
540        let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
541        for l in &lines {
542            self.ingest(l);
543        }
544        Ok(more)
545    }
546
547    fn summary(&self) -> &SessionSummary {
548        &self.summary
549    }
550
551    fn path(&self) -> &Path {
552        self.reader.path()
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use std::io::Write;
560    use std::time::Duration;
561
562    /// The bug this guards: the year and month directories were last touched
563    /// when a child directory was created, long before the rollout of
564    /// interest was written.
565    /// Write a rollout with an explicit modification time.
566    ///
567    /// Ordering must not be left to how finely the filesystem happens to
568    /// timestamp three writes microseconds apart: Linux gave all three the
569    /// same mtime, the stable sort preserved insertion order, and the test
570    /// failed there while passing on macOS.
571    fn rollout(dir: &Path, name: &str, written: SystemTime) -> PathBuf {
572        let p = dir.join(name);
573        std::fs::write(&p, b"x").unwrap();
574        let f = std::fs::File::options().write(true).open(&p).unwrap();
575        f.set_times(std::fs::FileTimes::new().set_accessed(written).set_modified(written)).unwrap();
576        p
577    }
578
579    const TIMEOUT: Duration = Duration::from_secs(15 * 60);
580
581    /// One app-server, several conversations. Every live one must get a row:
582    /// returning only the newest is what collapsed them into a single
583    /// mis-attributed row.
584    #[test]
585    fn every_live_codex_thread_is_returned_newest_first() {
586        let dir = std::env::temp_dir().join(format!("agent-top-threads-{}", std::process::id()));
587        let _ = std::fs::remove_dir_all(&dir);
588        std::fs::create_dir_all(&dir).unwrap();
589        let now = SystemTime::now();
590        let started = now - Duration::from_secs(600);
591
592        // Distinct write times, oldest first, so "newest first" has a single
593        // correct answer.
594        let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(300));
595        let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(200));
596        let c = rollout(&dir, "c.jsonl", now - Duration::from_secs(100));
597        let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
598            [&a, &b, &c].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
599
600        // The app-server case: the process cwd matches no conversation.
601        let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
602        assert_eq!(paths.len(), 3, "all three conversations get a row");
603        assert_eq!(paths[0], c, "newest activity first");
604        assert_eq!(attribution, Attribution::CwdHeuristic, "still a heuristic, and still labelled one");
605
606        // A conversation already claimed by another process is not shown twice.
607        let taken: HashSet<PathBuf> = [c.clone()].into_iter().collect();
608        let (paths, _) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
609        assert_eq!(paths.len(), 2);
610        assert!(!paths.contains(&c));
611
612        // A conversation nobody has written to for longer than the activity
613        // window has finished; it belongs in the stopped list, not on this
614        // process.
615        let stale = now + TIMEOUT + Duration::from_secs(60);
616        let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &HashSet::new(), stale, TIMEOUT);
617        assert!(paths.is_empty());
618        assert_eq!(attribution, Attribution::None);
619
620        // The CLI case: one conversation, in the directory the process runs in.
621        let (paths, _) = attribute(Some(Path::new("/Users/dev/code/one")), started, None, &recent, &HashSet::new(), now, TIMEOUT);
622        assert_eq!(paths.len(), 3, "a cwd match takes every conversation in that directory");
623        assert_eq!(paths[0], c);
624
625        // A rollout that predates the process is not this process's.
626        let (paths, _) = attribute(Some(Path::new("/")), now + Duration::from_secs(3600), None, &recent, &HashSet::new(), now, TIMEOUT);
627        assert!(paths.is_empty());
628
629        let _ = std::fs::remove_dir_all(&dir);
630    }
631
632    /// Two app-servers at once, the VS Code one and a CLI-spawned one, both
633    /// running from `/`. Without the open-file signal the one asked first
634    /// took every live thread. The bug this guards was found live on
635    /// 2026-09-04: two threads of a fresh app-server were shown on the four
636    /// day old VS Code one.
637    #[test]
638    fn an_open_rollout_belongs_to_the_process_holding_it() {
639        let dir = std::env::temp_dir().join(format!("agent-top-held-{}", std::process::id()));
640        let _ = std::fs::remove_dir_all(&dir);
641        std::fs::create_dir_all(&dir).unwrap();
642        let now = SystemTime::now();
643        let started = now - Duration::from_secs(600);
644        let a = rollout(&dir, "a.jsonl", now - Duration::from_secs(200));
645        let b = rollout(&dir, "b.jsonl", now - Duration::from_secs(100));
646        let recent: Vec<(PathBuf, PathBuf, SystemTime)> =
647            [&a, &b].iter().map(|p| ((*p).clone(), PathBuf::from("/Users/dev/code/one"), started)).collect();
648
649        // The newer app-server holds both rollouts open. It started after the
650        // rollouts' recorded start, which the heuristic would reject; the open
651        // file settles it.
652        let held = vec![a.clone(), b.clone()];
653        let (paths, attribution) = attribute(Some(Path::new("/")), now, Some(&held), &recent, &HashSet::new(), now, TIMEOUT);
654        assert_eq!(paths, vec![b.clone(), a.clone()], "held rollouts, newest written first");
655        assert_eq!(attribution, Attribution::OpenFile);
656
657        // The older app-server holds nothing. Its fallback would have taken
658        // both live rollouts; with them marked taken it gets no row.
659        let taken: HashSet<PathBuf> = held.iter().cloned().collect();
660        let (paths, attribution) = attribute(Some(Path::new("/")), started, None, &recent, &taken, now, TIMEOUT);
661        assert!(paths.is_empty());
662        assert_eq!(attribution, Attribution::None);
663
664        let _ = std::fs::remove_dir_all(&dir);
665    }
666
667    #[test]
668    fn the_rollout_id_follows_the_timestamp() {
669        assert_eq!(
670            rollout_id(Path::new("/x/2026/05/14/rollout-2026-05-14T21-37-50-01000000-0000-7000-0000-000000000000.jsonl")),
671            "01000000-0000-7000-0000-000000000000"
672        );
673        assert_eq!(rollout_id(Path::new("/x/odd.jsonl")), "odd");
674    }
675
676    #[test]
677    fn finds_a_fresh_rollout_under_stale_directories() {
678        let root = std::env::temp_dir().join(format!("agent-top-rollouts-{}", std::process::id()));
679        let day = root.join("2026").join("09").join("04");
680        std::fs::create_dir_all(&day).unwrap();
681        let fresh = day.join("rollout-fresh.jsonl");
682        let stale = day.join("rollout-stale.jsonl");
683        std::fs::write(&fresh, "{}\n").unwrap();
684        std::fs::write(&stale, "{}\n").unwrap();
685        let now = SystemTime::now();
686        let long_ago = now - Duration::from_secs(40 * 86_400);
687        std::fs::File::open(&stale).unwrap().set_modified(long_ago).unwrap();
688        for dir in [&root, &root.join("2026"), &root.join("2026").join("09"), &day] {
689            std::fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
690        }
691        let found = rollouts_under(&root, now - Duration::from_secs(1800));
692        assert_eq!(found, vec![fresh], "the fresh file is found through directories nobody has touched in weeks");
693        std::fs::remove_dir_all(&root).unwrap();
694    }
695
696    #[test]
697    fn reads_the_latest_rate_limit_snapshot() {
698        let dir = std::env::temp_dir().join(format!("agent-top-codex-rl-{}", std::process::id()));
699        std::fs::create_dir_all(&dir).unwrap();
700        let path = dir.join("rollout.jsonl");
701        let mut f = std::fs::File::create(&path).unwrap();
702        writeln!(f, r#"{{"timestamp":"2026-06-16T20:45:04.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":10,"output_tokens":1,"total_tokens":11}}}},"rate_limits":{{"limit_id":"codex","primary":{{"used_percent":1.0,"window_minutes":300,"resets_at":1781660699}},"secondary":{{"used_percent":27.0,"window_minutes":10080,"resets_at":1782080576}},"plan_type":"plus","rate_limit_reached_type":null}}}}}}"#).unwrap();
703        // A later snapshot with higher usage; the latest wins.
704        writeln!(f, r#"{{"timestamp":"2026-06-16T20:50:00.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":20,"output_tokens":2,"total_tokens":22}}}},"rate_limits":{{"limit_id":"codex","primary":{{"used_percent":42.0,"window_minutes":300,"resets_at":1781660999}},"secondary":{{"used_percent":28.0,"window_minutes":10080,"resets_at":1782080576}},"plan_type":"plus","rate_limit_reached_type":"primary"}}}}}}"#).unwrap();
705        let mut t = CodexTranscript::new(&path);
706        t.refresh().unwrap();
707        let rl = t.summary().rate_limit.as_ref().expect("rate limit parsed");
708        assert_eq!(rl.plan.as_deref(), Some("plus"));
709        assert!(rl.reached, "the latest snapshot reports the primary window hit");
710        let p = rl.primary.expect("primary window");
711        assert_eq!(p.used_percent, 42.0, "the latest value, not the first");
712        assert_eq!(p.window_minutes, 300);
713        assert_eq!(rl.secondary.unwrap().used_percent, 28.0);
714        assert_eq!(rl.tightest().map(|w| w.used_percent), Some(42.0));
715        let _ = std::fs::remove_dir_all(&dir);
716    }
717
718    #[test]
719    fn counts_mcp_calls_per_server_from_the_end_event() {
720        let dir = std::env::temp_dir().join(format!("agent-top-codex-mcp-{}", std::process::id()));
721        std::fs::create_dir_all(&dir).unwrap();
722        let path = dir.join("rollout.jsonl");
723        let mut f = std::fs::File::create(&path).unwrap();
724        // Two MCP servers, one call failing. The matching response_item
725        // function_call/output pair is what makes the tool-call count and span;
726        // the mcp_tool_call_end is the only line naming the server.
727        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c1","name":"github_fetch_file"}}}}"#).unwrap();
728        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c1"}}}}"#).unwrap();
729        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.100Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c1","invocation":{{"server":"codex_apps","tool":"github_fetch_file"}},"duration":{{"secs":1,"nanos":0}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
730        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:05.000Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c2","invocation":{{"server":"codex_apps","tool":"github_search"}},"duration":{{"secs":0,"nanos":0}},"result":{{"Err":"boom"}}}}}}"#).unwrap();
731        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:07.000Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c3","invocation":{{"server":"node_repl","tool":"js"}},"duration":{{"secs":0,"nanos":0}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
732        let mut t = CodexTranscript::new(&path);
733        t.refresh().unwrap();
734        let s = t.summary();
735        assert_eq!(s.mcp.len(), 2);
736        let apps = &s.mcp["codex_apps"];
737        assert_eq!((apps.calls, apps.errors), (2, 1));
738        assert_eq!(apps.last_call, parse_rfc3339_utc("2026-05-27T09:00:05.000Z"));
739        assert_eq!(s.mcp["node_repl"].calls, 1);
740        // The one call with a response_item pair is one tool call and one span;
741        // the mcp_tool_call_end lines do not add to that.
742        assert_eq!(s.tool_calls, 1, "mcp_tool_call_end must not double-count tool calls");
743        let _ = std::fs::remove_dir_all(&dir);
744    }
745
746    #[test]
747    fn sizes_context_per_tool_from_last_token_usage_and_skips_the_repeated_snapshot() {
748        let dir = std::env::temp_dir().join(format!("agent-top-codex-context-{}", std::process::id()));
749        std::fs::create_dir_all(&dir).unwrap();
750        let path = dir.join("rollout.jsonl");
751        let mut f = std::fs::File::create(&path).unwrap();
752        let count = |input: u64, cached: u64, out: u64| {
753            format!(
754                r#"{{"timestamp":"2026-05-27T09:00:00.000Z","type":"event_msg","payload":{{"type":"token_count","info":{{"total_token_usage":{{"input_tokens":{input},"cached_input_tokens":{cached},"output_tokens":{out}}},"last_token_usage":{{"input_tokens":{input},"cached_input_tokens":{cached},"output_tokens":{out}}}}}}}}}"#
755            )
756        };
757        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:00.000Z","type":"session_meta","payload":{{"id":"s","cwd":"/tmp"}}}}"#).unwrap();
758        writeln!(f, "{}", count(10_000, 0, 100)).unwrap();
759        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c1","name":"exec_command"}}}}"#).unwrap();
760        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:01.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"c2","name":"github_fetch_file"}}}}"#).unwrap();
761        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c1"}}}}"#).unwrap();
762        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.000Z","type":"response_item","payload":{{"type":"function_call_output","call_id":"c2"}}}}"#).unwrap();
763        // The server is named only after the output was written.
764        writeln!(f, r#"{{"timestamp":"2026-05-27T09:00:02.100Z","type":"event_msg","payload":{{"type":"mcp_tool_call_end","call_id":"c2","invocation":{{"server":"codex_apps","tool":"github_fetch_file"}},"result":{{"Ok":{{}}}}}}}}"#).unwrap();
765        // 10_000 + 100 reply + 3_000 of results, half each.
766        writeln!(f, "{}", count(13_100, 12_000, 40)).unwrap();
767        // A new turn re-emits the last record: not a response.
768        writeln!(f, r#"{{"timestamp":"2026-05-27T09:01:00.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
769        writeln!(f, "{}", count(13_100, 12_000, 40)).unwrap();
770        let mut t = CodexTranscript::new(&path).with_prices(pricing::builtin_table());
771        t.refresh().unwrap();
772        let c: HashMap<String, crate::model::ContextSource> =
773            t.summary().context.sources().into_iter().map(|c| (c.name.clone(), c)).collect();
774        assert_eq!(c["exec_command"].tokens, 1_500);
775        assert_eq!((c["codex_apps"].tokens, c["codex_apps"].origin), (1_500, ContextOrigin::Mcp));
776        assert!(!c.contains_key("github_fetch_file"), "re-filed under its server");
777        assert_eq!(c["other"].tokens, 10_100, "the repeated snapshot added nothing");
778        assert_eq!(c["other"].cost_usd, 0.0, "no price for the model: tokens only");
779        let _ = std::fs::remove_dir_all(&dir);
780    }
781
782    #[test]
783    fn reads_cumulative_usage_and_state() {
784        let dir = std::env::temp_dir().join(format!("agent-top-codex-{}", std::process::id()));
785        std::fs::create_dir_all(&dir).unwrap();
786        let path = dir.join("rollout.jsonl");
787        let mut f = std::fs::File::create(&path).unwrap();
788        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();
789        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:21.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#).unwrap();
790        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:22.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
791        writeln!(
792            f,
793            r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"shell"}}}}"#
794        )
795        .unwrap();
796        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();
797        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.000Z","type":"event_msg","payload":{{"type":"token_count","info":null}}}}"#)
798            .unwrap();
799        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.500Z","type":"response_item","payload":{{"type":"web_search_call","status":"completed"}}}}"#).unwrap();
800        let mut t = CodexTranscript::new(&path).with_prices(pricing::builtin_table());
801        t.refresh().unwrap();
802        let s = t.summary();
803        assert_eq!(s.session_id.as_deref(), Some("01a0"));
804        assert_eq!(s.model.as_deref(), Some("gpt-5-codex"));
805        assert_eq!(s.usage.input, 14778 - 12672);
806        assert_eq!(s.usage.cache_read, 12672);
807        assert_eq!(s.usage.total(), 15019);
808        // gpt-5-codex has no entry of its own; it resolves to gpt-5 by the
809        // longest-prefix rule (input 1.25, cache read 0.125, output 10).
810        assert_eq!(s.unpriced_tokens, 0, "priced now that OpenAI's rows are in the table");
811        assert!((s.cost_usd - (2106.0 * 1.25 + 12672.0 * 0.125 + 241.0 * 10.0) / 1_000_000.0).abs() < 1e-9, "{}", s.cost_usd);
812        assert_eq!(s.tool_calls, 1);
813        assert_eq!(s.activity, Activity::Working);
814        assert_eq!(read_meta(&path).unwrap().0, PathBuf::from("/tmp/p"));
815        assert_eq!(s.web_searches, 1);
816        let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
817        assert_eq!(tools.len(), 1);
818        assert_eq!(tools[0].name, "shell");
819        assert!(tools[0].is_open(), "no output item yet");
820        let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
821        assert_eq!(turns.len(), 1);
822        assert!(turns[0].is_open(), "task_started with no task_complete");
823        let _ = std::fs::remove_dir_all(&dir);
824    }
825
826    #[test]
827    fn pairs_calls_with_their_outputs() {
828        let dir = std::env::temp_dir().join(format!("agent-top-codex-spans-{}", std::process::id()));
829        std::fs::create_dir_all(&dir).unwrap();
830        let path = dir.join("rollout.jsonl");
831        let mut f = std::fs::File::create(&path).unwrap();
832        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();
833        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();
834        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();
835        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();
836        // The model answers the outputs 1.5 s after the last one, then the turn completes.
837        writeln!(
838            f,
839            r#"{{"timestamp":"2026-08-28T08:53:27.600Z","type":"response_item","payload":{{"type":"message","role":"assistant"}}}}"#
840        )
841        .unwrap();
842        writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:27.700Z","type":"event_msg","payload":{{"type":"task_complete"}}}}"#).unwrap();
843        let mut t = CodexTranscript::new(&path);
844        t.refresh().unwrap();
845        let all = t.summary().spans.to_vec();
846        let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
847        assert_eq!(spans.len(), 2);
848        assert_eq!(spans[0].name, "exec_command");
849        assert_eq!(spans[0].duration_ms, Some(1_000));
850        assert_eq!(spans[1].name, "apply_patch");
851        assert_eq!(spans[1].duration_ms, Some(3_000));
852        assert_eq!(t.summary().tool_calls, 2);
853        // One inference: opened by the first output at :24, not re-opened by the
854        // second at :26.1, ended by the assistant message at :27.6.
855        let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
856        assert_eq!(inf.len(), 1);
857        assert_eq!(inf[0].duration_ms, Some(3_600));
858        assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no task_started in this file");
859        let _ = std::fs::remove_dir_all(&dir);
860    }
861}