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}
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    /// Set a JSON Schema file path for structured output validation.
106    ///
107    /// The Codex CLI's `--output-schema` flag accepts a path to a JSON Schema
108    /// file that constrains the model's response shape.
109    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    /// Parse Codex NDJSON output to extract thread_id and agent message text.
168    ///
169    /// Codex's `--json` flag outputs streaming JSON events (NDJSON format).
170    /// The actual agent response is inside `item.completed` events where
171    /// `item.type == "agent_message"`. The thread_id is in the `thread.started` event.
172    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    /// Build an AgentOutput from raw codex output, parsing NDJSON if output_format is "json".
227    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    /// Build the argument list for a run/exec invocation.
243    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        // Skip --cd in sandbox (workspace handles root)
260        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    /// Create a `Command` either directly or wrapped in sandbox.
290    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(&current);
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(&current) {
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}