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