use std::io::IsTerminal;
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::time::Duration;
use anyhow::{bail, Result};
use clap::Parser;
use serde_json::{json, Value};
use tokio::process::{Child, Command};
use tokio_util::sync::CancellationToken;
use crate::daemon::client::DaemonClient;
use crate::daemon::paths;
use crate::daemon::protocol::DaemonEnvelope;
use crate::daemon::server;
use crate::sessions::codex_app_server::{AppServerClient, Report, StatusTracker};
const SERVICE: &str = "sessions";
const REPORT_TIMEOUT: Duration = Duration::from_secs(2);
const POLL_INTERVAL: Duration = Duration::from_secs(1);
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_POLL_FAILURES: u32 = 3;
const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(10);
const SERVER_STOP_GRACE: Duration = Duration::from_secs(2);
const SIGNAL_EXIT_BASE: i32 = 128;
const PASSTHROUGH_SUBCOMMANDS: &[&str] = &[
"agents",
"app",
"app-server",
"apply",
"a",
"archive",
"cloud",
"completion",
"debug",
"delete",
"doctor",
"e",
"exec",
"exec-server",
"features",
"help",
"login",
"logout",
"mcp",
"migrate-rollouts",
"plugin",
"remote-control",
"review",
"sandbox",
"unarchive",
"update",
];
#[derive(Parser)]
pub struct CodexWrapCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(
trailing_var_arg = true,
allow_hyphen_values = true,
value_name = "CMD"
)]
pub argv: Vec<String>,
}
impl CodexWrapCommand {
pub async fn execute(self) -> Result<()> {
let code = self.run().await?;
std::process::exit(code)
}
async fn run(self) -> Result<i32> {
let Some((program, args)) = self.argv.split_first() else {
bail!("codex-wrap needs a command to run, e.g. `omni-dev codex-wrap -- codex`");
};
if !std::io::stdout().is_terminal() || !is_tui_invocation(args) {
return Err(exec_replace(program, args));
}
let Some(listen) = listen_path() else {
return Err(exec_replace(program, args));
};
let Some(server) = start_server(program, &listen).await else {
return Err(exec_replace(program, args));
};
Ok(wrap(program, args, &listen, server, self.socket).await)
}
}
fn is_tui_invocation(args: &[String]) -> bool {
!args.iter().any(|arg| {
arg == "--remote"
|| arg.starts_with("--remote=")
|| PASSTHROUGH_SUBCOMMANDS.contains(&arg.as_str())
})
}
fn listen_path() -> Option<PathBuf> {
let dir = paths::runtime_dir().ok()?;
paths::ensure_dir_0700(&dir).ok()?;
let path = dir.join(format!("codex-wrap-{}.sock", std::process::id()));
(path.as_os_str().len() < paths::MAX_SOCKET_PATH_LEN).then_some(path)
}
async fn start_server(program: &str, listen: &Path) -> Option<Child> {
let _ = std::fs::remove_file(listen);
let mut child = Command::new(program)
.arg("app-server")
.arg("--listen")
.arg(format!("unix://{}", listen.display()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.ok()?;
let deadline = tokio::time::Instant::now() + SERVER_READY_TIMEOUT;
while tokio::time::Instant::now() < deadline {
if tokio::net::UnixStream::connect(listen).await.is_ok() {
return Some(child);
}
if matches!(child.try_wait(), Ok(Some(_))) {
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
stop_server(&mut child, listen).await;
None
}
async fn wrap(
program: &str,
args: &[String],
listen: &Path,
mut server: Child,
socket: Option<PathBuf>,
) -> i32 {
let tui = std::process::Command::new(program)
.arg("--remote")
.arg(format!("unix://{}", listen.display()))
.args(args)
.spawn();
let Ok(mut tui) = tui else {
stop_server(&mut server, listen).await;
return 127;
};
let stop = CancellationToken::new();
let observer = tokio::spawn(observe(listen.to_path_buf(), socket, stop.clone()));
let signals = tokio::spawn(forward_signals(tui.id()));
let status = tokio::task::spawn_blocking(move || tui.wait())
.await
.ok()
.and_then(Result::ok);
signals.abort();
stop.cancel();
let _ = observer.await;
stop_server(&mut server, listen).await;
status.map_or(1, exit_code)
}
async fn observe(listen: PathBuf, socket: Option<PathBuf>, stop: CancellationToken) {
let Ok(socket) = server::resolve_socket(socket) else {
return;
};
let connect = tokio::time::timeout(CONNECT_TIMEOUT, AppServerClient::connect(&listen));
let mut client = tokio::select! {
() = stop.cancelled() => return,
connected = connect => match connected {
Ok(Ok(client)) => client,
_ => return,
},
};
let mut tracker = StatusTracker::default();
let mut poll = tokio::time::interval(POLL_INTERVAL);
let mut failures = 0;
loop {
tokio::select! {
() = stop.cancelled() => break,
_ = poll.tick() => {}
}
let polled = tokio::select! {
() = stop.cancelled() => break,
polled = client.snapshot() => polled,
};
if let Ok(snapshot) = polled {
failures = 0;
send(&socket, tracker.update(&snapshot)).await;
} else {
failures += 1;
if failures >= MAX_POLL_FAILURES {
break;
}
}
}
send(&socket, tracker.finish()).await;
}
async fn send(socket: &Path, reports: Vec<Report>) {
for report in reports {
let (op, payload): (&str, Value) = match report {
Report::Observe(request) => match serde_json::to_value(request) {
Ok(payload) => ("observe", payload),
Err(_) => continue,
},
Report::End(id) => ("end", json!({ "session_id": id })),
};
let envelope = DaemonEnvelope::service(SERVICE, op, payload);
let _ =
tokio::time::timeout(REPORT_TIMEOUT, DaemonClient::new(socket).request(envelope)).await;
}
}
async fn stop_server(server: &mut Child, listen: &Path) {
if let Some(pid) = server.id().and_then(|pid| i32::try_from(pid).ok()) {
let _ = nix::sys::signal::kill(
nix::unistd::Pid::from_raw(pid),
nix::sys::signal::Signal::SIGTERM,
);
}
if tokio::time::timeout(SERVER_STOP_GRACE, server.wait())
.await
.is_err()
{
let _ = server.kill().await;
}
let _ = std::fs::remove_file(listen);
}
async fn forward_signals(child_pid: u32) {
use nix::sys::signal::Signal;
use tokio::signal::unix::{signal, SignalKind};
let Ok(pid) = i32::try_from(child_pid) else {
return;
};
let pid = nix::unistd::Pid::from_raw(pid);
let (Ok(mut terminate), Ok(mut hangup), Ok(mut interrupt)) = (
signal(SignalKind::terminate()),
signal(SignalKind::hangup()),
signal(SignalKind::interrupt()),
) else {
return;
};
loop {
let relay = tokio::select! {
_ = terminate.recv() => Some(Signal::SIGTERM),
_ = hangup.recv() => Some(Signal::SIGHUP),
_ = interrupt.recv() => None,
};
if let Some(signal) = relay {
let _ = nix::sys::signal::kill(pid, signal);
}
}
}
fn exec_replace(program: &str, args: &[String]) -> anyhow::Error {
let error = std::process::Command::new(program).args(args).exec();
anyhow::Error::new(error).context(format!("failed to exec {program}"))
}
fn exit_code(status: ExitStatus) -> i32 {
status
.code()
.or_else(|| status.signal().map(|signal| SIGNAL_EXIT_BASE + signal))
.unwrap_or(1)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn only_tui_invocations_are_wrapped() {
assert!(is_tui_invocation(&args(&[])));
assert!(is_tui_invocation(&args(&["fix the build"])));
assert!(is_tui_invocation(&args(&[
"-m",
"gpt-5.5",
"--no-alt-screen"
])));
assert!(is_tui_invocation(&args(&["resume", "019a-id"])));
assert!(is_tui_invocation(&args(&["fork", "--last"])));
assert!(!is_tui_invocation(&args(&["exec", "say hi"])));
assert!(!is_tui_invocation(&args(&["-c", "x=1", "login"])));
assert!(!is_tui_invocation(&args(&["--remote", "ws://h:1"])));
assert!(!is_tui_invocation(&args(&["--remote=ws://h:1"])));
}
#[test]
fn codex_wrap_parses_its_trailing_command() {
#[derive(Parser)]
struct Wrapper {
#[command(flatten)]
cmd: CodexWrapCommand,
}
let parsed = Wrapper::parse_from(["x", "--", "codex", "resume", "--last"]);
assert_eq!(parsed.cmd.argv, args(&["codex", "resume", "--last"]));
assert!(parsed.cmd.socket.is_none());
}
#[test]
fn exit_codes_follow_the_shell_convention() {
assert_eq!(exit_code(ExitStatus::from_raw(3 << 8)), 3);
assert_eq!(exit_code(ExitStatus::from_raw(9)), SIGNAL_EXIT_BASE + 9);
}
#[tokio::test]
async fn a_server_that_never_listens_is_abandoned_for_plain_codex() {
let tmp = tempfile::tempdir_in("/tmp").unwrap();
let listen = tmp.path().join("as.sock");
assert!(start_server("false", &listen).await.is_none());
assert!(!listen.exists());
assert!(start_server("/nonexistent/codex", &listen).await.is_none());
}
#[tokio::test]
async fn a_run_without_a_command_is_an_error() {
let cmd = CodexWrapCommand {
socket: None,
argv: Vec::new(),
};
let err = cmd.run().await.unwrap_err();
assert!(err.to_string().contains("needs a command"), "{err}");
}
}