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