Compare commits

...

45 Commits

Author SHA1 Message Date
oai-ragona
5b6224dd1b Merge branch 'main' into codex-rs-session 2025-04-28 09:53:28 -07:00
Ryan Ragona
798364e86b remove unused platform method 2025-04-27 15:34:19 -07:00
Ryan Ragona
34e849a89a windows build cleanup 2025-04-27 14:56:16 -07:00
Ryan Ragona
c7596debb1 cleanup 2025-04-27 12:56:35 -07:00
Ryan Ragona
ffe7e2277f artial, broken 2025-04-27 12:42:27 -07:00
Ryan Ragona
b344757fb0 first pass at get 2025-04-27 09:00:37 -07:00
Ryan Ragona
0fbe5f2069 cleanup 2025-04-27 08:46:45 -07:00
Ryan Ragona
aef7f25302 cleanup 2025-04-27 08:43:44 -07:00
Ryan Ragona
66a2e970f7 humansize 2025-04-27 08:35:21 -07:00
Ryan Ragona
e96055be36 move to_args closer to args 2025-04-27 08:12:09 -07:00
Ryan Ragona
359a09cd8d fmt 2025-04-27 07:50:12 -07:00
Ryan Ragona
8c672d5442 cuter job names 2025-04-27 07:49:59 -07:00
Ryan Ragona
4d26c773b9 drop ascii art 2025-04-26 16:34:03 -07:00
Ryan Ragona
026990fcc0 fmt 2025-04-26 16:31:47 -07:00
Ryan Ragona
ee51ffc130 fmt 2025-04-26 16:25:17 -07:00
Ryan Ragona
a4197ec97a truncate 2025-04-26 16:25:02 -07:00
Ryan Ragona
56e609d481 cleanup on clap args 2025-04-26 16:19:15 -07:00
Ryan Ragona
2b55e5a8f2 remove overcomments 2025-04-26 16:03:43 -07:00
Ryan Ragona
2420a6a898 clippy 2025-04-26 15:57:49 -07:00
Ryan Ragona
8ed2704191 in progress cleanup 2025-04-26 15:45:09 -07:00
Ryan Ragona
07911ddc3e cleanup pass 2025-04-26 15:34:08 -07:00
Ryan Ragona
f1c6625bf2 shorten timestamp 2025-04-26 14:38:17 -07:00
Ryan Ragona
337164738a fmt 2025-04-26 14:25:35 -07:00
Ryan Ragona
96d8d2a37a save session metadata 2025-04-26 14:25:26 -07:00
Ryan Ragona
e782378176 gate on windows 2025-04-26 12:20:27 -07:00
Ryan Ragona
d3b69e98bd fmt 2025-04-26 12:13:24 -07:00
Ryan Ragona
3d9ce18299 fix for tail 2025-04-26 12:13:17 -07:00
Ryan Ragona
1e2983d612 cleanup on failure 2025-04-26 12:09:04 -07:00
Ryan Ragona
a7a8fa1753 session validation 2025-04-26 11:48:42 -07:00
Ryan Ragona
6f0e4a5733 tail 2025-04-26 11:42:50 -07:00
Ryan Ragona
a09be2144e stdout tailing 2025-04-26 11:36:48 -07:00
Ryan Ragona
d0e8aa5233 impl kill 2025-04-26 11:20:46 -07:00
Ryan Ragona
dab7b1734d fmt 2025-04-26 10:03:34 -07:00
Ryan Ragona
9f10ec53b6 remove tui socket stuff 2025-04-26 10:03:24 -07:00
Ryan Ragona
786c81d706 draft still broken 2025-04-26 09:44:50 -07:00
Ryan Ragona
1d0d725494 draft broken 2025-04-26 09:41:06 -07:00
Ryan Ragona
f2b7b14284 draft of tui sock 2025-04-26 09:13:57 -07:00
Ryan Ragona
63ec18989a display kind 2025-04-26 08:27:26 -07:00
Ryan Ragona
2aa7f42dc9 fmt 2025-04-26 08:11:04 -07:00
Ryan Ragona
8f8479fd80 add repl subcommand 2025-04-26 08:10:46 -07:00
Ryan Ragona
9aaa947828 numeric prefix 2025-04-26 07:32:26 -07:00
Ryan Ragona
342ac711ca use dot dir 2025-04-26 07:06:20 -07:00
Ryan Ragona
b41f26f484 metadata 2025-04-26 07:05:19 -07:00
Ryan Ragona
abf0198a49 progress 2025-04-26 06:53:03 -07:00
Ryan Ragona
314d2216cb codex draft 2025-04-26 06:40:39 -07:00
17 changed files with 1491 additions and 12 deletions

