Skip to main content

zag_agent/providers/
codex.rs

1// provider-updated: 2026-04-05
2use 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
12/// Classify a Codex tool name into a normalized ToolKind.
13fn 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
31/// Return the Codex history file path: `~/.codex/history.jsonl`.
32pub 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
38/// Return the Codex TUI log path: `~/.codex/log/codex-tui.log`.
39pub 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    /// Set a JSON Schema file path for structured output validation.
108    ///
109    /// The Codex CLI's `--output-schema` flag accepts a path to a JSON Schema
110    /// file that constrains the model's response shape.
111    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    /// Parse Codex NDJSON output to extract thread_id and agent message text.
170    ///
171    /// Codex's `--json` flag outputs streaming JSON events (NDJSON format).
172    /// The actual agent response is inside `item.completed` events where
173    /// `item.type == "agent_message"`. The thread_id is in the `thread.started` event.
174    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    /// Build an AgentOutput from raw codex output, parsing NDJSON if output_format is "json".
229    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    /// Build the argument list for a run/exec invocation.
245    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        // Skip --cd in sandbox (workspace handles root)
262        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    /// Create a `Command` either directly or wrapped in sandbox.
292    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(&current);
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(&current) {
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}