unified-agent-api-codex 0.3.5

Async wrapper around the Codex CLI for programmatic prompting
Documentation
use std::{
    io::{self, Write},
    path::Path,
    process::ExitStatus,
    time::Duration,
};

use tokio::{
    io::{AsyncRead, AsyncReadExt},
    process::Command,
    task,
};

use crate::CodexError;

#[derive(Clone, Copy)]
pub(crate) enum ConsoleTarget {
    Stdout,
    Stderr,
}

pub(crate) async fn tee_stream<R>(
    mut reader: R,
    target: ConsoleTarget,
    mirror_console: bool,
) -> Result<Vec<u8>, io::Error>
where
    R: AsyncRead + Unpin,
{
    let mut buffer = Vec::new();
    let mut chunk = [0u8; 4096];
    loop {
        let n = reader.read(&mut chunk).await?;
        if n == 0 {
            break;
        }
        if mirror_console {
            task::block_in_place(|| match target {
                ConsoleTarget::Stdout => {
                    let mut out = io::stdout();
                    out.write_all(&chunk[..n])?;
                    out.flush()
                }
                ConsoleTarget::Stderr => {
                    let mut out = io::stderr();
                    out.write_all(&chunk[..n])?;
                    out.flush()
                }
            })?;
        }
        buffer.extend_from_slice(&chunk[..n]);
    }
    Ok(buffer)
}

pub(crate) fn spawn_with_retry(
    command: &mut Command,
    binary: &Path,
) -> Result<tokio::process::Child, CodexError> {
    let mut backoff = Duration::from_millis(2);
    for attempt in 0..5 {
        match command.spawn() {
            Ok(child) => return Ok(child),
            Err(source) => {
                let is_busy = matches!(source.kind(), std::io::ErrorKind::ExecutableFileBusy)
                    || source.raw_os_error() == Some(26);
                if is_busy && attempt < 4 {
                    std::thread::sleep(backoff);
                    backoff = std::cmp::min(backoff * 2, Duration::from_millis(50));
                    continue;
                }
                return Err(CodexError::Spawn {
                    binary: binary.to_path_buf(),
                    source,
                });
            }
        }
    }

    unreachable!("spawn_with_retry should return before exhausting retries")
}

pub(crate) fn command_output_text(output: &CommandOutput) -> String {
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
    let stdout = stdout.trim_end();
    let stderr = stderr.trim_end();
    if stdout.is_empty() {
        stderr.to_string()
    } else if stderr.is_empty() {
        stdout.to_string()
    } else {
        format!("{stdout}\n{stderr}")
    }
}

pub(crate) fn preferred_output_channel(output: &CommandOutput) -> String {
    let stderr = String::from_utf8(output.stderr.clone()).unwrap_or_default();
    let stdout = String::from_utf8(output.stdout.clone()).unwrap_or_default();
    if stderr.trim().is_empty() {
        stdout
    } else {
        stderr
    }
}

pub(crate) struct CommandOutput {
    pub(crate) status: ExitStatus,
    pub(crate) stdout: Vec<u8>,
    pub(crate) stderr: Vec<u8>,
}