280
codex-rs/Cargo.lock generated
View File

@@ -506,7 +506,7 @@ dependencies = [
"openssl-sys",
"patch",
"predicates",
"rand",
"rand 0.9.1",
"reqwest",
"seccompiler",
"serde",
@@ -572,12 +572,42 @@ dependencies = [
"clap",
"codex-core",
"owo-colors 4.2.0",
"rand",
"rand 0.9.1",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "codex-session"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"codex-core",
"codex-exec",
"codex-repl",
"command-group",
"dirs",
"humansize",
"libc",
"names",
"nix 0.28.0",
"petname",
"rand 0.9.1",
"serde",
"serde_json",
"sysinfo",
"tabwriter",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
"uuid",
"windows-sys 0.48.0",
]
[[package]]
name = "codex-tui"
version = "0.1.0"
@@ -631,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"
@@ -688,6 +730,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"
@@ -1424,6 +1485,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"
@@ -1858,6 +1928,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"
@@ -2029,6 +2105,15 @@ dependencies = [
"serde",
]
[[package]]
name = "names"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc"
dependencies = [
"rand 0.8.5",
]
[[package]]
name = "native-tls"
version = "0.2.14"
@@ -2061,6 +2146,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"
@@ -2100,6 +2196,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"
@@ -2327,6 +2432,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"
@@ -2464,14 +2583,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]]
@@ -2481,7 +2621,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]]
@@ -2514,6 +2663,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"
@@ -2760,7 +2929,7 @@ dependencies = [
"libc",
"log",
"memchr",
"nix",
"nix 0.28.0",
"radix_trie",
"unicode-segmentation",
"unicode-width 0.1.14",
@@ -3248,6 +3417,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"
@@ -3269,6 +3453,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"
@@ -3764,6 +3957,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 +4212,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 +4239,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 +4286,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 +4304,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 +4322,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 +4352,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 +4370,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 +4388,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 +4406,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"

View File

@@ -9,5 +9,6 @@ members = [
"execpolicy",
"interactive",
"repl",
"session",
"tui",
]

View File

@@ -5,8 +5,10 @@ use clap::ValueEnum;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use serde::Deserialize;
use serde::Serialize;
#[derive(Clone, Copy, Debug, ValueEnum)]
#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)]
#[value(rename_all = "kebab-case")]
pub enum ApprovalModeCliArg {
/// Run all commands without asking for user approval.
@@ -24,7 +26,7 @@ pub enum ApprovalModeCliArg {
Never,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
#[derive(Clone, Copy, Debug, ValueEnum, Serialize, Deserialize)]
#[value(rename_all = "kebab-case")]
pub enum SandboxModeCliArg {
/// Network syscalls will be blocked

View File

@@ -2,7 +2,9 @@ use clap::Parser;
use codex_core::SandboxModeCliArg;
use std::path::PathBuf;
#[derive(Parser, Debug)]
/// Command-line interface for the non-interactive `codex-exec` agent.
///
#[derive(Parser, Debug, Clone)]
#[command(version)]
pub struct Cli {
/// Optional image(s) to attach to the initial prompt.
@@ -30,3 +32,35 @@ pub struct Cli {
/// Initial instructions for the agent.
pub prompt: Option<String>,
}
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<String> {
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
}
}

View File

@@ -3,7 +3,7 @@ use codex_core::ApprovalModeCliArg;
use codex_core::SandboxModeCliArg;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[derive(Parser, Debug, Clone)]
#[command(version)]
pub struct Cli {
/// Optional image(s) to attach to the initial prompt.

View File

@@ -25,4 +25,4 @@ tokio = { version = "1", features = [
"signal",
] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

View File

@@ -1,11 +1,13 @@
use clap::ArgAction;
use clap::Parser;
use clap::ValueEnum;
use codex_core::ApprovalModeCliArg;
use codex_core::SandboxModeCliArg;
use std::path::PathBuf;
/// Commandline arguments.
#[derive(Debug, Parser)]
/// Command-line interface for the interactive `codex-repl` agent.
#[derive(Debug, Parser, Clone)]
#[command(
author,
version,
@@ -62,3 +64,70 @@ pub struct Cli {
#[arg(short = 'E', long)]
pub record_events: Option<PathBuf>,
}
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<String> {
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());
}
for _ in 0..self.verbose {
args.push("-v".into());
}
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());
}
if let Some(prompt) = &self.prompt {
args.push(prompt.clone());
}
args
}
}

View File

@@ -0,0 +1,56 @@
[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"] }
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
dirs = "6"
sysinfo = "0.29"
tabwriter = "1.3"
names = { version = "0.14", default-features = false }
nix = { version = "0.28", default-features = false, features = ["process", "signal", "term", "fs"] }
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" }
codex_repl = { package = "codex-repl", path = "../repl" }
humansize = "2.1.3"
command-group = { version = "5.0.1", features = ["with-tokio"] }
[dev-dependencies]
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",
] }

