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