Add Codex JSON sessions and usage limits

This commit is contained in:
iris committed 2026-09-07 23:29:15 -04:00
1 parent 0862b47f76
commit 6a0202b1b5
14 files changed
+1173 -52

No files matched your search

+563
View File
@@ -0,0 +1,563 @@
//! Codex CLI sessions over `codex exec --json`.
//!
//! Unlike Claude's long-lived stream, one Codex `exec` process is one turn. It
//! reports a thread id, exits after `turn.completed`, and the next turn is
//! `codex exec resume <id> --json …`. The process and its output files still
//! use the common adoption machinery, so a backend restart does not interrupt
//! a turn that is already running.
mod translate;
use std::collections::VecDeque;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
use super::process;
use super::transport::{Launch, Streams, Transport};
use crate::config::{ProviderConfig, SessionConfig};
use translate::Translator;
const STDOUT_LOG: &str = "codex-stdout.log";
const STDERR_LOG: &str = "codex-stderr.log";
const THREAD_FILE: &str = "codex-thread.json";
const QUEUE_FILE: &str = "codex-queue.json";
const POLL: std::time::Duration = std::time::Duration::from_millis(50);
#[derive(Clone, Serialize, Deserialize)]
struct Waiting {
id: String,
text: String,
attachments: Vec<AttachmentRef>,
}
#[derive(Default, Serialize, Deserialize)]
struct Queue {
waiting: VecDeque<Waiting>,
#[serde(skip)]
running: bool,
#[serde(skip)]
closed: bool,
#[serde(skip)]
interrupting: bool,
}
struct Settings {
model: Option<String>,
permission_mode: Option<String>,
effort: Option<String>,
}
struct Inner {
sink: EventSink,
queue: Mutex<Queue>,
settings: Mutex<Settings>,
program: String,
cwd: Option<PathBuf>,
transport: Transport,
session_dir: PathBuf,
reading: AtomicBool,
}
pub struct CodexDriver {
inner: Arc<Inner>,
}
impl CodexDriver {
pub fn launch(
meta: &SessionConfig,
provider: &ProviderConfig,
transport: Transport,
session_dir: &Path,
sink: EventSink,
) -> Result<Self> {
let mut queue = read_queue(session_dir);
let recorded = process::recorded(session_dir);
queue.running = matches!(
recorded,
Some((_, process::Liveness::Alive | process::Liveness::Unknown))
);
let inner = Arc::new(Inner {
sink,
queue: Mutex::new(queue),
settings: Mutex::new(Settings {
model: meta.model.clone(),
permission_mode: meta.permission_mode.clone(),
effort: meta.effort.clone(),
}),
program: provider.program().to_string(),
cwd: meta.cwd.clone(),
transport,
session_dir: session_dir.to_path_buf(),
reading: AtomicBool::new(true),
});
match recorded {
Some((record, process::Liveness::Alive | process::Liveness::Unknown)) => {
tracing::info!(
"session {} reattaching to the Codex turn it left running (pid {})",
meta.id,
record.pid
);
spawn_follower(Arc::clone(&inner), record);
}
Some((_, process::Liveness::Dead)) | None => {
process::clear(session_dir);
let next = inner.queue.lock().unwrap().waiting.pop_front();
if let Some(next) = next {
save_queue(&inner);
take_and_start(&inner, next);
} else {
let _ = inner.sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
}
}
Ok(Self { inner })
}
}
impl Driver for CodexDriver {
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
let mut queue = self.inner.queue.lock().unwrap();
if queue.closed {
drop(queue);
let _ = self.inner.sink.send(Event::Error {
message: "this Codex session has been stopped".to_string(),
});
return;
}
if queue.running {
let waiting = Waiting {
id: super::random_hex(),
text,
attachments,
};
queue.waiting.push_back(waiting.clone());
drop(queue);
save_queue(&self.inner);
let _ = self.inner.sink.send(Event::MessageQueued {
id: waiting.id,
text: waiting.text,
attachments: waiting.attachments,
});
return;
}
queue.running = true;
drop(queue);
let waiting = Waiting {
id: String::new(),
text,
attachments,
};
take_and_start(&self.inner, waiting);
}
fn unqueue(&self, id: &str) -> Unqueued {
let mut queue = self.inner.queue.lock().unwrap();
let Some(at) = queue.waiting.iter().position(|message| message.id == id) else {
return Unqueued::Unknown;
};
queue.waiting.remove(at);
drop(queue);
save_queue(&self.inner);
let _ = self
.inner
.sink
.send(Event::MessageDropped { id: id.to_string() });
Unqueued::Dropped
}
fn answer_question(&self, _id: &str, _answers: &[String]) {
let _ = self.inner.sink.send(Event::Error {
message: "Codex's JSON exec stream cannot continue an interactive question".to_string(),
});
}
fn interrupt(&self) {
let mut queue = self.inner.queue.lock().unwrap();
queue.interrupting = true;
drop(queue);
if let Some(record) = process::live(&self.inner.session_dir) {
process::stop(&record, process::STOP_GRACE);
}
}
fn set_model(&self, model: &str) {
self.inner.settings.lock().unwrap().model = Some(model.to_string());
let _ = self.inner.sink.send(Event::Settings {
model: Some(model.to_string()),
permission_mode: None,
});
}
fn set_permission_mode(&self, mode: &str) {
self.inner.settings.lock().unwrap().permission_mode = Some(mode.to_string());
let _ = self.inner.sink.send(Event::Settings {
model: None,
permission_mode: Some(mode.to_string()),
});
}
fn set_title(&self, _title: &str) {}
fn run_command(&self, text: &str) {
let _ = self.inner.sink.send(Event::Error {
message: format!("Codex's JSON exec stream has no `{text}` command channel"),
});
}
fn compact(&self) {
let _ = self.inner.sink.send(Event::Error {
message: "Codex manages compaction itself in JSON exec sessions".to_string(),
});
}
fn clear(&self) {
let path = self.inner.session_dir.join(THREAD_FILE);
if let Err(err) = std::fs::remove_file(&path)
&& err.kind() != std::io::ErrorKind::NotFound
{
let _ = self.inner.sink.send(Event::Error {
message: format!("couldn't clear the Codex thread id: {err}"),
});
return;
}
let _ = self.inner.sink.send(Event::Cleared);
}
fn between_turns(&self) -> bool {
!self.inner.queue.lock().unwrap().running
}
fn detach(&self) {
self.inner.reading.store(false, Ordering::SeqCst);
}
fn stop(&self) {
self.inner.reading.store(false, Ordering::SeqCst);
let dropped = {
let mut queue = self.inner.queue.lock().unwrap();
queue.closed = true;
queue
.waiting
.drain(..)
.map(|message| message.id)
.collect::<Vec<_>>()
};
save_queue(&self.inner);
for id in dropped {
let _ = self.inner.sink.send(Event::MessageDropped { id });
}
if let Some(record) = process::live(&self.inner.session_dir) {
process::stop(&record, process::STOP_GRACE);
}
process::clear(&self.inner.session_dir);
}
}
fn take_and_start(inner: &Arc<Inner>, message: Waiting) {
{
inner.queue.lock().unwrap().running = true;
}
match start_process(inner, &message.text, &message.attachments) {
Ok(record) => {
let _ = inner.sink.send(Event::MessageTaken {
id: (!message.id.is_empty()).then_some(message.id),
text: message.text,
attachments: message.attachments,
});
let _ = inner.sink.send(Event::Status {
state: SessionStatus::Running,
});
spawn_follower(Arc::clone(inner), record);
}
Err(err) => {
inner.queue.lock().unwrap().running = false;
if !message.id.is_empty() {
let _ = inner.sink.send(Event::MessageDropped { id: message.id });
}
let _ = inner.sink.send(Event::Error {
message: format!("couldn't start Codex: {err:#}"),
});
let _ = inner.sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
}
}
fn start_process(
inner: &Inner,
text: &str,
attachments: &[AttachmentRef],
) -> Result<process::Record> {
let settings = inner.settings.lock().unwrap();
let mut args = Vec::new();
match settings.permission_mode.as_deref() {
Some("bypassPermissions") => {
args.push("--dangerously-bypass-approvals-and-sandbox".to_string());
}
Some("manual") => {
args.extend(["--ask-for-approval".to_string(), "on-request".to_string()]);
}
Some("auto" | "acceptEdits") => args.push("--approve-for-me".to_string()),
Some("plan") => args.extend([
"--ask-for-approval".to_string(),
"never".to_string(),
"--sandbox".to_string(),
"read-only".to_string(),
]),
_ => {}
}
args.push("exec".to_string());
if let Some(thread) = read_thread(&inner.session_dir) {
args.extend(["resume".to_string(), thread]);
}
args.push("--json".to_string());
args.push("--skip-git-repo-check".to_string());
if let Some(model) = &settings.model {
args.extend(["--model".to_string(), model.clone()]);
}
if let Some(effort) = &settings.effort {
args.extend([
"--config".to_string(),
format!("model_reasoning_effort={}", json!(effort)),
]);
}
drop(settings);
let mut body = text.to_string();
for attachment in attachments {
let path = attachment_path(&inner.session_dir, attachment)?;
if crate::media::media_type_for(attachment).is_some()
&& matches!(inner.transport, Transport::Here)
{
args.extend(["--image".to_string(), path.display().to_string()]);
} else {
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str(&format!("Attached file: {}", path.display()));
}
}
args.push(body);
let stdout = create_log(&inner.session_dir.join(STDOUT_LOG))?;
let stderr = create_log(&inner.session_dir.join(STDERR_LOG))?;
let launch = Launch::new(&inner.program, args, inner.cwd.as_deref());
let mut child = inner.transport.spawn(
&launch,
Streams::Detached {
stdin: Stdio::null(),
stdout: stdout.into(),
stderr: stderr.into(),
},
)?;
let pid = child
.id()
.context("Codex exited before it could be recorded")?;
tokio::spawn(async move {
let _ = child.wait().await;
});
let record = process::Record::of(pid, process::Detail::Stdio { stdout_read: 0 })
.context("Codex was gone before its start time could be read")?;
process::write(&inner.session_dir, &record);
Ok(record)
}
fn spawn_follower(inner: Arc<Inner>, record: process::Record) {
let offset = match record.detail {
process::Detail::Stdio { stdout_read } => stdout_read,
_ => 0,
};
tokio::spawn(follow(inner, record, offset));
}
async fn follow(inner: Arc<Inner>, mut record: process::Record, mut offset: u64) {
let stdout = inner.session_dir.join(STDOUT_LOG);
let stderr = inner.session_dir.join(STDERR_LOG);
let mut translator = Translator::default();
while inner.reading.load(Ordering::SeqCst) {
let (bytes, _) = match process::read_from(&stdout, offset) {
Ok(read) => read,
Err(err) => {
let _ = inner.sink.send(Event::Error {
message: format!("couldn't read Codex output: {err:#}"),
});
return;
}
};
let complete = bytes
.iter()
.rposition(|byte| *byte == b'\n')
.map(|at| at + 1)
.unwrap_or(0);
for line in String::from_utf8_lossy(&bytes[..complete]).lines() {
let Ok(value) = serde_json::from_str::<Value>(line) else {
tracing::warn!(
"unparseable Codex JSONL line: {}",
line.chars().take(200).collect::<String>()
);
continue;
};
let before = translator.thread_id.clone();
for event in translator.translate(&value) {
let _ = inner.sink.send(event);
}
if translator.thread_id != before
&& let Some(thread) = &translator.thread_id
{
write_thread(&inner.session_dir, thread);
}
}
if complete > 0 {
offset += complete as u64;
record.detail = process::Detail::Stdio {
stdout_read: offset,
};
process::write(&inner.session_dir, &record);
}
match record.liveness() {
process::Liveness::Alive => {}
process::Liveness::Unknown => {}
process::Liveness::Dead if complete > 0 => {}
process::Liveness::Dead => {
process::clear(&inner.session_dir);
let mut queue = inner.queue.lock().unwrap();
let interrupted = std::mem::take(&mut queue.interrupting);
queue.running = false;
let next = queue.waiting.pop_front();
drop(queue);
save_queue(&inner);
if !translator.completed() && !translator.limited() && !interrupted {
let detail = stderr_tail(&stderr);
let _ = inner.sink.send(Event::Error {
message: if detail.is_empty() {
"Codex exited before completing the turn".to_string()
} else {
format!("Codex exited:\n{detail}")
},
});
}
if !translator.completed() {
let _ = inner.sink.send(Event::Status {
state: SessionStatus::Idle,
});
}
if let Some(next) = next {
take_and_start(&inner, next);
}
return;
}
}
tokio::time::sleep(POLL).await;
}
}
fn create_log(path: &Path) -> Result<std::fs::File> {
std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("creating {}", path.display()))
}
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
if id.contains("..")
|| !id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
{
anyhow::bail!("invalid attachment id");
}
Ok(session_dir.join("attachments").join(id))
}
fn read_thread(session_dir: &Path) -> Option<String> {
serde_json::from_str::<Value>(&std::fs::read_to_string(session_dir.join(THREAD_FILE)).ok()?)
.ok()?
.get("threadId")?
.as_str()
.map(str::to_string)
}
fn write_thread(session_dir: &Path, thread: &str) {
let path = session_dir.join(THREAD_FILE);
let written = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&path)
.and_then(|mut file| {
use std::io::Write;
file.write_all(json!({"threadId": thread}).to_string().as_bytes())
});
if let Err(err) = written {
tracing::error!(
"couldn't persist Codex thread id to {}: {err}",
path.display()
);
}
}
fn read_queue(session_dir: &Path) -> Queue {
std::fs::read_to_string(session_dir.join(QUEUE_FILE))
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
fn save_queue(inner: &Inner) {
let path = inner.session_dir.join(QUEUE_FILE);
let text = match serde_json::to_string(&*inner.queue.lock().unwrap()) {
Ok(text) => text,
Err(err) => {
tracing::error!("couldn't serialize the Codex queue: {err}");
return;
}
};
let temporary = path.with_extension("json.new");
let written = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(&temporary)
.and_then(|mut file| {
use std::io::Write;
file.write_all(text.as_bytes())
})
.and_then(|()| std::fs::rename(&temporary, &path));
if let Err(err) = written {
tracing::error!(
"couldn't persist the Codex queue to {}: {err}",
path.display()
);
let _ = std::fs::remove_file(temporary);
}
}
fn stderr_tail(path: &Path) -> String {
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.rev()
.take(20)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}