18
codex-rs/session/build.rs Normal file
View File

@@ -0,0 +1,18 @@
// 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}");
}

View File

@@ -0,0 +1,9 @@
//! 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")
}

477
codex-rs/session/src/cli.rs Normal file
View File

@@ -0,0 +1,477 @@
//! CLI command definitions and implementation for `codex-session`.
//!
//! The session manager can spawn two different Codex agent flavors:
//!
//! * `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.
use crate::meta::SessionMeta;
use crate::spawn;
use crate::store;
use anyhow::Context;
use anyhow::Result;
use chrono::SecondsFormat;
use clap::Args;
use clap::Parser;
use clap::Subcommand;
use petname::Generator;
use petname::Petnames;
use serde::Serialize;
#[cfg(unix)]
use codex_repl as _;
#[derive(Parser)]
#[command(
name = "codex-session",
about = "Manage background Codex agent 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::Attach(x) => x.run().await,
Commands::Delete(x) => x.run().await,
Commands::Logs(x) => x.run().await,
Commands::List(x) => x.run().await,
Commands::Get(x) => x.run().await,
}
}
}
#[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),
/// List all known sessions.
List(ListCmd),
/// Print the raw metadata JSON for a session.
Get(GetCmd),
}
#[derive(Subcommand)]
enum AgentKind {
/// Non-interactive execution agent.
Exec(ExecCreateCmd),
/// Interactive Read-Eval-Print-Loop agent.
#[cfg(unix)]
Repl(ReplCreateCmd),
}
#[derive(Args)]
pub struct CreateCmd {
/// Explicit session name. If omitted, a memorable random one is generated.
#[arg(long)]
id: Option<String>,
#[command(subcommand)]
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 {
Some(explicit) => explicit.clone(),
None => generate_session_id()?,
};
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<(
u32, // pid
Option<String>, // prompt preview
store::SessionKind, // kind
Vec<String>, // raw argv used to spawn the agent
)> = (|| match self.agent {
AgentKind::Exec(cmd) => {
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));
Ok((
child.id().unwrap_or_default(),
preview,
store::SessionKind::Exec,
args.clone(),
))
}
#[cfg(unix)]
AgentKind::Repl(cmd) => {
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));
Ok((
child.id().unwrap_or_default(),
preview,
store::SessionKind::Repl,
args.clone(),
))
}
})();
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
// 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 = SessionMeta::new(id.clone(), pid, kind, argv, prompt_preview);
store::write_meta(&paths, &meta)?;
println!("{id}");
Ok(())
}
}
fn truncate_preview(p: &str) -> String {
let slice: String = p.chars().take(40).collect();
if p.len() > 40 {
format!("{}...", slice)
} else {
slice
}
}
/// 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<String> {
let mut shortnames = Petnames::default();
shortnames.retain(|s| s.len() <= 5);
loop {
let id = shortnames
.generate_one(2, "-")
.context("failed to generate session ID")?;
if !store::paths_for(&id)?.dir.exists() {
return Ok(id);
}
}
}
#[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<()> {
let id = store::resolve_selector(&self.id)?;
let paths = store::paths_for(&id)?;
self.attach_line_oriented(&id, &paths).await
}
async fn attach_line_oriented(&self, id: &str, paths: &store::Paths) -> Result<()> {
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::time::sleep;
use tokio::time::Duration;
// 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}'"))?;
// Log tailing setup
//
// 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 (stdin -> session stdin pipe)
line = stdin_lines.next_line() => {
match line? {
Some(mut l) => {
l.push('\n');
pipe.write_all(l.as_bytes()).await?;
pipe.flush().await?;
}
// Ctrl-D -- end of interactive input
None => {
break;
}
}
}
// stdout updates
out_line = reader_out.next_line() => {
match out_line? {
Some(l) => println!("{l}"),
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;
}
}
}
}
Ok(())
}
}
#[derive(Args)]
pub struct DeleteCmd {
id: String,
}
impl DeleteCmd {
pub async fn run(self) -> Result<()> {
let id = store::resolve_selector(&self.id)?;
store::kill_session(&id).await?;
store::purge(&id)?;
Ok(())
}
}
#[derive(Args)]
pub struct LogsCmd {
id: String,
#[arg(long)]
stderr: bool,
}
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 file = tokio::fs::File::open(target).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(())
}
}
#[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 {
pub idx: usize,
pub id: String,
pub pid: u32,
pub kind: String,
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::PidExt;
use sysinfo::SystemExt;
let metas = store::list_sessions_sorted()?;
let mut sys = sysinfo::System::new();
sys.refresh_processes();
let bytes_formatter = humansize::make_format(humansize::DECIMAL);
let rows: Vec<StatusRow> = 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"
};
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);
(bytes_formatter(osz), bytes_formatter(esz))
} else {
("-".into(), "-".into())
};
StatusRow {
idx,
id: m.id,
pid: m.pid,
kind: format!("{:?}", m.kind).to_lowercase(),
status: status.into(),
created: m.created_at.to_rfc3339_opts(SecondsFormat::Secs, true),
prompt: m.prompt_preview.unwrap_or_default(),
out,
err,
}
})
.collect();
print_table(&rows)?;
Ok(())
}
}
pub 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\tTYPE\tSTATUS\tOUT\tERR\tCREATED\tPROMPT")?;
for r in rows {
writeln!(
tw,
"{}\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()?)?;
print!("{out}");
Ok(())
}

View File

@@ -0,0 +1,20 @@
//! Library entry-point re-exporting the CLI so the binary can stay tiny.
//! 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 build;
pub mod cli;
pub mod meta;
mod sig;
mod spawn;
pub mod store;
pub use cli::Cli;
/// Entry used by the bin crate.
pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
cli.dispatch().await
}

