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 return Err(crate::process::ProcessError {
336 exit_code: status.code(),
337 stderr: String::new(),
338 agent_name: "Codex".to_string(),
339 }
340 .into());
341 }
342 Ok(None)
343 } else if self.capture_output {
344 let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
345 log::debug!("Codex raw response ({} bytes): {}", raw.len(), raw);
346 Ok(Some(self.build_output(&raw)))
347 } else {
348 cmd.stdin(Stdio::inherit()).stdout(Stdio::inherit());
349 crate::process::run_with_captured_stderr(&mut cmd).await?;
350 Ok(None)
351 }
352 }
353}
354
355#[cfg(test)]
356#[path = "codex_tests.rs"]
357mod tests;
358
359impl Default for Codex {
360 fn default() -> Self {
361 Self::new()
362 }
363}
364
365impl CodexLiveLogAdapter {
366 pub fn new(ctx: LiveLogContext) -> Self {
367 Self {
368 _ctx: ctx,
369 tui_offset: file_len(&codex_tui_log_path()).unwrap_or(0),
370 history_offset: file_len(&codex_history_path()).unwrap_or(0),
371 thread_id: None,
372 pending_history: Vec::new(),
373 }
374 }
375}
376
377#[async_trait]
378impl LiveLogAdapter for CodexLiveLogAdapter {
379 async fn poll(&mut self, writer: &SessionLogWriter) -> Result<()> {
380 self.poll_tui(writer)?;
381 self.poll_history(writer)?;
382 Ok(())
383 }
384}
385
386impl CodexLiveLogAdapter {
387 fn poll_tui(&mut self, writer: &SessionLogWriter) -> Result<()> {
388 let path = codex_tui_log_path();
389 if !path.exists() {
390 return Ok(());
391 }
392 let mut reader = open_reader_from_offset(&path, &mut self.tui_offset)?;
393 let mut line = String::new();
394 while reader.read_line(&mut line)? > 0 {
395 let current = line.trim().to_string();
396 self.tui_offset += line.len() as u64;
397 if self.thread_id.is_none() {
398 self.thread_id = extract_thread_id(¤t);
399 if let Some(thread_id) = &self.thread_id {
400 writer.set_provider_session_id(Some(thread_id.clone()))?;
401 writer.add_source_path(path.to_string_lossy().to_string())?;
402 }
403 }
404 if let Some(thread_id) = &self.thread_id
405 && current.contains(thread_id)
406 {
407 if let Some(event) = parse_codex_tui_line(¤t) {
408 writer.emit(LogSourceKind::ProviderLog, event)?;
409 }
410 }
411 line.clear();
412 }
413 Ok(())
414 }
415
416 fn poll_history(&mut self, writer: &SessionLogWriter) -> Result<()> {
417 let path = codex_history_path();
418 if !path.exists() {
419 return Ok(());
420 }
421 let mut reader = open_reader_from_offset(&path, &mut self.history_offset)?;
422 let mut line = String::new();
423 while reader.read_line(&mut line)? > 0 {
424 self.history_offset += line.len() as u64;
425 let trimmed = line.trim();
426 if trimmed.is_empty() {
427 line.clear();
428 continue;
429 }
430 if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
431 && let (Some(session_id), Some(text)) = (
432 value.get("session_id").and_then(|value| value.as_str()),
433 value.get("text").and_then(|value| value.as_str()),
434 )
435 {
436 self.pending_history
437 .push((session_id.to_string(), text.to_string()));
438 }
439 line.clear();
440 }
441
442 if let Some(thread_id) = &self.thread_id {
443 let mut still_pending = Vec::new();
444 for (session_id, text) in self.pending_history.drain(..) {
445 if &session_id == thread_id {
446 writer.emit(
447 LogSourceKind::ProviderLog,
448 LogEventKind::UserMessage {
449 role: "user".to_string(),
450 content: text,
451 message_id: None,
452 },
453 )?;
454 } else {
455 still_pending.push((session_id, text));
456 }
457 }
458 self.pending_history = still_pending;
459 writer.add_source_path(path.to_string_lossy().to_string())?;
460 }
461
462 Ok(())
463 }
464}
465
466impl HistoricalLogAdapter for CodexHistoricalLogAdapter {
467 fn backfill(&self, _root: Option<&str>) -> Result<Vec<BackfilledSession>> {
468 let mut sessions = std::collections::HashMap::<String, BackfilledSession>::new();
469 let path = codex_history_path();
470 if path.exists() {
471 info!("Scanning Codex history: {}", path.display());
472 let file = std::fs::File::open(&path)?;
473 let reader = std::io::BufReader::new(file);
474 for line in reader.lines() {
475 let line = line?;
476 if line.trim().is_empty() {
477 continue;
478 }
479 let value: serde_json::Value = match serde_json::from_str(&line) {
480 Ok(value) => value,
481 Err(_) => continue,
482 };
483 let Some(session_id) = value.get("session_id").and_then(|value| value.as_str())
484 else {
485 continue;
486 };
487 let entry =
488 sessions
489 .entry(session_id.to_string())
490 .or_insert_with(|| BackfilledSession {
491 metadata: SessionLogMetadata {
492 provider: "codex".to_string(),
493 wrapper_session_id: session_id.to_string(),
494 provider_session_id: Some(session_id.to_string()),
495 workspace_path: None,
496 command: "backfill".to_string(),
497 model: None,
498 resumed: false,
499 backfilled: true,
500 },
501 completeness: LogCompleteness::Partial,
502 source_paths: vec![path.to_string_lossy().to_string()],
503 events: Vec::new(),
504 });
505 if let Some(text) = value.get("text").and_then(|value| value.as_str()) {
506 entry.events.push((
507 LogSourceKind::Backfill,
508 LogEventKind::UserMessage {
509 role: "user".to_string(),
510 content: text.to_string(),
511 message_id: None,
512 },
513 ));
514 }
515 }
516 }
517
518 let tui_path = codex_tui_log_path();
519 if tui_path.exists() {
520 info!("Scanning Codex TUI log: {}", tui_path.display());
521 let file = std::fs::File::open(&tui_path)?;
522 let reader = std::io::BufReader::new(file);
523 for line in reader.lines() {
524 let line = line?;
525 let Some(thread_id) = extract_thread_id(&line) else {
526 continue;
527 };
528 if let Some(session) = sessions.get_mut(&thread_id)
529 && let Some(event) = parse_codex_tui_line(&line)
530 {
531 session.events.push((LogSourceKind::Backfill, event));
532 if !session
533 .source_paths
534 .contains(&tui_path.to_string_lossy().to_string())
535 {
536 session
537 .source_paths
538 .push(tui_path.to_string_lossy().to_string());
539 }
540 }
541 }
542 }
543
544 Ok(sessions.into_values().collect())
545 }
546}
547
548fn parse_codex_tui_line(line: &str) -> Option<LogEventKind> {
549 if let Some(rest) = line.split("ToolCall: ").nth(1) {
550 let mut parts = rest.splitn(2, ' ');
551 let tool_name = parts.next()?.to_string();
552 let json_part = parts
553 .next()
554 .unwrap_or_default()
555 .split(" thread_id=")
556 .next()
557 .unwrap_or_default();
558 let input = serde_json::from_str(json_part).ok();
559 return Some(LogEventKind::ToolCall {
560 tool_kind: Some(tool_kind_from_name(&tool_name)),
561 tool_name,
562 tool_id: None,
563 input,
564 });
565 }
566
567 if line.contains("BackgroundEvent:") || line.contains("codex_core::client:") {
568 return Some(LogEventKind::ProviderStatus {
569 message: line.to_string(),
570 data: None,
571 });
572 }
573
574 None
575}
576
577fn extract_thread_id(line: &str) -> Option<String> {
578 let needle = "thread_id=";
579 let start = line.find(needle)? + needle.len();
580 let tail = &line[start..];
581 let end = tail.find([' ', '}', ':']).unwrap_or(tail.len());
582 Some(tail[..end].to_string())
583}
584
585fn codex_history_path() -> std::path::PathBuf {
586 history_path()
587}
588
589fn codex_tui_log_path() -> std::path::PathBuf {
590 tui_log_path()
591}
592
593fn file_len(path: &std::path::Path) -> Option<u64> {
594 std::fs::metadata(path).ok().map(|metadata| metadata.len())
595}
596
597fn open_reader_from_offset(
598 path: &std::path::Path,
599 offset: &mut u64,
600) -> Result<std::io::BufReader<std::fs::File>> {
601 let mut file = std::fs::File::open(path)?;
602 use std::io::Seek;
603 file.seek(std::io::SeekFrom::Start(*offset))?;
604 Ok(std::io::BufReader::new(file))
605}
606
607#[async_trait]
608impl Agent for Codex {
609 fn name(&self) -> &str {
610 "codex"
611 }
612
613 fn default_model() -> &'static str {
614 DEFAULT_MODEL
615 }
616
617 fn model_for_size(size: ModelSize) -> &'static str {
618 match size {
619 ModelSize::Small => "gpt-5.4-mini",
620 ModelSize::Medium => "gpt-5.3-codex",
621 ModelSize::Large => "gpt-5.4",
622 }
623 }
624
625 fn available_models() -> &'static [&'static str] {
626 AVAILABLE_MODELS
627 }
628
629 fn system_prompt(&self) -> &str {
630 &self.system_prompt
631 }
632
633 fn set_system_prompt(&mut self, prompt: String) {
634 self.system_prompt = prompt;
635 }
636
637 fn get_model(&self) -> &str {
638 &self.model
639 }
640
641 fn set_model(&mut self, model: String) {
642 self.model = model;
643 }
644
645 fn set_root(&mut self, root: String) {
646 self.root = Some(root);
647 }
648
649 fn set_skip_permissions(&mut self, skip: bool) {
650 self.skip_permissions = skip;
651 }
652
653 fn set_output_format(&mut self, format: Option<String>) {
654 self.output_format = format;
655 }
656
657 fn set_add_dirs(&mut self, dirs: Vec<String>) {
658 self.add_dirs = dirs;
659 }
660
661 fn set_env_vars(&mut self, vars: Vec<(String, String)>) {
662 self.env_vars = vars;
663 }
664
665 fn set_capture_output(&mut self, capture: bool) {
666 self.capture_output = capture;
667 }
668
669 fn set_sandbox(&mut self, config: SandboxConfig) {
670 self.sandbox = Some(config);
671 }
672
673 fn set_max_turns(&mut self, turns: u32) {
674 self.max_turns = Some(turns);
675 }
676
677 fn as_any_ref(&self) -> &dyn std::any::Any {
678 self
679 }
680
681 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
682 self
683 }
684
685 async fn run(&self, prompt: Option<&str>) -> Result<Option<AgentOutput>> {
686 self.execute(false, prompt).await
687 }
688
689 async fn run_interactive(&self, prompt: Option<&str>) -> Result<()> {
690 self.execute(true, prompt).await?;
691 Ok(())
692 }
693
694 async fn run_resume_with_prompt(
695 &self,
696 session_id: &str,
697 prompt: &str,
698 ) -> Result<Option<AgentOutput>> {
699 log::debug!(
700 "Codex resume with prompt: session={}, prompt={}",
701 session_id,
702 prompt
703 );
704 if !self.system_prompt.is_empty() {
705 self.write_agents_file().await?;
706 }
707
708 let in_sandbox = self.sandbox.is_some();
709 let mut args = vec!["exec".to_string(), "--skip-git-repo-check".to_string()];
710
711 if self.output_format.as_deref() == Some("json") {
712 args.push("--json".to_string());
713 }
714
715 if self.ephemeral {
716 args.push("--ephemeral".to_string());
717 }
718
719 if !in_sandbox && let Some(ref root) = self.root {
720 args.extend(["--cd".to_string(), root.clone()]);
721 }
722
723 args.extend(["--model".to_string(), self.model.clone()]);
724
725 for dir in &self.add_dirs {
726 args.extend(["--add-dir".to_string(), dir.clone()]);
727 }
728
729 if self.skip_permissions {
730 args.push("--full-auto".to_string());
731 }
732
733 if let Some(turns) = self.max_turns {
734 args.extend(["--max-turns".to_string(), turns.to_string()]);
735 }
736
737 if let Some(ref schema) = self.output_schema {
738 args.extend(["--output-schema".to_string(), schema.clone()]);
739 }
740
741 args.extend(["--resume".to_string(), session_id.to_string()]);
742 args.push(prompt.to_string());
743
744 let mut cmd = self.make_command(args);
745 let raw = crate::process::run_captured(&mut cmd, "Codex").await?;
746 Ok(Some(self.build_output(&raw)))
747 }
748
749 async fn run_resume(&self, session_id: Option<&str>, last: bool) -> Result<()> {
750 let in_sandbox = self.sandbox.is_some();
751 let mut args = vec!["resume".to_string()];
752
753 if let Some(id) = session_id {
754 args.push(id.to_string());
755 } else if last {
756 args.push("--last".to_string());
757 }
758
759 if !in_sandbox && let Some(ref root) = self.root {
760 args.extend(["--cd".to_string(), root.clone()]);
761 }
762
763 args.extend(["--model".to_string(), self.model.clone()]);
764
765 for dir in &self.add_dirs {
766 args.extend(["--add-dir".to_string(), dir.clone()]);
767 }
768
769 if self.skip_permissions {
770 args.push("--full-auto".to_string());
771 }
772
773 let mut cmd = self.make_command(args);
774
775 cmd.stdin(Stdio::inherit())
776 .stdout(Stdio::inherit())
777 .stderr(Stdio::inherit());
778
779 let status = cmd
780 .status()
781 .await
782 .context("Failed to execute 'codex' CLI. Is it installed and in PATH?")?;
783 if !status.success() {
784 return Err(crate::process::ProcessError {
785 exit_code: status.code(),
786 stderr: String::new(),
787 agent_name: "Codex".to_string(),
788 }
789 .into());
790 }
791 Ok(())
792 }
793
794 async fn cleanup(&self) -> Result<()> {
795 log::debug!("Cleaning up Codex agent resources");
796 let base = self.get_base_path();
797 let codex_dir = base.join(".codex");
798 let agents_file = codex_dir.join("AGENTS.md");
799
800 if agents_file.exists() {
801 fs::remove_file(&agents_file).await?;
802 }
803
804 if codex_dir.exists()
805 && fs::read_dir(&codex_dir)
806 .await?
807 .next_entry()
808 .await?
809 .is_none()
810 {
811 fs::remove_dir(&codex_dir).await?;
812 }
813
814 Ok(())
815 }
816}