1use crate::agent::{Agent, ModelSize};
2use crate::output::AgentOutput;
3use crate::sandbox::SandboxConfig;
4use crate::session_log::{
5 BackfilledSession, HistoricalLogAdapter, LiveLogAdapter, LiveLogContext, LogCompleteness,
6 LogEventKind, LogSourceKind, SessionLogMetadata, SessionLogWriter, ToolKind,
7};
8use anyhow::{Context, Result};
9use log::debug;
10
11fn tool_kind_from_name(name: &str) -> ToolKind {
13 match name {
14 "shell" | "bash" => ToolKind::Shell,
15 "read_file" | "view" => ToolKind::FileRead,
16 "write_file" => ToolKind::FileWrite,
17 "apply_patch" | "edit_file" => ToolKind::FileEdit,
18 "grep" | "find" | "search" => ToolKind::Search,
19 _ => ToolKind::Other,
20 }
21}
22use async_trait::async_trait;
23use log::info;
24use std::io::BufRead;
25use std::path::Path;
26use std::process::Stdio;
27use tokio::fs;
28use tokio::process::Command;
29
30pub fn history_path() -> std::path::PathBuf {
32 dirs::home_dir()
33 .unwrap_or_else(|| std::path::PathBuf::from("."))
34 .join(".codex/history.jsonl")
35}
36
37pub fn tui_log_path() -> std::path::PathBuf {
39 dirs::home_dir()
40 .unwrap_or_else(|| std::path::PathBuf::from("."))
41 .join(".codex/log/codex-tui.log")
42}
43
44pub const DEFAULT_MODEL: &str = "gpt-5.4";
45
46pub const AVAILABLE_MODELS: &[&str] = &[
47 "gpt-5.4",
48 "gpt-5.4-mini",
49 "gpt-5.3-codex",
50 "gpt-5.2-codex",
51 "gpt-5.2",
52 "gpt-5.1-codex-max",
53 "gpt-5.1-codex-mini",
54];
55
56pub struct Codex {
57 system_prompt: String,
58 model: String,
59 root: Option<String>,
60 skip_permissions: bool,
61 output_format: Option<String>,
62 add_dirs: Vec<String>,
63 capture_output: bool,
64 sandbox: Option<SandboxConfig>,
65 max_turns: Option<u32>,
66}
67
68pub struct CodexLiveLogAdapter {
69 _ctx: LiveLogContext,
70 tui_offset: u64,
71 history_offset: u64,
72 thread_id: Option<String>,
73 pending_history: Vec<(String, String)>,
74}
75
76pub struct CodexHistoricalLogAdapter;
77
78impl Codex {
79 pub fn new() -> Self {
80 Self {
81 system_prompt: String::new(),
82 model: DEFAULT_MODEL.to_string(),
83 root: None,
84 skip_permissions: false,
85 output_format: None,
86 add_dirs: Vec::new(),
87 capture_output: false,
88 sandbox: None,
89 max_turns: None,
90 }
91 }
92
93 fn get_base_path(&self) -> &Path {
94 self.root.as_ref().map(Path::new).unwrap_or(Path::new("."))
95 }
96
97 async fn write_agents_file(&self) -> Result<()> {
98 let base = self.get_base_path();
99 let codex_dir = base.join(".codex");
100 fs::create_dir_all(&codex_dir).await?;
101 fs::write(codex_dir.join("AGENTS.md"), &self.system_prompt).await?;
102 Ok(())
103 }
104
105 pub async fn review(
106 &self,
107 uncommitted: bool,
108 base: Option<&str>,
109 commit: Option<&str>,
110 title: Option<&str>,
111 ) -> Result<()> {
112 let mut cmd = Command::new("codex");
113 cmd.arg("review");
114
115 if uncommitted {
116 cmd.arg("--uncommitted");
117 }
118
119 if let Some(b) = base {
120 cmd.args(["--base", b]);
121 }
122
123 if let Some(c) = commit {
124 cmd.args(["--commit", c]);
125 }
126
127 if let Some(t) = title {
128 cmd.args(["--title", t]);
129 }
130
131 if let Some(ref root) = self.root {
132 cmd.args(["--cd", root]);
133 }
134
135 cmd.args(["--model", &self.model]);
136
137 if self.skip_permissions {
138 cmd.args([
139 "--dangerously-bypass-approvals-and-sandbox",
140 "--sandbox",
141 "danger-full-access",
142 ]);
143 }
144
145 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
146
147 crate::process::run_with_captured_stderr(&mut cmd).await?;
148 Ok(())
149 }
150
151 fn parse_ndjson_output(raw: &str) -> (Option<String>, Option<String>) {
157 let mut thread_id = None;
158 let mut agent_text = String::new();
159
160 for line in raw.lines() {
161 let line = line.trim();
162 if line.is_empty() {
163 continue;
164 }
165
166 if let Ok(event) = serde_json::from_str::<serde_json::Value>(line) {
167 match event.get("type").and_then(|t| t.as_str()) {
168 Some("thread.started") => {
169 thread_id = event
170 .get("thread_id")
171 .and_then(|t| t.as_str())
172 .map(String::from);
173 }
174 Some("item.completed") => {
175 if let Some(item) = event.get("item")
176 && item.get("type").and_then(|t| t.as_str()) == Some("agent_message")
177 && let Some(text) = item.get("text").and_then(|t| t.as_str())
178 {
179 if !agent_text.is_empty() {
180 agent_text.push('\n');
181 }
182 agent_text.push_str(text);
183 }
184 }
185 _ => {}
186 }
187 }
188 }
189
190 let text = if agent_text.is_empty() {
191 None
192 } else {
193 Some(agent_text)
194 };
195 (thread_id, text)
196 }
197
198 fn build_output(&self, raw: &str) -> AgentOutput {
200 if self.output_format.as_deref() == Some("json") {
201 let (thread_id, agent_text) = Self::parse_ndjson_output(raw);
202 let text = agent_text.unwrap_or_else(|| raw.to_string());
203 let mut output = AgentOutput::from_text("codex", &text);
204 if let Some(tid) = thread_id {
205 debug!("Codex thread_id for retries: {}", tid);
206 output.session_id = tid;
207 }
208 output
209 } else {
210 AgentOutput::from_text("codex", raw)
211 }
212 }
213
214 fn build_run_args(&self, interactive: bool, prompt: Option<&str>) -> Vec<String> {
216 let mut args = Vec::new();
217 let in_sandbox = self.sandbox.is_some();
218
219 if !interactive {
220 args.extend(["exec", "--skip-git-repo-check"].map(String::from));
221 if let Some(ref format) = self.output_format
222 && format == "json"
223 {
224 args.push("--json".to_string());
225 }
226 }
227
228 if !in_sandbox && let Some(ref root) = self.root {
230 args.extend(["--cd".to_string(), root.clone()]);
231 }
232
233 args.extend(["--model".to_string(), self.model.clone()]);
234
235 for dir in &self.add_dirs {
236 args.extend(["--add-dir".to_string(), dir.clone()]);
237 }
238
239 if self.skip_permissions {
240 args.extend(
241 [
242 "--dangerously-bypass-approvals-and-sandbox",
243 "--sandbox",
244 "danger-full-access",
245 ]
246 .map(String::from),
247 );
248 }
249
250 if let Some(turns) = self.max_turns {
251 args.extend(["--max-turns".to_string(), turns.to_string()]);
252 }
253
254 if let Some(p) = prompt {
255 args.push(p.to_string());
256 }
257
258 args
259 }
260
261 fn make_command(&self, agent_args: Vec<String>) -> Command {
263 if let Some(ref sb) = self.sandbox {
264 let std_cmd = crate::sandbox::build_sandbox_command(sb, agent_args);
265 Command::from(std_cmd)
266 } else {
267 let mut cmd = Command::new("codex");
268 cmd.args(&agent_args);
269 cmd
270 }
271 }
272
273 async fn execute(
274 &self,
275 interactive: bool,
276 prompt: Option<&str>,
277 ) -> Result<Option<AgentOutput>> {
278 if !self.system_prompt.is_empty() {
279 log::debug!(
280 "Codex system prompt (written to AGENTS.md): {}",
281 self.system_prompt
282 );
283 self.write_agents_file().await?;
284 }
285
286 let agent_args = self.build_run_args(interactive, prompt);
287 log::debug!("Codex command: codex {}", agent_args.join(" "));
288 if let Some(p) = prompt {
289 log::debug!("Codex user prompt: {}", p);
290 }
291 let mut cmd = self.make_command(agent_args);
292
293 if interactive {
294 cmd.stdin(Stdio::inherit())
295 .stdout(Stdio::inherit())
296 .stderr(Stdio::inherit());
297 let status = cmd
298 .status()
299 .await
300 .context("Failed to execute 'codex' CLI. Is it installed and in PATH?")?;
301 if !status.success() {
302 anyhow::bail!("Codex command failed with status: {}", status);
303 }
304 Ok(None)
305 } else if self.capture_output {
306 let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
307 log::debug!("Codex raw response ({} bytes): {}", raw.len(), raw);
308 Ok(Some(self.build_output(&raw)))
309 } else {
310 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
311 crate::process::run_with_captured_stderr(&mut cmd).await?;
312 Ok(None)
313 }
314 }
315}
316
317#[cfg(test)]
318#[path = "codex_tests.rs"]
319mod tests;
320
321impl Default for Codex {
322 fn default() -> Self {
323 Self::new()
324 }
325}
326
327impl CodexLiveLogAdapter {
328 pub fn new(ctx: LiveLogContext) -> Self {
329 Self {
330 _ctx: ctx,
331 tui_offset: file_len(&codex_tui_log_path()).unwrap_or(0),
332 history_offset: file_len(&codex_history_path()).unwrap_or(0),
333 thread_id: None,
334 pending_history: Vec::new(),
335 }
336 }
337}
338
339#[async_trait]
340impl LiveLogAdapter for CodexLiveLogAdapter {
341 async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
342 self.poll_tui(writer)?;
343 self.poll_history(writer)?;
344 Ok(())
345 }
346}
347
348impl CodexLiveLogAdapter {
349 fn poll_tui(&mut self, writer: &SessionLogWriter) -> Result<()> {
350 let path = codex_tui_log_path();
351 if !path.exists() {
352 return Ok(());
353 }
354 let mut reader = open_reader_from_offset(&path, &mut self.tui_offset)?;
355 let mut line = String::new();
356 while reader.read_line(&mut line)? > 0 {
357 let current = line.trim().to_string();
358 self.tui_offset += line.len() as u64;
359 if self.thread_id.is_none() {
360 self.thread_id = extract_thread_id(¤t);
361 if let Some(thread_id) = &self.thread_id {
362 writer.set_provider_session_id(Some(thread_id.clone()))?;
363 writer.add_source_path(path.to_string_lossy().to_string())?;
364 }
365 }
366 if let Some(thread_id) = &self.thread_id
367 && current.contains(thread_id)
368 {
369 if let Some(event) = parse_codex_tui_line(¤t) {
370 writer.emit(LogSourceKind::ProviderLog, event)?;
371 }
372 }
373 line.clear();
374 }
375 Ok(())
376 }
377
378 fn poll_history(&mut self, writer: &SessionLogWriter) -> Result<()> {
379 let path = codex_history_path();
380 if !path.exists() {
381 return Ok(());
382 }
383 let mut reader = open_reader_from_offset(&path, &mut self.history_offset)?;
384 let mut line = String::new();
385 while reader.read_line(&mut line)? > 0 {
386 self.history_offset += line.len() as u64;
387 let trimmed = line.trim();
388 if trimmed.is_empty() {
389 line.clear();
390 continue;
391 }
392 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
393 && let (Some(session_id), Some(text)) = (
394 value.get("session_id").and_then(|value| value.as_str()),
395 value.get("text").and_then(|value| value.as_str()),
396 )
397 {
398 self.pending_history
399 .push((session_id.to_string(), text.to_string()));
400 }
401 line.clear();
402 }
403
404 if let Some(thread_id) = &self.thread_id {
405 let mut still_pending = Vec::new();
406 for (session_id, text) in self.pending_history.drain(..) {
407 if &session_id == thread_id {
408 writer.emit(
409 LogSourceKind::ProviderLog,
410 LogEventKind::UserMessage {
411 role: "user".to_string(),
412 content: text,
413 message_id: None,
414 },
415 )?;
416 } else {
417 still_pending.push((session_id, text));
418 }
419 }
420 self.pending_history = still_pending;
421 writer.add_source_path(path.to_string_lossy().to_string())?;
422 }
423
424 Ok(())
425 }
426}
427
428impl HistoricalLogAdapter for CodexHistoricalLogAdapter {
429 fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
430 let mut sessions = std::collections::HashMap::<String, BackfilledSession>::new();
431 let path = codex_history_path();
432 if path.exists() {
433 info!("Scanning Codex history: {}", path.display());
434 let file = std::fs::File::open(&path)?;
435 let reader = std::io::BufReader::new(file);
436 for line in reader.lines() {
437 let line = line?;
438 if line.trim().is_empty() {
439 continue;
440 }
441 let value: serde_json::Value = match serde_json::from_str(&line) {
442 Ok(value) => value,
443 Err(_) => continue,
444 };
445 let Some(session_id) = value.get("session_id").and_then(|value| value.as_str())
446 else {
447 continue;
448 };
449 let entry =
450 sessions
451 .entry(session_id.to_string())
452 .or_insert_with(|| BackfilledSession {
453 metadata: SessionLogMetadata {
454 provider: "codex".to_string(),
455 wrapper_session_id: session_id.to_string(),
456 provider_session_id: Some(session_id.to_string()),
457 workspace_path: None,
458 command: "backfill".to_string(),
459 model: None,
460 resumed: false,
461 backfilled: true,
462 },
463 completeness: LogCompleteness::Partial,
464 source_paths: vec![path.to_string_lossy().to_string()],
465 events: Vec::new(),
466 });
467 if let Some(text) = value.get("text").and_then(|value| value.as_str()) {
468 entry.events.push((
469 LogSourceKind::Backfill,
470 LogEventKind::UserMessage {
471 role: "user".to_string(),
472 content: text.to_string(),
473 message_id: None,
474 },
475 ));
476 }
477 }
478 }
479
480 let tui_path = codex_tui_log_path();
481 if tui_path.exists() {
482 info!("Scanning Codex TUI log: {}", tui_path.display());
483 let file = std::fs::File::open(&tui_path)?;
484 let reader = std::io::BufReader::new(file);
485 for line in reader.lines() {
486 let line = line?;
487 let Some(thread_id) = extract_thread_id(&line) else {
488 continue;
489 };
490 if let Some(session) = sessions.get_mut(&thread_id)
491 && let Some(event) = parse_codex_tui_line(&line)
492 {
493 session.events.push((LogSourceKind::Backfill, event));
494 if !session
495 .source_paths
496 .contains(&tui_path.to_string_lossy().to_string())
497 {
498 session
499 .source_paths
500 .push(tui_path.to_string_lossy().to_string());
501 }
502 }
503 }
504 }
505
506 Ok(sessions.into_values().collect())
507 }
508}
509
510fn parse_codex_tui_line(line: &str) -> Option<LogEventKind> {
511 if let Some(rest) = line.split("ToolCall: ").nth(1) {
512 let mut parts = rest.splitn(2, ' ');
513 let tool_name = parts.next()?.to_string();
514 let json_part = parts
515 .next()
516 .unwrap_or_default()
517 .split(" thread_id=")
518 .next()
519 .unwrap_or_default();
520 let input = serde_json::from_str(json_part).ok();
521 return Some(LogEventKind::ToolCall {
522 tool_kind: Some(tool_kind_from_name(&tool_name)),
523 tool_name,
524 tool_id: None,
525 input,
526 });
527 }
528
529 if line.contains("BackgroundEvent:") || line.contains("codex_core::client:") {
530 return Some(LogEventKind::ProviderStatus {
531 message: line.to_string(),
532 data: None,
533 });
534 }
535
536 None
537}
538
539fn extract_thread_id(line: &str) -> Option<String> {
540 let needle = "thread_id=";
541 let start = line.find(needle)? + needle.len();
542 let tail = &line[start..];
543 let end = tail.find([' ', '}', ':']).unwrap_or(tail.len());
544 Some(tail[..end].to_string())
545}
546
547fn codex_history_path() -> std::path::PathBuf {
548 history_path()
549}
550
551fn codex_tui_log_path() -> std::path::PathBuf {
552 tui_log_path()
553}
554
555fn file_len(path: &std::path::Path) -> Option<u64> {
556 std::fs::metadata(path).ok().map(|metadata| metadata.len())
557}
558
559fn open_reader_from_offset(
560 path: &std::path::Path,
561 offset: &mut u64,
562) -> Result<std::io::BufReader<std::fs::File>> {
563 let mut file = std::fs::File::open(path)?;
564 use std::io::Seek;
565 file.seek(std::io::SeekFrom::Start(*offset))?;
566 Ok(std::io::BufReader::new(file))
567}
568
569#[async_trait]
570impl Agent for Codex {
571 fn name(&self) -> &str {
572 "codex"
573 }
574
575 fn default_model() -> &'static str {
576 DEFAULT_MODEL
577 }
578
579 fn model_for_size(size: ModelSize) -> &'static str {
580 match size {
581 ModelSize::Small => "gpt-5.4-mini",
582 ModelSize::Medium => "gpt-5.3-codex",
583 ModelSize::Large => "gpt-5.4",
584 }
585 }
586
587 fn available_models() -> &'static [&'static str] {
588 AVAILABLE_MODELS
589 }
590
591 fn system_prompt(&self) -> &str {
592 &self.system_prompt
593 }
594
595 fn set_system_prompt(&mut self, prompt: String) {
596 self.system_prompt = prompt;
597 }
598
599 fn get_model(&self) -> &str {
600 &self.model
601 }
602
603 fn set_model(&mut self, model: String) {
604 self.model = model;
605 }
606
607 fn set_root(&mut self, root: String) {
608 self.root = Some(root);
609 }
610
611 fn set_skip_permissions(&mut self, skip: bool) {
612 self.skip_permissions = skip;
613 }
614
615 fn set_output_format(&mut self, format: Option<String>) {
616 self.output_format = format;
617 }
618
619 fn set_add_dirs(&mut self, dirs: Vec<String>) {
620 self.add_dirs = dirs;
621 }
622
623 fn set_capture_output(&mut self, capture: bool) {
624 self.capture_output = capture;
625 }
626
627 fn set_sandbox(&mut self, config: SandboxConfig) {
628 self.sandbox = Some(config);
629 }
630
631 fn set_max_turns(&mut self, turns: u32) {
632 self.max_turns = Some(turns);
633 }
634
635 fn as_any_ref(&self) -> &dyn std::any::Any {
636 self
637 }
638
639 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
640 self
641 }
642
643 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
644 self.execute(false, prompt).await
645 }
646
647 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
648 self.execute(true, prompt).await?;
649 Ok(())
650 }
651
652 async fn run_resume_with_prompt(
653 &self,
654 session_id: &str,
655 prompt: &str,
656 ) -> Result<Option<AgentOutput>> {
657 log::debug!(
658 "Codex resume with prompt: session={}, prompt={}",
659 session_id,
660 prompt
661 );
662 if !self.system_prompt.is_empty() {
663 self.write_agents_file().await?;
664 }
665
666 let in_sandbox = self.sandbox.is_some();
667 let mut args = vec!["exec".to_string(), "--skip-git-repo-check".to_string()];
668
669 if self.output_format.as_deref() == Some("json") {
670 args.push("--json".to_string());
671 }
672
673 if !in_sandbox && let Some(ref root) = self.root {
674 args.extend(["--cd".to_string(), root.clone()]);
675 }
676
677 args.extend(["--model".to_string(), self.model.clone()]);
678
679 for dir in &self.add_dirs {
680 args.extend(["--add-dir".to_string(), dir.clone()]);
681 }
682
683 if self.skip_permissions {
684 args.extend(
685 [
686 "--dangerously-bypass-approvals-and-sandbox",
687 "--sandbox",
688 "danger-full-access",
689 ]
690 .map(String::from),
691 );
692 }
693
694 if let Some(turns) = self.max_turns {
695 args.extend(["--max-turns".to_string(), turns.to_string()]);
696 }
697
698 args.extend(["--resume".to_string(), session_id.to_string()]);
699 args.push(prompt.to_string());
700
701 let mut cmd = self.make_command(args);
702 let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
703 Ok(Some(self.build_output(&raw)))
704 }
705
706 async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
707 let in_sandbox = self.sandbox.is_some();
708 let mut args = vec!["resume".to_string()];
709
710 if let Some(id) = session_id {
711 args.push(id.to_string());
712 } else if last {
713 args.push("--last".to_string());
714 }
715
716 if !in_sandbox && let Some(ref root) = self.root {
717 args.extend(["--cd".to_string(), root.clone()]);
718 }
719
720 args.extend(["--model".to_string(), self.model.clone()]);
721
722 for dir in &self.add_dirs {
723 args.extend(["--add-dir".to_string(), dir.clone()]);
724 }
725
726 if self.skip_permissions {
727 args.extend(
728 [
729 "--dangerously-bypass-approvals-and-sandbox",
730 "--sandbox",
731 "danger-full-access",
732 ]
733 .map(String::from),
734 );
735 }
736
737 let mut cmd = self.make_command(args);
738
739 cmd.stdin(Stdio::inherit())
740 .stdout(Stdio::inherit())
741 .stderr(Stdio::inherit());
742
743 let status = cmd
744 .status()
745 .await
746 .context("Failed to execute 'codex' CLI. Is it installed and in PATH?")?;
747 if !status.success() {
748 anyhow::bail!("Codex resume failed with status: {}", status);
749 }
750 Ok(())
751 }
752
753 async fn cleanup(&self) -> Result<()> {
754 log::debug!("Cleaning up Codex agent resources");
755 let base = self.get_base_path();
756 let codex_dir = base.join(".codex");
757 let agents_file = codex_dir.join("AGENTS.md");
758
759 if agents_file.exists() {
760 fs::remove_file(&agents_file).await?;
761 }
762
763 if codex_dir.exists()
764 && fs::read_dir(&codex_dir)
765 .await?
766 .next_entry()
767 .await?
768 .is_none()
769 {
770 fs::remove_dir(&codex_dir).await?;
771 }
772
773 Ok(())
774 }
775}