View File

@@ -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(())
}

View File

@@ -0,0 +1,72 @@
//! 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
//! 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 <args...>`.
use chrono::DateTime;
use chrono::Utc;
use serde::Deserialize;
use serde::Serialize;
use crate::store::SessionKind;
/// JSON envelope version. Bump when the structure changes in a
/// backwards-incompatible way.
pub const CURRENT_VERSION: u8 = 1;
/// Persisted session metadata.
#[derive(Debug, Serialize, Deserialize)]
pub struct SessionMeta {
/// Unique identifier (also doubles as directory name).
pub id: String,
/// Leader process id (PID).
pub pid: u32,
/// Whether the session is an `exec` or `repl` one.
pub kind: SessionKind,
/// Raw command-line arguments that were used to spawn the agent
/// (`codex-exec ...` or `codex-repl ...`).
pub argv: Vec<String>,
/// Short preview of the user prompt (if any).
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_preview: Option<String>,
/// Wall-clock timestamp when the session was created.
pub created_at: DateTime<Utc>,
/// Git commit hash of the build that produced this file.
pub codex_commit: String,
/// Schema version (see [`CURRENT_VERSION`]).
pub version: u8,
}
impl SessionMeta {
#[allow(clippy::too_many_arguments)]
pub fn new(
id: String,
pid: u32,
kind: SessionKind,
argv: Vec<String>,
prompt_preview: Option<String>,
) -> Self {
Self {
id,
pid,
kind,
argv,
prompt_preview,
created_at: Utc::now(),
codex_commit: crate::build::git_sha().to_owned(),
version: CURRENT_VERSION,
}
}
}

