From 314d2216cbc9bda29993989a0646ba2f3d3e5299 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 06:40:39 -0700 Subject: [PATCH 01/44] codex draft --- codex-rs/Cargo.lock | 130 ++++++++++++++++++++++- codex-rs/Cargo.toml | 1 + codex-rs/session/Cargo.toml | 41 ++++++++ codex-rs/session/src/cli.rs | 191 ++++++++++++++++++++++++++++++++++ codex-rs/session/src/lib.rs | 13 +++ codex-rs/session/src/main.rs | 11 ++ codex-rs/session/src/spawn.rs | 80 ++++++++++++++ codex-rs/session/src/store.rs | 91 ++++++++++++++++ 8 files changed, 556 insertions(+), 2 deletions(-) create mode 100644 codex-rs/session/Cargo.toml create mode 100644 codex-rs/session/src/cli.rs create mode 100644 codex-rs/session/src/lib.rs create mode 100644 codex-rs/session/src/main.rs create mode 100644 codex-rs/session/src/spawn.rs create mode 100644 codex-rs/session/src/store.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f866ed6be..4309ee7f0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -578,6 +578,25 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "codex-session" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "codex-core", + "directories", + "libc", + "nix 0.27.1", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "codex-tui" version = "0.1.0" @@ -854,13 +873,22 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", ] [[package]] @@ -873,6 +901,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -2061,6 +2101,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.9.0", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.28.0" @@ -2760,7 +2811,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.28.0", "radix_trie", "unicode-segmentation", "unicode-width 0.1.14", @@ -3764,6 +3815,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "valuable" version = "0.1.1" @@ -4010,6 +4070,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4028,6 +4097,21 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4060,6 +4144,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4072,6 +4162,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4084,6 +4180,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4108,6 +4210,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4120,6 +4228,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4132,6 +4246,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4144,6 +4264,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 69c4e8a8a..bb8a38695 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -9,5 +9,6 @@ members = [ "execpolicy", "interactive", "repl", + "session", "tui", ] diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml new file mode 100644 index 000000000..16a59c7d8 --- /dev/null +++ b/codex-rs/session/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "codex-session" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "codex-session" +path = "src/main.rs" + +[lib] +name = "codex_session" +path = "src/lib.rs" + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +codex-core = { path = "../core" } +tokio = { version = "1", features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", + "signal", +] } +tracing = { version = "0.1.41", features = ["log"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +# new dependencies for session management +uuid = { version = "1", features = ["v4"] } +chrono = { version = "0.4", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1" +directories = "5" + +# unix-only process helpers +nix = { version = "0.27", optional = true, default-features = false, features = ["process"] } +libc = { version = "0.2", optional = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.27", default-features = false, features = ["process"] } +libc = "0.2" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs new file mode 100644 index 000000000..5c91229f1 --- /dev/null +++ b/codex-rs/session/src/cli.rs @@ -0,0 +1,191 @@ +//! Command-line interface definition and dispatch. + +use crate::{spawn, store}; +use anyhow::Result; +use clap::{Args, Parser, Subcommand}; + +/// Top-level CLI entry (re-exported by the crate). +#[derive(Parser)] +#[command(name = "codex-session", about = "Manage detached codex-exec sessions")] +pub struct Cli { + #[command(subcommand)] + cmd: Commands, +} + +impl Cli { + pub async fn dispatch(self) -> Result<()> { + match self.cmd { + Commands::Create(x) => x.run().await, + Commands::Delete(x) => x.run().await, + Commands::Logs(x) => x.run().await, + Commands::Exec(x) => x.run().await, + Commands::List(x) => x.run().await, + } + } +} + +#[derive(Subcommand)] +enum Commands { + /// Spawn a new, detached agent. + Create(CreateCmd), + + /// Kill a running session and delete on-disk artefacts. + Delete(DeleteCmd), + + /// Show (and optionally follow) stdout / stderr logs of a session. + Logs(LogsCmd), + + /// Execute a one-shot command inside an existing session. + Exec(ExecCmd), + + /// List all known session IDs. + List(ListCmd), +} + +// ----------------------------------------------------------------------------- +// create + +#[derive(Args)] +pub struct CreateCmd { + /// A custom session identifier. When omitted, a random UUIDv4 is used. + #[arg(long)] + id: Option, + + /// Path to the `codex-exec` binary. Defaults to relying on $PATH. + #[arg(long, default_value = "codex-exec")] + exec: String, + + /// If set, terminate the agent when the CLI process exits ("attached" mode). + #[arg(long)] + kill_on_drop: bool, +} + +impl CreateCmd { + pub async fn run(self) -> Result<()> { + let id = self + .id + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + // Persist basic metadata & directory skeleton *before* spawning the process. + let meta = store::SessionMeta { + id: id.clone(), + created_at: chrono::Utc::now(), + }; + + let paths = store::paths_for(&id)?; + store::materialise(&paths, &meta)?; + + // Spawn the background agent and immediately detach. + let mut child = spawn::spawn_agent(&self.exec, &id, &paths, self.kill_on_drop)?; + + if self.kill_on_drop { + // Hold the handle for the lifetime of the CLI; when we drop at the end of + // `run()` the agent will be terminated by the `kill_on_drop` setting. + tokio::spawn(async move { + let _ = child.wait().await; + }); + } + + // When not in kill_on_drop mode we *immediately* drop the handle so the agent can + // outlive us. + println!("{id}"); + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// delete + +#[derive(Args)] +pub struct DeleteCmd { + /// Session ID to terminate and remove. + id: String, +} + +impl DeleteCmd { + pub async fn run(self) -> Result<()> { + store::kill_session(&self.id).await?; + store::purge(&self.id)?; + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// logs + +#[derive(Args)] +pub struct LogsCmd { + /// Session ID whose logs should be printed. + id: String, + + /// Follow the file and stream appended lines (like `tail -f`). + #[arg(short, long)] + follow: bool, + + /// Show stderr instead of stdout. + #[arg(long)] + stderr: bool, +} + +impl LogsCmd { + pub async fn run(self) -> Result<()> { + use tokio::io::AsyncBufReadExt; + + let paths = store::paths_for(&self.id)?; + let target = if self.stderr { + &paths.stderr + } else { + &paths.stdout + }; + + let file = tokio::fs::File::open(target).await?; + + if self.follow { + let mut reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + println!("{line}"); + } + } else { + // Simply dump the file contents to stdout. + let mut stdout = tokio::io::stdout(); + tokio::io::copy(&mut tokio::io::BufReader::new(file), &mut stdout).await?; + } + + Ok(()) + } +} + +// ----------------------------------------------------------------------------- +// exec (not implemented yet) + +#[derive(Args)] +pub struct ExecCmd { + id: String, + + /// Remaining arguments form the command to execute. + #[arg(trailing_var_arg = true)] + cmd: Vec, +} + +impl ExecCmd { + pub async fn run(self) -> Result<()> { + anyhow::bail!("exec inside an existing session is not yet implemented"); + } +} + +// ----------------------------------------------------------------------------- +// list + +#[derive(Args)] +pub struct ListCmd; + +impl ListCmd { + pub async fn run(self) -> Result<()> { + let sessions = store::list_sessions()?; + for meta in sessions { + println!("{}\t{}", meta.id, meta.created_at); + } + Ok(()) + } +} diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs new file mode 100644 index 000000000..eb44a37b7 --- /dev/null +++ b/codex-rs/session/src/lib.rs @@ -0,0 +1,13 @@ +//! Library entry-point re-exporting the CLI so the binary can stay tiny. + +pub mod cli; +mod spawn; +mod store; + +pub use cli::Cli; + +/// Binary entry – the bin crate’s `main.rs` calls into this for testability. +pub async fn run_main(cli: Cli) -> anyhow::Result<()> { + cli.dispatch().await +} + diff --git a/codex-rs/session/src/main.rs b/codex-rs/session/src/main.rs new file mode 100644 index 000000000..325ad69a7 --- /dev/null +++ b/codex-rs/session/src/main.rs @@ -0,0 +1,11 @@ +use clap::Parser; +use codex_session::run_main; +use codex_session::Cli; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + run_main(cli).await?; + + Ok(()) +} diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs new file mode 100644 index 000000000..4b0ffd878 --- /dev/null +++ b/codex-rs/session/src/spawn.rs @@ -0,0 +1,80 @@ +//! Cross-platform helper to spawn a detached `codex-exec` agent. + +use crate::store::Paths; +use anyhow::{Context, Result}; +use std::fs::OpenOptions; +use tokio::process::{Child, Command}; + +#[cfg(unix)] +pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { +use std::io; + + // Prepare stdio handles first. + let stdin = OpenOptions::new().read(true).open("/dev/null")?; + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + let mut cmd = Command::new(exec); + cmd.arg("--job").arg(id) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); + + if kill_on_drop { + cmd.kill_on_drop(true); + } + + // Detach: make a new session and ignore SIGHUP. + unsafe { + cmd.pre_exec(|| { + unsafe { + // setsid(2) + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + libc::signal(libc::SIGHUP, libc::SIG_IGN); + } + Ok(()) + }); + } + + let child = cmd.spawn().context("failed to spawn agent")?; + Ok(child) +} + +#[cfg(windows)] +pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { + use std::os::windows::process::CommandExt; + + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + let mut cmd = Command::new(exec); + cmd.arg("--job").arg(id) + .stdin(std::process::Stdio::null()) + .stdout(stdout) + .stderr(stderr) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + + if kill_on_drop { + cmd.kill_on_drop(true); + } + + let child = cmd.spawn().context("failed to spawn agent")?; + Ok(child) +} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs new file mode 100644 index 000000000..0f13a8d8e --- /dev/null +++ b/codex-rs/session/src/store.rs @@ -0,0 +1,91 @@ +//! Session bookkeeping – on-disk layout and simple helpers. + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub struct Paths { + pub dir: PathBuf, + pub stdout: PathBuf, + pub stderr: PathBuf, + pub meta: PathBuf, +} + +/// Calculate canonical paths for the given session ID. +pub fn paths_for(id: &str) -> Result { + let dir = base_dir()?.join(id); + Ok(Paths { + dir: dir.clone(), + stdout: dir.join("stdout.log"), + stderr: dir.join("stderr.log"), + meta: dir.join("meta.json"), + }) +} + +fn base_dir() -> Result { + let dirs = ProjectDirs::from("dev", "codex", "codex-session") + .context("unable to resolve data directory")?; + Ok(dirs.data_dir().to_owned()) +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SessionMeta { + pub id: String, + pub created_at: chrono::DateTime, +} + +/// Create the on-disk directory structure and write metadata + empty log files. +pub fn materialise(paths: &Paths, meta: &SessionMeta) -> Result<()> { + std::fs::create_dir_all(&paths.dir)?; + + // Metadata (pretty-printed for manual inspection). + std::fs::write(&paths.meta, serde_json::to_vec_pretty(meta)?)?; + + // Touch stdout/stderr so they exist even before the agent writes. + for p in [&paths.stdout, &paths.stderr] { + std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(p)?; + } + + Ok(()) +} + +/// Enumerate all sessions by loading each `meta.json`. +pub fn list_sessions() -> Result> { + let mut res = Vec::new(); + let base = base_dir()?; + if base.exists() { + for entry in std::fs::read_dir(base)? { + let entry = entry?; + let meta_path = entry.path().join("meta.json"); + if let Ok(bytes) = std::fs::read(&meta_path) { + if let Ok(meta) = serde_json::from_slice::(&bytes) { + res.push(meta); + } + } + } + } + Ok(res) +} + +/// Send a polite termination request to the session’s process. +/// +/// NOTE: Full PID accounting is a future improvement; for now the function +/// simply returns `Ok(())` so the `delete` command doesn’t fail. +pub async fn kill_session(_id: &str) -> Result<()> { + // TODO: record PID at spawn time and terminate here. + Ok(()) +} + +/// Remove the session directory and all its contents. +pub fn purge(id: &str) -> Result<()> { + let paths = paths_for(id)?; + if paths.dir.exists() { + std::fs::remove_dir_all(paths.dir)?; + } + Ok(()) +} From abf0198a49cebddaeff75eb6fedcc11d758a4f5e Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 06:53:03 -0700 Subject: [PATCH 02/44] progress --- codex-rs/Cargo.lock | 1 + codex-rs/session/Cargo.toml | 3 + codex-rs/session/src/cli.rs | 60 +++++++++++------- codex-rs/session/src/spawn.rs | 113 ++++++++++++++++------------------ 4 files changed, 97 insertions(+), 80 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4309ee7f0..7614ccbbf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -586,6 +586,7 @@ dependencies = [ "chrono", "clap", "codex-core", + "codex-exec", "directories", "libc", "nix 0.27.1", diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 16a59c7d8..0e8fc13b4 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -32,6 +32,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1" directories = "5" +# Re-use the codex-exec library for its CLI definition +codex_exec = { package = "codex-exec", path = "../exec" } + # unix-only process helpers nix = { version = "0.27", optional = true, default-features = false, features = ["process"] } libc = { version = "0.2", optional = true } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 5c91229f1..a07c5e006 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -47,17 +47,13 @@ enum Commands { #[derive(Args)] pub struct CreateCmd { - /// A custom session identifier. When omitted, a random UUIDv4 is used. + /// Session identifier. Generates a random UUIDv4 when omitted. #[arg(long)] id: Option, - /// Path to the `codex-exec` binary. Defaults to relying on $PATH. - #[arg(long, default_value = "codex-exec")] - exec: String, - - /// If set, terminate the agent when the CLI process exits ("attached" mode). - #[arg(long)] - kill_on_drop: bool, + /// All flags following `create` are forwarded to `codex-exec`. + #[clap(flatten)] + exec_cli: codex_exec::Cli, } impl CreateCmd { @@ -75,24 +71,46 @@ impl CreateCmd { let paths = store::paths_for(&id)?; store::materialise(&paths, &meta)?; - // Spawn the background agent and immediately detach. - let mut child = spawn::spawn_agent(&self.exec, &id, &paths, self.kill_on_drop)?; + // Convert exec_cli back into a Vec so we can forward them verbatim. + let exec_args = build_exec_args(&self.exec_cli); - if self.kill_on_drop { - // Hold the handle for the lifetime of the CLI; when we drop at the end of - // `run()` the agent will be terminated by the `kill_on_drop` setting. - tokio::spawn(async move { - let _ = child.wait().await; - }); - } - - // When not in kill_on_drop mode we *immediately* drop the handle so the agent can - // outlive us. + // Spawn the background agent and immediately detach – we never hold on to the + // Child handle. + let _child = spawn::spawn_agent(&paths, &exec_args)?; println!("{id}"); Ok(()) } } +/// Re-serialize a `codex_exec::Cli` struct back into the exact CLI args. +fn build_exec_args(cli: &codex_exec::Cli) -> Vec { + let mut args = Vec::new(); + + for path in &cli.images { + args.push("--image".to_string()); + args.push(path.to_string_lossy().into_owned()); + } + + if let Some(model) = &cli.model { + args.push("--model".to_string()); + args.push(model.clone()); + } + + if cli.skip_git_repo_check { + args.push("--skip-git-repo-check".to_string()); + } + + if cli.disable_response_storage { + args.push("--disable-response-storage".to_string()); + } + + if let Some(prompt) = &cli.prompt { + args.push(prompt.clone()); + } + + args +} + // ----------------------------------------------------------------------------- // delete @@ -141,7 +159,7 @@ impl LogsCmd { let file = tokio::fs::File::open(target).await?; if self.follow { - let mut reader = tokio::io::BufReader::new(file); + let reader = tokio::io::BufReader::new(file); let mut lines = reader.lines(); while let Some(line) = lines.next_line().await? { println!("{line}"); diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 4b0ffd878..ad57e2022 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -1,80 +1,75 @@ -//! Cross-platform helper to spawn a detached `codex-exec` agent. +//! Cross-platform helper to spawn a fully-detached `codex-exec` process. use crate::store::Paths; use anyhow::{Context, Result}; use std::fs::OpenOptions; use tokio::process::{Child, Command}; -#[cfg(unix)] -pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { -use std::io; +/// Spawn `codex-exec` with `exec_args`, redirecting stdio to the per-session log files and +/// detaching the process group so it survives the parent CLI. +pub fn spawn_agent(paths: &Paths, exec_args: &[String]) -> Result { + #[cfg(unix)] + { + use std::io; - // Prepare stdio handles first. - let stdin = OpenOptions::new().read(true).open("/dev/null")?; - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stderr)?; + // Prepare stdio handles first. + let stdin = OpenOptions::new().read(true).open("/dev/null")?; + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; - let mut cmd = Command::new(exec); - cmd.arg("--job").arg(id) - .stdin(stdin) - .stdout(stdout) - .stderr(stderr); + let mut cmd = Command::new("codex-exec"); + cmd.args(exec_args) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); - if kill_on_drop { - cmd.kill_on_drop(true); - } - - // Detach: make a new session and ignore SIGHUP. - unsafe { - cmd.pre_exec(|| { - unsafe { - // setsid(2) + // Detach from the controlling terminal: setsid + ignore SIGHUP. + // SAFETY: calling an `unsafe` method (`pre_exec`). Runs in the parent process right + // before fork; the closure then executes in the child. + unsafe { + cmd.pre_exec(|| { if libc::setsid() == -1 { return Err(io::Error::last_os_error()); } libc::signal(libc::SIGHUP, libc::SIG_IGN); - } - Ok(()) - }); - } + Ok(()) + }); + } - let child = cmd.spawn().context("failed to spawn agent")?; - Ok(child) -} + let child = cmd.spawn().context("failed to spawn codex-exec")?; + return Ok(child); + } -#[cfg(windows)] -pub fn spawn_agent(exec: &str, id: &str, paths: &Paths, kill_on_drop: bool) -> Result { - use std::os::windows::process::CommandExt; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; - const DETACHED_PROCESS: u32 = 0x00000008; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stderr)?; + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; - let mut cmd = Command::new(exec); - cmd.arg("--job").arg(id) - .stdin(std::process::Stdio::null()) - .stdout(stdout) - .stderr(stderr) - .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + let mut cmd = Command::new("codex-exec"); + cmd.args(exec_args) + .stdin(std::process::Stdio::null()) + .stdout(stdout) + .stderr(stderr) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); - if kill_on_drop { - cmd.kill_on_drop(true); + let child = cmd.spawn().context("failed to spawn codex-exec")?; + return Ok(child); } - - let child = cmd.spawn().context("failed to spawn agent")?; - Ok(child) } From b41f26f4843678e6f20cb57b37549736e50aea0c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 07:05:19 -0700 Subject: [PATCH 03/44] metadata --- codex-rs/Cargo.lock | 88 ++++++++++++++++++++++++++++++++++- codex-rs/session/Cargo.toml | 3 ++ codex-rs/session/src/cli.rs | 48 ++++++++++++++----- codex-rs/session/src/store.rs | 20 ++++---- 4 files changed, 137 insertions(+), 22 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7614ccbbf..8fc396b24 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -495,7 +495,7 @@ dependencies = [ "bytes", "clap", "codex-apply-patch", - "dirs", + "dirs 6.0.0", "env-flags", "eventsource-stream", "fs-err", @@ -587,11 +587,14 @@ dependencies = [ "clap", "codex-core", "codex-exec", + "comfy-table", "directories", + "dirs 5.0.1", "libc", "nix 0.27.1", "serde", "serde_json", + "sysinfo", "tokio", "tracing", "tracing-subscriber", @@ -651,6 +654,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -708,6 +722,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -883,6 +916,15 @@ dependencies = [ "dirs-sys 0.4.1", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2152,6 +2194,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2566,6 +2617,26 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.11" @@ -3300,6 +3371,21 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "sysinfo" +version = "0.29.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd727fc423c2060f6c92d9534cef765c65a6ed3f428a03d7def74a8c4348e666" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "winapi", +] + [[package]] name = "system-configuration" version = "0.6.1" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 0e8fc13b4..27d8af7d3 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -31,6 +31,9 @@ chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" directories = "5" +dirs = "5" +comfy-table = "7" +sysinfo = "0.29" # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index a07c5e006..0a06f81c0 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -62,21 +62,21 @@ impl CreateCmd { .id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - // Persist basic metadata & directory skeleton *before* spawning the process. - let meta = store::SessionMeta { - id: id.clone(), - created_at: chrono::Utc::now(), - }; - let paths = store::paths_for(&id)?; - store::materialise(&paths, &meta)?; + store::prepare_dirs(&paths)?; - // Convert exec_cli back into a Vec so we can forward them verbatim. let exec_args = build_exec_args(&self.exec_cli); - // Spawn the background agent and immediately detach – we never hold on to the - // Child handle. - let _child = spawn::spawn_agent(&paths, &exec_args)?; + // Spawn the background agent and immediately detach. + let child = spawn::spawn_agent(&paths, &exec_args)?; + + // Record metadata (with PID) *after* successful spawn. + let meta = store::SessionMeta { + id: id.clone(), + pid: child.id().unwrap_or_default(), + created_at: chrono::Utc::now(), + }; + store::write_meta(&paths, &meta)?; println!("{id}"); Ok(()) } @@ -200,10 +200,34 @@ pub struct ListCmd; impl ListCmd { pub async fn run(self) -> Result<()> { + use comfy_table::{Cell, Table}; + use sysinfo::{SystemExt, Pid, PidExt}; + let sessions = store::list_sessions()?; + let mut sys = sysinfo::System::new_all(); + sys.refresh_processes(); + + let mut table = Table::new(); + table.set_header(["ID", "PID", "STATUS", "CREATED"]); + for meta in sessions { - println!("{}\t{}", meta.id, meta.created_at); + let status: &str = if meta.pid == 0 { + "unknown" + } else if sys.process(Pid::from_u32(meta.pid)).is_some() { + "running" + } else { + "exited" + }; + + table.add_row([ + Cell::new(&meta.id), + Cell::new(meta.pid), + Cell::new(status), + Cell::new(meta.created_at.to_rfc3339()), + ]); } + + println!("{table}"); Ok(()) } } diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 0f13a8d8e..f8f95274d 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -1,7 +1,6 @@ //! Session bookkeeping – on-disk layout and simple helpers. use anyhow::{Context, Result}; -use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -25,32 +24,35 @@ pub fn paths_for(id: &str) -> Result { } fn base_dir() -> Result { - let dirs = ProjectDirs::from("dev", "codex", "codex-session") - .context("unable to resolve data directory")?; - Ok(dirs.data_dir().to_owned()) + // ~/.codex/sessions + let home = dirs::home_dir().context("could not resolve home directory")?; + Ok(home.join("codex").join("sessions")) } #[derive(Serialize, Deserialize, Debug)] pub struct SessionMeta { pub id: String, + pub pid: u32, pub created_at: chrono::DateTime, } /// Create the on-disk directory structure and write metadata + empty log files. -pub fn materialise(paths: &Paths, meta: &SessionMeta) -> Result<()> { +/// Create directory & empty log files. Does **not** write metadata; caller should write that +/// once the child process has actually been spawned so we can record its PID. +pub fn prepare_dirs(paths: &Paths) -> Result<()> { std::fs::create_dir_all(&paths.dir)?; - // Metadata (pretty-printed for manual inspection). - std::fs::write(&paths.meta, serde_json::to_vec_pretty(meta)?)?; - - // Touch stdout/stderr so they exist even before the agent writes. for p in [&paths.stdout, &paths.stderr] { std::fs::OpenOptions::new() .create(true) .append(true) .open(p)?; } + Ok(()) +} +pub fn write_meta(paths: &Paths, meta: &SessionMeta) -> Result<()> { + std::fs::write(&paths.meta, serde_json::to_vec_pretty(meta)?)?; Ok(()) } From 342ac711ca1bbe0a438e754add9f877887e7af91 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 07:06:20 -0700 Subject: [PATCH 04/44] use dot dir --- codex-rs/Cargo.lock | 10 ---------- codex-rs/session/Cargo.toml | 1 - codex-rs/session/src/store.rs | 2 +- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8fc396b24..0c7c08b2a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -588,7 +588,6 @@ dependencies = [ "codex-core", "codex-exec", "comfy-table", - "directories", "dirs 5.0.1", "libc", "nix 0.27.1", @@ -907,15 +906,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" -[[package]] -name = "directories" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "5.0.1" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 27d8af7d3..5d18976a7 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -30,7 +30,6 @@ uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" -directories = "5" dirs = "5" comfy-table = "7" sysinfo = "0.29" diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index f8f95274d..47b3bf380 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -26,7 +26,7 @@ pub fn paths_for(id: &str) -> Result { fn base_dir() -> Result { // ~/.codex/sessions let home = dirs::home_dir().context("could not resolve home directory")?; - Ok(home.join("codex").join("sessions")) + Ok(home.join(".codex").join("sessions")) } #[derive(Serialize, Deserialize, Debug)] From 9aaa9478283c01462acb086f1510ee6505f9990a Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 07:32:26 -0700 Subject: [PATCH 05/44] numeric prefix --- codex-rs/Cargo.lock | 238 +++++++++++++++++++++++++++++----- codex-rs/session/Cargo.toml | 4 +- codex-rs/session/src/cli.rs | 223 ++++++++++++++++++++----------- codex-rs/session/src/store.rs | 38 ++++++ 4 files changed, 397 insertions(+), 106 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0c7c08b2a..c42bc29d1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -235,6 +235,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.4.0" @@ -387,6 +398,23 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clap" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" +dependencies = [ + "atty", + "bitflags 1.3.2", + "clap_derive 3.2.25", + "clap_lex 0.2.4", + "indexmap 1.9.3", + "once_cell", + "strsim 0.10.0", + "termcolor", + "textwrap 0.16.2", +] + [[package]] name = "clap" version = "4.5.37" @@ -394,7 +422,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", - "clap_derive", + "clap_derive 4.5.32", ] [[package]] @@ -405,23 +433,45 @@ checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", - "clap_lex", + "clap_lex 0.7.4", "strsim 0.11.1", "terminal_size", ] +[[package]] +name = "clap_derive" +version = "3.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "clap_derive" version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.100", ] +[[package]] +name = "clap_lex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" +dependencies = [ + "os_str_bytes", +] + [[package]] name = "clap_lex" version = "0.7.4" @@ -472,7 +522,7 @@ name = "codex-cli" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.37", "codex-core", "codex-exec", "codex-interactive", @@ -493,7 +543,7 @@ dependencies = [ "async-channel", "base64 0.21.7", "bytes", - "clap", + "clap 4.5.37", "codex-apply-patch", "dirs 6.0.0", "env-flags", @@ -506,7 +556,7 @@ dependencies = [ "openssl-sys", "patch", "predicates", - "rand", + "rand 0.9.1", "reqwest", "seccompiler", "serde", @@ -527,7 +577,7 @@ name = "codex-exec" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.37", "codex-core", "tokio", "tracing", @@ -540,7 +590,7 @@ version = "0.1.0" dependencies = [ "allocative", "anyhow", - "clap", + "clap 4.5.37", "derive_more", "env_logger", "log", @@ -559,7 +609,7 @@ name = "codex-interactive" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.37", "codex-core", "tokio", ] @@ -569,10 +619,10 @@ name = "codex-repl" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.37", "codex-core", "owo-colors 4.2.0", - "rand", + "rand 0.9.1", "tokio", "tracing", "tracing-subscriber", @@ -584,16 +634,18 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", - "clap", + "clap 4.5.37", "codex-core", "codex-exec", - "comfy-table", "dirs 5.0.1", "libc", + "names", "nix 0.27.1", "serde", "serde_json", + "serde_yaml", "sysinfo", + "tabwriter", "tokio", "tracing", "tracing-subscriber", @@ -605,7 +657,7 @@ name = "codex-tui" version = "0.1.0" dependencies = [ "anyhow", - "clap", + "clap 4.5.37", "codex-ansi-escape", "codex-core", "color-eyre", @@ -653,17 +705,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "comfy-table" -version = "7.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" -dependencies = [ - "crossterm", - "unicode-segmentation", - "unicode-width 0.2.0", -] - [[package]] name = "compact_str" version = "0.8.1" @@ -1418,12 +1459,27 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.3.9" @@ -2102,6 +2158,16 @@ dependencies = [ "serde", ] +[[package]] +name = "names" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc" +dependencies = [ + "clap 3.2.25", + "rand 0.8.5", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2322,6 +2388,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" + [[package]] name = "overload" version = "0.1.1" @@ -2523,6 +2595,30 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.95" @@ -2557,14 +2653,35 @@ dependencies = [ "nibble_vec", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -2574,7 +2691,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -3071,6 +3197,19 @@ dependencies = [ "syn 2.0.100", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.9.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3201,7 +3340,7 @@ dependencies = [ "starlark_syntax", "static_assertions", "strsim 0.10.0", - "textwrap", + "textwrap 0.11.0", "thiserror 1.0.69", ] @@ -3306,7 +3445,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -3397,6 +3536,15 @@ dependencies = [ "libc", ] +[[package]] +name = "tabwriter" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" +dependencies = [ + "unicode-width 0.2.0", +] + [[package]] name = "tempfile" version = "3.19.1" @@ -3421,6 +3569,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "terminal_size" version = "0.4.2" @@ -3446,6 +3603,12 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" + [[package]] name = "thiserror" version = "1.0.69" @@ -3856,6 +4019,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4062,6 +4231,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 5d18976a7..36b77329d 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -31,8 +31,10 @@ chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" dirs = "5" -comfy-table = "7" sysinfo = "0.29" +tabwriter = "1.3" +serde_yaml = "0.9" +names = "0.14" # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 0a06f81c0..cbb6a39f9 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -1,12 +1,12 @@ -//! Command-line interface definition and dispatch. +//! CLI command definitions and implementation. use crate::{spawn, store}; use anyhow::Result; -use clap::{Args, Parser, Subcommand}; +use clap::{Args, Parser, Subcommand, ValueEnum}; +use serde::Serialize; -/// Top-level CLI entry (re-exported by the crate). #[derive(Parser)] -#[command(name = "codex-session", about = "Manage detached codex-exec sessions")] +#[command(name = "codex-session", about = "Manage codex-exec background sessions")] pub struct Cli { #[command(subcommand)] cmd: Commands, @@ -24,21 +24,28 @@ impl Cli { } } +fn human_bytes(b: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let f = b as f64; + if f >= GB { + format!("{:.1}G", f / GB) + } else if f >= MB { + format!("{:.1}M", f / MB) + } else if f >= KB { + format!("{:.1}K", f / KB) + } else { + format!("{}B", b) + } +} + #[derive(Subcommand)] enum Commands { - /// Spawn a new, detached agent. Create(CreateCmd), - - /// Kill a running session and delete on-disk artefacts. Delete(DeleteCmd), - - /// Show (and optionally follow) stdout / stderr logs of a session. Logs(LogsCmd), - - /// Execute a one-shot command inside an existing session. Exec(ExecCmd), - - /// List all known session IDs. List(ListCmd), } @@ -47,61 +54,87 @@ enum Commands { #[derive(Args)] pub struct CreateCmd { - /// Session identifier. Generates a random UUIDv4 when omitted. + /// Explicit session name. If omitted, a memorable random one is generated. #[arg(long)] id: Option, - /// All flags following `create` are forwarded to `codex-exec`. + /// Flags passed through to codex-exec. #[clap(flatten)] exec_cli: codex_exec::Cli, } impl CreateCmd { pub async fn run(self) -> Result<()> { - let id = self - .id - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let id = match self.id { + Some(id) => id, + None => generate_session_id()?, + }; let paths = store::paths_for(&id)?; store::prepare_dirs(&paths)?; let exec_args = build_exec_args(&self.exec_cli); - // Spawn the background agent and immediately detach. + // Preview first 40 printable chars of prompt for status listing + let prompt_preview = self + .exec_cli + .prompt + .as_ref() + .map(|p| { + let slice: String = p.chars().take(40).collect(); + if p.len() > 40 { + format!("{}…", slice) + } else { + slice + } + }); + + // Spawn process let child = spawn::spawn_agent(&paths, &exec_args)?; - // Record metadata (with PID) *after* successful spawn. let meta = store::SessionMeta { id: id.clone(), pid: child.id().unwrap_or_default(), created_at: chrono::Utc::now(), + prompt_preview, }; store::write_meta(&paths, &meta)?; + println!("{id}"); Ok(()) } } -/// Re-serialize a `codex_exec::Cli` struct back into the exact CLI args. +fn generate_session_id() -> Result { + let mut generator = names::Generator::with_naming(names::Name::Numbered); + loop { + let candidate = generator.next().unwrap(); + let paths = store::paths_for(&candidate)?; + if !paths.dir.exists() { + return Ok(candidate); + } + } +} + fn build_exec_args(cli: &codex_exec::Cli) -> Vec { let mut args = Vec::new(); - for path in &cli.images { - args.push("--image".to_string()); - args.push(path.to_string_lossy().into_owned()); + for img in &cli.images { + args.push("--image".into()); + args.push(img.to_string_lossy().into_owned()); } if let Some(model) = &cli.model { - args.push("--model".to_string()); + args.push("--model".into()); args.push(model.clone()); } if cli.skip_git_repo_check { - args.push("--skip-git-repo-check".to_string()); + args.push("--skip-git-repo-check".into()); } if cli.disable_response_storage { - args.push("--disable-response-storage".to_string()); + args.push("--disable-response-storage".into()); } if let Some(prompt) = &cli.prompt { @@ -116,14 +149,14 @@ fn build_exec_args(cli: &codex_exec::Cli) -> Vec { #[derive(Args)] pub struct DeleteCmd { - /// Session ID to terminate and remove. id: String, } impl DeleteCmd { pub async fn run(self) -> Result<()> { - store::kill_session(&self.id).await?; - store::purge(&self.id)?; + let id = store::resolve_selector(&self.id)?; + store::kill_session(&id).await?; + store::purge(&id)?; Ok(()) } } @@ -133,101 +166,141 @@ impl DeleteCmd { #[derive(Args)] pub struct LogsCmd { - /// Session ID whose logs should be printed. id: String, - /// Follow the file and stream appended lines (like `tail -f`). #[arg(short, long)] follow: bool, - /// Show stderr instead of stdout. #[arg(long)] stderr: bool, } impl LogsCmd { pub async fn run(self) -> Result<()> { - use tokio::io::AsyncBufReadExt; - - let paths = store::paths_for(&self.id)?; - let target = if self.stderr { - &paths.stderr - } else { - &paths.stdout - }; + let id = store::resolve_selector(&self.id)?; + let paths = store::paths_for(&id)?; + let target = if self.stderr { &paths.stderr } else { &paths.stdout }; let file = tokio::fs::File::open(target).await?; if self.follow { - let reader = tokio::io::BufReader::new(file); - let mut lines = reader.lines(); + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(file).lines(); while let Some(line) = lines.next_line().await? { println!("{line}"); } } else { - // Simply dump the file contents to stdout. - let mut stdout = tokio::io::stdout(); - tokio::io::copy(&mut tokio::io::BufReader::new(file), &mut stdout).await?; + tokio::io::copy(&mut tokio::io::BufReader::new(file), &mut tokio::io::stdout()).await?; } - Ok(()) } } // ----------------------------------------------------------------------------- -// exec (not implemented yet) +// exec (TODO) #[derive(Args)] pub struct ExecCmd { id: String, - - /// Remaining arguments form the command to execute. #[arg(trailing_var_arg = true)] cmd: Vec, } impl ExecCmd { pub async fn run(self) -> Result<()> { - anyhow::bail!("exec inside an existing session is not yet implemented"); + let _id = store::resolve_selector(&self.id)?; + anyhow::bail!("exec inside session not implemented yet"); } } // ----------------------------------------------------------------------------- // list +#[derive(Copy, Clone, ValueEnum, Debug)] +enum OutputFormat { Table, Json, Yaml } + #[derive(Args)] -pub struct ListCmd; +pub struct ListCmd { + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, +} + +#[derive(Serialize)] +struct StatusRow { + idx: usize, + id: String, + pid: u32, + status: String, + created: String, + prompt: String, + out: String, + err: String, +} impl ListCmd { pub async fn run(self) -> Result<()> { - use comfy_table::{Cell, Table}; - use sysinfo::{SystemExt, Pid, PidExt}; + use sysinfo::{SystemExt, PidExt}; - let sessions = store::list_sessions()?; - let mut sys = sysinfo::System::new_all(); + let metas = store::list_sessions_sorted()?; + + let mut sys = sysinfo::System::new(); sys.refresh_processes(); - let mut table = Table::new(); - table.set_header(["ID", "PID", "STATUS", "CREATED"]); - - for meta in sessions { - let status: &str = if meta.pid == 0 { - "unknown" - } else if sys.process(Pid::from_u32(meta.pid)).is_some() { - "running" - } else { - "exited" - }; - - table.add_row([ - Cell::new(&meta.id), - Cell::new(meta.pid), - Cell::new(status), - Cell::new(meta.created_at.to_rfc3339()), - ]); + let rows: Vec = metas + .into_iter() + .enumerate() + .map(|(idx, m)| { + let status = if m.pid == 0 { + "unknown" + } else if sys.process(sysinfo::Pid::from_u32(m.pid)).is_some() { + "running" + } else { + "exited" + }; + + // file sizes + let paths = store::paths_for(&m.id).ok(); + let (out, err) = if let Some(p) = &paths { + let osz = std::fs::metadata(&p.stdout).map(|m| m.len()).unwrap_or(0); + let esz = std::fs::metadata(&p.stderr).map(|m| m.len()).unwrap_or(0); + (human_bytes(osz), human_bytes(esz)) + } else { + ("-".into(), "-".into()) + }; + + StatusRow { + idx, + id: m.id, + pid: m.pid, + status: status.into(), + created: m.created_at.to_rfc3339(), + prompt: m.prompt_preview.unwrap_or_default(), + out, + err, + } + }) + .collect(); + + match self.output { + OutputFormat::Table => print_table(&rows)?, + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rows)?), + OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&rows)?), } - println!("{table}"); Ok(()) } } + +fn print_table(rows: &[StatusRow]) -> Result<()> { + use std::io::Write; + use tabwriter::TabWriter; + + let mut tw = TabWriter::new(Vec::new()).padding(2); + writeln!(tw, "#\tID\tPID\tSTATUS\tOUT\tERR\tCREATED\tPROMPT")?; + for r in rows { + writeln!(tw, "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", r.idx, r.id, r.pid, r.status, r.out, r.err, r.created, r.prompt)?; + } + let out = String::from_utf8(tw.into_inner()?)?; + print!("{out}"); + Ok(()) +} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 47b3bf380..0386c64e8 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -34,6 +34,8 @@ pub struct SessionMeta { pub id: String, pub pid: u32, pub created_at: chrono::DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_preview: Option, } /// Create the on-disk directory structure and write metadata + empty log files. @@ -74,6 +76,42 @@ pub fn list_sessions() -> Result> { Ok(res) } +/// List sessions sorted by newest first (created_at desc). +pub fn list_sessions_sorted() -> Result> { + let mut v = list_sessions()?; + v.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(v) +} + +/// Resolve a user-supplied selector to a concrete session id. +/// +/// Rules: +/// 1. Pure integer ⇒ index into newest-first list (0 = most recent) +/// 2. Otherwise try exact id match, then unique prefix match. +pub fn resolve_selector(sel: &str) -> Result { + let list = list_sessions_sorted()?; + + // numeric index + if let Ok(idx) = sel.parse::() { + return list.get(idx) + .map(|m| m.id.clone()) + .context(format!("no session at index {idx}")); + } + + // exact match + if let Some(m) = list.iter().find(|m| m.id == sel) { + return Ok(m.id.clone()); + } + + // unique prefix match + let mut matches: Vec<&SessionMeta> = list.iter().filter(|m| m.id.starts_with(sel)).collect(); + match matches.len() { + 1 => Ok(matches.remove(0).id.clone()), + 0 => anyhow::bail!("no session matching '{sel}'"), + _ => anyhow::bail!("selector '{sel}' is ambiguous ({} matches)", matches.len()), + } +} + /// Send a polite termination request to the session’s process. /// /// NOTE: Full PID accounting is a future improvement; for now the function From 8f8479fd80c004f1926ba0c6db4035e7e63329a8 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 08:10:46 -0700 Subject: [PATCH 06/44] add repl subcommand --- codex-rs/Cargo.lock | 2 + codex-rs/session/Cargo.toml | 10 +- codex-rs/session/src/cli.rs | 327 ++++++++++++++++++++++++++-------- codex-rs/session/src/lib.rs | 14 +- codex-rs/session/src/spawn.rs | 84 ++++++++- codex-rs/session/src/store.rs | 16 +- 6 files changed, 362 insertions(+), 91 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c42bc29d1..1e2a34410 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -637,6 +637,7 @@ dependencies = [ "clap 4.5.37", "codex-core", "codex-exec", + "codex-repl", "dirs 5.0.1", "libc", "names", @@ -646,6 +647,7 @@ dependencies = [ "serde_yaml", "sysinfo", "tabwriter", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 36b77329d..bc7baa805 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -36,13 +36,17 @@ tabwriter = "1.3" serde_yaml = "0.9" names = "0.14" -# Re-use the codex-exec library for its CLI definition -codex_exec = { package = "codex-exec", path = "../exec" } - # unix-only process helpers nix = { version = "0.27", optional = true, default-features = false, features = ["process"] } libc = { version = "0.2", optional = true } +# Re-use the codex-exec library for its CLI definition +codex_exec = { package = "codex-exec", path = "../exec" } +codex_repl = { package = "codex-repl", path = "../repl" } + +[dev-dependencies] +tempfile = "3" + [target.'cfg(unix)'.dependencies] nix = { version = "0.27", default-features = false, features = ["process"] } libc = "0.2" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index cbb6a39f9..4382f5748 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -1,12 +1,40 @@ -//! CLI command definitions and implementation. +//! CLI command definitions and implementation for `codex-session`. +//! +//! The session manager can spawn two different Codex agent flavors: +//! +//! * `codex-exec` – non-interactive batch agent (legacy behaviour) +//! * `codex-repl` – interactive REPL that requires user input after launch +//! +//! The `create` command therefore has mutually exclusive sub-commands so the appropriate +//! arguments can be forwarded to the underlying agent binaries. use crate::{spawn, store}; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand, ValueEnum}; use serde::Serialize; +/// A human-friendly representation of a byte count (e.g. 1.4M). +pub fn human_bytes(b: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let f = b as f64; + if f >= GB { + format!("{:.1}G", f / GB) + } else if f >= MB { + format!("{:.1}M", f / MB) + } else if f >= KB { + format!("{:.1}K", f / KB) + } else { + format!("{}B", b) + } +} + +// ----------------------------------------------------------------------------- +// Top-level CLI definition + #[derive(Parser)] -#[command(name = "codex-session", about = "Manage codex-exec background sessions")] +#[command(name = "codex-session", about = "Manage background Codex agent sessions")] pub struct Cli { #[command(subcommand)] cmd: Commands, @@ -16,85 +44,100 @@ impl Cli { pub async fn dispatch(self) -> Result<()> { match self.cmd { Commands::Create(x) => x.run().await, + Commands::Attach(x) => x.run().await, Commands::Delete(x) => x.run().await, Commands::Logs(x) => x.run().await, - Commands::Exec(x) => x.run().await, Commands::List(x) => x.run().await, } } } -fn human_bytes(b: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - const GB: f64 = MB * 1024.0; - let f = b as f64; - if f >= GB { - format!("{:.1}G", f / GB) - } else if f >= MB { - format!("{:.1}M", f / MB) - } else if f >= KB { - format!("{:.1}K", f / KB) - } else { - format!("{}B", b) - } -} - #[derive(Subcommand)] enum Commands { + /// Spawn a new background session. Create(CreateCmd), + /// Attach the current terminal to a running interactive session. + Attach(AttachCmd), + /// Terminate a session and remove its on-disk state. Delete(DeleteCmd), + /// Show (and optionally follow) the stdout / stderr logs of a session. Logs(LogsCmd), - Exec(ExecCmd), + /// List all known sessions. List(ListCmd), } // ----------------------------------------------------------------------------- // create +#[derive(Subcommand)] +enum AgentKind { + /// Non-interactive execution agent. + Exec(ExecCreateCmd), + + /// Interactive Read-Eval-Print-Loop agent. + Repl(ReplCreateCmd), +} + #[derive(Args)] pub struct CreateCmd { /// Explicit session name. If omitted, a memorable random one is generated. #[arg(long)] id: Option, - /// Flags passed through to codex-exec. + #[command(subcommand)] + agent: AgentKind, +} + +#[derive(Args)] +pub struct ExecCreateCmd { #[clap(flatten)] exec_cli: codex_exec::Cli, } +#[derive(Args)] +pub struct ReplCreateCmd { + #[clap(flatten)] + repl_cli: codex_repl::Cli, +} + impl CreateCmd { pub async fn run(self) -> Result<()> { - let id = match self.id { - Some(id) => id, + let id = match &self.id { + Some(explicit) => explicit.clone(), None => generate_session_id()?, }; let paths = store::paths_for(&id)?; store::prepare_dirs(&paths)?; - let exec_args = build_exec_args(&self.exec_cli); - - // Preview first 40 printable chars of prompt for status listing - let prompt_preview = self - .exec_cli - .prompt - .as_ref() - .map(|p| { - let slice: String = p.chars().take(40).collect(); - if p.len() > 40 { - format!("{}…", slice) - } else { - slice - } - }); - - // Spawn process - let child = spawn::spawn_agent(&paths, &exec_args)?; + // Spawn underlying agent + let (pid, prompt_preview): (u32, Option) = match self.agent { + AgentKind::Exec(cmd) => { + let args = build_exec_args(&cmd.exec_cli); + let child = spawn::spawn_exec(&paths, &args)?; + let preview = cmd + .exec_cli + .prompt + .as_ref() + .map(|p| truncate_preview(p)); + (child.id().unwrap_or_default(), preview) + } + AgentKind::Repl(cmd) => { + let args = build_repl_args(&cmd.repl_cli); + let child = spawn::spawn_repl(&paths, &args)?; + let preview = cmd + .repl_cli + .prompt + .as_ref() + .map(|p| truncate_preview(p)); + (child.id().unwrap_or_default(), preview) + } + }; + // Persist metadata **after** the process has been spawned so we can record its PID. let meta = store::SessionMeta { id: id.clone(), - pid: child.id().unwrap_or_default(), + pid, created_at: chrono::Utc::now(), prompt_preview, }; @@ -105,6 +148,15 @@ impl CreateCmd { } } +fn truncate_preview(p: &str) -> String { + let slice: String = p.chars().take(40).collect(); + if p.len() > 40 { + format!("{}…", slice) + } else { + slice + } +} + fn generate_session_id() -> Result { let mut generator = names::Generator::with_naming(names::Name::Numbered); loop { @@ -144,6 +196,141 @@ fn build_exec_args(cli: &codex_exec::Cli) -> Vec { args } +fn build_repl_args(cli: &codex_repl::Cli) -> Vec { + let mut args = Vec::new(); + + // Positional prompt argument (optional) – needs to be *last* so push it later. + + if let Some(model) = &cli.model { + args.push("--model".into()); + args.push(model.clone()); + } + + for img in &cli.images { + args.push("--image".into()); + args.push(img.to_string_lossy().into_owned()); + } + + if cli.no_ansi { + args.push("--no-ansi".into()); + } + + // Verbose flag is additive (-v -vv …). + for _ in 0..cli.verbose { + args.push("-v".into()); + } + + // Approval + sandbox policies + args.push("--ask-for-approval".into()); + args.push(match cli.approval_policy { + codex_core::ApprovalModeCliArg::OnFailure => "on-failure".into(), + codex_core::ApprovalModeCliArg::UnlessAllowListed => "unless-allow-listed".into(), + codex_core::ApprovalModeCliArg::Never => "never".into(), + }); + + args.push("--sandbox".into()); + args.push(match cli.sandbox_policy { + codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(), + codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(), + codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => "network-and-file-write-restricted".into(), + codex_core::SandboxModeCliArg::DangerousNoRestrictions => "dangerous-no-restrictions".into(), + }); + + if cli.allow_no_git_exec { + args.push("--allow-no-git-exec".into()); + } + + if cli.disable_response_storage { + args.push("--disable-response-storage".into()); + } + + if let Some(path) = &cli.record_submissions { + args.push("--record-submissions".into()); + args.push(path.to_string_lossy().into_owned()); + } + + if let Some(path) = &cli.record_events { + args.push("--record-events".into()); + args.push(path.to_string_lossy().into_owned()); + } + + // Finally positional prompt argument. + if let Some(prompt) = &cli.prompt { + args.push(prompt.clone()); + } + + args +} + +// ----------------------------------------------------------------------------- +// attach + +#[derive(Args)] +pub struct AttachCmd { + /// Session selector (index, id or prefix) to attach to. + id: String, + + /// Also print stderr stream in addition to stdout. + #[arg(long)] + stderr: bool, +} + +impl AttachCmd { + pub async fn run(self) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; + use tokio::time::{sleep, Duration}; + + let id = store::resolve_selector(&self.id)?; + let paths = store::paths_for(&id)?; + + // Ensure stdin pipe exists. + if !paths.stdin.exists() { + anyhow::bail!("session '{id}' is not interactive (stdin pipe missing)"); + } + + // Open writer to the session's stdin pipe. + let mut pipe = tokio::fs::OpenOptions::new() + .write(true) + .open(&paths.stdin) + .await + .with_context(|| format!("failed to open stdin pipe for session '{id}'"))?; + + // Open stdout / stderr for tailing. + let file_out = tokio::fs::File::open(&paths.stdout).await?; + let mut reader_out = tokio::io::BufReader::new(file_out).lines(); + + let mut stdin_lines = tokio::io::BufReader::new(tokio::io::stdin()).lines(); + + loop { + tokio::select! { + // User supplied input + line = stdin_lines.next_line() => { + match line? { + Some(mut l) => { + l.push('\n'); + pipe.write_all(l.as_bytes()).await?; + pipe.flush().await?; + } + None => { + // Ctrl-D + break; + } + } + } + // stdout updates + out_line = reader_out.next_line() => { + match out_line? { + Some(l) => println!("{l}"), + None => sleep(Duration::from_millis(200)).await, + } + } + } + } + + Ok(()) + } +} + // ----------------------------------------------------------------------------- // delete @@ -190,56 +377,49 @@ impl LogsCmd { println!("{line}"); } } else { - tokio::io::copy(&mut tokio::io::BufReader::new(file), &mut tokio::io::stdout()).await?; + tokio::io::copy( + &mut tokio::io::BufReader::new(file), + &mut tokio::io::stdout(), + ) + .await?; } Ok(()) } } -// ----------------------------------------------------------------------------- -// exec (TODO) - -#[derive(Args)] -pub struct ExecCmd { - id: String, - #[arg(trailing_var_arg = true)] - cmd: Vec, -} - -impl ExecCmd { - pub async fn run(self) -> Result<()> { - let _id = store::resolve_selector(&self.id)?; - anyhow::bail!("exec inside session not implemented yet"); - } -} - // ----------------------------------------------------------------------------- // list #[derive(Copy, Clone, ValueEnum, Debug)] -enum OutputFormat { Table, Json, Yaml } +enum OutputFormat { + Table, + Json, + Yaml, +} #[derive(Args)] pub struct ListCmd { + /// Output format (default: table). #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] output: OutputFormat, } #[derive(Serialize)] -struct StatusRow { - idx: usize, - id: String, - pid: u32, - status: String, - created: String, - prompt: String, - out: String, - err: String, +#[allow(missing_docs)] +pub struct StatusRow { + pub idx: usize, + pub id: String, + pub pid: u32, + pub status: String, + pub created: String, + pub prompt: String, + pub out: String, + pub err: String, } impl ListCmd { pub async fn run(self) -> Result<()> { - use sysinfo::{SystemExt, PidExt}; + use sysinfo::{PidExt, SystemExt}; let metas = store::list_sessions_sorted()?; @@ -258,7 +438,6 @@ impl ListCmd { "exited" }; - // file sizes let paths = store::paths_for(&m.id).ok(); let (out, err) = if let Some(p) = &paths { let osz = std::fs::metadata(&p.stdout).map(|m| m.len()).unwrap_or(0); @@ -291,7 +470,7 @@ impl ListCmd { } } -fn print_table(rows: &[StatusRow]) -> Result<()> { +pub fn print_table(rows: &[StatusRow]) -> Result<()> { use std::io::Write; use tabwriter::TabWriter; diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index eb44a37b7..887d0f5a3 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -1,13 +1,17 @@ //! Library entry-point re-exporting the CLI so the binary can stay tiny. -pub mod cli; -mod spawn; -mod store; +//! Manage background `codex-exec` agents. +//! +//! This library is thin: it only re-exports the clap CLI and helpers so +//! the binary can stay small and unit tests can call into pure Rust APIs. + +pub mod cli; // public so main.rs can access it. +mod spawn; // process creation helpers +pub mod store; // on-disk bookkeeping (public for tests) pub use cli::Cli; -/// Binary entry – the bin crate’s `main.rs` calls into this for testability. +/// Entry used by the bin crate. pub async fn run_main(cli: Cli) -> anyhow::Result<()> { cli.dispatch().await } - diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index ad57e2022..8f6b81e14 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -1,18 +1,21 @@ -//! Cross-platform helper to spawn a fully-detached `codex-exec` process. +//! Spawn detached Codex agent processes. +//! +//! The session manager supports multiple agent flavors. `codex-exec` requires no interactive +//! stdin so we can safely redirect it to `/dev/null`. `codex-repl` however needs to read user +//! input after it is launched. The background process therefore receives a **named pipe** as +//! its standard input which later `codex-session attach` commands can open for writing. use crate::store::Paths; use anyhow::{Context, Result}; use std::fs::OpenOptions; use tokio::process::{Child, Command}; -/// Spawn `codex-exec` with `exec_args`, redirecting stdio to the per-session log files and -/// detaching the process group so it survives the parent CLI. -pub fn spawn_agent(paths: &Paths, exec_args: &[String]) -> Result { +/// Spawn a `codex-exec` agent. +pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { use std::io; - // Prepare stdio handles first. let stdin = OpenOptions::new().read(true).open("/dev/null")?; let stdout = OpenOptions::new() .create(true) @@ -29,9 +32,7 @@ pub fn spawn_agent(paths: &Paths, exec_args: &[String]) -> Result { .stdout(stdout) .stderr(stderr); - // Detach from the controlling terminal: setsid + ignore SIGHUP. - // SAFETY: calling an `unsafe` method (`pre_exec`). Runs in the parent process right - // before fork; the closure then executes in the child. + // Detach session so the child is not killed with the parent. unsafe { cmd.pre_exec(|| { if libc::setsid() == -1 { @@ -73,3 +74,70 @@ pub fn spawn_agent(paths: &Paths, exec_args: &[String]) -> Result { return Ok(child); } } + +/// Spawn a `codex-repl` agent. The process is detached like `spawn_exec` but its standard input +/// is connected to a named pipe inside the session directory so additional CLI instances can +/// attach later and feed user input. +pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { + #[cfg(unix)] + { + use std::io; + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::ffi::OsStrExt; + + // Ensure the FIFO exists (create with 600 permissions). + if !paths.stdin.exists() { + let c_path = std::ffi::CString::new(paths.stdin.as_os_str().as_bytes()).unwrap(); + // SAFETY: libc call, check return value. + let res = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; + if res != 0 { + let err = std::io::Error::last_os_error(); + // Ignore EEXIST if some race created it first. + if err.kind() != io::ErrorKind::AlreadyExists { + return Err(err).context("mkfifo failed"); + } + } + } + + // Open the FIFO read-write so `open()` does **not** block even though no external writer + // is connected yet. Keeping the write end open inside the child prevents an EOF on the + // read end while no `attach` session is active. + let stdin = OpenOptions::new() + .read(true) + .write(true) + .open(&paths.stdin)?; + + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + let mut cmd = Command::new("codex-repl"); + cmd.args(repl_args) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); + + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + libc::signal(libc::SIGHUP, libc::SIG_IGN); + Ok(()) + }); + } + + let child = cmd.spawn().context("failed to spawn codex-repl")?; + return Ok(child); + } + + #[cfg(windows)] + { + anyhow::bail!("codex-repl background sessions are not yet supported on Windows"); + } +} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 0386c64e8..b088602eb 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -1,4 +1,8 @@ -//! Session bookkeeping – on-disk layout and simple helpers. +//! Session bookkeeping helpers. +//! +//! A session lives in `~/.codex/sessions//` and contains: +//! * stdout.log / stderr.log - redirect of agent io +//! * meta.json - small struct saved by `write_meta`. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; @@ -9,16 +13,22 @@ pub struct Paths { pub dir: PathBuf, pub stdout: PathBuf, pub stderr: PathBuf, + /// Named pipe used for interactive stdin when the session runs a `codex-repl` agent. + /// + /// The file is **only** created for repl sessions. Exec sessions ignore the path. + pub stdin: PathBuf, pub meta: PathBuf, } /// Calculate canonical paths for the given session ID. pub fn paths_for(id: &str) -> Result { + // No IO here. Only build the paths. let dir = base_dir()?.join(id); Ok(Paths { dir: dir.clone(), stdout: dir.join("stdout.log"), stderr: dir.join("stderr.log"), + stdin: dir.join("stdin.pipe"), meta: dir.join("meta.json"), }) } @@ -42,6 +52,7 @@ pub struct SessionMeta { /// Create directory & empty log files. Does **not** write metadata; caller should write that /// once the child process has actually been spawned so we can record its PID. pub fn prepare_dirs(paths: &Paths) -> Result<()> { + // Called before spawn to make sure log files already exist. std::fs::create_dir_all(&paths.dir)?; for p in [&paths.stdout, &paths.stderr] { @@ -54,6 +65,7 @@ pub fn prepare_dirs(paths: &Paths) -> Result<()> { } pub fn write_meta(paths: &Paths, meta: &SessionMeta) -> Result<()> { + // Persist metadata after successful spawn so we can record PID. std::fs::write(&paths.meta, serde_json::to_vec_pretty(meta)?)?; Ok(()) } @@ -77,6 +89,7 @@ pub fn list_sessions() -> Result> { } /// List sessions sorted by newest first (created_at desc). +/// Newest-first list (created_at descending). pub fn list_sessions_sorted() -> Result> { let mut v = list_sessions()?; v.sort_by(|a, b| b.created_at.cmp(&a.created_at)); @@ -89,6 +102,7 @@ pub fn list_sessions_sorted() -> Result> { /// 1. Pure integer ⇒ index into newest-first list (0 = most recent) /// 2. Otherwise try exact id match, then unique prefix match. pub fn resolve_selector(sel: &str) -> Result { + // Accept index, full id, or unique prefix. let list = list_sessions_sorted()?; // numeric index From 2aa7f42dc951ff0d0d94d42a24fa006434cd7aec Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 08:11:04 -0700 Subject: [PATCH 07/44] fmt --- codex-rs/session/src/cli.rs | 57 ++++++++++++++++++++++------------- codex-rs/session/src/lib.rs | 4 +-- codex-rs/session/src/spawn.rs | 8 +++-- codex-rs/session/src/store.rs | 9 ++++-- 4 files changed, 49 insertions(+), 29 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 4382f5748..f47231dc0 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -8,9 +8,14 @@ //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. -use crate::{spawn, store}; -use anyhow::{Context, Result}; -use clap::{Args, Parser, Subcommand, ValueEnum}; +use crate::spawn; +use crate::store; +use anyhow::Context; +use anyhow::Result; +use clap::Args; +use clap::Parser; +use clap::Subcommand; +use clap::ValueEnum; use serde::Serialize; /// A human-friendly representation of a byte count (e.g. 1.4M). @@ -34,7 +39,10 @@ pub fn human_bytes(b: u64) -> String { // Top-level CLI definition #[derive(Parser)] -#[command(name = "codex-session", about = "Manage background Codex agent sessions")] +#[command( + name = "codex-session", + about = "Manage background Codex agent sessions" +)] pub struct Cli { #[command(subcommand)] cmd: Commands, @@ -115,21 +123,13 @@ impl CreateCmd { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); let child = spawn::spawn_exec(&paths, &args)?; - let preview = cmd - .exec_cli - .prompt - .as_ref() - .map(|p| truncate_preview(p)); + let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); (child.id().unwrap_or_default(), preview) } AgentKind::Repl(cmd) => { let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; - let preview = cmd - .repl_cli - .prompt - .as_ref() - .map(|p| truncate_preview(p)); + let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); (child.id().unwrap_or_default(), preview) } }; @@ -232,8 +232,12 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec { args.push(match cli.sandbox_policy { codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(), codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(), - codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => "network-and-file-write-restricted".into(), - codex_core::SandboxModeCliArg::DangerousNoRestrictions => "dangerous-no-restrictions".into(), + codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => { + "network-and-file-write-restricted".into() + } + codex_core::SandboxModeCliArg::DangerousNoRestrictions => { + "dangerous-no-restrictions".into() + } }); if cli.allow_no_git_exec { @@ -277,8 +281,10 @@ pub struct AttachCmd { impl AttachCmd { pub async fn run(self) -> Result<()> { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; - use tokio::time::{sleep, Duration}; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::time::sleep; + use tokio::time::Duration; let id = store::resolve_selector(&self.id)?; let paths = store::paths_for(&id)?; @@ -366,7 +372,11 @@ impl LogsCmd { pub async fn run(self) -> Result<()> { let id = store::resolve_selector(&self.id)?; let paths = store::paths_for(&id)?; - let target = if self.stderr { &paths.stderr } else { &paths.stdout }; + let target = if self.stderr { + &paths.stderr + } else { + &paths.stdout + }; let file = tokio::fs::File::open(target).await?; @@ -419,7 +429,8 @@ pub struct StatusRow { impl ListCmd { pub async fn run(self) -> Result<()> { - use sysinfo::{PidExt, SystemExt}; + use sysinfo::PidExt; + use sysinfo::SystemExt; let metas = store::list_sessions_sorted()?; @@ -477,7 +488,11 @@ pub fn print_table(rows: &[StatusRow]) -> Result<()> { let mut tw = TabWriter::new(Vec::new()).padding(2); writeln!(tw, "#\tID\tPID\tSTATUS\tOUT\tERR\tCREATED\tPROMPT")?; for r in rows { - writeln!(tw, "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", r.idx, r.id, r.pid, r.status, r.out, r.err, r.created, r.prompt)?; + writeln!( + tw, + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + r.idx, r.id, r.pid, r.status, r.out, r.err, r.created, r.prompt + )?; } let out = String::from_utf8(tw.into_inner()?)?; print!("{out}"); diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index 887d0f5a3..54b66deb1 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -5,8 +5,8 @@ //! This library is thin: it only re-exports the clap CLI and helpers so //! the binary can stay small and unit tests can call into pure Rust APIs. -pub mod cli; // public so main.rs can access it. -mod spawn; // process creation helpers +pub mod cli; // public so main.rs can access it. +mod spawn; // process creation helpers pub mod store; // on-disk bookkeeping (public for tests) pub use cli::Cli; diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 8f6b81e14..baae8adee 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -6,9 +6,11 @@ //! its standard input which later `codex-session attach` commands can open for writing. use crate::store::Paths; -use anyhow::{Context, Result}; +use anyhow::Context; +use anyhow::Result; use std::fs::OpenOptions; -use tokio::process::{Child, Command}; +use tokio::process::Child; +use tokio::process::Command; /// Spawn a `codex-exec` agent. pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { @@ -82,8 +84,8 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] { use std::io; - use std::os::unix::fs::OpenOptionsExt; use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::OpenOptionsExt; // Ensure the FIFO exists (create with 600 permissions). if !paths.stdin.exists() { diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index b088602eb..c219fe9fc 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -4,8 +4,10 @@ //! * stdout.log / stderr.log - redirect of agent io //! * meta.json - small struct saved by `write_meta`. -use anyhow::{Context, Result}; -use serde::{Deserialize, Serialize}; +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; #[derive(Clone, Debug)] @@ -107,7 +109,8 @@ pub fn resolve_selector(sel: &str) -> Result { // numeric index if let Ok(idx) = sel.parse::() { - return list.get(idx) + return list + .get(idx) .map(|m| m.id.clone()) .context(format!("no session at index {idx}")); } From 63ec18989a989eaa963671c679a6b7746668b897 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 08:27:26 -0700 Subject: [PATCH 08/44] display kind --- codex-rs/session/src/cli.rs | 15 +++++++++------ codex-rs/session/src/spawn.rs | 1 - codex-rs/session/src/store.rs | 13 +++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index f47231dc0..cf6cacc30 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -119,18 +119,18 @@ impl CreateCmd { store::prepare_dirs(&paths)?; // Spawn underlying agent - let (pid, prompt_preview): (u32, Option) = match self.agent { + let (pid, prompt_preview, kind): (u32, Option, store::SessionKind) = match self.agent { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); let child = spawn::spawn_exec(&paths, &args)?; let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); - (child.id().unwrap_or_default(), preview) + (child.id().unwrap_or_default(), preview, store::SessionKind::Exec) } AgentKind::Repl(cmd) => { let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); - (child.id().unwrap_or_default(), preview) + (child.id().unwrap_or_default(), preview, store::SessionKind::Repl) } }; @@ -138,6 +138,7 @@ impl CreateCmd { let meta = store::SessionMeta { id: id.clone(), pid, + kind, created_at: chrono::Utc::now(), prompt_preview, }; @@ -420,6 +421,7 @@ pub struct StatusRow { pub idx: usize, pub id: String, pub pid: u32, + pub kind: String, pub status: String, pub created: String, pub prompt: String, @@ -462,6 +464,7 @@ impl ListCmd { idx, id: m.id, pid: m.pid, + kind: format!("{:?}", m.kind).to_lowercase(), status: status.into(), created: m.created_at.to_rfc3339(), prompt: m.prompt_preview.unwrap_or_default(), @@ -486,12 +489,12 @@ pub fn print_table(rows: &[StatusRow]) -> Result<()> { use tabwriter::TabWriter; let mut tw = TabWriter::new(Vec::new()).padding(2); - writeln!(tw, "#\tID\tPID\tSTATUS\tOUT\tERR\tCREATED\tPROMPT")?; + writeln!(tw, "#\tID\tPID\tTYPE\tSTATUS\tOUT\tERR\tCREATED\tPROMPT")?; for r in rows { writeln!( tw, - "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", - r.idx, r.id, r.pid, r.status, r.out, r.err, r.created, r.prompt + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + r.idx, r.id, r.pid, r.kind, r.status, r.out, r.err, r.created, r.prompt )?; } let out = String::from_utf8(tw.into_inner()?)?; diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index baae8adee..465675293 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -85,7 +85,6 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { { use std::io; use std::os::unix::ffi::OsStrExt; - use std::os::unix::fs::OpenOptionsExt; // Ensure the FIFO exists (create with 600 permissions). if !paths.stdin.exists() { diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index c219fe9fc..fd02154c4 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -46,10 +46,23 @@ pub struct SessionMeta { pub id: String, pub pid: u32, pub created_at: chrono::DateTime, + #[serde(default)] + pub kind: SessionKind, #[serde(skip_serializing_if = "Option::is_none")] pub prompt_preview: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SessionKind { + Exec, + Repl, +} + +impl Default for SessionKind { + fn default() -> Self { SessionKind::Exec } +} + /// Create the on-disk directory structure and write metadata + empty log files. /// Create directory & empty log files. Does **not** write metadata; caller should write that /// once the child process has actually been spawned so we can record its PID. From f2b7b1428442303fb5dc05857aa0ee22f8854f1a Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 09:13:57 -0700 Subject: [PATCH 09/44] draft of tui sock --- codex-rs/Cargo.lock | 44 ++++++++-- codex-rs/session/Cargo.toml | 13 ++- codex-rs/session/src/cli.rs | 155 ++++++++++++++++++++++++++++++++-- codex-rs/session/src/spawn.rs | 96 +++++++++++++++++++++ codex-rs/session/src/store.rs | 4 + 5 files changed, 298 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e2a34410..b0fc4f1a6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -633,11 +633,14 @@ name = "codex-session" version = "0.1.0" dependencies = [ "anyhow", + "bytes", "chrono", "clap 4.5.37", "codex-core", "codex-exec", "codex-repl", + "codex-tui", + "crossterm 0.27.0", "dirs 5.0.1", "libc", "names", @@ -663,7 +666,7 @@ dependencies = [ "codex-ansi-escape", "codex-core", "color-eyre", - "crossterm", + "crossterm 0.28.1", "ratatui", "shlex", "tokio", @@ -789,6 +792,22 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags 2.9.0", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -797,7 +816,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.0", "crossterm_winapi", - "mio", + "mio 1.0.3", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -2139,6 +2158,18 @@ dependencies = [ "adler", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.3" @@ -2723,7 +2754,7 @@ dependencies = [ "bitflags 2.9.0", "cassowary", "compact_str", - "crossterm", + "crossterm 0.28.1", "indoc", "instability", "itertools 0.13.0", @@ -3244,7 +3275,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 0.8.11", + "mio 1.0.3", "signal-hook", ] @@ -3720,7 +3752,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.3", "pin-project-lite", "signal-hook-registry", "socket2", @@ -3969,7 +4001,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "crossterm", + "crossterm 0.28.1", "ratatui", "unicode-width 0.2.0", ] diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index bc7baa805..e1a772394 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -25,6 +25,14 @@ tokio = { version = "1", features = [ tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +# --- additions for PTY/TUI support --- +bytes = "1.5" + +# Raw terminal handling when attaching to TUI sessions +crossterm = "0.27" + +# PTY helpers (unix only) + # new dependencies for session management uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } @@ -37,16 +45,15 @@ serde_yaml = "0.9" names = "0.14" # unix-only process helpers -nix = { version = "0.27", optional = true, default-features = false, features = ["process"] } -libc = { version = "0.2", optional = true } +nix = { version = "0.27", default-features = false, features = ["process", "signal", "term"] } # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } codex_repl = { package = "codex-repl", path = "../repl" } +codex_tui = { package = "codex-tui", path = "../tui" } [dev-dependencies] tempfile = "3" [target.'cfg(unix)'.dependencies] -nix = { version = "0.27", default-features = false, features = ["process"] } libc = "0.2" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index cf6cacc30..ab83b68fa 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -84,6 +84,9 @@ enum AgentKind { /// Interactive Read-Eval-Print-Loop agent. Repl(ReplCreateCmd), + + /// Full-screen Terminal User Interface agent. + Tui(TuiCreateCmd), } #[derive(Args)] @@ -108,6 +111,12 @@ pub struct ReplCreateCmd { repl_cli: codex_repl::Cli, } +#[derive(Args)] +pub struct TuiCreateCmd { + #[clap(flatten)] + tui_cli: codex_tui::Cli, +} + impl CreateCmd { pub async fn run(self) -> Result<()> { let id = match &self.id { @@ -132,6 +141,12 @@ impl CreateCmd { let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); (child.id().unwrap_or_default(), preview, store::SessionKind::Repl) } + AgentKind::Tui(cmd) => { + let args = build_tui_args(&cmd.tui_cli); + let child = spawn::spawn_tui(&paths, &args)?; + let preview = cmd.tui_cli.prompt.as_ref().map(|p| truncate_preview(p)); + (child.id().unwrap_or_default(), preview, store::SessionKind::Tui) + } }; // Persist metadata **after** the process has been spawned so we can record its PID. @@ -267,6 +282,66 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec { args } +// Build argument vector for spawning `codex-tui`. +// For the first implementation we forward only a minimal subset of options that +// are already handled in the REPL helper above. Future work can extend this +// with the full flag surface. +fn build_tui_args(cli: &codex_tui::Cli) -> Vec { + let mut args = Vec::new(); + + // Positional prompt argument (optional) – must be last. + + if let Some(model) = &cli.model { + args.push("--model".into()); + args.push(model.clone()); + } + + for img in &cli.images { + args.push("--image".into()); + args.push(img.to_string_lossy().into_owned()); + } + + if cli.skip_git_repo_check { + args.push("--skip-git-repo-check".into()); + } + + if cli.disable_response_storage { + args.push("--disable-response-storage".into()); + } + + // Approval + sandbox policies + args.push("--ask-for-approval".into()); + args.push(match cli.approval_policy { + codex_core::ApprovalModeCliArg::OnFailure => "on-failure".into(), + codex_core::ApprovalModeCliArg::UnlessAllowListed => "unless-allow-listed".into(), + codex_core::ApprovalModeCliArg::Never => "never".into(), + }); + + args.push("--sandbox".into()); + args.push(match cli.sandbox_policy { + codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(), + codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(), + codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => + "network-and-file-write-restricted".into(), + codex_core::SandboxModeCliArg::DangerousNoRestrictions => + "dangerous-no-restrictions".into(), + }); + + // Convenience flags + if cli.full_auto { + args.push("--full-auto".into()); + } + if cli.suggest { + args.push("--suggest".into()); + } + + if let Some(prompt) = &cli.prompt { + args.push(prompt.clone()); + } + + args +} + // ----------------------------------------------------------------------------- // attach @@ -282,14 +357,30 @@ pub struct AttachCmd { impl AttachCmd { pub async fn run(self) -> Result<()> { - use tokio::io::AsyncBufReadExt; - use tokio::io::AsyncWriteExt; - use tokio::time::sleep; - use tokio::time::Duration; - let id = store::resolve_selector(&self.id)?; let paths = store::paths_for(&id)?; + // Load meta in order to decide which attach strategy to use. + let meta_bytes = std::fs::read(&paths.meta)?; + let meta: store::SessionMeta = serde_json::from_slice(&meta_bytes)?; + + match meta.kind { + store::SessionKind::Exec | store::SessionKind::Repl => { + self.attach_line_oriented(&id, &paths).await + } + store::SessionKind::Tui => { + self.attach_tui(&paths).await + } + } + } + + // ------------------------------------------------------------------ + // Original FIFO based attach (exec / repl) + async fn attach_line_oriented(&self, id: &str, paths: &store::Paths) -> Result<()> { + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::time::{sleep, Duration}; + // Ensure stdin pipe exists. if !paths.stdin.exists() { anyhow::bail!("session '{id}' is not interactive (stdin pipe missing)"); @@ -336,6 +427,60 @@ impl AttachCmd { Ok(()) } + + // ------------------------------------------------------------------ + // TUI attach: raw byte forwarding over unix socket + async fn attach_tui(&self, paths: &store::Paths) -> Result<()> { + #[cfg(unix)] + { + use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; + use tokio::io::{self}; + use tokio::net::UnixStream; + + let sock_path = paths.dir.join("sock"); + if !sock_path.exists() { + anyhow::bail!( + "tui session socket not found ({}). Is the session fully initialised?", + sock_path.display() + ); + } + + // Put local terminal in raw mode – undone automatically at drop. + enable_raw_mode()?; + + // Connect to the session socket. + let stream = UnixStream::connect(&sock_path).await?; + let (mut reader, mut writer) = stream.into_split(); + + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); + + // Two independent tasks: socket → stdout and stdin → socket. + let to_stdout = tokio::spawn(async move { + io::copy(&mut reader, &mut stdout).await + }); + + let to_socket = tokio::spawn(async move { + io::copy(&mut stdin, &mut writer).await + }); + + let res = tokio::select! { + r = to_stdout => r?, + r = to_socket => r?, + }; + + disable_raw_mode()?; + + // Propagate I/O errors if any. + res?; + Ok(()) + } + + #[cfg(not(unix))] + { + anyhow::bail!("tui sessions are only supported on Unix at the moment"); + } + } } // ----------------------------------------------------------------------------- diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 465675293..a008c3652 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -142,3 +142,99 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { anyhow::bail!("codex-repl background sessions are not yet supported on Windows"); } } + +/// Spawn a `codex-tui` agent **inside a pseudo-terminal (pty)** so that a later +/// `codex-session attach` command can hook up an interactive terminal. The +/// current implementation is intentionally minimal: it only takes care of +/// running the agent detached in the background and redirecting the master +/// side of the pty to `stdout.log`. A future patch will extend this with a +/// proper multi-client socket fan-out as outlined in the design document – the +/// extra indirection is *not* required for compilation tests. +pub fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result { + #[cfg(unix)] + { + use std::io; + use std::os::unix::io::{FromRawFd, IntoRawFd, RawFd}; + + // Allocate a new pty. + let pty = nix::pty::openpty(None, None).context("failed to open pty")?; + + // Safe because we immediately hand the raw fds to Stdio which takes + // ownership. + // Extract *raw* fds from the OwnedFd handles returned by nix. + let slave_fd: RawFd = pty.slave.into_raw_fd(); + let master_fd: RawFd = pty.master.into_raw_fd(); + + // Helper to wrap a raw fd into a Stdio object (takes ownership). + let make_stdio_from_fd = |fd: RawFd| unsafe { std::process::Stdio::from_raw_fd(fd) }; + + // SAFETY: libc::dup returns a new fd or -1 on error (checked). + let dup_fd = |fd: RawFd| -> Result { + let new_fd = unsafe { libc::dup(fd) }; + if new_fd == -1 { + Err(anyhow::anyhow!(std::io::Error::last_os_error())) + } else { + Ok(new_fd) + } + }; + + let stdin = make_stdio_from_fd(dup_fd(slave_fd)?); + let stdout = make_stdio_from_fd(dup_fd(slave_fd)?); + let stderr = make_stdio_from_fd(slave_fd); + + // Spawn the codex-tui process *detached* from our controlling tty so + // the background session survives once the `create` CLI exits. + let mut cmd = Command::new("codex-tui"); + cmd.args(tui_args) + .stdin(stdin) + .stdout(stdout) + .stderr(stderr); + + unsafe { + cmd.pre_exec(|| { + // Create new session (like setsid()) so the child is not tied + // to the CLI process. + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + libc::signal(libc::SIGHUP, libc::SIG_IGN); + Ok(()) + }); + } + + // Spawn child first so that we know its PID for metadata. + let child = cmd.spawn().context("failed to spawn codex-tui")?; + + // ------------ background copy: master → stdout.log --------------- + // Turn the master fd into a std::fs::File which **owns** the fd. + let master_file = unsafe { std::fs::File::from_raw_fd(master_fd) }; + let mut log_file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + + // Spawn blocking thread instead of async; simpler and good enough for + // the build-time smoke tests. + std::thread::spawn(move || { + use std::io::{Read, Write}; + let mut r = master_file; + let mut buf = [0u8; 4096]; + loop { + match r.read(&mut buf) { + Ok(0) => break, // eof + Ok(n) => { + let _ = log_file.write_all(&buf[..n]); + } + Err(_) => break, + } + } + }); + + Ok(child) + } + + #[cfg(windows)] + { + anyhow::bail!("codex-tui sessions are not yet supported on Windows"); + } +} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index fd02154c4..ca924f9fd 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -55,8 +55,12 @@ pub struct SessionMeta { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SessionKind { + /// Non-interactive batch session – `codex-exec`. Exec, + /// Line-oriented interactive session – `codex-repl`. Repl, + /// Full terminal-UI session (crossterm / ratatui) – `codex-tui`. + Tui, } impl Default for SessionKind { From 1d0d7254942c71f5907a46f9066188fddc5c274c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 09:41:06 -0700 Subject: [PATCH 10/44] draft broken --- codex-rs/session/Cargo.toml | 2 +- codex-rs/session/src/cli.rs | 31 +++- codex-rs/session/src/spawn.rs | 279 ++++++++++++++++++++++++---------- 3 files changed, 229 insertions(+), 83 deletions(-) diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index e1a772394..d48e65e03 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -45,7 +45,7 @@ serde_yaml = "0.9" names = "0.14" # unix-only process helpers -nix = { version = "0.27", default-features = false, features = ["process", "signal", "term"] } +nix = { version = "0.27", default-features = false, features = ["process", "signal", "term", "fs"] } # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index ab83b68fa..ecfc425fa 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -56,6 +56,7 @@ impl Cli { Commands::Delete(x) => x.run().await, Commands::Logs(x) => x.run().await, Commands::List(x) => x.run().await, + Commands::Mux(x) => x.run().await, } } } @@ -72,6 +73,10 @@ enum Commands { Logs(LogsCmd), /// List all known sessions. List(ListCmd), + + /// Internal helper process: PTY multiplexer daemon (hidden). + #[command(hide = true, name = "__mux")] + Mux(MuxCmd), } // ----------------------------------------------------------------------------- @@ -143,7 +148,7 @@ impl CreateCmd { } AgentKind::Tui(cmd) => { let args = build_tui_args(&cmd.tui_cli); - let child = spawn::spawn_tui(&paths, &args)?; + let child = spawn::spawn_tui(&paths, &args).await?; let preview = cmd.tui_cli.prompt.as_ref().map(|p| truncate_preview(p)); (child.id().unwrap_or_default(), preview, store::SessionKind::Tui) } @@ -164,6 +169,30 @@ impl CreateCmd { } } +// ----------------------------------------------------------------------------- +// internal mux helper sub-command (hidden) + +#[derive(Args)] +pub struct MuxCmd { + /// Raw PTY master file descriptor passed from the parent process. + #[arg(long)] + fd: i32, + + /// Path to the Unix-domain socket that clients attach to. + #[arg(long)] + sock: std::path::PathBuf, + + /// Path to the binary stdout log file. + #[arg(long)] + log: std::path::PathBuf, +} + +impl MuxCmd { + pub async fn run(self) -> Result<()> { + crate::spawn::mux_main(self.fd, self.sock, self.log).await + } +} + fn truncate_preview(p: &str) -> String { let slice: String = p.chars().take(40).collect(); if p.len() > 40 { diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index a008c3652..2159786e3 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -1,18 +1,21 @@ -//! Spawn detached Codex agent processes. +//! Spawn detached Codex agent processes (exec, repl, tui). //! -//! The session manager supports multiple agent flavors. `codex-exec` requires no interactive -//! stdin so we can safely redirect it to `/dev/null`. `codex-repl` however needs to read user -//! input after it is launched. The background process therefore receives a **named pipe** as -//! its standard input which later `codex-session attach` commands can open for writing. +//! The *exec* and *repl* helpers reuse the original FIFO/pipe strategy while +//! the new **tui** flavour allocates a pseudo-terminal so the crossterm / +//! ratatui application sees a *real* tty. A small socket fan-out forwards raw +//! bytes between the PTY and every `codex-session attach` client. use crate::store::Paths; -use anyhow::Context; -use anyhow::Result; +use anyhow::{Context, Result}; use std::fs::OpenOptions; -use tokio::process::Child; -use tokio::process::Command; +use tokio::process::{Child, Command}; + +#[cfg(unix)] +use std::os::unix::process::CommandExt; // for pre_exec + +// ----------------------------------------------------------------------------- +// exec – non-interactive batch agent (stdin = /dev/null) -/// Spawn a `codex-exec` agent. pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { @@ -77,9 +80,9 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { } } -/// Spawn a `codex-repl` agent. The process is detached like `spawn_exec` but its standard input -/// is connected to a named pipe inside the session directory so additional CLI instances can -/// attach later and feed user input. +// ----------------------------------------------------------------------------- +// repl – interactive but **line-oriented** (FIFO for stdin) + pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] { @@ -93,16 +96,14 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { let res = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; if res != 0 { let err = std::io::Error::last_os_error(); - // Ignore EEXIST if some race created it first. if err.kind() != io::ErrorKind::AlreadyExists { return Err(err).context("mkfifo failed"); } } } - // Open the FIFO read-write so `open()` does **not** block even though no external writer - // is connected yet. Keeping the write end open inside the child prevents an EOF on the - // read end while no `attach` session is active. + // Open the FIFO read-write so `open()` does **not** block even though + // no external writer is connected yet. let stdin = OpenOptions::new() .read(true) .write(true) @@ -139,61 +140,54 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(windows)] { - anyhow::bail!("codex-repl background sessions are not yet supported on Windows"); + anyhow::bail!("codex-repl sessions are not yet supported on Windows"); } } -/// Spawn a `codex-tui` agent **inside a pseudo-terminal (pty)** so that a later -/// `codex-session attach` command can hook up an interactive terminal. The -/// current implementation is intentionally minimal: it only takes care of -/// running the agent detached in the background and redirecting the master -/// side of the pty to `stdout.log`. A future patch will extend this with a -/// proper multi-client socket fan-out as outlined in the design document – the -/// extra indirection is *not* required for compilation tests. -pub fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result { +// ----------------------------------------------------------------------------- +// tui – full terminal UI (PTY + socket fan-out) + +use bytes::Bytes; + +#[cfg(unix)] +use { + nix::unistd::dup, + std::os::unix::io::{FromRawFd, IntoRawFd, RawFd}, +}; + +/// Spawn `codex-tui` inside a pseudo-terminal and start the background +/// multiplexer so future `attach` commands can talk to it. +pub async fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result { #[cfg(unix)] { use std::io; - use std::os::unix::io::{FromRawFd, IntoRawFd, RawFd}; - // Allocate a new pty. - let pty = nix::pty::openpty(None, None).context("failed to open pty")?; + // 1. PTY allocation --------------------------------------------------- + let pty = nix::pty::openpty(None, None).context("openpty failed")?; - // Safe because we immediately hand the raw fds to Stdio which takes - // ownership. - // Extract *raw* fds from the OwnedFd handles returned by nix. let slave_fd: RawFd = pty.slave.into_raw_fd(); let master_fd: RawFd = pty.master.into_raw_fd(); - // Helper to wrap a raw fd into a Stdio object (takes ownership). - let make_stdio_from_fd = |fd: RawFd| unsafe { std::process::Stdio::from_raw_fd(fd) }; - - // SAFETY: libc::dup returns a new fd or -1 on error (checked). - let dup_fd = |fd: RawFd| -> Result { - let new_fd = unsafe { libc::dup(fd) }; - if new_fd == -1 { - Err(anyhow::anyhow!(std::io::Error::last_os_error())) - } else { - Ok(new_fd) - } - }; + // Ensure master_fd is inheritable (clear FD_CLOEXEC) + { + use nix::fcntl::{fcntl, FcntlArg, FdFlag}; + let _ = fcntl(master_fd, FcntlArg::F_SETFD(FdFlag::empty())); + } - let stdin = make_stdio_from_fd(dup_fd(slave_fd)?); - let stdout = make_stdio_from_fd(dup_fd(slave_fd)?); - let stderr = make_stdio_from_fd(slave_fd); + // 2. Spawn codex-tui -------------------------------------------------- + let make_stdio = |fd: RawFd| unsafe { std::process::Stdio::from_raw_fd(fd) }; + let stdin = make_stdio(dup(slave_fd)?); + let stdout = make_stdio(dup(slave_fd)?); + let stderr = make_stdio(slave_fd); - // Spawn the codex-tui process *detached* from our controlling tty so - // the background session survives once the `create` CLI exits. - let mut cmd = Command::new("codex-tui"); - cmd.args(tui_args) + let mut tui_cmd = Command::new("codex-tui"); + tui_cmd.args(tui_args) .stdin(stdin) .stdout(stdout) .stderr(stderr); unsafe { - cmd.pre_exec(|| { - // Create new session (like setsid()) so the child is not tied - // to the CLI process. + tui_cmd.pre_exec(|| { if libc::setsid() == -1 { return Err(io::Error::last_os_error()); } @@ -202,39 +196,162 @@ pub fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result { }); } - // Spawn child first so that we know its PID for metadata. - let child = cmd.spawn().context("failed to spawn codex-tui")?; + let child = tui_cmd.spawn().context("failed to spawn codex-tui")?; - // ------------ background copy: master → stdout.log --------------- - // Turn the master fd into a std::fs::File which **owns** the fd. - let master_file = unsafe { std::fs::File::from_raw_fd(master_fd) }; - let mut log_file = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; + // 3. Spawn mux helper process --------------------------------------- - // Spawn blocking thread instead of async; simpler and good enough for - // the build-time smoke tests. - std::thread::spawn(move || { - use std::io::{Read, Write}; - let mut r = master_file; - let mut buf = [0u8; 4096]; - loop { - match r.read(&mut buf) { - Ok(0) => break, // eof - Ok(n) => { - let _ = log_file.write_all(&buf[..n]); - } - Err(_) => break, - } - } - }); + let sock_path = paths.dir.join("sock"); + if sock_path.exists() { + let _ = std::fs::remove_file(&sock_path); + } + + let current_exe = std::env::current_exe()?; + let mut mux_cmd = std::process::Command::new(current_exe); + mux_cmd.arg("__mux") + .arg("--fd").arg(format!("{master_fd}")) + .arg("--sock").arg(&sock_path) + .arg("--log").arg(&paths.stdout) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + + // Detach mux process (own session) + unsafe { + mux_cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + + let _ = mux_cmd.spawn().context("failed to spawn mux helper")?; Ok(child) } - #[cfg(windows)] + #[cfg(not(unix))] + { + anyhow::bail!("tui sessions are only supported on Unix right now"); + } +} + +#[cfg(unix)] +async fn spawn_client( + sock: tokio::net::UnixStream, + pty_write_fd: RawFd, + tx: &tokio::sync::broadcast::Sender, +) { + use tokio::io::AsyncWriteExt; + + let (mut s_read, mut s_write) = sock.into_split(); + + // Clone PTY master *write* side for this client + let pty_write = unsafe { + tokio::fs::File::from_std(std::fs::File::from_raw_fd(pty_write_fd)) + }; + let mut pty_write = pty_write; + + // subscribe + let mut rx = tx.subscribe(); + + // socket → pty + let to_pty = tokio::spawn(async move { + tokio::io::copy(&mut s_read, &mut pty_write).await.ok(); + }); + + // pty broadcast → socket + let from_pty = tokio::spawn(async move { + while let Ok(bytes) = rx.recv().await { + if s_write.write_all(&bytes).await.is_err() { + break; + } + } + }); + + let _ = tokio::join!(to_pty, from_pty); +} + +/// Actual multiplexer event loop that runs inside the forked daemon process. +#[cfg(unix)] +#[cfg(unix)] +pub async fn mux_main(master_fd: RawFd, sock_path: std::path::PathBuf, stdout_log: std::path::PathBuf) -> anyhow::Result<()> { + use tokio::{net::UnixListener, sync::broadcast}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Bind socket (should succeed; stale already removed). + let listener = match UnixListener::bind(&sock_path) { + Ok(l) => l, + Err(e) => { + eprintln!("socket bind failed: {e}"); + return Ok(()); + } + }; + + // Async read handle for PTY master. + let master_read = unsafe { + tokio::fs::File::from_std(std::fs::File::from_raw_fd(dup(master_fd).expect("dup master"))) + }; + + // binary log file + let mut log_file = match tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&stdout_log) + .await { - anyhow::bail!("codex-tui sessions are not yet supported on Windows"); + Ok(f) => f, + Err(e) => { + eprintln!("log open failed: {e}"); + return Ok(()); + } + }; + + let (tx, _) = broadcast::channel::(64); + + // Reader task + let tx_read = tx.clone(); + tokio::spawn(async move { + let mut buf = [0u8; 4096]; + let mut r = master_read; + loop { + match r.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + let _ = log_file.write_all(&buf[..n]).await; + let _ = tx_read.send(Bytes::copy_from_slice(&buf[..n])); + } + Err(e) => { + eprintln!("pty read error: {e}"); + break; + } + } + } + }); + + // Accept-loop + loop { + match listener.accept().await { + Ok((sock, _)) => { + match dup(master_fd) { + Ok(fd) => { + let tx_c = tx.clone(); + tokio::spawn(async move { + spawn_client(sock, fd, &tx_c).await; + }); + } + Err(e) => eprintln!("dup failed: {e}"), + } + } + Err(e) => { + eprintln!("accept error: {e}"); + break; + } + } } + + Ok(()) +} + +#[cfg(not(unix))] +pub async fn mux_main(_fd: i32, _sock: std::path::PathBuf, _log: std::path::PathBuf) -> anyhow::Result<()> { + anyhow::bail!("tui sessions are only supported on unix"); } From 786c81d7062c6c618734ac8138081d4ae15060b1 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 09:44:50 -0700 Subject: [PATCH 11/44] draft still broken --- codex-rs/session/src/cli.rs | 54 ++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index ecfc425fa..66859be23 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -462,10 +462,28 @@ impl AttachCmd { async fn attach_tui(&self, paths: &store::Paths) -> Result<()> { #[cfg(unix)] { - use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; - use tokio::io::{self}; + use crossterm::{execute, terminal}; + use tokio::io; use tokio::net::UnixStream; + // RAII guard for raw mode + alternate screen. + struct TermGuard; + impl TermGuard { + fn new() -> anyhow::Result { + execute!(std::io::stdout(), terminal::EnterAlternateScreen)?; + terminal::enable_raw_mode()?; + Ok(Self) + } + } + impl Drop for TermGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen); + } + } + + let _term = TermGuard::new()?; + let sock_path = paths.dir.join("sock"); if !sock_path.exists() { anyhow::bail!( @@ -474,34 +492,34 @@ impl AttachCmd { ); } - // Put local terminal in raw mode – undone automatically at drop. - enable_raw_mode()?; - - // Connect to the session socket. + // Connect to session socket. let stream = UnixStream::connect(&sock_path).await?; let (mut reader, mut writer) = stream.into_split(); let mut stdin = tokio::io::stdin(); let mut stdout = tokio::io::stdout(); - // Two independent tasks: socket → stdout and stdin → socket. - let to_stdout = tokio::spawn(async move { - io::copy(&mut reader, &mut stdout).await + let mut to_stdout = tokio::spawn(async move { + let _ = io::copy(&mut reader, &mut stdout).await; }); - let to_socket = tokio::spawn(async move { - io::copy(&mut stdin, &mut writer).await + let mut to_socket = tokio::spawn(async move { + let _ = io::copy(&mut stdin, &mut writer).await; }); - let res = tokio::select! { - r = to_stdout => r?, - r = to_socket => r?, - }; + // Wait for either direction, then abort the other. + tokio::select! { + _ = &mut to_stdout => { + to_socket.abort(); + } + _ = &mut to_socket => { + to_stdout.abort(); + } + } - disable_raw_mode()?; + let _ = to_stdout.await; + let _ = to_socket.await; - // Propagate I/O errors if any. - res?; Ok(()) } From 9f10ec53b69bd9dbf0080750f281ac0b65990c7e Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 10:03:24 -0700 Subject: [PATCH 12/44] remove tui socket stuff --- codex-rs/Cargo.lock | 44 +------ codex-rs/session/Cargo.toml | 3 - codex-rs/session/src/cli.rs | 182 +------------------------- codex-rs/session/src/spawn.rs | 236 +--------------------------------- codex-rs/session/src/store.rs | 2 - 5 files changed, 16 insertions(+), 451 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b0fc4f1a6..1e2a34410 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -633,14 +633,11 @@ name = "codex-session" version = "0.1.0" dependencies = [ "anyhow", - "bytes", "chrono", "clap 4.5.37", "codex-core", "codex-exec", "codex-repl", - "codex-tui", - "crossterm 0.27.0", "dirs 5.0.1", "libc", "names", @@ -666,7 +663,7 @@ dependencies = [ "codex-ansi-escape", "codex-core", "color-eyre", - "crossterm 0.28.1", + "crossterm", "ratatui", "shlex", "tokio", @@ -792,22 +789,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags 2.9.0", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" @@ -816,7 +797,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.0", "crossterm_winapi", - "mio 1.0.3", + "mio", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -2158,18 +2139,6 @@ dependencies = [ "adler", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.0.3" @@ -2754,7 +2723,7 @@ dependencies = [ "bitflags 2.9.0", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", "indoc", "instability", "itertools 0.13.0", @@ -3275,8 +3244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", - "mio 1.0.3", + "mio", "signal-hook", ] @@ -3752,7 +3720,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio 1.0.3", + "mio", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4001,7 +3969,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a5318dd619ed73c52a9417ad19046724effc1287fb75cdcc4eca1d6ac1acbae" dependencies = [ - "crossterm 0.28.1", + "crossterm", "ratatui", "unicode-width 0.2.0", ] diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index d48e65e03..21d2801a5 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -26,10 +26,8 @@ tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } # --- additions for PTY/TUI support --- -bytes = "1.5" # Raw terminal handling when attaching to TUI sessions -crossterm = "0.27" # PTY helpers (unix only) @@ -50,7 +48,6 @@ nix = { version = "0.27", default-features = false, features = ["process", "sign # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } codex_repl = { package = "codex-repl", path = "../repl" } -codex_tui = { package = "codex-tui", path = "../tui" } [dev-dependencies] tempfile = "3" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 66859be23..35fb89480 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -56,7 +56,6 @@ impl Cli { Commands::Delete(x) => x.run().await, Commands::Logs(x) => x.run().await, Commands::List(x) => x.run().await, - Commands::Mux(x) => x.run().await, } } } @@ -74,9 +73,7 @@ enum Commands { /// List all known sessions. List(ListCmd), - /// Internal helper process: PTY multiplexer daemon (hidden). - #[command(hide = true, name = "__mux")] - Mux(MuxCmd), + // (previous mux variant removed) } // ----------------------------------------------------------------------------- @@ -90,8 +87,6 @@ enum AgentKind { /// Interactive Read-Eval-Print-Loop agent. Repl(ReplCreateCmd), - /// Full-screen Terminal User Interface agent. - Tui(TuiCreateCmd), } #[derive(Args)] @@ -116,11 +111,6 @@ pub struct ReplCreateCmd { repl_cli: codex_repl::Cli, } -#[derive(Args)] -pub struct TuiCreateCmd { - #[clap(flatten)] - tui_cli: codex_tui::Cli, -} impl CreateCmd { pub async fn run(self) -> Result<()> { @@ -146,12 +136,6 @@ impl CreateCmd { let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); (child.id().unwrap_or_default(), preview, store::SessionKind::Repl) } - AgentKind::Tui(cmd) => { - let args = build_tui_args(&cmd.tui_cli); - let child = spawn::spawn_tui(&paths, &args).await?; - let preview = cmd.tui_cli.prompt.as_ref().map(|p| truncate_preview(p)); - (child.id().unwrap_or_default(), preview, store::SessionKind::Tui) - } }; // Persist metadata **after** the process has been spawned so we can record its PID. @@ -169,29 +153,7 @@ impl CreateCmd { } } -// ----------------------------------------------------------------------------- -// internal mux helper sub-command (hidden) - -#[derive(Args)] -pub struct MuxCmd { - /// Raw PTY master file descriptor passed from the parent process. - #[arg(long)] - fd: i32, - - /// Path to the Unix-domain socket that clients attach to. - #[arg(long)] - sock: std::path::PathBuf, - - /// Path to the binary stdout log file. - #[arg(long)] - log: std::path::PathBuf, -} - -impl MuxCmd { - pub async fn run(self) -> Result<()> { - crate::spawn::mux_main(self.fd, self.sock, self.log).await - } -} +// (mux helper removed) fn truncate_preview(p: &str) -> String { let slice: String = p.chars().take(40).collect(); @@ -315,61 +277,6 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec { // For the first implementation we forward only a minimal subset of options that // are already handled in the REPL helper above. Future work can extend this // with the full flag surface. -fn build_tui_args(cli: &codex_tui::Cli) -> Vec { - let mut args = Vec::new(); - - // Positional prompt argument (optional) – must be last. - - if let Some(model) = &cli.model { - args.push("--model".into()); - args.push(model.clone()); - } - - for img in &cli.images { - args.push("--image".into()); - args.push(img.to_string_lossy().into_owned()); - } - - if cli.skip_git_repo_check { - args.push("--skip-git-repo-check".into()); - } - - if cli.disable_response_storage { - args.push("--disable-response-storage".into()); - } - - // Approval + sandbox policies - args.push("--ask-for-approval".into()); - args.push(match cli.approval_policy { - codex_core::ApprovalModeCliArg::OnFailure => "on-failure".into(), - codex_core::ApprovalModeCliArg::UnlessAllowListed => "unless-allow-listed".into(), - codex_core::ApprovalModeCliArg::Never => "never".into(), - }); - - args.push("--sandbox".into()); - args.push(match cli.sandbox_policy { - codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(), - codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(), - codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => - "network-and-file-write-restricted".into(), - codex_core::SandboxModeCliArg::DangerousNoRestrictions => - "dangerous-no-restrictions".into(), - }); - - // Convenience flags - if cli.full_auto { - args.push("--full-auto".into()); - } - if cli.suggest { - args.push("--suggest".into()); - } - - if let Some(prompt) = &cli.prompt { - args.push(prompt.clone()); - } - - args -} // ----------------------------------------------------------------------------- // attach @@ -389,18 +296,7 @@ impl AttachCmd { let id = store::resolve_selector(&self.id)?; let paths = store::paths_for(&id)?; - // Load meta in order to decide which attach strategy to use. - let meta_bytes = std::fs::read(&paths.meta)?; - let meta: store::SessionMeta = serde_json::from_slice(&meta_bytes)?; - - match meta.kind { - store::SessionKind::Exec | store::SessionKind::Repl => { - self.attach_line_oriented(&id, &paths).await - } - store::SessionKind::Tui => { - self.attach_tui(&paths).await - } - } + self.attach_line_oriented(&id, &paths).await } // ------------------------------------------------------------------ @@ -457,77 +353,7 @@ impl AttachCmd { Ok(()) } - // ------------------------------------------------------------------ - // TUI attach: raw byte forwarding over unix socket - async fn attach_tui(&self, paths: &store::Paths) -> Result<()> { - #[cfg(unix)] - { - use crossterm::{execute, terminal}; - use tokio::io; - use tokio::net::UnixStream; - - // RAII guard for raw mode + alternate screen. - struct TermGuard; - impl TermGuard { - fn new() -> anyhow::Result { - execute!(std::io::stdout(), terminal::EnterAlternateScreen)?; - terminal::enable_raw_mode()?; - Ok(Self) - } - } - impl Drop for TermGuard { - fn drop(&mut self) { - let _ = terminal::disable_raw_mode(); - let _ = execute!(std::io::stdout(), terminal::LeaveAlternateScreen); - } - } - - let _term = TermGuard::new()?; - - let sock_path = paths.dir.join("sock"); - if !sock_path.exists() { - anyhow::bail!( - "tui session socket not found ({}). Is the session fully initialised?", - sock_path.display() - ); - } - - // Connect to session socket. - let stream = UnixStream::connect(&sock_path).await?; - let (mut reader, mut writer) = stream.into_split(); - - let mut stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - - let mut to_stdout = tokio::spawn(async move { - let _ = io::copy(&mut reader, &mut stdout).await; - }); - - let mut to_socket = tokio::spawn(async move { - let _ = io::copy(&mut stdin, &mut writer).await; - }); - - // Wait for either direction, then abort the other. - tokio::select! { - _ = &mut to_stdout => { - to_socket.abort(); - } - _ = &mut to_socket => { - to_stdout.abort(); - } - } - - let _ = to_stdout.await; - let _ = to_socket.await; - - Ok(()) - } - - #[cfg(not(unix))] - { - anyhow::bail!("tui sessions are only supported on Unix at the moment"); - } - } + // (TUI attach removed) } // ----------------------------------------------------------------------------- diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 2159786e3..7decb84a4 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -1,20 +1,12 @@ -//! Spawn detached Codex agent processes (exec, repl, tui). -//! -//! The *exec* and *repl* helpers reuse the original FIFO/pipe strategy while -//! the new **tui** flavour allocates a pseudo-terminal so the crossterm / -//! ratatui application sees a *real* tty. A small socket fan-out forwards raw -//! bytes between the PTY and every `codex-session attach` client. +//! Spawn detached Codex agent processes for exec and repl sessions. use crate::store::Paths; use anyhow::{Context, Result}; use std::fs::OpenOptions; use tokio::process::{Child, Command}; -#[cfg(unix)] -use std::os::unix::process::CommandExt; // for pre_exec - // ----------------------------------------------------------------------------- -// exec – non-interactive batch agent (stdin = /dev/null) +// exec – non-interactive batch agent pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] @@ -37,7 +29,6 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { .stdout(stdout) .stderr(stderr); - // Detach session so the child is not killed with the parent. unsafe { cmd.pre_exec(|| { if libc::setsid() == -1 { @@ -76,12 +67,12 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); let child = cmd.spawn().context("failed to spawn codex-exec")?; - return Ok(child); + Ok(child) } } // ----------------------------------------------------------------------------- -// repl – interactive but **line-oriented** (FIFO for stdin) +// repl – interactive FIFO stdin pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] @@ -89,10 +80,8 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { use std::io; use std::os::unix::ffi::OsStrExt; - // Ensure the FIFO exists (create with 600 permissions). if !paths.stdin.exists() { let c_path = std::ffi::CString::new(paths.stdin.as_os_str().as_bytes()).unwrap(); - // SAFETY: libc call, check return value. let res = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; if res != 0 { let err = std::io::Error::last_os_error(); @@ -102,8 +91,6 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { } } - // Open the FIFO read-write so `open()` does **not** block even though - // no external writer is connected yet. let stdin = OpenOptions::new() .read(true) .write(true) @@ -135,223 +122,12 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { } let child = cmd.spawn().context("failed to spawn codex-repl")?; - return Ok(child); - } - - #[cfg(windows)] - { - anyhow::bail!("codex-repl sessions are not yet supported on Windows"); - } -} - -// ----------------------------------------------------------------------------- -// tui – full terminal UI (PTY + socket fan-out) - -use bytes::Bytes; - -#[cfg(unix)] -use { - nix::unistd::dup, - std::os::unix::io::{FromRawFd, IntoRawFd, RawFd}, -}; - -/// Spawn `codex-tui` inside a pseudo-terminal and start the background -/// multiplexer so future `attach` commands can talk to it. -pub async fn spawn_tui(paths: &Paths, tui_args: &[String]) -> Result { - #[cfg(unix)] - { - use std::io; - - // 1. PTY allocation --------------------------------------------------- - let pty = nix::pty::openpty(None, None).context("openpty failed")?; - - let slave_fd: RawFd = pty.slave.into_raw_fd(); - let master_fd: RawFd = pty.master.into_raw_fd(); - - // Ensure master_fd is inheritable (clear FD_CLOEXEC) - { - use nix::fcntl::{fcntl, FcntlArg, FdFlag}; - let _ = fcntl(master_fd, FcntlArg::F_SETFD(FdFlag::empty())); - } - - // 2. Spawn codex-tui -------------------------------------------------- - let make_stdio = |fd: RawFd| unsafe { std::process::Stdio::from_raw_fd(fd) }; - let stdin = make_stdio(dup(slave_fd)?); - let stdout = make_stdio(dup(slave_fd)?); - let stderr = make_stdio(slave_fd); - - let mut tui_cmd = Command::new("codex-tui"); - tui_cmd.args(tui_args) - .stdin(stdin) - .stdout(stdout) - .stderr(stderr); - - unsafe { - tui_cmd.pre_exec(|| { - if libc::setsid() == -1 { - return Err(io::Error::last_os_error()); - } - libc::signal(libc::SIGHUP, libc::SIG_IGN); - Ok(()) - }); - } - - let child = tui_cmd.spawn().context("failed to spawn codex-tui")?; - - // 3. Spawn mux helper process --------------------------------------- - - let sock_path = paths.dir.join("sock"); - if sock_path.exists() { - let _ = std::fs::remove_file(&sock_path); - } - - let current_exe = std::env::current_exe()?; - let mut mux_cmd = std::process::Command::new(current_exe); - mux_cmd.arg("__mux") - .arg("--fd").arg(format!("{master_fd}")) - .arg("--sock").arg(&sock_path) - .arg("--log").arg(&paths.stdout) - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - - // Detach mux process (own session) - unsafe { - mux_cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - - let _ = mux_cmd.spawn().context("failed to spawn mux helper")?; - Ok(child) } - #[cfg(not(unix))] - { - anyhow::bail!("tui sessions are only supported on Unix right now"); - } -} - -#[cfg(unix)] -async fn spawn_client( - sock: tokio::net::UnixStream, - pty_write_fd: RawFd, - tx: &tokio::sync::broadcast::Sender, -) { - use tokio::io::AsyncWriteExt; - - let (mut s_read, mut s_write) = sock.into_split(); - - // Clone PTY master *write* side for this client - let pty_write = unsafe { - tokio::fs::File::from_std(std::fs::File::from_raw_fd(pty_write_fd)) - }; - let mut pty_write = pty_write; - - // subscribe - let mut rx = tx.subscribe(); - - // socket → pty - let to_pty = tokio::spawn(async move { - tokio::io::copy(&mut s_read, &mut pty_write).await.ok(); - }); - - // pty broadcast → socket - let from_pty = tokio::spawn(async move { - while let Ok(bytes) = rx.recv().await { - if s_write.write_all(&bytes).await.is_err() { - break; - } - } - }); - - let _ = tokio::join!(to_pty, from_pty); -} - -/// Actual multiplexer event loop that runs inside the forked daemon process. -#[cfg(unix)] -#[cfg(unix)] -pub async fn mux_main(master_fd: RawFd, sock_path: std::path::PathBuf, stdout_log: std::path::PathBuf) -> anyhow::Result<()> { - use tokio::{net::UnixListener, sync::broadcast}; - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // Bind socket (should succeed; stale already removed). - let listener = match UnixListener::bind(&sock_path) { - Ok(l) => l, - Err(e) => { - eprintln!("socket bind failed: {e}"); - return Ok(()); - } - }; - - // Async read handle for PTY master. - let master_read = unsafe { - tokio::fs::File::from_std(std::fs::File::from_raw_fd(dup(master_fd).expect("dup master"))) - }; - - // binary log file - let mut log_file = match tokio::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&stdout_log) - .await + #[cfg(windows)] { - Ok(f) => f, - Err(e) => { - eprintln!("log open failed: {e}"); - return Ok(()); - } - }; - - let (tx, _) = broadcast::channel::(64); - - // Reader task - let tx_read = tx.clone(); - tokio::spawn(async move { - let mut buf = [0u8; 4096]; - let mut r = master_read; - loop { - match r.read(&mut buf).await { - Ok(0) => break, - Ok(n) => { - let _ = log_file.write_all(&buf[..n]).await; - let _ = tx_read.send(Bytes::copy_from_slice(&buf[..n])); - } - Err(e) => { - eprintln!("pty read error: {e}"); - break; - } - } - } - }); - - // Accept-loop - loop { - match listener.accept().await { - Ok((sock, _)) => { - match dup(master_fd) { - Ok(fd) => { - let tx_c = tx.clone(); - tokio::spawn(async move { - spawn_client(sock, fd, &tx_c).await; - }); - } - Err(e) => eprintln!("dup failed: {e}"), - } - } - Err(e) => { - eprintln!("accept error: {e}"); - break; - } - } + anyhow::bail!("codex-repl sessions are not supported on Windows yet"); } - - Ok(()) } -#[cfg(not(unix))] -pub async fn mux_main(_fd: i32, _sock: std::path::PathBuf, _log: std::path::PathBuf) -> anyhow::Result<()> { - anyhow::bail!("tui sessions are only supported on unix"); -} diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index ca924f9fd..2f81b14fe 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -59,8 +59,6 @@ pub enum SessionKind { Exec, /// Line-oriented interactive session – `codex-repl`. Repl, - /// Full terminal-UI session (crossterm / ratatui) – `codex-tui`. - Tui, } impl Default for SessionKind { From dab7b1734ddd770dfb173730fd3f687c79bd9259 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 10:03:34 -0700 Subject: [PATCH 13/44] fmt --- codex-rs/session/src/cli.rs | 43 ++++++++++++++++++++--------------- codex-rs/session/src/spawn.rs | 7 +++--- codex-rs/session/src/store.rs | 4 +++- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 35fb89480..9ae9794d8 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -72,7 +72,6 @@ enum Commands { Logs(LogsCmd), /// List all known sessions. List(ListCmd), - // (previous mux variant removed) } @@ -86,7 +85,6 @@ enum AgentKind { /// Interactive Read-Eval-Print-Loop agent. Repl(ReplCreateCmd), - } #[derive(Args)] @@ -111,7 +109,6 @@ pub struct ReplCreateCmd { repl_cli: codex_repl::Cli, } - impl CreateCmd { pub async fn run(self) -> Result<()> { let id = match &self.id { @@ -123,20 +120,29 @@ impl CreateCmd { store::prepare_dirs(&paths)?; // Spawn underlying agent - let (pid, prompt_preview, kind): (u32, Option, store::SessionKind) = match self.agent { - AgentKind::Exec(cmd) => { - let args = build_exec_args(&cmd.exec_cli); - let child = spawn::spawn_exec(&paths, &args)?; - let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); - (child.id().unwrap_or_default(), preview, store::SessionKind::Exec) - } - AgentKind::Repl(cmd) => { - let args = build_repl_args(&cmd.repl_cli); - let child = spawn::spawn_repl(&paths, &args)?; - let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); - (child.id().unwrap_or_default(), preview, store::SessionKind::Repl) - } - }; + let (pid, prompt_preview, kind): (u32, Option, store::SessionKind) = + match self.agent { + AgentKind::Exec(cmd) => { + let args = build_exec_args(&cmd.exec_cli); + let child = spawn::spawn_exec(&paths, &args)?; + let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); + ( + child.id().unwrap_or_default(), + preview, + store::SessionKind::Exec, + ) + } + AgentKind::Repl(cmd) => { + let args = build_repl_args(&cmd.repl_cli); + let child = spawn::spawn_repl(&paths, &args)?; + let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); + ( + child.id().unwrap_or_default(), + preview, + store::SessionKind::Repl, + ) + } + }; // Persist metadata **after** the process has been spawned so we can record its PID. let meta = store::SessionMeta { @@ -304,7 +310,8 @@ impl AttachCmd { async fn attach_line_oriented(&self, id: &str, paths: &store::Paths) -> Result<()> { use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; - use tokio::time::{sleep, Duration}; + use tokio::time::sleep; + use tokio::time::Duration; // Ensure stdin pipe exists. if !paths.stdin.exists() { diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 7decb84a4..7cde58115 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -1,9 +1,11 @@ //! Spawn detached Codex agent processes for exec and repl sessions. use crate::store::Paths; -use anyhow::{Context, Result}; +use anyhow::Context; +use anyhow::Result; use std::fs::OpenOptions; -use tokio::process::{Child, Command}; +use tokio::process::Child; +use tokio::process::Command; // ----------------------------------------------------------------------------- // exec – non-interactive batch agent @@ -130,4 +132,3 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { anyhow::bail!("codex-repl sessions are not supported on Windows yet"); } } - diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 2f81b14fe..857d91beb 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -62,7 +62,9 @@ pub enum SessionKind { } impl Default for SessionKind { - fn default() -> Self { SessionKind::Exec } + fn default() -> Self { + SessionKind::Exec + } } /// Create the on-disk directory structure and write metadata + empty log files. From d0e8aa5233318c0ba3e3705b8f65e34dbc0d9956 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 11:20:46 -0700 Subject: [PATCH 14/44] impl kill --- codex-rs/Cargo.lock | 1 + codex-rs/session/Cargo.toml | 7 ++ codex-rs/session/src/store.rs | 141 +++++++++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e2a34410..a0bf33c4b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -652,6 +652,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "windows-sys 0.48.0", ] [[package]] diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 21d2801a5..e0b8f550a 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -54,3 +54,10 @@ tempfile = "3" [target.'cfg(unix)'.dependencies] libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.48", features = [ + "Win32_Foundation", + "Win32_System_Console", + "Win32_System_Threading", +] } diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 857d91beb..675e137f3 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -150,8 +150,145 @@ pub fn resolve_selector(sel: &str) -> Result { /// /// NOTE: Full PID accounting is a future improvement; for now the function /// simply returns `Ok(())` so the `delete` command doesn’t fail. -pub async fn kill_session(_id: &str) -> Result<()> { - // TODO: record PID at spawn time and terminate here. +/// Attempt to terminate the process (group) that belongs to the given session id. +/// +/// Behaviour +/// 1. A *graceful* `SIGTERM` (or `CTRL-BREAK` on Windows) is sent to the **process group** +/// that was created when the agent was spawned (`setsid` / `CREATE_NEW_PROCESS_GROUP`). +/// 2. We wait for a short grace period so the process can exit cleanly. +/// 3. If the process (identified by the original PID) is still alive we force-kill it +/// with `SIGKILL` (or the Win32 `TerminateProcess` API). +/// 4. The function is **idempotent** – calling it again when the session is already +/// terminated returns an error (`Err(AlreadyDead)`) so callers can decide whether +/// they still need to clean up the directory (`store::purge`). +/// +/// NOTE: only a very small amount of asynchronous work is required (the sleeps between +/// TERM → KILL). We keep the function `async` so the public signature stays unchanged. +pub async fn kill_session(id: &str) -> Result<()> { + use std::time::Duration; + + // Resolve paths and read metadata so we know the target PID. + let paths = paths_for(id)?; + + // Load meta.json – we need the PID written at spawn time. + let bytes = std::fs::read(&paths.meta) + .with_context(|| format!("could not read metadata for session '{id}'"))?; + let meta: SessionMeta = serde_json::from_slice(&bytes) + .context("failed to deserialize session metadata")?; + + let pid_u32 = meta.pid; + + // Helper – check if the original *leader* process is still around. + #[cfg(unix)] + fn is_alive(pid: libc::pid_t) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + #[cfg(windows)] + fn is_alive(pid: u32) -> bool { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, GetExitCodeProcess}; + const STILL_ACTIVE: u32 = 259; + + unsafe { + let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if handle == 0 { + return false; + } + let mut exit_code: u32 = 0; + let ok = GetExitCodeProcess(handle, &mut exit_code as *mut _); + CloseHandle(handle); + ok != 0 && exit_code == STILL_ACTIVE + } + } + + // If the process is already gone we bail out so the caller knows the session + // directory might need manual clean-up. + #[cfg(unix)] + let mut still_running = is_alive(pid_u32 as libc::pid_t); + #[cfg(windows)] + let mut still_running = is_alive(pid_u32); + + if !still_running { + anyhow::bail!( + "session process (PID {pid_u32}) is not running – directory cleanup still required" + ); + } + + //--------------------------------------------------------------------- + // Step 1 – send graceful termination. + //--------------------------------------------------------------------- + + #[cfg(unix)] + { + // Negative PID = process-group. + let pgid = -(pid_u32 as i32); + unsafe { + libc::kill(pgid, libc::SIGTERM); + } + } + + #[cfg(windows)] + { + use windows_sys::Win32::System::Console::GenerateConsoleCtrlEvent; + const CTRL_BREAK_EVENT: u32 = 1; // Using BREAK instead of C for detached groups. + // The process group id on Windows *is* the pid that we passed to CREATE_NEW_PROCESS_GROUP. + unsafe { + GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid_u32); + } + } + + // Give the process up to 2 seconds to exit. + let grace_period = Duration::from_secs(2); + let poll_interval = Duration::from_millis(100); + + let start = std::time::Instant::now(); + while start.elapsed() < grace_period { + #[cfg(unix)] + { + if !is_alive(pid_u32 as libc::pid_t) { + still_running = false; + break; + } + } + #[cfg(windows)] + { + if !is_alive(pid_u32) { + still_running = false; + break; + } + } + tokio::time::sleep(poll_interval).await; + } + + //--------------------------------------------------------------------- + // Step 2 – force kill if necessary. + //--------------------------------------------------------------------- + + if still_running { + #[cfg(unix)] + { + let pgid = -(pid_u32 as i32); + unsafe { + libc::kill(pgid, libc::SIGKILL); + } + } + + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + + unsafe { + let handle: HANDLE = OpenProcess(PROCESS_TERMINATE, 0, pid_u32); + if handle != 0 { + TerminateProcess(handle, 1); + CloseHandle(handle); + } + } + } + } + Ok(()) } From a09be2144e983fc4e6354e75106aeb7812a58f2f Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 11:36:48 -0700 Subject: [PATCH 15/44] stdout tailing --- codex-rs/session/src/cli.rs | 50 ++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 9ae9794d8..2cdde555e 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -325,15 +325,34 @@ impl AttachCmd { .await .with_context(|| format!("failed to open stdin pipe for session '{id}'"))?; - // Open stdout / stderr for tailing. + // ------------------------------------------------------------------ + // Log tailing setup + // + // The original implementation always tailed *stdout* only. Honour the + // `--stderr` flag so users can observe an interactive agent’s error + // stream as well. When the flag is **not** supplied we keep the + // previous behaviour for backwards-compatibility. + + // Always open stdout so the select! branches below stay simple. let file_out = tokio::fs::File::open(&paths.stdout).await?; let mut reader_out = tokio::io::BufReader::new(file_out).lines(); + // Conditionally open stderr if the user asked for it. Keeping the + // reader in an `Option` allows us to reuse the same select! loop – the + // helper future simply parks forever when stderr is disabled. + let mut reader_err = if self.stderr { + let file_err = tokio::fs::File::open(&paths.stderr).await?; + Some(tokio::io::BufReader::new(file_err).lines()) + } else { + None + }; + let mut stdin_lines = tokio::io::BufReader::new(tokio::io::stdin()).lines(); loop { tokio::select! { - // User supplied input + // ------------------------------------------------------------------ + // User supplied input (stdin → session stdin pipe) line = stdin_lines.next_line() => { match line? { Some(mut l) => { @@ -342,11 +361,13 @@ impl AttachCmd { pipe.flush().await?; } None => { - // Ctrl-D + // Ctrl-D – end of interactive input break; } } } + + // ------------------------------------------------------------------ // stdout updates out_line = reader_out.next_line() => { match out_line? { @@ -354,6 +375,29 @@ impl AttachCmd { None => sleep(Duration::from_millis(200)).await, } } + + // ------------------------------------------------------------------ + // stderr updates (optional) + // + // To keep `tokio::select!` happy we always supply a branch – when the + // user did *not* request stderr we hand it a future that will never + // finish (pending forever). This avoids `Option` juggling within the + // select! macro. + err_line = async { + if let Some(reader) = &mut reader_err { + reader.next_line().await + } else { + // Never resolves – equivalent to `futures::future::pending()` + std::future::pending().await + } + } => { + if let Some(line) = err_line? { + // Use a visible prefix so users can distinguish the streams. + println!("[stderr] {line}"); + } else { + sleep(Duration::from_millis(200)).await; + } + } } } From 6f0e4a5733b39020eebdc853ee79158cbfbc89ce Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 11:42:50 -0700 Subject: [PATCH 16/44] tail --- codex-rs/session/src/cli.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 2cdde555e..8fcdffc7e 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -451,10 +451,27 @@ impl LogsCmd { let file = tokio::fs::File::open(target).await?; if self.follow { + // ------------------------------------------------------------------ + // Corrected `--follow` implementation (tail -f semantics) + // + // The previous version exited as soon as EOF was reached because + // `lines.next_line()` returned `None`. We now mimic the behaviour + // of `tail -f` by sleeping for a short interval and retrying the + // read when EOF is encountered. The loop continues until the + // program is terminated (Ctrl-C, SIGINT, …). + use tokio::io::AsyncBufReadExt; + use tokio::time::{sleep, Duration}; + let mut lines = tokio::io::BufReader::new(file).lines(); - while let Some(line) = lines.next_line().await? { - println!("{line}"); + loop { + match lines.next_line().await? { + Some(l) => println!("{l}"), + None => { + // EOF – wait a little and retry. + sleep(Duration::from_millis(100)).await; + } + } } } else { tokio::io::copy( From a7a8fa175349f5ae17a39fc0bb5b6fc833e1ca9f Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 11:48:42 -0700 Subject: [PATCH 17/44] session validation --- codex-rs/session/src/store.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 675e137f3..8c017ced2 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -23,7 +23,24 @@ pub struct Paths { } /// Calculate canonical paths for the given session ID. +/// Build a [`Paths`] struct for a given session identifier. +/// +/// The function validates the input to avoid path-traversal attacks or +/// accidental creation of nested directories. Only the following ASCII +/// characters are accepted: +/// +/// * `A–Z`, `a–z`, `0–9` +/// * underscore (`_`) +/// * hyphen (`-`) +/// +/// Any other byte – especially path separators such as `/` or `\` – results +/// in an error. +/// +/// Keeping the validation local to this helper ensures that *all* call-sites +/// (CLI, library, tests) get the same guarantees. pub fn paths_for(id: &str) -> Result { + validate_id(id)?; + // No IO here. Only build the paths. let dir = base_dir()?.join(id); Ok(Paths { @@ -35,6 +52,22 @@ pub fn paths_for(id: &str) -> Result { }) } +/// Internal helper: ensure the supplied session id is well-formed. +fn validate_id(id: &str) -> Result<()> { + if id.is_empty() { + anyhow::bail!("session id must not be empty"); + } + + for b in id.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-' => {} + _ => anyhow::bail!("invalid character in session id: {:?}", b as char), + } + } + + Ok(()) +} + fn base_dir() -> Result { // ~/.codex/sessions let home = dirs::home_dir().context("could not resolve home directory")?; From 1e2983d61231bb13291a9546341fc54bb12313b4 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 12:09:04 -0700 Subject: [PATCH 18/44] cleanup on failure --- codex-rs/session/src/cli.rs | 39 ++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 8fcdffc7e..41b89eb98 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -117,32 +117,57 @@ impl CreateCmd { }; let paths = store::paths_for(&id)?; + // ----------------------------------------------------------------- + // Prepare session directory *before* spawning the agent so stdout/ + // stderr redirection works even when the child process itself fails + // immediately. + // ----------------------------------------------------------------- + store::prepare_dirs(&paths)?; - // Spawn underlying agent - let (pid, prompt_preview, kind): (u32, Option, store::SessionKind) = + // ----------------------------------------------------------------- + // Spawn underlying agent. + // + // IMPORTANT: If the spawn call fails we end up with an empty (or + // almost empty) directory inside ~/.codex/sessions/. To avoid + // confusing stale entries we attempt to purge the directory before + // bubbling up the error to the caller. + // ----------------------------------------------------------------- + + let spawn_result: Result<(u32, Option, store::SessionKind)> = (|| { match self.agent { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); let child = spawn::spawn_exec(&paths, &args)?; let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); - ( + Ok(( child.id().unwrap_or_default(), preview, store::SessionKind::Exec, - ) + )) } AgentKind::Repl(cmd) => { let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); - ( + Ok(( child.id().unwrap_or_default(), preview, store::SessionKind::Repl, - ) + )) } - }; + } + })(); + + let (pid, prompt_preview, kind) = match spawn_result { + Ok(tuple) => tuple, + Err(err) => { + // Best effort clean-up – ignore failures so we don't mask the + // original spawn error. + let _ = store::purge(&id); + return Err(err); + } + }; // Persist metadata **after** the process has been spawned so we can record its PID. let meta = store::SessionMeta { From 3d9ce18299414a1e0f8ba3a594a082d9adea990d Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 12:13:17 -0700 Subject: [PATCH 19/44] fix for tail --- codex-rs/session/src/cli.rs | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 41b89eb98..01b0e9f9c 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -475,20 +475,27 @@ impl LogsCmd { let file = tokio::fs::File::open(target).await?; - if self.follow { + if self.follow { // ------------------------------------------------------------------ - // Corrected `--follow` implementation (tail -f semantics) + // Improved `--follow` implementation (tail -f semantics) // - // The previous version exited as soon as EOF was reached because - // `lines.next_line()` returned `None`. We now mimic the behaviour - // of `tail -f` by sleeping for a short interval and retrying the - // read when EOF is encountered. The loop continues until the - // program is terminated (Ctrl-C, SIGINT, …). - - use tokio::io::AsyncBufReadExt; + // 1. Start at *the end* of the file so we only stream *new* output + // that appears after the command has been issued. This avoids + // re-printing potentially huge log histories when the user is + // solely interested in live updates. + // 2. Keep retrying after EOF so the behaviour matches the familiar + // `tail -f` utility. + + use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; use tokio::time::{sleep, Duration}; - let mut lines = tokio::io::BufReader::new(file).lines(); + // Jump to EOF before we start reading so we don't emit historical + // data. Ignore errors from `seek` on special files – in that case + // we just fall back to the normal behaviour. + let mut file = file; + let _ = file.seek(std::io::SeekFrom::End(0)).await; + + let mut lines = BufReader::new(file).lines(); loop { match lines.next_line().await? { Some(l) => println!("{l}"), From d3b69e98bd2413bac22565d75ac084a125d075ba Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 12:13:24 -0700 Subject: [PATCH 20/44] fmt --- codex-rs/session/src/cli.rs | 51 ++++++++++++++++++----------------- codex-rs/session/src/store.rs | 20 +++++++++----- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 01b0e9f9c..f80664764 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -134,28 +134,26 @@ impl CreateCmd { // bubbling up the error to the caller. // ----------------------------------------------------------------- - let spawn_result: Result<(u32, Option, store::SessionKind)> = (|| { - match self.agent { - AgentKind::Exec(cmd) => { - let args = build_exec_args(&cmd.exec_cli); - let child = spawn::spawn_exec(&paths, &args)?; - let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); - Ok(( - child.id().unwrap_or_default(), - preview, - store::SessionKind::Exec, - )) - } - AgentKind::Repl(cmd) => { - let args = build_repl_args(&cmd.repl_cli); - let child = spawn::spawn_repl(&paths, &args)?; - let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); - Ok(( - child.id().unwrap_or_default(), - preview, - store::SessionKind::Repl, - )) - } + let spawn_result: Result<(u32, Option, store::SessionKind)> = (|| match self.agent { + AgentKind::Exec(cmd) => { + let args = build_exec_args(&cmd.exec_cli); + let child = spawn::spawn_exec(&paths, &args)?; + let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); + Ok(( + child.id().unwrap_or_default(), + preview, + store::SessionKind::Exec, + )) + } + AgentKind::Repl(cmd) => { + let args = build_repl_args(&cmd.repl_cli); + let child = spawn::spawn_repl(&paths, &args)?; + let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); + Ok(( + child.id().unwrap_or_default(), + preview, + store::SessionKind::Repl, + )) } })(); @@ -475,7 +473,7 @@ impl LogsCmd { let file = tokio::fs::File::open(target).await?; - if self.follow { + if self.follow { // ------------------------------------------------------------------ // Improved `--follow` implementation (tail -f semantics) // @@ -486,8 +484,11 @@ impl LogsCmd { // 2. Keep retrying after EOF so the behaviour matches the familiar // `tail -f` utility. - use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; - use tokio::time::{sleep, Duration}; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncSeekExt; + use tokio::io::BufReader; + use tokio::time::sleep; + use tokio::time::Duration; // Jump to EOF before we start reading so we don't emit historical // data. Ignore errors from `seek` on special files – in that case diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 8c017ced2..6cc06bc0a 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -206,8 +206,8 @@ pub async fn kill_session(id: &str) -> Result<()> { // Load meta.json – we need the PID written at spawn time. let bytes = std::fs::read(&paths.meta) .with_context(|| format!("could not read metadata for session '{id}'"))?; - let meta: SessionMeta = serde_json::from_slice(&bytes) - .context("failed to deserialize session metadata")?; + let meta: SessionMeta = + serde_json::from_slice(&bytes).context("failed to deserialize session metadata")?; let pid_u32 = meta.pid; @@ -219,8 +219,11 @@ pub async fn kill_session(id: &str) -> Result<()> { #[cfg(windows)] fn is_alive(pid: u32) -> bool { - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; - use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, GetExitCodeProcess}; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Threading::GetExitCodeProcess; + use windows_sys::Win32::System::Threading::OpenProcess; + use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; const STILL_ACTIVE: u32 = 259; unsafe { @@ -265,7 +268,7 @@ pub async fn kill_session(id: &str) -> Result<()> { { use windows_sys::Win32::System::Console::GenerateConsoleCtrlEvent; const CTRL_BREAK_EVENT: u32 = 1; // Using BREAK instead of C for detached groups. - // The process group id on Windows *is* the pid that we passed to CREATE_NEW_PROCESS_GROUP. + // The process group id on Windows *is* the pid that we passed to CREATE_NEW_PROCESS_GROUP. unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid_u32); } @@ -309,8 +312,11 @@ pub async fn kill_session(id: &str) -> Result<()> { #[cfg(windows)] { - use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; - use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Threading::OpenProcess; + use windows_sys::Win32::System::Threading::TerminateProcess; + use windows_sys::Win32::System::Threading::PROCESS_TERMINATE; unsafe { let handle: HANDLE = OpenProcess(PROCESS_TERMINATE, 0, pid_u32); From e7823781762c48aba05a09ba906d67d8d8e1676a Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 12:20:27 -0700 Subject: [PATCH 21/44] gate on windows --- codex-rs/session/src/cli.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index f80664764..594ef1814 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -16,6 +16,12 @@ use clap::Args; use clap::Parser; use clap::Subcommand; use clap::ValueEnum; + +// ----------------------------------------------------------------------------- +// Platform-specific imports + +#[cfg(unix)] +use codex_repl as _; // Ensures the dependency is only required on Unix. use serde::Serialize; /// A human-friendly representation of a byte count (e.g. 1.4M). @@ -84,6 +90,7 @@ enum AgentKind { Exec(ExecCreateCmd), /// Interactive Read-Eval-Print-Loop agent. + #[cfg(unix)] Repl(ReplCreateCmd), } @@ -103,6 +110,7 @@ pub struct ExecCreateCmd { exec_cli: codex_exec::Cli, } +#[cfg(unix)] #[derive(Args)] pub struct ReplCreateCmd { #[clap(flatten)] @@ -145,6 +153,7 @@ impl CreateCmd { store::SessionKind::Exec, )) } + #[cfg(unix)] AgentKind::Repl(cmd) => { let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; @@ -232,6 +241,7 @@ fn build_exec_args(cli: &codex_exec::Cli) -> Vec { args } +#[cfg(unix)] fn build_repl_args(cli: &codex_repl::Cli) -> Vec { let mut args = Vec::new(); From 96d8d2a37ae92d0f09d9e2dc1e83ac8c7e76591d Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 14:25:26 -0700 Subject: [PATCH 22/44] save session metadata --- codex-rs/Cargo.lock | 3 + codex-rs/core/src/approval_mode_cli_arg.rs | 5 +- codex-rs/exec/Cargo.toml | 3 + codex-rs/exec/src/cli.rs | 7 +- codex-rs/interactive/Cargo.toml | 1 + codex-rs/interactive/src/cli.rs | 3 +- codex-rs/repl/Cargo.toml | 3 + codex-rs/repl/src/cli.rs | 8 +- codex-rs/session/build.rs | 19 +++++ codex-rs/session/src/build.rs | 10 +++ codex-rs/session/src/cli.rs | 28 ++++--- codex-rs/session/src/lib.rs | 2 + codex-rs/session/src/meta.rs | 89 ++++++++++++++++++++++ codex-rs/session/src/store.rs | 20 ++--- codex-rs/session/tests/meta_round_trip.rs | 36 +++++++++ 15 files changed, 211 insertions(+), 26 deletions(-) create mode 100644 codex-rs/session/build.rs create mode 100644 codex-rs/session/src/build.rs create mode 100644 codex-rs/session/src/meta.rs create mode 100644 codex-rs/session/tests/meta_round_trip.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a0bf33c4b..989ae385c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -579,6 +579,7 @@ dependencies = [ "anyhow", "clap 4.5.37", "codex-core", + "serde", "tokio", "tracing", "tracing-subscriber", @@ -611,6 +612,7 @@ dependencies = [ "anyhow", "clap 4.5.37", "codex-core", + "serde", "tokio", ] @@ -623,6 +625,7 @@ dependencies = [ "codex-core", "owo-colors 4.2.0", "rand 0.9.1", + "serde", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/core/src/approval_mode_cli_arg.rs b/codex-rs/core/src/approval_mode_cli_arg.rs index eb90b24d8..7c31912c1 100644 --- a/codex-rs/core/src/approval_mode_cli_arg.rs +++ b/codex-rs/core/src/approval_mode_cli_arg.rs @@ -5,8 +5,9 @@ use clap::ValueEnum; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, ValueEnum)] +#[derive(Clone, Debug, ValueEnum, Serialize, Deserialize)] #[value(rename_all = "kebab-case")] pub enum ApprovalModeCliArg { /// Run all commands without asking for user approval. @@ -24,7 +25,7 @@ pub enum ApprovalModeCliArg { Never, } -#[derive(Clone, Debug, ValueEnum)] +#[derive(Clone, Debug, ValueEnum, Serialize, Deserialize)] #[value(rename_all = "kebab-case")] pub enum SandboxModeCliArg { /// Network syscalls will be blocked diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index f214f9004..228e537e7 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -24,3 +24,6 @@ tokio = { version = "1", features = [ ] } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +# For serialising the `Cli` struct into the on-disk session metadata. +serde = { version = "1.0", features = ["derive"] } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 299e85879..f628f67d4 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,7 +1,12 @@ use clap::Parser; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[derive(Parser, Debug)] +/// Command-line interface for the non-interactive `codex-exec` agent. +/// +/// The struct needs to be serialisable so the full invocation can be stored +/// in the on-disk session `meta.json` for later introspection. +#[derive(Parser, Debug, Clone, Serialize, Deserialize)] #[command(version)] pub struct Cli { /// Optional image(s) to attach to the initial prompt. diff --git a/codex-rs/interactive/Cargo.toml b/codex-rs/interactive/Cargo.toml index b2a7234e2..8af96cf50 100644 --- a/codex-rs/interactive/Cargo.toml +++ b/codex-rs/interactive/Cargo.toml @@ -15,6 +15,7 @@ path = "src/lib.rs" anyhow = "1" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core", features = ["cli"] } +serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/interactive/src/cli.rs b/codex-rs/interactive/src/cli.rs index ffb61dfc2..3e17cca46 100644 --- a/codex-rs/interactive/src/cli.rs +++ b/codex-rs/interactive/src/cli.rs @@ -1,9 +1,10 @@ use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Clone, Serialize, Deserialize)] #[command(version)] pub struct Cli { /// Optional image(s) to attach to the initial prompt. diff --git a/codex-rs/repl/Cargo.toml b/codex-rs/repl/Cargo.toml index 24494ea01..010295ce5 100644 --- a/codex-rs/repl/Cargo.toml +++ b/codex-rs/repl/Cargo.toml @@ -26,3 +26,6 @@ tokio = { version = "1", features = [ ] } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +# For serialising the `Cli` struct into the on-disk session metadata. +serde = { version = "1.0", features = ["derive"] } diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index ec6c65251..a86b1f462 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -2,10 +2,16 @@ use clap::ArgAction; use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; /// Command‑line arguments. -#[derive(Debug, Parser)] +/// Command-line interface for the interactive `codex-repl` agent. +/// +/// Making the struct serialisable allows us to persist the full configuration +/// inside the session metadata so we can inspect the exact flags that were +/// used to launch the session at a later time. +#[derive(Debug, Parser, Clone, Serialize, Deserialize)] #[command( author, version, diff --git a/codex-rs/session/build.rs b/codex-rs/session/build.rs new file mode 100644 index 000000000..8dd08f764 --- /dev/null +++ b/codex-rs/session/build.rs @@ -0,0 +1,19 @@ +// build.rs – emit the current git commit so the code can embed it in the +// session metadata file. + +fn main() { + // Try to run `git rev-parse HEAD` – if that fails we fall back to + // "unknown" so the build does not break when the source is not a git + // repository (e.g., during `cargo publish`). + let git_sha = std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_owned()) + .unwrap_or_else(|| "unknown".into()); + + println!("cargo:rustc-env=GIT_SHA={git_sha}"); +} + diff --git a/codex-rs/session/src/build.rs b/codex-rs/session/src/build.rs new file mode 100644 index 000000000..f2ec2eb08 --- /dev/null +++ b/codex-rs/session/src/build.rs @@ -0,0 +1,10 @@ +//! Build-time information helpers (git commit hash, version, …). + +/// Return the git commit hash that was recorded at compile time via the +/// `build.rs` build-script. Falls back to the static string "unknown" when the +/// build script failed to determine the hash (e.g. when building from a +/// source tarball without the `.git` directory). +pub fn git_sha() -> &'static str { + env!("GIT_SHA") +} + diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 594ef1814..dacc3fbc8 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -8,6 +8,7 @@ //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. +use crate::meta::{AgentCli, SessionMeta}; use crate::spawn; use crate::store; use anyhow::Context; @@ -142,31 +143,44 @@ impl CreateCmd { // bubbling up the error to the caller. // ----------------------------------------------------------------- - let spawn_result: Result<(u32, Option, store::SessionKind)> = (|| match self.agent { + // Capture the child PID *and* the full CLI config so we can persist it + // in the metadata file. + let spawn_result: Result<( + u32, // pid + Option, // prompt preview + store::SessionKind, // kind + AgentCli, // full CLI config + )> = (|| match self.agent { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); let child = spawn::spawn_exec(&paths, &args)?; + let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); + Ok(( child.id().unwrap_or_default(), preview, store::SessionKind::Exec, + AgentCli::Exec(cmd.exec_cli.clone()), )) } #[cfg(unix)] AgentKind::Repl(cmd) => { let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; + let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); + Ok(( child.id().unwrap_or_default(), preview, store::SessionKind::Repl, + AgentCli::Repl(cmd.repl_cli.clone()), )) } })(); - let (pid, prompt_preview, kind) = match spawn_result { + let (pid, prompt_preview, kind, cli_cfg) = match spawn_result { Ok(tuple) => tuple, Err(err) => { // Best effort clean-up – ignore failures so we don't mask the @@ -177,13 +191,9 @@ impl CreateCmd { }; // Persist metadata **after** the process has been spawned so we can record its PID. - let meta = store::SessionMeta { - id: id.clone(), - pid, - kind, - created_at: chrono::Utc::now(), - prompt_preview, - }; + // Persist metadata **after** the process has been spawned so we can record its PID. + let meta = SessionMeta::new(id.clone(), pid, kind, cli_cfg, prompt_preview); + store::write_meta(&paths, &meta)?; println!("{id}"); diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index 54b66deb1..5456d5a3b 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -8,6 +8,8 @@ pub mod cli; // public so main.rs can access it. mod spawn; // process creation helpers pub mod store; // on-disk bookkeeping (public for tests) +pub mod meta; // richer on-disk metadata envelope +pub mod build; // build-time information helpers pub use cli::Cli; diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs new file mode 100644 index 000000000..28f3018ec --- /dev/null +++ b/codex-rs/session/src/meta.rs @@ -0,0 +1,89 @@ +//! Rich on-disk session metadata envelope. +//! +//! The file is written as `meta.json` inside every session directory so users +//! (and other tools) can inspect how a particular session was started even +//! months later. Keeping the full CLI invocation together with a few extra +//! bits of contextual information (like the git commit of the build) makes +//! debugging and reproducibility significantly easier. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::store::SessionKind; + +/// The CLI configuration that was used to launch the underlying agent. +/// +/// Depending on the chosen agent flavour (`codex-exec` vs `codex-repl`) the +/// contained configuration differs. We use an *externally tagged* enum so +/// the JSON clearly states which variant was used while still keeping the +/// nested structure as-is. +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "agent", rename_all = "lowercase")] +pub enum AgentCli { + /// Non-interactive batch agent. + Exec(codex_exec::Cli), + + /// Interactive REPL agent (only available on Unix-like systems). + #[cfg(unix)] + Repl(codex_repl::Cli), +} + +/// Versioned envelope that is persisted to disk. +/// +/// A monotonically increasing `version` field allows us to evolve the schema +/// over time while still being able to parse *older* files. +#[derive(Debug, Serialize, Deserialize)] +pub struct SessionMeta { + /// Unique identifier – also doubles as the directory name. + pub id: String, + + /// Process ID of the *leader* process belonging to the session. + pub pid: u32, + + /// Whether the session is an `exec` or `repl` one. + pub kind: SessionKind, + + /// Complete CLI configuration that was used to spawn the agent. + pub cli: AgentCli, + + /// Short preview of the natural-language prompt (if present). + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_preview: Option, + + /// Wall-clock timestamp when the session was created. + pub created_at: DateTime, + + /// Git commit hash of the `codex-rs` build that produced this file. + pub codex_commit: String, + + /// Schema version so we can migrate later. + pub version: u8, +} + +impl SessionMeta { + /// Bump this whenever the structure changes in a backwards-incompatible + /// way. + pub const CURRENT_VERSION: u8 = 1; + + /// Convenience constructor. + #[allow(clippy::too_many_arguments)] + pub fn new( + id: String, + pid: u32, + kind: SessionKind, + cli: AgentCli, + prompt_preview: Option, + ) -> Self { + Self { + id, + pid, + kind, + cli, + prompt_preview, + created_at: Utc::now(), + codex_commit: crate::build::git_sha().to_owned(), + version: Self::CURRENT_VERSION, + } + } +} + diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 6cc06bc0a..f225ebdb0 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -6,8 +6,11 @@ use anyhow::Context; use anyhow::Result; -use serde::Deserialize; -use serde::Serialize; + +// The rich metadata envelope lives in its own module so other parts of the +// crate can import it without pulling in the whole `store` implementation. +use crate::meta::SessionMeta; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Clone, Debug)] @@ -74,16 +77,9 @@ fn base_dir() -> Result { Ok(home.join(".codex").join("sessions")) } -#[derive(Serialize, Deserialize, Debug)] -pub struct SessionMeta { - pub id: String, - pub pid: u32, - pub created_at: chrono::DateTime, - #[serde(default)] - pub kind: SessionKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub prompt_preview: Option, -} +// Keep the original `SessionKind` enum here so we don't need a breaking change +// in all call-sites. The enum is re-exported so other modules (e.g. the newly +// added `meta` module) can still rely on the single source of truth. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] diff --git a/codex-rs/session/tests/meta_round_trip.rs b/codex-rs/session/tests/meta_round_trip.rs new file mode 100644 index 000000000..18386cf6c --- /dev/null +++ b/codex-rs/session/tests/meta_round_trip.rs @@ -0,0 +1,36 @@ +//! Simple round-trip test that serialises a freshly constructed `SessionMeta` +//! and deserialises it back to ensure the schema is self-consistent. + +use codex_session::meta::{AgentCli, SessionMeta}; +use codex_session::store::SessionKind; + +#[test] +fn meta_round_trip() { + let exec_cli = codex_exec::Cli { + images: vec![], + model: Some("gpt-4o-mini".into()), + skip_git_repo_check: true, + disable_response_storage: false, + prompt: Some("hello world".into()), + }; + + let meta = SessionMeta::new( + "test-session".into(), + 42, + SessionKind::Exec, + AgentCli::Exec(exec_cli.clone()), + exec_cli.prompt.clone(), + ); + + // Serialise with pretty printer so humans can read the file as well. + let json = serde_json::to_string_pretty(&meta).expect("serialise"); + + // … and parse it back. + let de: SessionMeta = serde_json::from_str(&json).expect("deserialise"); + + assert_eq!(de.version, SessionMeta::CURRENT_VERSION); + assert_eq!(de.id, "test-session"); + assert_eq!(de.pid, 42); + assert!(matches!(de.cli, AgentCli::Exec(_))); +} + From 337164738ab5ee2280f17f7628c2e2e69ae22631 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 14:25:35 -0700 Subject: [PATCH 23/44] fmt --- codex-rs/core/src/approval_mode_cli_arg.rs | 3 ++- codex-rs/exec/src/cli.rs | 3 ++- codex-rs/interactive/src/cli.rs | 3 ++- codex-rs/repl/src/cli.rs | 3 ++- codex-rs/session/build.rs | 1 - codex-rs/session/src/build.rs | 1 - codex-rs/session/src/cli.rs | 11 ++++++----- codex-rs/session/src/lib.rs | 6 +++--- codex-rs/session/src/meta.rs | 7 ++++--- codex-rs/session/src/store.rs | 3 ++- codex-rs/session/tests/meta_round_trip.rs | 4 ++-- 11 files changed, 25 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/approval_mode_cli_arg.rs b/codex-rs/core/src/approval_mode_cli_arg.rs index 7c31912c1..55c092541 100644 --- a/codex-rs/core/src/approval_mode_cli_arg.rs +++ b/codex-rs/core/src/approval_mode_cli_arg.rs @@ -5,7 +5,8 @@ use clap::ValueEnum; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; #[derive(Clone, Debug, ValueEnum, Serialize, Deserialize)] #[value(rename_all = "kebab-case")] diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index f628f67d4..d7f654748 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,5 +1,6 @@ use clap::Parser; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; /// Command-line interface for the non-interactive `codex-exec` agent. diff --git a/codex-rs/interactive/src/cli.rs b/codex-rs/interactive/src/cli.rs index 3e17cca46..a090f8f70 100644 --- a/codex-rs/interactive/src/cli.rs +++ b/codex-rs/interactive/src/cli.rs @@ -1,7 +1,8 @@ use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; #[derive(Parser, Debug, Clone, Serialize, Deserialize)] diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index a86b1f462..ccb610493 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -2,7 +2,8 @@ use clap::ArgAction; use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; /// Command‑line arguments. diff --git a/codex-rs/session/build.rs b/codex-rs/session/build.rs index 8dd08f764..16da93620 100644 --- a/codex-rs/session/build.rs +++ b/codex-rs/session/build.rs @@ -16,4 +16,3 @@ fn main() { println!("cargo:rustc-env=GIT_SHA={git_sha}"); } - diff --git a/codex-rs/session/src/build.rs b/codex-rs/session/src/build.rs index f2ec2eb08..edcff408d 100644 --- a/codex-rs/session/src/build.rs +++ b/codex-rs/session/src/build.rs @@ -7,4 +7,3 @@ pub fn git_sha() -> &'static str { env!("GIT_SHA") } - diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index dacc3fbc8..89a1e4cf2 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -8,7 +8,8 @@ //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. -use crate::meta::{AgentCli, SessionMeta}; +use crate::meta::AgentCli; +use crate::meta::SessionMeta; use crate::spawn; use crate::store; use anyhow::Context; @@ -146,10 +147,10 @@ impl CreateCmd { // Capture the child PID *and* the full CLI config so we can persist it // in the metadata file. let spawn_result: Result<( - u32, // pid - Option, // prompt preview - store::SessionKind, // kind - AgentCli, // full CLI config + u32, // pid + Option, // prompt preview + store::SessionKind, // kind + AgentCli, // full CLI config )> = (|| match self.agent { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index 5456d5a3b..027da4aed 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -5,11 +5,11 @@ //! This library is thin: it only re-exports the clap CLI and helpers so //! the binary can stay small and unit tests can call into pure Rust APIs. +pub mod build; pub mod cli; // public so main.rs can access it. -mod spawn; // process creation helpers -pub mod store; // on-disk bookkeeping (public for tests) pub mod meta; // richer on-disk metadata envelope -pub mod build; // build-time information helpers +mod spawn; // process creation helpers +pub mod store; // on-disk bookkeeping (public for tests) // build-time information helpers pub use cli::Cli; diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs index 28f3018ec..88ac72bcb 100644 --- a/codex-rs/session/src/meta.rs +++ b/codex-rs/session/src/meta.rs @@ -6,8 +6,10 @@ //! bits of contextual information (like the git commit of the build) makes //! debugging and reproducibility significantly easier. -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; use crate::store::SessionKind; @@ -86,4 +88,3 @@ impl SessionMeta { } } } - diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index f225ebdb0..70d73a4e1 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -10,7 +10,8 @@ use anyhow::Result; // The rich metadata envelope lives in its own module so other parts of the // crate can import it without pulling in the whole `store` implementation. use crate::meta::SessionMeta; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; +use serde::Serialize; use std::path::PathBuf; #[derive(Clone, Debug)] diff --git a/codex-rs/session/tests/meta_round_trip.rs b/codex-rs/session/tests/meta_round_trip.rs index 18386cf6c..25f4c8e24 100644 --- a/codex-rs/session/tests/meta_round_trip.rs +++ b/codex-rs/session/tests/meta_round_trip.rs @@ -1,7 +1,8 @@ //! Simple round-trip test that serialises a freshly constructed `SessionMeta` //! and deserialises it back to ensure the schema is self-consistent. -use codex_session::meta::{AgentCli, SessionMeta}; +use codex_session::meta::AgentCli; +use codex_session::meta::SessionMeta; use codex_session::store::SessionKind; #[test] @@ -33,4 +34,3 @@ fn meta_round_trip() { assert_eq!(de.pid, 42); assert!(matches!(de.cli, AgentCli::Exec(_))); } - From f1c6625bf2bc22f52ee3b9644345f99d77fe5eee Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 14:38:17 -0700 Subject: [PATCH 24/44] shorten timestamp --- codex-rs/session/src/cli.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 89a1e4cf2..dd7ccd636 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -14,6 +14,7 @@ use crate::spawn; use crate::store; use anyhow::Context; use anyhow::Result; +use chrono::SecondsFormat; use clap::Args; use clap::Parser; use clap::Subcommand; @@ -606,7 +607,7 @@ impl ListCmd { pid: m.pid, kind: format!("{:?}", m.kind).to_lowercase(), status: status.into(), - created: m.created_at.to_rfc3339(), + created: m.created_at.to_rfc3339_opts(SecondsFormat::Secs, true), prompt: m.prompt_preview.unwrap_or_default(), out, err, From 07911ddc3e82050f28b4d00f4a71d2c7e4ea8bfd Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 15:34:08 -0700 Subject: [PATCH 25/44] cleanup pass --- codex-rs/Cargo.lock | 168 ++-------------------- codex-rs/exec/Cargo.toml | 1 - codex-rs/exec/src/cli.rs | 6 +- codex-rs/interactive/Cargo.toml | 1 - codex-rs/interactive/src/cli.rs | 4 +- codex-rs/session/Cargo.toml | 11 +- codex-rs/session/src/cli.rs | 85 +++-------- codex-rs/session/src/meta.rs | 65 ++++----- codex-rs/session/src/spawn.rs | 83 +++++------ codex-rs/session/src/store.rs | 48 ++----- codex-rs/session/tests/meta_round_trip.rs | 36 ----- 11 files changed, 108 insertions(+), 400 deletions(-) delete mode 100644 codex-rs/session/tests/meta_round_trip.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 989ae385c..a66c4fd6a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -235,17 +235,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.4.0" @@ -398,23 +387,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "clap" -version = "3.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" -dependencies = [ - "atty", - "bitflags 1.3.2", - "clap_derive 3.2.25", - "clap_lex 0.2.4", - "indexmap 1.9.3", - "once_cell", - "strsim 0.10.0", - "termcolor", - "textwrap 0.16.2", -] - [[package]] name = "clap" version = "4.5.37" @@ -422,7 +394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" dependencies = [ "clap_builder", - "clap_derive 4.5.32", + "clap_derive", ] [[package]] @@ -433,45 +405,23 @@ checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" dependencies = [ "anstream", "anstyle", - "clap_lex 0.7.4", + "clap_lex", "strsim 0.11.1", "terminal_size", ] -[[package]] -name = "clap_derive" -version = "3.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" -dependencies = [ - "heck 0.4.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "clap_derive" version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.100", ] -[[package]] -name = "clap_lex" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5" -dependencies = [ - "os_str_bytes", -] - [[package]] name = "clap_lex" version = "0.7.4" @@ -522,7 +472,7 @@ name = "codex-cli" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.5.37", + "clap", "codex-core", "codex-exec", "codex-interactive", @@ -543,7 +493,7 @@ dependencies = [ "async-channel", "base64 0.21.7", "bytes", - "clap 4.5.37", + "clap", "codex-apply-patch", "dirs 6.0.0", "env-flags", @@ -577,9 +527,8 @@ name = "codex-exec" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.5.37", + "clap", "codex-core", - "serde", "tokio", "tracing", "tracing-subscriber", @@ -591,7 +540,7 @@ version = "0.1.0" dependencies = [ "allocative", "anyhow", - "clap 4.5.37", + "clap", "derive_more", "env_logger", "log", @@ -610,9 +559,8 @@ name = "codex-interactive" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.5.37", + "clap", "codex-core", - "serde", "tokio", ] @@ -621,7 +569,7 @@ name = "codex-repl" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.5.37", + "clap", "codex-core", "owo-colors 4.2.0", "rand 0.9.1", @@ -637,7 +585,7 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", - "clap 4.5.37", + "clap", "codex-core", "codex-exec", "codex-repl", @@ -647,7 +595,6 @@ dependencies = [ "nix 0.27.1", "serde", "serde_json", - "serde_yaml", "sysinfo", "tabwriter", "tempfile", @@ -663,7 +610,7 @@ name = "codex-tui" version = "0.1.0" dependencies = [ "anyhow", - "clap 4.5.37", + "clap", "codex-ansi-escape", "codex-core", "color-eyre", @@ -1465,27 +1412,12 @@ dependencies = [ "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hermit-abi" version = "0.3.9" @@ -2170,7 +2102,6 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc" dependencies = [ - "clap 3.2.25", "rand 0.8.5", ] @@ -2394,12 +2325,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "os_str_bytes" -version = "6.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" - [[package]] name = "overload" version = "0.1.1" @@ -2601,30 +2526,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.95" @@ -3203,19 +3104,6 @@ dependencies = [ "syn 2.0.100", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.9.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -3346,7 +3234,7 @@ dependencies = [ "starlark_syntax", "static_assertions", "strsim 0.10.0", - "textwrap 0.11.0", + "textwrap", "thiserror 1.0.69", ] @@ -3451,7 +3339,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "rustversion", @@ -3575,15 +3463,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" version = "0.4.2" @@ -3609,12 +3488,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" - [[package]] name = "thiserror" version = "1.0.69" @@ -4025,12 +3898,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -4237,15 +4104,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 228e537e7..6ed6a4807 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -26,4 +26,3 @@ tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } # For serialising the `Cli` struct into the on-disk session metadata. -serde = { version = "1.0", features = ["derive"] } diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index d7f654748..12be13480 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,13 +1,9 @@ use clap::Parser; -use serde::Deserialize; -use serde::Serialize; use std::path::PathBuf; /// Command-line interface for the non-interactive `codex-exec` agent. /// -/// The struct needs to be serialisable so the full invocation can be stored -/// in the on-disk session `meta.json` for later introspection. -#[derive(Parser, Debug, Clone, Serialize, Deserialize)] +#[derive(Parser, Debug, Clone)] #[command(version)] pub struct Cli { /// Optional image(s) to attach to the initial prompt. diff --git a/codex-rs/interactive/Cargo.toml b/codex-rs/interactive/Cargo.toml index 8af96cf50..b2a7234e2 100644 --- a/codex-rs/interactive/Cargo.toml +++ b/codex-rs/interactive/Cargo.toml @@ -15,7 +15,6 @@ path = "src/lib.rs" anyhow = "1" clap = { version = "4", features = ["derive"] } codex-core = { path = "../core", features = ["cli"] } -serde = { version = "1.0", features = ["derive"] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/interactive/src/cli.rs b/codex-rs/interactive/src/cli.rs index a090f8f70..ff09447b6 100644 --- a/codex-rs/interactive/src/cli.rs +++ b/codex-rs/interactive/src/cli.rs @@ -1,11 +1,9 @@ use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; -use serde::Deserialize; -use serde::Serialize; use std::path::PathBuf; -#[derive(Parser, Debug, Clone, Serialize, Deserialize)] +#[derive(Parser, Debug, Clone)] #[command(version)] pub struct Cli { /// Optional image(s) to attach to the initial prompt. diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index e0b8f550a..2a8e166e2 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -24,14 +24,6 @@ tokio = { version = "1", features = [ ] } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } - -# --- additions for PTY/TUI support --- - -# Raw terminal handling when attaching to TUI sessions - -# PTY helpers (unix only) - -# new dependencies for session management uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } @@ -39,8 +31,7 @@ serde_json = "1" dirs = "5" sysinfo = "0.29" tabwriter = "1.3" -serde_yaml = "0.9" -names = "0.14" +names = { version = "0.14", default-features = false } # unix-only process helpers nix = { version = "0.27", default-features = false, features = ["process", "signal", "term", "fs"] } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index dd7ccd636..038257882 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -8,7 +8,6 @@ //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. -use crate::meta::AgentCli; use crate::meta::SessionMeta; use crate::spawn; use crate::store; @@ -25,7 +24,8 @@ use clap::ValueEnum; #[cfg(unix)] use codex_repl as _; // Ensures the dependency is only required on Unix. -use serde::Serialize; +#[allow(unused_imports)] +use serde::Serialize; // still needed for table print rows in tests /// A human-friendly representation of a byte count (e.g. 1.4M). pub fn human_bytes(b: u64) -> String { @@ -151,7 +151,7 @@ impl CreateCmd { u32, // pid Option, // prompt preview store::SessionKind, // kind - AgentCli, // full CLI config + Vec, // raw argv used to spawn the agent )> = (|| match self.agent { AgentKind::Exec(cmd) => { let args = build_exec_args(&cmd.exec_cli); @@ -163,7 +163,7 @@ impl CreateCmd { child.id().unwrap_or_default(), preview, store::SessionKind::Exec, - AgentCli::Exec(cmd.exec_cli.clone()), + args.clone(), )) } #[cfg(unix)] @@ -177,12 +177,12 @@ impl CreateCmd { child.id().unwrap_or_default(), preview, store::SessionKind::Repl, - AgentCli::Repl(cmd.repl_cli.clone()), + args.clone(), )) } })(); - let (pid, prompt_preview, kind, cli_cfg) = match spawn_result { + let (pid, prompt_preview, kind, argv) = match spawn_result { Ok(tuple) => tuple, Err(err) => { // Best effort clean-up – ignore failures so we don't mask the @@ -194,7 +194,7 @@ impl CreateCmd { // Persist metadata **after** the process has been spawned so we can record its PID. // Persist metadata **after** the process has been spawned so we can record its PID. - let meta = SessionMeta::new(id.clone(), pid, kind, cli_cfg, prompt_preview); + let meta = SessionMeta::new(id.clone(), pid, kind, argv, prompt_preview); store::write_meta(&paths, &meta)?; @@ -476,9 +476,6 @@ impl DeleteCmd { pub struct LogsCmd { id: String, - #[arg(short, long)] - follow: bool, - #[arg(long)] stderr: bool, } @@ -495,66 +492,22 @@ impl LogsCmd { let file = tokio::fs::File::open(target).await?; - if self.follow { - // ------------------------------------------------------------------ - // Improved `--follow` implementation (tail -f semantics) - // - // 1. Start at *the end* of the file so we only stream *new* output - // that appears after the command has been issued. This avoids - // re-printing potentially huge log histories when the user is - // solely interested in live updates. - // 2. Keep retrying after EOF so the behaviour matches the familiar - // `tail -f` utility. - - use tokio::io::AsyncBufReadExt; - use tokio::io::AsyncSeekExt; - use tokio::io::BufReader; - use tokio::time::sleep; - use tokio::time::Duration; - - // Jump to EOF before we start reading so we don't emit historical - // data. Ignore errors from `seek` on special files – in that case - // we just fall back to the normal behaviour. - let mut file = file; - let _ = file.seek(std::io::SeekFrom::End(0)).await; - - let mut lines = BufReader::new(file).lines(); - loop { - match lines.next_line().await? { - Some(l) => println!("{l}"), - None => { - // EOF – wait a little and retry. - sleep(Duration::from_millis(100)).await; - } - } - } - } else { - tokio::io::copy( - &mut tokio::io::BufReader::new(file), - &mut tokio::io::stdout(), - ) - .await?; - } + // Stream the complete file to stdout. Users can pipe to `tail -f`, + // `less +F`, etc. if they only want live updates. + tokio::io::copy( + &mut tokio::io::BufReader::new(file), + &mut tokio::io::stdout(), + ) + .await?; Ok(()) } } // ----------------------------------------------------------------------------- -// list - -#[derive(Copy, Clone, ValueEnum, Debug)] -enum OutputFormat { - Table, - Json, - Yaml, -} +// list – newest-first overview of all sessions #[derive(Args)] -pub struct ListCmd { - /// Output format (default: table). - #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] - output: OutputFormat, -} +pub struct ListCmd {} #[derive(Serialize)] #[allow(missing_docs)] @@ -615,11 +568,7 @@ impl ListCmd { }) .collect(); - match self.output { - OutputFormat::Table => print_table(&rows)?, - OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&rows)?), - OutputFormat::Yaml => println!("{}", serde_yaml::to_string(&rows)?), - } + print_table(&rows)?; Ok(()) } diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs index 88ac72bcb..31b371c35 100644 --- a/codex-rs/session/src/meta.rs +++ b/codex-rs/session/src/meta.rs @@ -1,10 +1,12 @@ -//! Rich on-disk session metadata envelope. +//! Lightweight on-disk session metadata. //! -//! The file is written as `meta.json` inside every session directory so users -//! (and other tools) can inspect how a particular session was started even -//! months later. Keeping the full CLI invocation together with a few extra -//! bits of contextual information (like the git commit of the build) makes -//! debugging and reproducibility significantly easier. +//! The metadata is persisted as `meta.json` inside each session directory so +//! users – or other tooling – can inspect **how** a session was started even +//! months later. Instead of serialising the full, typed CLI structs (which +//! would force every agent crate to depend on `serde`) we only keep the raw +//! argument vector that was passed to the spawned process. This keeps the +//! public API surface minimal while still giving us reproducibility – a +//! session can always be re-spawned with `codex `. use chrono::DateTime; use chrono::Utc; @@ -13,78 +15,59 @@ use serde::Serialize; use crate::store::SessionKind; -/// The CLI configuration that was used to launch the underlying agent. -/// -/// Depending on the chosen agent flavour (`codex-exec` vs `codex-repl`) the -/// contained configuration differs. We use an *externally tagged* enum so -/// the JSON clearly states which variant was used while still keeping the -/// nested structure as-is. -#[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "agent", rename_all = "lowercase")] -pub enum AgentCli { - /// Non-interactive batch agent. - Exec(codex_exec::Cli), - - /// Interactive REPL agent (only available on Unix-like systems). - #[cfg(unix)] - Repl(codex_repl::Cli), -} +/// JSON envelope version. Bump when the structure changes in a +/// backwards-incompatible way. +pub const CURRENT_VERSION: u8 = 2; -/// Versioned envelope that is persisted to disk. -/// -/// A monotonically increasing `version` field allows us to evolve the schema -/// over time while still being able to parse *older* files. +/// Persisted session metadata. #[derive(Debug, Serialize, Deserialize)] pub struct SessionMeta { - /// Unique identifier – also doubles as the directory name. + /// Unique identifier (also doubles as directory name). pub id: String, - /// Process ID of the *leader* process belonging to the session. + /// Leader process id (PID). pub pid: u32, /// Whether the session is an `exec` or `repl` one. pub kind: SessionKind, - /// Complete CLI configuration that was used to spawn the agent. - pub cli: AgentCli, + /// Raw command-line arguments that were used to spawn the agent + /// (`codex-exec …` or `codex-repl …`). + pub argv: Vec, - /// Short preview of the natural-language prompt (if present). + /// Short preview of the user prompt (if any). #[serde(skip_serializing_if = "Option::is_none")] pub prompt_preview: Option, /// Wall-clock timestamp when the session was created. pub created_at: DateTime, - /// Git commit hash of the `codex-rs` build that produced this file. + /// Git commit hash of the build that produced this file. pub codex_commit: String, - /// Schema version so we can migrate later. + /// Schema version (see [`CURRENT_VERSION`]). pub version: u8, } impl SessionMeta { - /// Bump this whenever the structure changes in a backwards-incompatible - /// way. - pub const CURRENT_VERSION: u8 = 1; - - /// Convenience constructor. #[allow(clippy::too_many_arguments)] pub fn new( id: String, pid: u32, kind: SessionKind, - cli: AgentCli, + argv: Vec, prompt_preview: Option, ) -> Self { Self { id, pid, kind, - cli, + argv, prompt_preview, created_at: Utc::now(), codex_commit: crate::build::git_sha().to_owned(), - version: Self::CURRENT_VERSION, + version: CURRENT_VERSION, } } } + diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 7cde58115..e436fbde2 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -7,6 +7,38 @@ use std::fs::OpenOptions; use tokio::process::Child; use tokio::process::Command; +// ----------------------------------------------------------------------------- +// Internal helpers + +/// Open (and create if necessary) the log files that stdout / stderr of the +/// spawned agent will be redirected to. +fn open_log_files(paths: &Paths) -> Result<(std::fs::File, std::fs::File)> { + let stdout = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stdout)?; + + let stderr = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.stderr)?; + + Ok((stdout, stderr)) +} + +/// Configure a `tokio::process::Command` with the common options that are the +/// same for both `codex-exec` and `codex-repl` sessions. +fn base_command(bin: &str, paths: &Paths) -> Result { + let (stdout, stderr) = open_log_files(paths)?; + + let mut cmd = Command::new(bin); + cmd.stdin(std::process::Stdio::null()) + .stdout(stdout) + .stderr(stderr); + + Ok(cmd) +} + // ----------------------------------------------------------------------------- // exec – non-interactive batch agent @@ -15,21 +47,14 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { { use std::io; + let mut cmd = base_command("codex-exec", paths)?; + cmd.args(exec_args); + + // Replace the `stdin` that `base_command` configured (null) with + // `/dev/null` opened for reading – keeps the previous behaviour while + // still leveraging the common helper. let stdin = OpenOptions::new().read(true).open("/dev/null")?; - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stderr)?; - - let mut cmd = Command::new("codex-exec"); - cmd.args(exec_args) - .stdin(stdin) - .stdout(stdout) - .stderr(stderr); + cmd.stdin(stdin); unsafe { cmd.pre_exec(|| { @@ -52,20 +77,8 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { const DETACHED_PROCESS: u32 = 0x00000008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stderr)?; - - let mut cmd = Command::new("codex-exec"); + let mut cmd = base_command("codex-exec", paths)?; cmd.args(exec_args) - .stdin(std::process::Stdio::null()) - .stdout(stdout) - .stderr(stderr) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); let child = cmd.spawn().context("failed to spawn codex-exec")?; @@ -98,20 +111,8 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { .write(true) .open(&paths.stdin)?; - let stdout = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stdout)?; - let stderr = OpenOptions::new() - .create(true) - .append(true) - .open(&paths.stderr)?; - - let mut cmd = Command::new("codex-repl"); - cmd.args(repl_args) - .stdin(stdin) - .stdout(stdout) - .stderr(stderr); + let mut cmd = base_command("codex-repl", paths)?; + cmd.args(repl_args).stdin(stdin); unsafe { cmd.pre_exec(|| { diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 70d73a4e1..26208d109 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -208,38 +208,18 @@ pub async fn kill_session(id: &str) -> Result<()> { let pid_u32 = meta.pid; - // Helper – check if the original *leader* process is still around. - #[cfg(unix)] - fn is_alive(pid: libc::pid_t) -> bool { - unsafe { libc::kill(pid, 0) == 0 } - } - - #[cfg(windows)] + // Helper – cross-platform liveness probe based on the `sysinfo` crate. fn is_alive(pid: u32) -> bool { - use windows_sys::Win32::Foundation::CloseHandle; - use windows_sys::Win32::Foundation::HANDLE; - use windows_sys::Win32::System::Threading::GetExitCodeProcess; - use windows_sys::Win32::System::Threading::OpenProcess; - use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; - const STILL_ACTIVE: u32 = 259; + use sysinfo::PidExt; + use sysinfo::SystemExt; - unsafe { - let handle: HANDLE = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); - if handle == 0 { - return false; - } - let mut exit_code: u32 = 0; - let ok = GetExitCodeProcess(handle, &mut exit_code as *mut _); - CloseHandle(handle); - ok != 0 && exit_code == STILL_ACTIVE - } + let mut sys = sysinfo::System::new(); + sys.refresh_process(sysinfo::Pid::from_u32(pid)); + sys.process(sysinfo::Pid::from_u32(pid)).is_some() } // If the process is already gone we bail out so the caller knows the session // directory might need manual clean-up. - #[cfg(unix)] - let mut still_running = is_alive(pid_u32 as libc::pid_t); - #[cfg(windows)] let mut still_running = is_alive(pid_u32); if !still_running { @@ -277,19 +257,9 @@ pub async fn kill_session(id: &str) -> Result<()> { let start = std::time::Instant::now(); while start.elapsed() < grace_period { - #[cfg(unix)] - { - if !is_alive(pid_u32 as libc::pid_t) { - still_running = false; - break; - } - } - #[cfg(windows)] - { - if !is_alive(pid_u32) { - still_running = false; - break; - } + if !is_alive(pid_u32) { + still_running = false; + break; } tokio::time::sleep(poll_interval).await; } diff --git a/codex-rs/session/tests/meta_round_trip.rs b/codex-rs/session/tests/meta_round_trip.rs deleted file mode 100644 index 25f4c8e24..000000000 --- a/codex-rs/session/tests/meta_round_trip.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Simple round-trip test that serialises a freshly constructed `SessionMeta` -//! and deserialises it back to ensure the schema is self-consistent. - -use codex_session::meta::AgentCli; -use codex_session::meta::SessionMeta; -use codex_session::store::SessionKind; - -#[test] -fn meta_round_trip() { - let exec_cli = codex_exec::Cli { - images: vec![], - model: Some("gpt-4o-mini".into()), - skip_git_repo_check: true, - disable_response_storage: false, - prompt: Some("hello world".into()), - }; - - let meta = SessionMeta::new( - "test-session".into(), - 42, - SessionKind::Exec, - AgentCli::Exec(exec_cli.clone()), - exec_cli.prompt.clone(), - ); - - // Serialise with pretty printer so humans can read the file as well. - let json = serde_json::to_string_pretty(&meta).expect("serialise"); - - // … and parse it back. - let de: SessionMeta = serde_json::from_str(&json).expect("deserialise"); - - assert_eq!(de.version, SessionMeta::CURRENT_VERSION); - assert_eq!(de.id, "test-session"); - assert_eq!(de.pid, 42); - assert!(matches!(de.cli, AgentCli::Exec(_))); -} From 8ed2704191092fe191dc6c730eb09407e3b44bff Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 15:45:09 -0700 Subject: [PATCH 26/44] in progress cleanup --- codex-rs/Cargo.lock | 10 ++++++++++ codex-rs/exec/Cargo.toml | 2 -- codex-rs/repl/src/cli.rs | 8 +------- codex-rs/session/Cargo.toml | 3 +-- codex-rs/session/src/cli.rs | 32 +++++++++++++++----------------- codex-rs/session/src/meta.rs | 7 +++---- codex-rs/session/src/spawn.rs | 6 +++--- codex-rs/session/src/store.rs | 20 ++++++++++---------- 8 files changed, 43 insertions(+), 45 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a66c4fd6a..82d398865 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -592,6 +592,7 @@ dependencies = [ "dirs 5.0.1", "libc", "names", + "nanoid", "nix 0.27.1", "serde", "serde_json", @@ -2105,6 +2106,15 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "nanoid" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" +dependencies = [ + "rand 0.8.5", +] + [[package]] name = "native-tls" version = "0.2.14" diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 6ed6a4807..f214f9004 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -24,5 +24,3 @@ tokio = { version = "1", features = [ ] } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } - -# For serialising the `Cli` struct into the on-disk session metadata. diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index ccb610493..cb7749c15 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -2,17 +2,11 @@ use clap::ArgAction; use clap::Parser; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; -use serde::Deserialize; -use serde::Serialize; use std::path::PathBuf; /// Command‑line arguments. /// Command-line interface for the interactive `codex-repl` agent. -/// -/// Making the struct serialisable allows us to persist the full configuration -/// inside the session metadata so we can inspect the exact flags that were -/// used to launch the session at a later time. -#[derive(Debug, Parser, Clone, Serialize, Deserialize)] +#[derive(Debug, Parser, Clone)] #[command( author, version, diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 2a8e166e2..31b24b99d 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -32,9 +32,8 @@ dirs = "5" sysinfo = "0.29" tabwriter = "1.3" names = { version = "0.14", default-features = false } - -# unix-only process helpers nix = { version = "0.27", default-features = false, features = ["process", "signal", "term", "fs"] } +nanoid = "0.4.0" # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 038257882..52c9fcc5a 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -2,8 +2,8 @@ //! //! The session manager can spawn two different Codex agent flavors: //! -//! * `codex-exec` – non-interactive batch agent (legacy behaviour) -//! * `codex-repl` – interactive REPL that requires user input after launch +//! * `codex-exec` -- non-interactive batch agent (legacy behaviour) +//! * `codex-repl` -- interactive REPL that requires user input after launch //! //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. @@ -17,7 +17,7 @@ use chrono::SecondsFormat; use clap::Args; use clap::Parser; use clap::Subcommand; -use clap::ValueEnum; +use nanoid::nanoid; // ----------------------------------------------------------------------------- // Platform-specific imports @@ -185,7 +185,7 @@ impl CreateCmd { let (pid, prompt_preview, kind, argv) = match spawn_result { Ok(tuple) => tuple, Err(err) => { - // Best effort clean-up – ignore failures so we don't mask the + // Best effort clean-up -- ignore failures so we don't mask the // original spawn error. let _ = store::purge(&id); return Err(err); @@ -208,19 +208,17 @@ impl CreateCmd { fn truncate_preview(p: &str) -> String { let slice: String = p.chars().take(40).collect(); if p.len() > 40 { - format!("{}…", slice) + format!("{}...", slice) } else { slice } } fn generate_session_id() -> Result { - let mut generator = names::Generator::with_naming(names::Name::Numbered); loop { - let candidate = generator.next().unwrap(); - let paths = store::paths_for(&candidate)?; - if !paths.dir.exists() { - return Ok(candidate); + let id = nanoid!(8); + if !store::paths_for(&id)?.dir.exists() { + return Ok(id); } } } @@ -257,7 +255,7 @@ fn build_exec_args(cli: &codex_exec::Cli) -> Vec { fn build_repl_args(cli: &codex_repl::Cli) -> Vec { let mut args = Vec::new(); - // Positional prompt argument (optional) – needs to be *last* so push it later. + // Positional prompt argument (optional) -- needs to be *last* so push it later. if let Some(model) = &cli.model { args.push("--model".into()); @@ -273,7 +271,7 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec { args.push("--no-ansi".into()); } - // Verbose flag is additive (-v -vv …). + // Verbose flag is additive (-v -vv ...). for _ in 0..cli.verbose { args.push("-v".into()); } @@ -383,7 +381,7 @@ impl AttachCmd { let mut reader_out = tokio::io::BufReader::new(file_out).lines(); // Conditionally open stderr if the user asked for it. Keeping the - // reader in an `Option` allows us to reuse the same select! loop – the + // reader in an `Option` allows us to reuse the same select! loop -- the // helper future simply parks forever when stderr is disabled. let mut reader_err = if self.stderr { let file_err = tokio::fs::File::open(&paths.stderr).await?; @@ -406,7 +404,7 @@ impl AttachCmd { pipe.flush().await?; } None => { - // Ctrl-D – end of interactive input + // Ctrl-D -- end of interactive input break; } } @@ -424,7 +422,7 @@ impl AttachCmd { // ------------------------------------------------------------------ // stderr updates (optional) // - // To keep `tokio::select!` happy we always supply a branch – when the + // To keep `tokio::select!` happy we always supply a branch -- when the // user did *not* request stderr we hand it a future that will never // finish (pending forever). This avoids `Option` juggling within the // select! macro. @@ -432,7 +430,7 @@ impl AttachCmd { if let Some(reader) = &mut reader_err { reader.next_line().await } else { - // Never resolves – equivalent to `futures::future::pending()` + // Never resolves -- equivalent to `futures::future::pending()` std::future::pending().await } } => { @@ -504,7 +502,7 @@ impl LogsCmd { } // ----------------------------------------------------------------------------- -// list – newest-first overview of all sessions +// list -- newest-first overview of all sessions #[derive(Args)] pub struct ListCmd {} diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs index 31b371c35..4107afc30 100644 --- a/codex-rs/session/src/meta.rs +++ b/codex-rs/session/src/meta.rs @@ -1,12 +1,12 @@ //! Lightweight on-disk session metadata. //! //! The metadata is persisted as `meta.json` inside each session directory so -//! users – or other tooling – can inspect **how** a session was started even +//! users -- or other tooling -- can inspect **how** a session was started even //! months later. Instead of serialising the full, typed CLI structs (which //! would force every agent crate to depend on `serde`) we only keep the raw //! argument vector that was passed to the spawned process. This keeps the -//! public API surface minimal while still giving us reproducibility – a -//! session can always be re-spawned with `codex `. +//! public API surface minimal while still giving us reproducibility -- a +//! session can always be re-spawned with `codex `. use chrono::DateTime; use chrono::Utc; @@ -70,4 +70,3 @@ impl SessionMeta { } } } - diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index e436fbde2..73e4275f9 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -40,7 +40,7 @@ fn base_command(bin: &str, paths: &Paths) -> Result { } // ----------------------------------------------------------------------------- -// exec – non-interactive batch agent +// exec -- non-interactive batch agent pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] @@ -51,7 +51,7 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { cmd.args(exec_args); // Replace the `stdin` that `base_command` configured (null) with - // `/dev/null` opened for reading – keeps the previous behaviour while + // `/dev/null` opened for reading -- keeps the previous behaviour while // still leveraging the common helper. let stdin = OpenOptions::new().read(true).open("/dev/null")?; cmd.stdin(stdin); @@ -87,7 +87,7 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { } // ----------------------------------------------------------------------------- -// repl – interactive FIFO stdin +// repl -- interactive FIFO stdin pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 26208d109..b0f0a3732 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -33,11 +33,11 @@ pub struct Paths { /// accidental creation of nested directories. Only the following ASCII /// characters are accepted: /// -/// * `A–Z`, `a–z`, `0–9` +/// * `A-Z`, `a-z`, `0-9` /// * underscore (`_`) /// * hyphen (`-`) /// -/// Any other byte – especially path separators such as `/` or `\` – results +/// Any other byte -- especially path separators such as `/` or `\\` -- results /// in an error. /// /// Keeping the validation local to this helper ensures that *all* call-sites @@ -85,9 +85,9 @@ fn base_dir() -> Result { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SessionKind { - /// Non-interactive batch session – `codex-exec`. + /// Non-interactive batch session -- `codex-exec`. Exec, - /// Line-oriented interactive session – `codex-repl`. + /// Line-oriented interactive session -- `codex-repl`. Repl, } @@ -188,7 +188,7 @@ pub fn resolve_selector(sel: &str) -> Result { /// 2. We wait for a short grace period so the process can exit cleanly. /// 3. If the process (identified by the original PID) is still alive we force-kill it /// with `SIGKILL` (or the Win32 `TerminateProcess` API). -/// 4. The function is **idempotent** – calling it again when the session is already +/// 4. The function is **idempotent** -- calling it again when the session is already /// terminated returns an error (`Err(AlreadyDead)`) so callers can decide whether /// they still need to clean up the directory (`store::purge`). /// @@ -200,7 +200,7 @@ pub async fn kill_session(id: &str) -> Result<()> { // Resolve paths and read metadata so we know the target PID. let paths = paths_for(id)?; - // Load meta.json – we need the PID written at spawn time. + // Load meta.json -- we need the PID written at spawn time. let bytes = std::fs::read(&paths.meta) .with_context(|| format!("could not read metadata for session '{id}'"))?; let meta: SessionMeta = @@ -208,7 +208,7 @@ pub async fn kill_session(id: &str) -> Result<()> { let pid_u32 = meta.pid; - // Helper – cross-platform liveness probe based on the `sysinfo` crate. + // Helper -- cross-platform liveness probe based on the `sysinfo` crate. fn is_alive(pid: u32) -> bool { use sysinfo::PidExt; use sysinfo::SystemExt; @@ -224,12 +224,12 @@ pub async fn kill_session(id: &str) -> Result<()> { if !still_running { anyhow::bail!( - "session process (PID {pid_u32}) is not running – directory cleanup still required" + "session process (PID {pid_u32}) is not running -- directory cleanup still required" ); } //--------------------------------------------------------------------- - // Step 1 – send graceful termination. + // Step 1 -- send graceful termination. //--------------------------------------------------------------------- #[cfg(unix)] @@ -265,7 +265,7 @@ pub async fn kill_session(id: &str) -> Result<()> { } //--------------------------------------------------------------------- - // Step 2 – force kill if necessary. + // Step 2 -- force kill if necessary. //--------------------------------------------------------------------- if still_running { From 2420a6a89846300032f90692fa51ebcf6ead495c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 15:57:49 -0700 Subject: [PATCH 27/44] clippy --- codex-rs/Cargo.lock | 27 +++------------------------ codex-rs/session/Cargo.toml | 2 +- codex-rs/session/build.rs | 4 ++-- codex-rs/session/src/build.rs | 2 +- codex-rs/session/src/meta.rs | 2 +- codex-rs/session/src/spawn.rs | 2 +- codex-rs/session/src/store.rs | 8 ++------ 7 files changed, 11 insertions(+), 36 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 82d398865..101219c32 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -495,7 +495,7 @@ dependencies = [ "bytes", "clap", "codex-apply-patch", - "dirs 6.0.0", + "dirs", "env-flags", "eventsource-stream", "fs-err", @@ -589,7 +589,7 @@ dependencies = [ "codex-core", "codex-exec", "codex-repl", - "dirs 5.0.1", + "dirs", "libc", "names", "nanoid", @@ -901,22 +901,13 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] @@ -929,18 +920,6 @@ dependencies = [ "dirs-sys-next", ] -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", -] - [[package]] name = "dirs-sys" version = "0.5.0" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 31b24b99d..4263376dd 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -28,7 +28,7 @@ uuid = { version = "1", features = ["v4"] } chrono = { version = "0.4", features = ["serde"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" -dirs = "5" +dirs = "6" sysinfo = "0.29" tabwriter = "1.3" names = { version = "0.14", default-features = false } diff --git a/codex-rs/session/build.rs b/codex-rs/session/build.rs index 16da93620..38e2d6222 100644 --- a/codex-rs/session/build.rs +++ b/codex-rs/session/build.rs @@ -1,8 +1,8 @@ -// build.rs – emit the current git commit so the code can embed it in the +// build.rs -- emit the current git commit so the code can embed it in the // session metadata file. fn main() { - // Try to run `git rev-parse HEAD` – if that fails we fall back to + // Try to run `git rev-parse HEAD` -- if that fails we fall back to // "unknown" so the build does not break when the source is not a git // repository (e.g., during `cargo publish`). let git_sha = std::process::Command::new("git") diff --git a/codex-rs/session/src/build.rs b/codex-rs/session/src/build.rs index edcff408d..70b1f4db3 100644 --- a/codex-rs/session/src/build.rs +++ b/codex-rs/session/src/build.rs @@ -1,4 +1,4 @@ -//! Build-time information helpers (git commit hash, version, …). +//! Build-time information helpers (git commit hash, version, ...). /// Return the git commit hash that was recorded at compile time via the /// `build.rs` build-script. Falls back to the static string "unknown" when the diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs index 4107afc30..95ae50c82 100644 --- a/codex-rs/session/src/meta.rs +++ b/codex-rs/session/src/meta.rs @@ -32,7 +32,7 @@ pub struct SessionMeta { pub kind: SessionKind, /// Raw command-line arguments that were used to spawn the agent - /// (`codex-exec …` or `codex-repl …`). + /// (`codex-exec ...` or `codex-repl ...`). pub argv: Vec, /// Short preview of the user prompt (if any). diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index 73e4275f9..cd61af0bf 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -67,7 +67,7 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { } let child = cmd.spawn().context("failed to spawn codex-exec")?; - return Ok(child); + Ok(child) } #[cfg(windows)] diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index b0f0a3732..5425b53b7 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -82,20 +82,16 @@ fn base_dir() -> Result { // in all call-sites. The enum is re-exported so other modules (e.g. the newly // added `meta` module) can still rely on the single source of truth. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] pub enum SessionKind { /// Non-interactive batch session -- `codex-exec`. + #[default] Exec, /// Line-oriented interactive session -- `codex-repl`. Repl, } -impl Default for SessionKind { - fn default() -> Self { - SessionKind::Exec - } -} /// Create the on-disk directory structure and write metadata + empty log files. /// Create directory & empty log files. Does **not** write metadata; caller should write that From 2b55e5a8f28695886d0b1b863fdab6c0ba3b4b29 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:03:43 -0700 Subject: [PATCH 28/44] remove overcomments --- codex-rs/session/src/cli.rs | 51 ++++++------------------------------- 1 file changed, 8 insertions(+), 43 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 52c9fcc5a..8e2a21bc1 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -2,8 +2,8 @@ //! //! The session manager can spawn two different Codex agent flavors: //! -//! * `codex-exec` -- non-interactive batch agent (legacy behaviour) -//! * `codex-repl` -- interactive REPL that requires user input after launch +//! * `codex-exec` -- non-interactive single turn agent +//! * `codex-repl` -- basic stdin/out REPL that can request user input after launch //! //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. @@ -19,13 +19,11 @@ use clap::Parser; use clap::Subcommand; use nanoid::nanoid; -// ----------------------------------------------------------------------------- // Platform-specific imports #[cfg(unix)] -use codex_repl as _; // Ensures the dependency is only required on Unix. -#[allow(unused_imports)] -use serde::Serialize; // still needed for table print rows in tests +use codex_repl as _; +use serde::Serialize; /// A human-friendly representation of a byte count (e.g. 1.4M). pub fn human_bytes(b: u64) -> String { @@ -44,9 +42,6 @@ pub fn human_bytes(b: u64) -> String { } } -// ----------------------------------------------------------------------------- -// Top-level CLI definition - #[derive(Parser)] #[command( name = "codex-session", @@ -81,12 +76,8 @@ enum Commands { Logs(LogsCmd), /// List all known sessions. List(ListCmd), - // (previous mux variant removed) } -// ----------------------------------------------------------------------------- -// create - #[derive(Subcommand)] enum AgentKind { /// Non-interactive execution agent. @@ -128,23 +119,19 @@ impl CreateCmd { }; let paths = store::paths_for(&id)?; - // ----------------------------------------------------------------- + // Prepare session directory *before* spawning the agent so stdout/ // stderr redirection works even when the child process itself fails // immediately. - // ----------------------------------------------------------------- - store::prepare_dirs(&paths)?; - // ----------------------------------------------------------------- // Spawn underlying agent. // // IMPORTANT: If the spawn call fails we end up with an empty (or // almost empty) directory inside ~/.codex/sessions/. To avoid // confusing stale entries we attempt to purge the directory before // bubbling up the error to the caller. - // ----------------------------------------------------------------- - + // // Capture the child PID *and* the full CLI config so we can persist it // in the metadata file. let spawn_result: Result<( @@ -192,7 +179,6 @@ impl CreateCmd { } }; - // Persist metadata **after** the process has been spawned so we can record its PID. // Persist metadata **after** the process has been spawned so we can record its PID. let meta = SessionMeta::new(id.clone(), pid, kind, argv, prompt_preview); @@ -203,8 +189,6 @@ impl CreateCmd { } } -// (mux helper removed) - fn truncate_preview(p: &str) -> String { let slice: String = p.chars().take(40).collect(); if p.len() > 40 { @@ -255,8 +239,6 @@ fn build_exec_args(cli: &codex_exec::Cli) -> Vec { fn build_repl_args(cli: &codex_repl::Cli) -> Vec { let mut args = Vec::new(); - // Positional prompt argument (optional) -- needs to be *last* so push it later. - if let Some(model) = &cli.model { args.push("--model".into()); args.push(model.clone()); @@ -322,14 +304,6 @@ fn build_repl_args(cli: &codex_repl::Cli) -> Vec { args } -// Build argument vector for spawning `codex-tui`. -// For the first implementation we forward only a minimal subset of options that -// are already handled in the REPL helper above. Future work can extend this -// with the full flag surface. - -// ----------------------------------------------------------------------------- -// attach - #[derive(Args)] pub struct AttachCmd { /// Session selector (index, id or prefix) to attach to. @@ -348,8 +322,6 @@ impl AttachCmd { self.attach_line_oriented(&id, &paths).await } - // ------------------------------------------------------------------ - // Original FIFO based attach (exec / repl) async fn attach_line_oriented(&self, id: &str, paths: &store::Paths) -> Result<()> { use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; @@ -368,14 +340,8 @@ impl AttachCmd { .await .with_context(|| format!("failed to open stdin pipe for session '{id}'"))?; - // ------------------------------------------------------------------ // Log tailing setup // - // The original implementation always tailed *stdout* only. Honour the - // `--stderr` flag so users can observe an interactive agent’s error - // stream as well. When the flag is **not** supplied we keep the - // previous behaviour for backwards-compatibility. - // Always open stdout so the select! branches below stay simple. let file_out = tokio::fs::File::open(&paths.stdout).await?; let mut reader_out = tokio::io::BufReader::new(file_out).lines(); @@ -394,8 +360,7 @@ impl AttachCmd { loop { tokio::select! { - // ------------------------------------------------------------------ - // User supplied input (stdin → session stdin pipe) + // User supplied input (stdin -> session stdin pipe) line = stdin_lines.next_line() => { match line? { Some(mut l) => { @@ -403,8 +368,8 @@ impl AttachCmd { pipe.write_all(l.as_bytes()).await?; pipe.flush().await?; } + // Ctrl-D -- end of interactive input None => { - // Ctrl-D -- end of interactive input break; } } From 56e609d481c0e8f52eafcf09c01c5ca05f27d29f Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:19:15 -0700 Subject: [PATCH 29/44] cleanup on clap args --- codex-rs/session/src/cli.rs | 42 +++++++++++++++++-------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 8e2a21bc1..526515d61 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -80,12 +80,19 @@ enum Commands { #[derive(Subcommand)] enum AgentKind { - /// Non-interactive execution agent. - Exec(ExecCreateCmd), + /// Non-interactive execution agent (`codex-exec`). + Exec(codex_exec::Cli), - /// Interactive Read-Eval-Print-Loop agent. + /// Line-oriented interactive agent (`codex-repl`). #[cfg(unix)] - Repl(ReplCreateCmd), + Repl(codex_repl::Cli), + + // On non-Unix targets we still include a private variant so the enum shape + // remains identical - callers don’t need `cfg` on their match arms. + #[cfg(not(unix))] + #[allow(dead_code)] + #[clap(skip)] + Repl, } #[derive(Args)] @@ -98,19 +105,6 @@ pub struct CreateCmd { agent: AgentKind, } -#[derive(Args)] -pub struct ExecCreateCmd { - #[clap(flatten)] - exec_cli: codex_exec::Cli, -} - -#[cfg(unix)] -#[derive(Args)] -pub struct ReplCreateCmd { - #[clap(flatten)] - repl_cli: codex_repl::Cli, -} - impl CreateCmd { pub async fn run(self) -> Result<()> { let id = match &self.id { @@ -140,11 +134,11 @@ impl CreateCmd { store::SessionKind, // kind Vec, // raw argv used to spawn the agent )> = (|| match self.agent { - AgentKind::Exec(cmd) => { - let args = build_exec_args(&cmd.exec_cli); + AgentKind::Exec(ref cli) => { + let args = build_exec_args(cli); let child = spawn::spawn_exec(&paths, &args)?; - let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); + let preview = cli.prompt.as_ref().map(|p| truncate_preview(p)); Ok(( child.id().unwrap_or_default(), @@ -154,11 +148,11 @@ impl CreateCmd { )) } #[cfg(unix)] - AgentKind::Repl(cmd) => { - let args = build_repl_args(&cmd.repl_cli); + AgentKind::Repl(ref cli) => { + let args = build_repl_args(cli); let child = spawn::spawn_repl(&paths, &args)?; - let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); + let preview = cli.prompt.as_ref().map(|p| truncate_preview(p)); Ok(( child.id().unwrap_or_default(), @@ -167,6 +161,8 @@ impl CreateCmd { args.clone(), )) } + #[cfg(not(unix))] + AgentKind::Repl => unreachable!(), })(); let (pid, prompt_preview, kind, argv) = match spawn_result { From a4197ec97ab9f20300f0c8a386329edbbc7df7c8 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:25:02 -0700 Subject: [PATCH 30/44] truncate --- codex-rs/session/src/cli.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 526515d61..490ddf8e1 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -185,10 +185,22 @@ impl CreateCmd { } } +/// Sanitize a prompt snippet so it is safe to embed in `meta.json`. Control +/// characters and new-lines are removed; the resulting string is truncated to +/// at most 40 visible code-points so extremely long prompts do not blow up the +/// listing output. fn truncate_preview(p: &str) -> String { - let slice: String = p.chars().take(40).collect(); - if p.len() > 40 { - format!("{}...", slice) + // 1. Remove anything that is not printable (ASCII control chars, newlines + // etc.). + let cleaned: String = p + .chars() + .filter(|c| !c.is_control()) + .collect(); + + // 2. Truncate to 40 code-points. + let slice: String = cleaned.chars().take(40).collect(); + if cleaned.chars().count() > 40 { + format!("{slice}…") } else { slice } From ee51ffc130f46e046768ee751b63b185b9370ff5 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:25:17 -0700 Subject: [PATCH 31/44] fmt --- codex-rs/session/src/cli.rs | 5 +---- codex-rs/session/src/store.rs | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 490ddf8e1..338f83032 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -192,10 +192,7 @@ impl CreateCmd { fn truncate_preview(p: &str) -> String { // 1. Remove anything that is not printable (ASCII control chars, newlines // etc.). - let cleaned: String = p - .chars() - .filter(|c| !c.is_control()) - .collect(); + let cleaned: String = p.chars().filter(|c| !c.is_control()).collect(); // 2. Truncate to 40 code-points. let slice: String = cleaned.chars().take(40).collect(); diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index 5425b53b7..c2b71c1f2 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -92,7 +92,6 @@ pub enum SessionKind { Repl, } - /// Create the on-disk directory structure and write metadata + empty log files. /// Create directory & empty log files. Does **not** write metadata; caller should write that /// once the child process has actually been spawned so we can record its PID. From 026990fcc05a192125a3ef7dc51e4e04328ed4ec Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:31:47 -0700 Subject: [PATCH 32/44] fmt --- codex-rs/session/src/cli.rs | 70 ++++++++++++++----------------------- 1 file changed, 26 insertions(+), 44 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 338f83032..1909395aa 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -80,19 +80,12 @@ enum Commands { #[derive(Subcommand)] enum AgentKind { - /// Non-interactive execution agent (`codex-exec`). - Exec(codex_exec::Cli), + /// Non-interactive execution agent. + Exec(ExecCreateCmd), - /// Line-oriented interactive agent (`codex-repl`). + /// Interactive Read-Eval-Print-Loop agent. #[cfg(unix)] - Repl(codex_repl::Cli), - - // On non-Unix targets we still include a private variant so the enum shape - // remains identical - callers don’t need `cfg` on their match arms. - #[cfg(not(unix))] - #[allow(dead_code)] - #[clap(skip)] - Repl, + Repl(ReplCreateCmd), } #[derive(Args)] @@ -105,6 +98,19 @@ pub struct CreateCmd { agent: AgentKind, } +#[derive(Args)] +pub struct ExecCreateCmd { + #[clap(flatten)] + exec_cli: codex_exec::Cli, +} + +#[cfg(unix)] +#[derive(Args)] +pub struct ReplCreateCmd { + #[clap(flatten)] + repl_cli: codex_repl::Cli, +} + impl CreateCmd { pub async fn run(self) -> Result<()> { let id = match &self.id { @@ -134,11 +140,11 @@ impl CreateCmd { store::SessionKind, // kind Vec, // raw argv used to spawn the agent )> = (|| match self.agent { - AgentKind::Exec(ref cli) => { - let args = build_exec_args(cli); + AgentKind::Exec(cmd) => { + let args = build_exec_args(&cmd.exec_cli); let child = spawn::spawn_exec(&paths, &args)?; - let preview = cli.prompt.as_ref().map(|p| truncate_preview(p)); + let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); Ok(( child.id().unwrap_or_default(), @@ -148,11 +154,11 @@ impl CreateCmd { )) } #[cfg(unix)] - AgentKind::Repl(ref cli) => { - let args = build_repl_args(cli); + AgentKind::Repl(cmd) => { + let args = build_repl_args(&cmd.repl_cli); let child = spawn::spawn_repl(&paths, &args)?; - let preview = cli.prompt.as_ref().map(|p| truncate_preview(p)); + let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); Ok(( child.id().unwrap_or_default(), @@ -161,8 +167,6 @@ impl CreateCmd { args.clone(), )) } - #[cfg(not(unix))] - AgentKind::Repl => unreachable!(), })(); let (pid, prompt_preview, kind, argv) = match spawn_result { @@ -185,19 +189,10 @@ impl CreateCmd { } } -/// Sanitize a prompt snippet so it is safe to embed in `meta.json`. Control -/// characters and new-lines are removed; the resulting string is truncated to -/// at most 40 visible code-points so extremely long prompts do not blow up the -/// listing output. fn truncate_preview(p: &str) -> String { - // 1. Remove anything that is not printable (ASCII control chars, newlines - // etc.). - let cleaned: String = p.chars().filter(|c| !c.is_control()).collect(); - - // 2. Truncate to 40 code-points. - let slice: String = cleaned.chars().take(40).collect(); - if cleaned.chars().count() > 40 { - format!("{slice}…") + let slice: String = p.chars().take(40).collect(); + if p.len() > 40 { + format!("{}...", slice) } else { slice } @@ -380,7 +375,6 @@ impl AttachCmd { } } - // ------------------------------------------------------------------ // stdout updates out_line = reader_out.next_line() => { match out_line? { @@ -389,7 +383,6 @@ impl AttachCmd { } } - // ------------------------------------------------------------------ // stderr updates (optional) // // To keep `tokio::select!` happy we always supply a branch -- when the @@ -416,13 +409,8 @@ impl AttachCmd { Ok(()) } - - // (TUI attach removed) } -// ----------------------------------------------------------------------------- -// delete - #[derive(Args)] pub struct DeleteCmd { id: String, @@ -437,9 +425,6 @@ impl DeleteCmd { } } -// ----------------------------------------------------------------------------- -// logs - #[derive(Args)] pub struct LogsCmd { id: String, @@ -471,9 +456,6 @@ impl LogsCmd { } } -// ----------------------------------------------------------------------------- -// list -- newest-first overview of all sessions - #[derive(Args)] pub struct ListCmd {} From 4d26c773b91c619c9385a07bf03b960ee4c4f3aa Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sat, 26 Apr 2025 16:34:03 -0700 Subject: [PATCH 33/44] drop ascii art --- codex-rs/session/src/spawn.rs | 9 --------- codex-rs/session/src/store.rs | 4 ---- 2 files changed, 13 deletions(-) diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index cd61af0bf..d74bcc63d 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -7,9 +7,6 @@ use std::fs::OpenOptions; use tokio::process::Child; use tokio::process::Command; -// ----------------------------------------------------------------------------- -// Internal helpers - /// Open (and create if necessary) the log files that stdout / stderr of the /// spawned agent will be redirected to. fn open_log_files(paths: &Paths) -> Result<(std::fs::File, std::fs::File)> { @@ -39,9 +36,6 @@ fn base_command(bin: &str, paths: &Paths) -> Result { Ok(cmd) } -// ----------------------------------------------------------------------------- -// exec -- non-interactive batch agent - pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { @@ -86,9 +80,6 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { } } -// ----------------------------------------------------------------------------- -// repl -- interactive FIFO stdin - pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] { diff --git a/codex-rs/session/src/store.rs b/codex-rs/session/src/store.rs index c2b71c1f2..7ff62bbbd 100644 --- a/codex-rs/session/src/store.rs +++ b/codex-rs/session/src/store.rs @@ -223,9 +223,7 @@ pub async fn kill_session(id: &str) -> Result<()> { ); } - //--------------------------------------------------------------------- // Step 1 -- send graceful termination. - //--------------------------------------------------------------------- #[cfg(unix)] { @@ -259,9 +257,7 @@ pub async fn kill_session(id: &str) -> Result<()> { tokio::time::sleep(poll_interval).await; } - //--------------------------------------------------------------------- // Step 2 -- force kill if necessary. - //--------------------------------------------------------------------- if still_running { #[cfg(unix)] From 8c672d54429f0e1eff6e7f738dbfdae348259e3c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 07:49:59 -0700 Subject: [PATCH 34/44] cuter job names --- codex-rs/Cargo.lock | 26 ++++++++++++++++---------- codex-rs/session/Cargo.toml | 3 ++- codex-rs/session/src/cli.rs | 13 +++++++++++-- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 101219c32..dbcdd7488 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -592,8 +592,9 @@ dependencies = [ "dirs", "libc", "names", - "nanoid", "nix 0.27.1", + "petname", + "rand 0.9.1", "serde", "serde_json", "sysinfo", @@ -2085,15 +2086,6 @@ dependencies = [ "rand 0.8.5", ] -[[package]] -name = "nanoid" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" -dependencies = [ - "rand 0.8.5", -] - [[package]] name = "native-tls" version = "0.2.14" @@ -2412,6 +2404,20 @@ dependencies = [ "indexmap 2.9.0", ] +[[package]] +name = "petname" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd31dcfdbbd7431a807ef4df6edd6473228e94d5c805e8cf671227a21bad068" +dependencies = [ + "anyhow", + "clap", + "itertools 0.13.0", + "proc-macro2", + "quote", + "rand 0.8.5", +] + [[package]] name = "phf_shared" version = "0.11.3" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 4263376dd..9b8d29237 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -33,7 +33,8 @@ sysinfo = "0.29" tabwriter = "1.3" names = { version = "0.14", default-features = false } nix = { version = "0.27", default-features = false, features = ["process", "signal", "term", "fs"] } -nanoid = "0.4.0" +petname = "2.0.2" +rand = "0.9.1" # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 1909395aa..41ac7d2de 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -17,12 +17,13 @@ use chrono::SecondsFormat; use clap::Args; use clap::Parser; use clap::Subcommand; -use nanoid::nanoid; // Platform-specific imports #[cfg(unix)] use codex_repl as _; +use petname::Generator; +use petname::Petnames; use serde::Serialize; /// A human-friendly representation of a byte count (e.g. 1.4M). @@ -198,9 +199,17 @@ fn truncate_preview(p: &str) -> String { } } +/// Generate a new unique session identifier. +/// +/// We use the `petname` crate to create short, memorable names consisting of +/// two random words separated by a dash (e.g. "autumn-panda"). In the rare +/// event of a collision with an existing session directory we retry until we +/// find an unused ID. fn generate_session_id() -> Result { + let mut shortnames = Petnames::default(); + shortnames.retain(|s| s.len() <= 5); loop { - let id = nanoid!(8); + let id = shortnames.generate_one(2, "-").context("failed to generate session ID")?; if !store::paths_for(&id)?.dir.exists() { return Ok(id); } From 359a09cd8d6f94ead079bbd1ef296519029839d6 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 07:50:12 -0700 Subject: [PATCH 35/44] fmt --- codex-rs/session/src/cli.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 41ac7d2de..5bb66ff65 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -209,7 +209,9 @@ fn generate_session_id() -> Result { let mut shortnames = Petnames::default(); shortnames.retain(|s| s.len() <= 5); loop { - let id = shortnames.generate_one(2, "-").context("failed to generate session ID")?; + let id = shortnames + .generate_one(2, "-") + .context("failed to generate session ID")?; if !store::paths_for(&id)?.dir.exists() { return Ok(id); } From e96055be36d7b8847af9b4258a65590949c15c31 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 08:11:56 -0700 Subject: [PATCH 36/44] move to_args closer to args --- codex-rs/exec/src/cli.rs | 32 +++++++++++ codex-rs/repl/src/cli.rs | 71 ++++++++++++++++++++++++ codex-rs/session/src/cli.rs | 105 ++---------------------------------- 3 files changed, 107 insertions(+), 101 deletions(-) diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 12be13480..e80b05248 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -25,3 +25,35 @@ pub struct Cli { /// Initial instructions for the agent. pub prompt: Option, } + +impl Cli { + /// This is effectively the opposite of Clap; we want the ability to take + /// a structured `Cli` object, and then pass it to a binary as argv[]. + pub fn to_args(&self) -> Vec { + let mut args = Vec::new(); + + for img in &self.images { + args.push("--image".into()); + args.push(img.to_string_lossy().into_owned()); + } + + if let Some(model) = &self.model { + args.push("--model".into()); + args.push(model.clone()); + } + + if self.skip_git_repo_check { + args.push("--skip-git-repo-check".into()); + } + + if self.disable_response_storage { + args.push("--disable-response-storage".into()); + } + + if let Some(prompt) = &self.prompt { + args.push(prompt.clone()); + } + + args + } +} diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index cb7749c15..795a5efba 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -1,5 +1,6 @@ use clap::ArgAction; use clap::Parser; +use clap::ValueEnum; use codex_core::ApprovalModeCliArg; use codex_core::SandboxModeCliArg; use std::path::PathBuf; @@ -63,3 +64,73 @@ pub struct Cli { #[arg(short = 'E', long)] pub record_events: Option, } + +impl Cli { + /// This is effectively the opposite of Clap; we want the ability to take + /// a structured `Cli` object, and then pass it to a binary as argv[]. + pub fn to_args(&self) -> Vec { + let mut args = vec![]; + + if let Some(model) = &self.model { + args.push("--model".into()); + args.push(model.clone()); + } + + for img in &self.images { + args.push("--image".into()); + args.push(img.to_string_lossy().into_owned()); + } + + if self.no_ansi { + args.push("--no-ansi".into()); + } + + // Verbose flag is additive (-v -vv ...). + for _ in 0..self.verbose { + args.push("-v".into()); + } + + // Approval + sandbox policies + args.push("--ask-for-approval".into()); + args.push( + self.approval_policy + .to_possible_value() + .expect("foo") + .get_name() + .to_string(), + ); + + args.push("--sandbox".into()); + args.push( + self.sandbox_policy + .to_possible_value() + .expect("foo") + .get_name() + .to_string(), + ); + + if self.allow_no_git_exec { + args.push("--allow-no-git-exec".into()); + } + + if self.disable_response_storage { + args.push("--disable-response-storage".into()); + } + + if let Some(path) = &self.record_submissions { + args.push("--record-submissions".into()); + args.push(path.to_string_lossy().into_owned()); + } + + if let Some(path) = &self.record_events { + args.push("--record-events".into()); + args.push(path.to_string_lossy().into_owned()); + } + + // Finally positional prompt argument. + if let Some(prompt) = &self.prompt { + args.push(prompt.clone()); + } + args + } +} diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 5bb66ff65..94f50b92f 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -2,8 +2,8 @@ //! //! The session manager can spawn two different Codex agent flavors: //! -//! * `codex-exec` -- non-interactive single turn agent -//! * `codex-repl` -- basic stdin/out REPL that can request user input after launch +//! * `codex-exec` -- non-interactive single-turn agent +//! * `codex-repl` -- interactive multi-turn agent //! //! The `create` command therefore has mutually exclusive sub-commands so the appropriate //! arguments can be forwarded to the underlying agent binaries. @@ -142,7 +142,7 @@ impl CreateCmd { Vec, // raw argv used to spawn the agent )> = (|| match self.agent { AgentKind::Exec(cmd) => { - let args = build_exec_args(&cmd.exec_cli); + let args = cmd.exec_cli.to_args(); let child = spawn::spawn_exec(&paths, &args)?; let preview = cmd.exec_cli.prompt.as_ref().map(|p| truncate_preview(p)); @@ -156,7 +156,7 @@ impl CreateCmd { } #[cfg(unix)] AgentKind::Repl(cmd) => { - let args = build_repl_args(&cmd.repl_cli); + let args = cmd.repl_cli.to_args(); let child = spawn::spawn_repl(&paths, &args)?; let preview = cmd.repl_cli.prompt.as_ref().map(|p| truncate_preview(p)); @@ -218,103 +218,6 @@ fn generate_session_id() -> Result { } } -fn build_exec_args(cli: &codex_exec::Cli) -> Vec { - let mut args = Vec::new(); - - for img in &cli.images { - args.push("--image".into()); - args.push(img.to_string_lossy().into_owned()); - } - - if let Some(model) = &cli.model { - args.push("--model".into()); - args.push(model.clone()); - } - - if cli.skip_git_repo_check { - args.push("--skip-git-repo-check".into()); - } - - if cli.disable_response_storage { - args.push("--disable-response-storage".into()); - } - - if let Some(prompt) = &cli.prompt { - args.push(prompt.clone()); - } - - args -} - -#[cfg(unix)] -fn build_repl_args(cli: &codex_repl::Cli) -> Vec { - let mut args = Vec::new(); - - if let Some(model) = &cli.model { - args.push("--model".into()); - args.push(model.clone()); - } - - for img in &cli.images { - args.push("--image".into()); - args.push(img.to_string_lossy().into_owned()); - } - - if cli.no_ansi { - args.push("--no-ansi".into()); - } - - // Verbose flag is additive (-v -vv ...). - for _ in 0..cli.verbose { - args.push("-v".into()); - } - - // Approval + sandbox policies - args.push("--ask-for-approval".into()); - args.push(match cli.approval_policy { - codex_core::ApprovalModeCliArg::OnFailure => "on-failure".into(), - codex_core::ApprovalModeCliArg::UnlessAllowListed => "unless-allow-listed".into(), - codex_core::ApprovalModeCliArg::Never => "never".into(), - }); - - args.push("--sandbox".into()); - args.push(match cli.sandbox_policy { - codex_core::SandboxModeCliArg::NetworkRestricted => "network-restricted".into(), - codex_core::SandboxModeCliArg::FileWriteRestricted => "file-write-restricted".into(), - codex_core::SandboxModeCliArg::NetworkAndFileWriteRestricted => { - "network-and-file-write-restricted".into() - } - codex_core::SandboxModeCliArg::DangerousNoRestrictions => { - "dangerous-no-restrictions".into() - } - }); - - if cli.allow_no_git_exec { - args.push("--allow-no-git-exec".into()); - } - - if cli.disable_response_storage { - args.push("--disable-response-storage".into()); - } - - if let Some(path) = &cli.record_submissions { - args.push("--record-submissions".into()); - args.push(path.to_string_lossy().into_owned()); - } - - if let Some(path) = &cli.record_events { - args.push("--record-events".into()); - args.push(path.to_string_lossy().into_owned()); - } - - // Finally positional prompt argument. - if let Some(prompt) = &cli.prompt { - args.push(prompt.clone()); - } - - args -} - #[derive(Args)] pub struct AttachCmd { /// Session selector (index, id or prefix) to attach to. From 66a2e970f75e0fc56e2d97a2d6d7c57f29866c0c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 08:35:21 -0700 Subject: [PATCH 37/44] humansize --- codex-rs/Cargo.lock | 16 ++++++++++++++++ codex-rs/session/Cargo.toml | 1 + codex-rs/session/src/cli.rs | 27 +++++---------------------- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dbcdd7488..e847e7f58 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -590,6 +590,7 @@ dependencies = [ "codex-exec", "codex-repl", "dirs", + "humansize", "libc", "names", "nix 0.27.1", @@ -1472,6 +1473,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "hyper" version = "1.6.0" @@ -1906,6 +1916,12 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libm" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72" + [[package]] name = "libredox" version = "0.1.3" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 9b8d29237..3ac65b386 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -39,6 +39,7 @@ rand = "0.9.1" # Re-use the codex-exec library for its CLI definition codex_exec = { package = "codex-exec", path = "../exec" } codex_repl = { package = "codex-repl", path = "../repl" } +humansize = "2.1.3" [dev-dependencies] tempfile = "3" diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 94f50b92f..da7a5ecdb 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -17,31 +17,12 @@ use chrono::SecondsFormat; use clap::Args; use clap::Parser; use clap::Subcommand; - -// Platform-specific imports - -#[cfg(unix)] -use codex_repl as _; use petname::Generator; use petname::Petnames; use serde::Serialize; -/// A human-friendly representation of a byte count (e.g. 1.4M). -pub fn human_bytes(b: u64) -> String { - const KB: f64 = 1024.0; - const MB: f64 = KB * 1024.0; - const GB: f64 = MB * 1024.0; - let f = b as f64; - if f >= GB { - format!("{:.1}G", f / GB) - } else if f >= MB { - format!("{:.1}M", f / MB) - } else if f >= KB { - format!("{:.1}K", f / KB) - } else { - format!("{}B", b) - } -} +#[cfg(unix)] +use codex_repl as _; #[derive(Parser)] #[command( @@ -397,6 +378,8 @@ impl ListCmd { let mut sys = sysinfo::System::new(); sys.refresh_processes(); + let bytes_formatter = humansize::make_format(humansize::DECIMAL); + let rows: Vec = metas .into_iter() .enumerate() @@ -413,7 +396,7 @@ impl ListCmd { let (out, err) = if let Some(p) = &paths { let osz = std::fs::metadata(&p.stdout).map(|m| m.len()).unwrap_or(0); let esz = std::fs::metadata(&p.stderr).map(|m| m.len()).unwrap_or(0); - (human_bytes(osz), human_bytes(esz)) + (bytes_formatter(osz), bytes_formatter(esz)) } else { ("-".into(), "-".into()) }; From aef7f2530286e3be3a2c66e8442e4d9af4237555 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 08:43:44 -0700 Subject: [PATCH 38/44] cleanup --- codex-rs/Cargo.lock | 1 - codex-rs/repl/Cargo.toml | 5 +---- codex-rs/repl/src/cli.rs | 3 --- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e847e7f58..22e7eb597 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -573,7 +573,6 @@ dependencies = [ "codex-core", "owo-colors 4.2.0", "rand 0.9.1", - "serde", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/repl/Cargo.toml b/codex-rs/repl/Cargo.toml index 010295ce5..c3f5054b1 100644 --- a/codex-rs/repl/Cargo.toml +++ b/codex-rs/repl/Cargo.toml @@ -25,7 +25,4 @@ tokio = { version = "1", features = [ "signal", ] } tracing = { version = "0.1.41", features = ["log"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } - -# For serialising the `Cli` struct into the on-disk session metadata. -serde = { version = "1.0", features = ["derive"] } +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } \ No newline at end of file diff --git a/codex-rs/repl/src/cli.rs b/codex-rs/repl/src/cli.rs index 795a5efba..e945f0c67 100644 --- a/codex-rs/repl/src/cli.rs +++ b/codex-rs/repl/src/cli.rs @@ -85,12 +85,10 @@ impl Cli { args.push("--no-ansi".into()); } - // Verbose flag is additive (-v -vv ...). for _ in 0..self.verbose { args.push("-v".into()); } - // Approval + sandbox policies args.push("--ask-for-approval".into()); args.push( self.approval_policy @@ -127,7 +125,6 @@ impl Cli { args.push(path.to_string_lossy().into_owned()); } - // Finally positional prompt argument. if let Some(prompt) = &self.prompt { args.push(prompt.clone()); } From 0fbe5f20697e7645ec8d90e19326374e51942ab1 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 08:46:45 -0700 Subject: [PATCH 39/44] cleanup --- codex-rs/session/src/lib.rs | 8 ++++---- codex-rs/session/src/meta.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index 027da4aed..6ebacae0b 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -6,10 +6,10 @@ //! the binary can stay small and unit tests can call into pure Rust APIs. pub mod build; -pub mod cli; // public so main.rs can access it. -pub mod meta; // richer on-disk metadata envelope -mod spawn; // process creation helpers -pub mod store; // on-disk bookkeeping (public for tests) // build-time information helpers +pub mod cli; +pub mod meta; +mod spawn; +pub mod store; pub use cli::Cli; diff --git a/codex-rs/session/src/meta.rs b/codex-rs/session/src/meta.rs index 95ae50c82..1522b543c 100644 --- a/codex-rs/session/src/meta.rs +++ b/codex-rs/session/src/meta.rs @@ -17,7 +17,7 @@ use crate::store::SessionKind; /// JSON envelope version. Bump when the structure changes in a /// backwards-incompatible way. -pub const CURRENT_VERSION: u8 = 2; +pub const CURRENT_VERSION: u8 = 1; /// Persisted session metadata. #[derive(Debug, Serialize, Deserialize)] From b344757fb0ccff41d2ac5b34c7719f1371802307 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 09:00:37 -0700 Subject: [PATCH 40/44] first pass at get --- codex-rs/session/src/cli.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index da7a5ecdb..18c6f0d6c 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -42,6 +42,7 @@ impl Cli { Commands::Delete(x) => x.run().await, Commands::Logs(x) => x.run().await, Commands::List(x) => x.run().await, + Commands::Get(x) => x.run().await, } } } @@ -58,6 +59,9 @@ enum Commands { Logs(LogsCmd), /// List all known sessions. List(ListCmd), + + /// Print the raw metadata JSON for a session. + Get(GetCmd), } #[derive(Subcommand)] @@ -354,6 +358,39 @@ impl LogsCmd { #[derive(Args)] pub struct ListCmd {} +// ----------------------------------------------------------------------------- +// get – print metadata +// ----------------------------------------------------------------------------- + +#[derive(Args)] +pub struct GetCmd { + /// Session selector (index, id or prefix) to print metadata for. + id: String, +} + +impl GetCmd { + pub async fn run(self) -> Result<()> { + // Re-use the same selector resolution that `attach`, `delete`, … use so users can refer + // to sessions by index or prefix. + let id = store::resolve_selector(&self.id)?; + let paths = store::paths_for(&id)?; + + let bytes = std::fs::read(&paths.meta) + .with_context(|| format!("failed to read metadata for session '{id}'"))?; + + // We *could* just write the file contents as-is but parsing + re-serialising guarantees + // the output is valid and nicely formatted even when the on-disk representation ever + // switches away from pretty-printed JSON. + let meta: SessionMeta = serde_json::from_slice(&bytes) + .context("failed to deserialize session metadata")?; + + let pretty = serde_json::to_string_pretty(&meta)?; + println!("{pretty}"); + + Ok(()) + } +} + #[derive(Serialize)] #[allow(missing_docs)] pub struct StatusRow { From ffe7e2277fa6c9dbeabea1599305aa47fa319978 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 12:42:14 -0700 Subject: [PATCH 41/44] artial, broken --- codex-rs/Cargo.lock | 15 +++++- codex-rs/session/Cargo.toml | 3 +- codex-rs/session/src/lib.rs | 1 + codex-rs/session/src/sig.rs | 29 ++++++++++++ codex-rs/session/src/spawn.rs | 86 +++++++++++++++++++++++------------ 5 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 codex-rs/session/src/sig.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 22e7eb597..9572b8afe 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -588,11 +588,12 @@ dependencies = [ "codex-core", "codex-exec", "codex-repl", + "command-group", "dirs", "humansize", "libc", "names", - "nix 0.27.1", + "nix 0.28.0", "petname", "rand 0.9.1", "serde", @@ -660,6 +661,18 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "command-group" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68fa787550392a9d58f44c21a3022cfb3ea3e2458b7f85d3b399d0ceeccf409" +dependencies = [ + "async-trait", + "nix 0.27.1", + "tokio", + "winapi", +] + [[package]] name = "compact_str" version = "0.8.1" diff --git a/codex-rs/session/Cargo.toml b/codex-rs/session/Cargo.toml index 3ac65b386..4a4c279c7 100644 --- a/codex-rs/session/Cargo.toml +++ b/codex-rs/session/Cargo.toml @@ -32,7 +32,7 @@ dirs = "6" sysinfo = "0.29" tabwriter = "1.3" names = { version = "0.14", default-features = false } -nix = { version = "0.27", default-features = false, features = ["process", "signal", "term", "fs"] } +nix = { version = "0.28", default-features = false, features = ["process", "signal", "term", "fs"] } petname = "2.0.2" rand = "0.9.1" @@ -40,6 +40,7 @@ rand = "0.9.1" codex_exec = { package = "codex-exec", path = "../exec" } codex_repl = { package = "codex-repl", path = "../repl" } humansize = "2.1.3" +command-group = { version = "5.0.1", features = ["with-tokio"] } [dev-dependencies] tempfile = "3" diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index 6ebacae0b..bd2ddf9d3 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -9,6 +9,7 @@ pub mod build; pub mod cli; pub mod meta; mod spawn; +mod sig; pub mod store; pub use cli::Cli; diff --git a/codex-rs/session/src/sig.rs b/codex-rs/session/src/sig.rs new file mode 100644 index 000000000..21a27418d --- /dev/null +++ b/codex-rs/session/src/sig.rs @@ -0,0 +1,29 @@ +//! Small safe wrappers around a handful of `nix::sys::signal` calls that are +//! considered `unsafe` by the `nix` crate. By concentrating the `unsafe` blocks +//! in a single, well-audited module we can keep the rest of the codebase — and +//! in particular `spawn.rs` — entirely `unsafe`-free. + +#[cfg(unix)] +use nix::sys::signal::{signal as nix_signal, SigHandler, Signal}; + +/// Safely ignore `SIGHUP` for the current process. +/// +/// Internally this delegates to `nix::sys::signal::signal(…, SigIgn)` which is +/// marked *unsafe* because changing signal handlers can break invariants in +/// foreign code. In our very controlled environment we *only* ever install the +/// predefined, always-safe `SIG_IGN` handler, which is guaranteed not to cause +/// undefined behaviour. Therefore it is sound to wrap the call in `unsafe` and +/// expose it as a safe function. +#[cfg(unix)] +pub fn ignore_sighup() -> nix::Result<()> { + // SAFETY: Installing the built-in `SIG_IGN` handler is always safe. + unsafe { nix_signal(Signal::SIGHUP, SigHandler::SigIgn) }.map(|_| ()) +} + +#[cfg(not(unix))] +#[allow(clippy::unused_io_amount)] +pub fn ignore_sighup() -> std::io::Result<()> { + // No-op on non-Unix platforms. + Ok(()) +} + diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index d74bcc63d..b65f2d241 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -7,6 +7,22 @@ use std::fs::OpenOptions; use tokio::process::Child; use tokio::process::Command; +// ------------------------------------------------------------------------- +// Additional (Unix-only) imports to replace the former unsafe `libc` calls. +// These are guarded by `cfg(unix)` so Windows builds are completely unaffected. +// ------------------------------------------------------------------------- +#[cfg(unix)] +use command_group::AsyncCommandGroup; // provides `group_spawn` for tokio::process::Command + +#[cfg(unix)] +use nix::{ + errno::Errno, + sys::{ + stat::Mode, + }, + unistd::mkfifo, +}; + /// Open (and create if necessary) the log files that stdout / stderr of the /// spawned agent will be redirected to. fn open_log_files(paths: &Paths) -> Result<(std::fs::File, std::fs::File)> { @@ -39,28 +55,36 @@ fn base_command(bin: &str, paths: &Paths) -> Result { pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { - use std::io; + // ----------------------------------------------------------------- + // UNIX IMPLEMENTATION (now 100 % safe) + // ----------------------------------------------------------------- + // Build the base command and add the user-supplied arguments. let mut cmd = base_command("codex-exec", paths)?; cmd.args(exec_args); // Replace the `stdin` that `base_command` configured (null) with - // `/dev/null` opened for reading -- keeps the previous behaviour while + // `/dev/null` opened for reading – keeps the previous behaviour while // still leveraging the common helper. let stdin = OpenOptions::new().read(true).open("/dev/null")?; cmd.stdin(stdin); - unsafe { - cmd.pre_exec(|| { - if libc::setsid() == -1 { - return Err(io::Error::last_os_error()); - } - libc::signal(libc::SIGHUP, libc::SIG_IGN); - Ok(()) - }); - } + // Spawn the child as a *process group* / new session leader. + // `group_spawn()` internally performs the traditional + // 1. `fork()` + // 2. `setsid()` + // 3. `execvp()` + // sequence that we previously had to code manually via an unsafe + // `pre_exec` closure. + let child = cmd + .group_spawn() // <- safe wrapper from the `command-group` crate + .context("failed to spawn codex-exec")? + .into_inner(); // convert AsyncGroupChild -> tokio::process::Child + + // Ignore SIGHUP in the parent, mirroring the behaviour of the previous + // unsafe `libc::signal` call. + crate::sig::ignore_sighup()?; - let child = cmd.spawn().context("failed to spawn codex-exec")?; Ok(child) } @@ -83,39 +107,41 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] { - use std::io; - use std::os::unix::ffi::OsStrExt; + // ----------------------------------------------------------------- + // UNIX IMPLEMENTATION (now 100 % safe) + // ----------------------------------------------------------------- + // Ensure a FIFO exists at `paths.stdin` with permissions rw------- if !paths.stdin.exists() { - let c_path = std::ffi::CString::new(paths.stdin.as_os_str().as_bytes()).unwrap(); - let res = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; - if res != 0 { - let err = std::io::Error::last_os_error(); - if err.kind() != io::ErrorKind::AlreadyExists { - return Err(err).context("mkfifo failed"); + if let Err(e) = mkfifo(&paths.stdin, Mode::from_bits_truncate(0o600)) { + // If the FIFO already exists we silently accept, just as the + // previous implementation did. + if e != Errno::EEXIST { + return Err(std::io::Error::from(e)).context("mkfifo failed"); } } } + // Open the FIFO for *both* reading and writing so we don't deadlock + // when there is no writer yet (mimics the previous behaviour). let stdin = OpenOptions::new() .read(true) .write(true) .open(&paths.stdin)?; + // Build the command. let mut cmd = base_command("codex-repl", paths)?; cmd.args(repl_args).stdin(stdin); - unsafe { - cmd.pre_exec(|| { - if libc::setsid() == -1 { - return Err(io::Error::last_os_error()); - } - libc::signal(libc::SIGHUP, libc::SIG_IGN); - Ok(()) - }); - } + // Detached spawn. + let child = cmd + .group_spawn() + .context("failed to spawn codex-repl")? + .into_inner(); + + // Ignore SIGHUP as before. + crate::sig::ignore_sighup()?; - let child = cmd.spawn().context("failed to spawn codex-repl")?; Ok(child) } From c7596debb11ff03a02c7c50931cfbab405782e4c Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 12:56:35 -0700 Subject: [PATCH 42/44] cleanup --- codex-rs/session/src/cli.rs | 4 +-- codex-rs/session/src/lib.rs | 2 +- codex-rs/session/src/sig.rs | 7 ++++-- codex-rs/session/src/spawn.rs | 46 ++++++++--------------------------- 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/codex-rs/session/src/cli.rs b/codex-rs/session/src/cli.rs index 18c6f0d6c..304081c14 100644 --- a/codex-rs/session/src/cli.rs +++ b/codex-rs/session/src/cli.rs @@ -381,8 +381,8 @@ impl GetCmd { // We *could* just write the file contents as-is but parsing + re-serialising guarantees // the output is valid and nicely formatted even when the on-disk representation ever // switches away from pretty-printed JSON. - let meta: SessionMeta = serde_json::from_slice(&bytes) - .context("failed to deserialize session metadata")?; + let meta: SessionMeta = + serde_json::from_slice(&bytes).context("failed to deserialize session metadata")?; let pretty = serde_json::to_string_pretty(&meta)?; println!("{pretty}"); diff --git a/codex-rs/session/src/lib.rs b/codex-rs/session/src/lib.rs index bd2ddf9d3..79ba3ad2d 100644 --- a/codex-rs/session/src/lib.rs +++ b/codex-rs/session/src/lib.rs @@ -8,8 +8,8 @@ pub mod build; pub mod cli; pub mod meta; -mod spawn; mod sig; +mod spawn; pub mod store; pub use cli::Cli; diff --git a/codex-rs/session/src/sig.rs b/codex-rs/session/src/sig.rs index 21a27418d..586b06093 100644 --- a/codex-rs/session/src/sig.rs +++ b/codex-rs/session/src/sig.rs @@ -4,7 +4,11 @@ //! in particular `spawn.rs` — entirely `unsafe`-free. #[cfg(unix)] -use nix::sys::signal::{signal as nix_signal, SigHandler, Signal}; +use nix::sys::signal::signal as nix_signal; +#[cfg(unix)] +use nix::sys::signal::SigHandler; +#[cfg(unix)] +use nix::sys::signal::Signal; /// Safely ignore `SIGHUP` for the current process. /// @@ -26,4 +30,3 @@ pub fn ignore_sighup() -> std::io::Result<()> { // No-op on non-Unix platforms. Ok(()) } - diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index b65f2d241..b222f2105 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -7,21 +7,14 @@ use std::fs::OpenOptions; use tokio::process::Child; use tokio::process::Command; -// ------------------------------------------------------------------------- -// Additional (Unix-only) imports to replace the former unsafe `libc` calls. -// These are guarded by `cfg(unix)` so Windows builds are completely unaffected. -// ------------------------------------------------------------------------- #[cfg(unix)] -use command_group::AsyncCommandGroup; // provides `group_spawn` for tokio::process::Command - +use command_group::AsyncCommandGroup; +#[cfg(unix)] +use nix::errno::Errno; #[cfg(unix)] -use nix::{ - errno::Errno, - sys::{ - stat::Mode, - }, - unistd::mkfifo, -}; +use nix::sys::stat::Mode; +#[cfg(unix)] +use nix::unistd::mkfifo; /// Open (and create if necessary) the log files that stdout / stderr of the /// spawned agent will be redirected to. @@ -55,34 +48,20 @@ fn base_command(bin: &str, paths: &Paths) -> Result { pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { - // ----------------------------------------------------------------- - // UNIX IMPLEMENTATION (now 100 % safe) - // ----------------------------------------------------------------- - // Build the base command and add the user-supplied arguments. let mut cmd = base_command("codex-exec", paths)?; cmd.args(exec_args); - // Replace the `stdin` that `base_command` configured (null) with - // `/dev/null` opened for reading – keeps the previous behaviour while - // still leveraging the common helper. + // exec is non-interactive, use /dev/null for stdin. let stdin = OpenOptions::new().read(true).open("/dev/null")?; cmd.stdin(stdin); - // Spawn the child as a *process group* / new session leader. - // `group_spawn()` internally performs the traditional - // 1. `fork()` - // 2. `setsid()` - // 3. `execvp()` - // sequence that we previously had to code manually via an unsafe - // `pre_exec` closure. + // Spawn the child as a process group / new session leader. let child = cmd - .group_spawn() // <- safe wrapper from the `command-group` crate + .group_spawn() .context("failed to spawn codex-exec")? - .into_inner(); // convert AsyncGroupChild -> tokio::process::Child + .into_inner(); - // Ignore SIGHUP in the parent, mirroring the behaviour of the previous - // unsafe `libc::signal` call. crate::sig::ignore_sighup()?; Ok(child) @@ -107,10 +86,6 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { #[cfg(unix)] { - // ----------------------------------------------------------------- - // UNIX IMPLEMENTATION (now 100 % safe) - // ----------------------------------------------------------------- - // Ensure a FIFO exists at `paths.stdin` with permissions rw------- if !paths.stdin.exists() { if let Err(e) = mkfifo(&paths.stdin, Mode::from_bits_truncate(0o600)) { @@ -139,7 +114,6 @@ pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { .context("failed to spawn codex-repl")? .into_inner(); - // Ignore SIGHUP as before. crate::sig::ignore_sighup()?; Ok(child) From 34e849a89a9abfb89a12b62d6ad8b92705ce4655 Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 14:56:16 -0700 Subject: [PATCH 43/44] windows build cleanup --- codex-rs/session/src/spawn.rs | 60 +++++++++++++++-------------------- 1 file changed, 26 insertions(+), 34 deletions(-) diff --git a/codex-rs/session/src/spawn.rs b/codex-rs/session/src/spawn.rs index b222f2105..0d33e0b63 100644 --- a/codex-rs/session/src/spawn.rs +++ b/codex-rs/session/src/spawn.rs @@ -45,6 +45,7 @@ fn base_command(bin: &str, paths: &Paths) -> Result { Ok(cmd) } +#[allow(dead_code)] pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(unix)] { @@ -69,8 +70,6 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { #[cfg(windows)] { - use std::os::windows::process::CommandExt; - const DETACHED_PROCESS: u32 = 0x00000008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; @@ -83,44 +82,37 @@ pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result { } } +#[cfg(unix)] pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result { - #[cfg(unix)] - { - // Ensure a FIFO exists at `paths.stdin` with permissions rw------- - if !paths.stdin.exists() { - if let Err(e) = mkfifo(&paths.stdin, Mode::from_bits_truncate(0o600)) { - // If the FIFO already exists we silently accept, just as the - // previous implementation did. - if e != Errno::EEXIST { - return Err(std::io::Error::from(e)).context("mkfifo failed"); - } + // Ensure a FIFO exists at `paths.stdin` with permissions rw------- + if !paths.stdin.exists() { + if let Err(e) = mkfifo(&paths.stdin, Mode::from_bits_truncate(0o600)) { + // If the FIFO already exists we silently accept, just as the + // previous implementation did. + if e != Errno::EEXIST { + return Err(std::io::Error::from(e)).context("mkfifo failed"); } } + } - // Open the FIFO for *both* reading and writing so we don't deadlock - // when there is no writer yet (mimics the previous behaviour). - let stdin = OpenOptions::new() - .read(true) - .write(true) - .open(&paths.stdin)?; + // Open the FIFO for *both* reading and writing so we don't deadlock + // when there is no writer yet (mimics the previous behaviour). + let stdin = OpenOptions::new() + .read(true) + .write(true) + .open(&paths.stdin)?; - // Build the command. - let mut cmd = base_command("codex-repl", paths)?; - cmd.args(repl_args).stdin(stdin); + // Build the command. + let mut cmd = base_command("codex-repl", paths)?; + cmd.args(repl_args).stdin(stdin); - // Detached spawn. - let child = cmd - .group_spawn() - .context("failed to spawn codex-repl")? - .into_inner(); + // Detached spawn. + let child = cmd + .group_spawn() + .context("failed to spawn codex-repl")? + .into_inner(); - crate::sig::ignore_sighup()?; - - Ok(child) - } + crate::sig::ignore_sighup()?; - #[cfg(windows)] - { - anyhow::bail!("codex-repl sessions are not supported on Windows yet"); - } + Ok(child) } From 798364e86bc79a558719b05fe6aa775071d0343f Mon Sep 17 00:00:00 2001 From: Ryan Ragona Date: Sun, 27 Apr 2025 15:34:19 -0700 Subject: [PATCH 44/44] remove unused platform method --- codex-rs/session/src/sig.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/codex-rs/session/src/sig.rs b/codex-rs/session/src/sig.rs index 586b06093..672057164 100644 --- a/codex-rs/session/src/sig.rs +++ b/codex-rs/session/src/sig.rs @@ -23,10 +23,3 @@ pub fn ignore_sighup() -> nix::Result<()> { // SAFETY: Installing the built-in `SIG_IGN` handler is always safe. unsafe { nix_signal(Signal::SIGHUP, SigHandler::SigIgn) }.map(|_| ()) } - -#[cfg(not(unix))] -#[allow(clippy::unused_io_amount)] -pub fn ignore_sighup() -> std::io::Result<()> { - // No-op on non-Unix platforms. - Ok(()) -}