1use std::collections::BTreeMap;
48use std::fs::File;
49use std::io::{BufRead, BufReader};
50use std::path::{Path, PathBuf};
51
52use serde::Deserialize;
53
54use crate::adapters::Adapter;
55use crate::error::CoreError;
56use crate::model::{Event, EventKind, Session, SessionRef, ToolCall, Usage};
57
58pub struct Codex;
60
61fn codex_roots() -> Vec<PathBuf> {
65 match std::env::var("CODEX_HOME") {
66 Ok(val) if !val.trim().is_empty() => val
67 .split(',')
68 .map(str::trim)
69 .filter(|s| !s.is_empty())
70 .map(PathBuf::from)
71 .collect(),
72 _ => directories::BaseDirs::new()
73 .map(|b| vec![b.home_dir().join(".codex")])
74 .unwrap_or_default(),
75 }
76}
77
78fn session_dirs(root: &Path) -> [PathBuf; 2] {
80 [root.join("sessions"), root.join("archived_sessions")]
81}
82
83impl Adapter for Codex {
84 fn id(&self) -> &'static str {
85 "codex"
86 }
87
88 fn detect(&self) -> bool {
89 codex_roots()
90 .iter()
91 .any(|r| session_dirs(r).iter().any(|d| d.is_dir()))
92 }
93
94 fn discover(&self) -> anyhow::Result<Vec<SessionRef>> {
95 let roots = codex_roots();
96 if roots.is_empty() {
97 anyhow::bail!("could not determine the home directory (set CODEX_HOME)");
98 }
99 let mut refs = Vec::new();
100 for root in &roots {
101 for dir in session_dirs(root) {
102 if !dir.is_dir() {
103 continue;
104 }
105 for entry in walkdir::WalkDir::new(&dir).follow_links(false) {
106 let entry = match entry {
107 Ok(e) => e,
108 Err(e) => {
109 tracing::debug!("skipping unreadable directory entry: {e}");
110 continue;
111 }
112 };
113 if !entry.file_type().is_file()
114 || entry.path().extension().and_then(|x| x.to_str()) != Some("jsonl")
115 {
116 continue;
117 }
118 let meta = entry.metadata().ok();
119 refs.push(SessionRef {
120 path: entry.path().to_path_buf(),
121 agent: self.id().to_string(),
122 project: None,
125 size_bytes: meta.as_ref().map_or(0, |m| m.len()),
126 modified: meta
127 .and_then(|m| m.modified().ok())
128 .and_then(|t| jiff::Timestamp::try_from(t).ok()),
129 });
130 }
131 }
132 }
133 refs.sort_by_key(|r| std::cmp::Reverse(r.modified));
134 Ok(refs)
135 }
136
137 fn parse(&self, r: &SessionRef) -> anyhow::Result<Session> {
138 let file = File::open(&r.path).map_err(|source| CoreError::Io {
139 path: r.path.clone(),
140 source,
141 })?;
142
143 let mut session = Session {
144 id: session_id_for(&r.path),
145 agent: self.id().to_string(),
146 project: r.project.clone(),
147 model: None,
148 parent_session: None,
149 started_at: None,
150 ended_at: None,
151 events: Vec::new(),
152 sub_agents: Vec::new(),
153 skipped_lines: 0,
154 };
155 let mut current_model: Option<String> = None;
159 let mut model_counts: BTreeMap<String, u64> = BTreeMap::new();
160
161 for (idx, line) in BufReader::new(file).lines().enumerate() {
162 let line = match line {
163 Ok(l) => l,
164 Err(e) => {
165 session.skipped_lines += 1;
166 tracing::debug!("{}:{}: unreadable line: {e}", r.path.display(), idx + 1);
167 continue;
168 }
169 };
170 if line.trim().is_empty() {
171 continue;
172 }
173 let raw: RawLine = match serde_json::from_str(&line) {
174 Ok(v) => v,
175 Err(e) => {
176 session.skipped_lines += 1;
177 tracing::debug!("{}:{}: unparseable JSON: {e}", r.path.display(), idx + 1);
178 continue;
179 }
180 };
181 let ts = parse_ts(&raw.timestamp);
182 match raw.kind.as_deref() {
183 Some("session_meta") => apply_session_meta(&raw, &mut session, &mut current_model),
184 Some("turn_context") => {
185 if let Some(model) = raw.payload.model.as_deref() {
186 if !model.is_empty() {
187 current_model = Some(model.to_string());
188 }
189 }
190 }
191 Some("response_item") => push_response_item(&raw, ts, &mut session),
192 Some("event_msg") => {
193 push_event_msg(&raw, ts, ¤t_model, &mut session, &mut model_counts)
194 }
195 other => {
196 session.skipped_lines += 1;
197 tracing::debug!(
198 "{}:{}: unknown line type {:?}",
199 r.path.display(),
200 idx + 1,
201 other
202 );
203 }
204 }
205 }
206
207 session.started_at = session.events.iter().filter_map(|e| e.ts).min();
208 session.ended_at = session.events.iter().filter_map(|e| e.ts).max();
209 session.model = model_counts
212 .into_iter()
213 .max_by_key(|(_, count)| *count)
214 .map(|(model, _)| model)
215 .or(current_model);
216 Ok(session)
217 }
218}
219
220fn session_id_for(path: &Path) -> String {
223 path.file_stem()
224 .map(|s| s.to_string_lossy().into_owned())
225 .unwrap_or_else(|| path.to_string_lossy().into_owned())
226}
227
228fn parse_ts(raw: &Option<String>) -> Option<jiff::Timestamp> {
229 raw.as_deref().and_then(|t| t.parse().ok())
230}
231
232fn snippet(text: &str) -> Option<String> {
234 let first = text.lines().find(|l| !l.trim().is_empty())?;
235 let s: String = first.trim().chars().take(80).collect();
236 (!s.is_empty()).then_some(s)
237}
238
239fn message_text(content: &serde_json::Value) -> String {
241 if let Some(text) = content.as_str() {
242 return text.to_string();
243 }
244 let Some(blocks) = content.as_array() else {
245 return String::new();
246 };
247 let mut out = String::new();
248 for block in blocks {
249 if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
250 if !out.is_empty() {
251 out.push('\n');
252 }
253 out.push_str(text);
254 }
255 }
256 out
257}
258
259fn apply_session_meta(raw: &RawLine, session: &mut Session, current_model: &mut Option<String>) {
262 if session.project.is_none() {
263 if let Some(cwd) = raw.payload.cwd.as_deref() {
264 if !cwd.is_empty() {
265 session.project = Some(cwd.to_string());
266 }
267 }
268 }
269 if current_model.is_none() {
272 if let Some(model) = raw.payload.model.as_deref() {
273 if !model.is_empty() {
274 *current_model = Some(model.to_string());
275 }
276 }
277 }
278}
279
280fn push_response_item(raw: &RawLine, ts: Option<jiff::Timestamp>, session: &mut Session) {
286 match raw.payload.item_type.as_deref() {
287 Some("message") => {
288 let text = message_text(&raw.payload.content);
289 let kind = match raw.payload.role.as_deref() {
290 Some("assistant") => EventKind::Assistant,
291 _ => EventKind::User,
293 };
294 session.events.push(Event {
295 kind,
296 ts,
297 request_id: None,
298 model: None,
299 usage: None,
301 tool_calls: Vec::new(),
302 sidechain: false,
303 content_summary: snippet(&text),
304 content_chars: text.chars().count() as u64,
305 thinking_chars: 0,
306 has_thinking: false,
307 tool_use_id: None,
308 attachment_kind: None,
309 item_count: 0,
310 });
311 }
312 Some("function_call") => {
313 let name = raw
314 .payload
315 .name
316 .clone()
317 .unwrap_or_else(|| "unknown".to_string());
318 let id = raw.payload.call_id.clone().unwrap_or_default();
319 let input_bytes = raw
320 .payload
321 .arguments
322 .as_ref()
323 .map_or(0, |a| a.chars().count() as u64);
324 session.events.push(Event {
325 kind: EventKind::ToolCall,
326 ts,
327 request_id: None,
328 model: None,
329 usage: None,
330 tool_calls: vec![ToolCall {
331 server: ToolCall::server_from_name(&name),
332 id,
333 name,
334 input_bytes,
335 }],
336 sidechain: false,
337 content_summary: None,
338 content_chars: input_bytes,
339 thinking_chars: 0,
340 has_thinking: false,
341 tool_use_id: None,
342 attachment_kind: None,
343 item_count: 0,
344 });
345 }
346 _ => {}
349 }
350}
351
352fn push_event_msg(
356 raw: &RawLine,
357 ts: Option<jiff::Timestamp>,
358 current_model: &Option<String>,
359 session: &mut Session,
360 model_counts: &mut BTreeMap<String, u64>,
361) {
362 match raw.payload.item_type.as_deref() {
363 Some("token_count") => {
364 let Some(info) = raw.payload.info.as_ref() else {
366 session.skipped_lines += 1;
367 return;
368 };
369 let Some(last) = info.last_token_usage.as_ref() else {
370 return;
373 };
374 let usage = map_last_usage(last);
375 let has_thinking = last.reasoning_output_tokens.unwrap_or(0) > 0;
376 if let Some(model) = current_model {
377 *model_counts.entry(model.clone()).or_default() += 1;
378 }
379 session.events.push(Event {
380 kind: EventKind::Assistant,
381 ts,
382 request_id: None,
385 model: current_model.clone(),
386 usage: Some(usage),
387 tool_calls: Vec::new(),
388 sidechain: false,
389 content_summary: None,
390 content_chars: 0,
391 thinking_chars: 0,
392 has_thinking,
393 tool_use_id: None,
394 attachment_kind: None,
395 item_count: 0,
396 });
397 }
398 Some("context_compacted") => session.events.push(Event {
399 kind: EventKind::Compaction,
400 ts,
401 request_id: None,
402 model: None,
403 usage: None,
404 tool_calls: Vec::new(),
405 sidechain: false,
406 content_summary: Some("context compacted".to_string()),
407 content_chars: 0,
408 thinking_chars: 0,
409 has_thinking: false,
410 tool_use_id: None,
411 attachment_kind: None,
412 item_count: 0,
413 }),
414 Some("mcp_tool_call_end") => {
418 if let Some(call) = mcp_tool_call(raw) {
419 session.events.push(Event {
420 kind: EventKind::ToolCall,
421 ts,
422 request_id: None,
423 model: None,
424 usage: None,
425 tool_calls: vec![call],
426 sidechain: false,
427 content_summary: None,
428 content_chars: 0,
429 thinking_chars: 0,
430 has_thinking: false,
431 tool_use_id: None,
432 attachment_kind: None,
433 item_count: 0,
434 });
435 }
436 }
437 _ => {}
442 }
443}
444
445fn mcp_tool_call(raw: &RawLine) -> Option<ToolCall> {
450 let inv = raw.payload.invocation.as_ref();
451 let server = inv.and_then(|i| i.server.clone());
452 let tool = inv
453 .and_then(|i| i.tool.clone())
454 .or_else(|| raw.payload.tool.clone());
455 let name = match (&server, &tool) {
456 (Some(s), Some(t)) => format!("mcp__{s}__{t}"),
457 (None, Some(t)) => t.clone(),
458 _ => return None,
459 };
460 Some(ToolCall {
461 server: server.or_else(|| ToolCall::server_from_name(&name)),
462 id: raw.payload.call_id.clone().unwrap_or_default(),
463 name,
464 input_bytes: 0,
465 })
466}
467
468fn map_last_usage(last: &RawTokenUsage) -> Usage {
484 let cached = last.cached_input_tokens.unwrap_or(0);
485 let reasoning = last.reasoning_output_tokens.unwrap_or(0);
486 Usage {
487 input: last.input_tokens.map(|i| i.saturating_sub(cached)),
488 output: last.output_tokens.map(|o| o.saturating_sub(reasoning)),
489 cache_creation: None,
490 cache_creation_5m: None,
491 cache_creation_1h: None,
492 cache_read: last.cached_input_tokens,
493 thinking: last.reasoning_output_tokens,
494 }
495}
496
497#[derive(Deserialize)]
500struct RawLine {
501 #[serde(rename = "type")]
502 kind: Option<String>,
503 timestamp: Option<String>,
504 #[serde(default)]
505 payload: RawPayload,
506}
507
508#[derive(Deserialize, Default)]
511struct RawPayload {
512 #[serde(rename = "type")]
514 item_type: Option<String>,
515 #[allow(dead_code)]
517 id: Option<String>,
518 cwd: Option<String>,
520 model: Option<String>,
522 role: Option<String>,
524 #[serde(default)]
526 content: serde_json::Value,
527 name: Option<String>,
529 arguments: Option<String>,
531 call_id: Option<String>,
533 info: Option<RawTokenInfo>,
535 invocation: Option<RawInvocation>,
537 tool: Option<String>,
539}
540
541#[derive(Deserialize)]
543struct RawTokenInfo {
544 #[allow(dead_code)]
547 total_token_usage: Option<RawTokenUsage>,
548 last_token_usage: Option<RawTokenUsage>,
550}
551
552#[derive(Deserialize)]
554struct RawTokenUsage {
555 input_tokens: Option<u64>,
556 cached_input_tokens: Option<u64>,
557 output_tokens: Option<u64>,
558 reasoning_output_tokens: Option<u64>,
559 #[allow(dead_code)]
560 total_tokens: Option<u64>,
561}
562
563#[derive(Deserialize)]
565struct RawInvocation {
566 server: Option<String>,
567 tool: Option<String>,
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
578 fn last_usage_maps_disjointly_to_total_tokens() {
579 let last = RawTokenUsage {
580 input_tokens: Some(2000),
581 cached_input_tokens: Some(500),
582 output_tokens: Some(300),
583 reasoning_output_tokens: Some(100),
584 total_tokens: Some(2300),
585 };
586 let u = map_last_usage(&last);
587 assert_eq!(u.cache_read, Some(500));
588 assert_eq!(u.input, Some(1500), "input minus cached subset");
589 assert_eq!(u.thinking, Some(100));
590 assert_eq!(u.output, Some(200), "output minus reasoning subset");
591 assert_eq!(u.cache_creation, None, "Codex has no cache-creation split");
592 assert_eq!(
593 u.known_total(),
594 2300,
595 "Σ disjoint fields == total_tokens for the request"
596 );
597 }
598
599 #[test]
601 fn underflow_saturates_instead_of_panicking() {
602 let last = RawTokenUsage {
603 input_tokens: Some(100),
604 cached_input_tokens: Some(250),
605 output_tokens: Some(40),
606 reasoning_output_tokens: Some(90),
607 total_tokens: Some(140),
608 };
609 let u = map_last_usage(&last);
610 assert_eq!(u.input, Some(0));
611 assert_eq!(u.output, Some(0));
612 assert_eq!(u.cache_read, Some(250));
613 assert_eq!(u.thinking, Some(90));
614 }
615
616 #[test]
619 fn codex_home_parses_comma_separated_roots() {
620 std::env::set_var("CODEX_HOME", " /a/one , /b/two ,, /c/three ");
621 let roots = codex_roots();
622 std::env::remove_var("CODEX_HOME");
623 assert_eq!(
624 roots,
625 vec![
626 PathBuf::from("/a/one"),
627 PathBuf::from("/b/two"),
628 PathBuf::from("/c/three"),
629 ],
630 "trimmed, empties dropped"
631 );
632 }
633
634 #[test]
637 fn token_count_and_compaction_map_to_events() {
638 let mut session = Session {
639 id: "s".into(),
640 agent: "codex".into(),
641 project: None,
642 model: None,
643 parent_session: None,
644 started_at: None,
645 ended_at: None,
646 events: Vec::new(),
647 sub_agents: Vec::new(),
648 skipped_lines: 0,
649 };
650 let mut counts: BTreeMap<String, u64> = BTreeMap::new();
651 let model = Some("gpt-5.5".to_string());
652
653 let tc: RawLine = serde_json::from_str(
654 r#"{"timestamp":"2026-06-15T10:00:00Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":0,"output_tokens":200,"reasoning_output_tokens":50,"total_tokens":1200}}}}"#,
655 )
656 .unwrap();
657 push_event_msg(
658 &tc,
659 parse_ts(&tc.timestamp),
660 &model,
661 &mut session,
662 &mut counts,
663 );
664
665 let cc: RawLine = serde_json::from_str(
666 r#"{"timestamp":"2026-06-15T10:00:01Z","type":"event_msg","payload":{"type":"context_compacted"}}"#,
667 )
668 .unwrap();
669 push_event_msg(
670 &cc,
671 parse_ts(&cc.timestamp),
672 &model,
673 &mut session,
674 &mut counts,
675 );
676
677 assert_eq!(session.events.len(), 2);
678 assert_eq!(session.events[0].kind, EventKind::Assistant);
679 assert_eq!(session.events[0].model.as_deref(), Some("gpt-5.5"));
680 assert!(session.events[0].has_thinking, "reasoning_output_tokens>0");
681 assert_eq!(session.events[0].usage.unwrap().known_total(), 1200);
682 assert_eq!(session.events[1].kind, EventKind::Compaction);
683 assert_eq!(counts.get("gpt-5.5"), Some(&1));
684 }
685
686 #[test]
688 fn mcp_tool_call_end_attributes_server() {
689 let raw: RawLine = serde_json::from_str(
690 r#"{"timestamp":"2026-06-15T10:00:00Z","type":"event_msg","payload":{"type":"mcp_tool_call_end","invocation":{"server":"acme-db","tool":"query"},"call_id":"c1"}}"#,
691 )
692 .unwrap();
693 let call = mcp_tool_call(&raw).expect("a tool call");
694 assert_eq!(call.name, "mcp__acme-db__query");
695 assert_eq!(call.server.as_deref(), Some("acme-db"));
696 }
697}