View File

@@ -0,0 +1,25 @@
//! 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;
#[cfg(unix)]
use nix::sys::signal::SigHandler;
#[cfg(unix)]
use nix::sys::signal::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(|_| ())
}

View File

@@ -0,0 +1,118 @@
//! Spawn detached Codex agent processes for exec and repl sessions.
use crate::store::Paths;
use anyhow::Context;
use anyhow::Result;
use std::fs::OpenOptions;
use tokio::process::Child;
use tokio::process::Command;
#[cfg(unix)]
use command_group::AsyncCommandGroup;
#[cfg(unix)]
use nix::errno::Errno;
#[cfg(unix)]
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.
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<Command> {
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)
}
#[allow(dead_code)]
pub fn spawn_exec(paths: &Paths, exec_args: &[String]) -> Result<Child> {
#[cfg(unix)]
{
// Build the base command and add the user-supplied arguments.
let mut cmd = base_command("codex-exec", paths)?;
cmd.args(exec_args);
// 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.
let child = cmd
.group_spawn()
.context("failed to spawn codex-exec")?
.into_inner();
crate::sig::ignore_sighup()?;
Ok(child)
}
#[cfg(windows)]
{
const DETACHED_PROCESS: u32 = 0x00000008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
let mut cmd = base_command("codex-exec", paths)?;
cmd.args(exec_args)
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
let child = cmd.spawn().context("failed to spawn codex-exec")?;
Ok(child)
}
}
#[cfg(unix)]
pub fn spawn_repl(paths: &Paths, repl_args: &[String]) -> Result<Child> {
// 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)?;
// 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();
crate::sig::ignore_sighup()?;
Ok(child)
}

View File

@@ -0,0 +1,299 @@
//! Session bookkeeping helpers.
//!
//! A session lives in `~/.codex/sessions/<id>/` and contains:
//! * stdout.log / stderr.log - redirect of agent io
//! * meta.json - small struct saved by `write_meta`.
use anyhow::Context;
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;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Clone, Debug)]
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.
/// 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<Paths> {
validate_id(id)?;
// 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"),
})
}
/// 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<PathBuf> {
// ~/.codex/sessions
let home = dirs::home_dir().context("could not resolve home directory")?;
Ok(home.join(".codex").join("sessions"))
}
// 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, Default)]
#[serde(rename_all = "lowercase")]
pub enum SessionKind {
/// Non-interactive batch session -- `codex-exec`.
#[default]
Exec,
/// Line-oriented interactive session -- `codex-repl`.
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.
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] {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(p)?;
}
Ok(())
}
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(())
}
/// Enumerate all sessions by loading each `meta.json`.
pub fn list_sessions() -> Result<Vec<SessionMeta>> {
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::<SessionMeta>(&bytes) {
res.push(meta);
}
}
}
}
Ok(res)
}
/// List sessions sorted by newest first (created_at desc).
/// Newest-first list (created_at descending).
pub fn list_sessions_sorted() -> Result<Vec<SessionMeta>> {
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<String> {
// Accept index, full id, or unique prefix.
let list = list_sessions_sorted()?;
// numeric index
if let Ok(idx) = sel.parse::<usize>() {
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 sessions process.
///
/// NOTE: Full PID accounting is a future improvement; for now the function
/// simply returns `Ok(())` so the `delete` command doesnt fail.
/// 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 -- cross-platform liveness probe based on the `sysinfo` crate.
fn is_alive(pid: u32) -> bool {
use sysinfo::PidExt;
use sysinfo::SystemExt;
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.
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 {
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;
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);
if handle != 0 {
TerminateProcess(handle, 1);
CloseHandle(handle);
}
}
}
}
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(())
}