1use 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
42pub 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
62pub fn recent_rollouts(since: SystemTime) -> Vec<PathBuf> {
64 let Some(root) = sessions_dir() else { return Vec::new() };
65 rollouts_under(&root, since)
66}
67
68pub(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
97pub 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 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 fn begin_inference(&mut self, ts: SystemTime) {
140 if self.summary.spans.open_of_kind(SpanKind::Inference).is_some() {
141 return;
142 }
143 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 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 pub fn with_prices(mut self, prices: &'static Table) -> Self {
163 self.prices = prices;
164 self
165 }
166
167 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_usd = p.cost(&usage);
218 self.summary.unpriced_tokens = 0;
219 }
220 None => {
221 self.summary.cost_usd = 0.0;
222 self.summary.unpriced_tokens = usage.total();
223 }
224 }
225 }
226 }
227 "task_started" => {
228 self.summary.activity = Activity::Working;
229 if let Some(ts) = ts {
230 self.turns += 1;
231 let id = format!("turn:{}", self.turns);
232 self.summary.spans.open_kind(id.clone(), "turn".into(), ts, false, SpanKind::Turn);
233 self.turn = Some(id);
234 }
235 }
236 "user_message" => self.summary.activity = Activity::Working,
237 "task_complete" | "turn_aborted" | "error" => {
238 self.summary.activity = Activity::Waiting;
239 if let (Some(ts), Some(id)) = (ts, self.turn.take()) {
240 self.summary.spans.end_at(&id, ts);
241 }
242 if let Some(id) = self.inference.take() {
243 self.summary.spans.discard_open(&id);
244 }
245 }
246 _ => {}
247 },
248 "response_item" => match ptype {
249 "function_call" | "custom_tool_call" | "local_shell_call" => {
250 self.summary.tool_calls += 1;
251 if let (Some(ts), Some(p)) = (ts, payload) {
252 self.end_inference(ts);
253 let id = call_id(p);
254 let name = p.get("name").and_then(Value::as_str).unwrap_or(ptype);
255 self.summary.spans.open(id, name.to_string(), ts, false);
256 }
257 }
258 "function_call_output" | "custom_tool_call_output" | "local_shell_call_output" => {
259 if let (Some(ts), Some(p)) = (ts, payload) {
260 self.summary.spans.close(&call_id(p), ts, false);
264 self.begin_inference(ts);
265 }
266 }
267 "web_search_call" => {
270 self.summary.web_searches += 1;
271 if let Some(ts) = ts {
272 self.end_inference(ts);
273 }
274 }
275 "reasoning" => {
276 if let Some(ts) = ts {
277 self.end_inference(ts);
278 }
279 }
280 "message" => match payload.and_then(|p| p.get("role")).and_then(Value::as_str) {
281 Some("assistant") => {
282 self.summary.turns += 1;
283 self.summary.health.billable_messages += 1;
284 if let Some(ts) = ts {
285 self.end_inference(ts);
286 }
287 }
288 Some("user") => {
289 if let Some(ts) = ts {
290 self.begin_inference(ts);
291 }
292 }
293 _ => {}
294 },
295 _ => {}
296 },
297 _ => {}
298 }
299 }
300}
301
302fn call_id(payload: &Value) -> String {
304 payload.get("call_id").or_else(|| payload.get("id")).and_then(Value::as_str).unwrap_or_default().to_string()
305}
306
307impl SessionTracker for CodexTranscript {
308 fn refresh(&mut self) -> anyhow::Result<bool> {
309 let (lines, more) = self.reader.read_new_lines(REFRESH_BUDGET_BYTES)?;
310 for l in &lines {
311 self.ingest(l);
312 }
313 Ok(more)
314 }
315
316 fn summary(&self) -> &SessionSummary {
317 &self.summary
318 }
319
320 fn path(&self) -> &Path {
321 self.reader.path()
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use std::io::Write;
329 use std::time::Duration;
330
331 #[test]
335 fn finds_a_fresh_rollout_under_stale_directories() {
336 let root = std::env::temp_dir().join(format!("agent-top-rollouts-{}", std::process::id()));
337 let day = root.join("2026").join("09").join("04");
338 std::fs::create_dir_all(&day).unwrap();
339 let fresh = day.join("rollout-fresh.jsonl");
340 let stale = day.join("rollout-stale.jsonl");
341 std::fs::write(&fresh, "{}\n").unwrap();
342 std::fs::write(&stale, "{}\n").unwrap();
343 let now = SystemTime::now();
344 let long_ago = now - Duration::from_secs(40 * 86_400);
345 std::fs::File::open(&stale).unwrap().set_modified(long_ago).unwrap();
346 for dir in [&root, &root.join("2026"), &root.join("2026").join("09"), &day] {
347 std::fs::File::open(dir).unwrap().set_modified(long_ago).unwrap();
348 }
349 let found = rollouts_under(&root, now - Duration::from_secs(1800));
350 assert_eq!(found, vec![fresh], "the fresh file is found through directories nobody has touched in weeks");
351 std::fs::remove_dir_all(&root).unwrap();
352 }
353
354 #[test]
355 fn reads_cumulative_usage_and_state() {
356 let dir = std::env::temp_dir().join(format!("agent-top-codex-{}", std::process::id()));
357 std::fs::create_dir_all(&dir).unwrap();
358 let path = dir.join("rollout.jsonl");
359 let mut f = std::fs::File::create(&path).unwrap();
360 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();
361 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:21.000Z","type":"turn_context","payload":{{"model":"gpt-5-codex"}}}}"#).unwrap();
362 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:22.000Z","type":"event_msg","payload":{{"type":"task_started"}}}}"#).unwrap();
363 writeln!(
364 f,
365 r#"{{"timestamp":"2026-08-28T08:53:23.000Z","type":"response_item","payload":{{"type":"function_call","call_id":"call_1","name":"shell"}}}}"#
366 )
367 .unwrap();
368 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();
369 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.000Z","type":"event_msg","payload":{{"type":"token_count","info":null}}}}"#)
370 .unwrap();
371 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:25.500Z","type":"response_item","payload":{{"type":"web_search_call","status":"completed"}}}}"#).unwrap();
372 let mut t = CodexTranscript::new(&path);
373 t.refresh().unwrap();
374 let s = t.summary();
375 assert_eq!(s.session_id.as_deref(), Some("01a0"));
376 assert_eq!(s.model.as_deref(), Some("gpt-5-codex"));
377 assert_eq!(s.usage.input, 14778 - 12672);
378 assert_eq!(s.usage.cache_read, 12672);
379 assert_eq!(s.usage.total(), 15019);
380 assert_eq!(s.unpriced_tokens, 15019);
381 assert_eq!(s.tool_calls, 1);
382 assert_eq!(s.activity, Activity::Working);
383 assert_eq!(read_meta(&path).unwrap().0, PathBuf::from("/tmp/p"));
384 assert_eq!(s.web_searches, 1);
385 let tools: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
386 assert_eq!(tools.len(), 1);
387 assert_eq!(tools[0].name, "shell");
388 assert!(tools[0].is_open(), "no output item yet");
389 let turns: Vec<_> = s.spans.iter().filter(|sp| sp.kind == SpanKind::Turn).collect();
390 assert_eq!(turns.len(), 1);
391 assert!(turns[0].is_open(), "task_started with no task_complete");
392 let _ = std::fs::remove_dir_all(&dir);
393 }
394
395 #[test]
396 fn pairs_calls_with_their_outputs() {
397 let dir = std::env::temp_dir().join(format!("agent-top-codex-spans-{}", std::process::id()));
398 std::fs::create_dir_all(&dir).unwrap();
399 let path = dir.join("rollout.jsonl");
400 let mut f = std::fs::File::create(&path).unwrap();
401 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();
402 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();
403 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();
404 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();
405 writeln!(
407 f,
408 r#"{{"timestamp":"2026-08-28T08:53:27.600Z","type":"response_item","payload":{{"type":"message","role":"assistant"}}}}"#
409 )
410 .unwrap();
411 writeln!(f, r#"{{"timestamp":"2026-08-28T08:53:27.700Z","type":"event_msg","payload":{{"type":"task_complete"}}}}"#).unwrap();
412 let mut t = CodexTranscript::new(&path);
413 t.refresh().unwrap();
414 let all = t.summary().spans.to_vec();
415 let spans: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Tool).collect();
416 assert_eq!(spans.len(), 2);
417 assert_eq!(spans[0].name, "exec_command");
418 assert_eq!(spans[0].duration_ms, Some(1_000));
419 assert_eq!(spans[1].name, "apply_patch");
420 assert_eq!(spans[1].duration_ms, Some(3_000));
421 assert_eq!(t.summary().tool_calls, 2);
422 let inf: Vec<_> = all.iter().filter(|sp| sp.kind == SpanKind::Inference).collect();
425 assert_eq!(inf.len(), 1);
426 assert_eq!(inf[0].duration_ms, Some(3_600));
427 assert!(all.iter().all(|sp| sp.kind != SpanKind::Turn), "no task_started in this file");
428 let _ = std::fs::remove_dir_all(&dir);
429 }
430}