feat: codex exec writes only the final message to stdout (#4644)

This updates `codex exec` so that, by default, most of the agent's
activity is written to stderr so that only the final agent message is
written to stdout. This makes it easier to pipe `codex exec` into
another tool without extra filtering.

I introduced `#![deny(clippy::print_stdout)]` to help enforce this
change and renamed the `ts_println!()` macro to `ts_msg()` because (1)
it no longer calls `println!()` and (2), `ts_eprintln!()` seemed too
long of a name.

While here, this also adds `-o` as an alias for `--output-last-message`.

Fixes https://github.com/openai/codex/issues/1670
This commit is contained in:
Michael Bolin
2025-10-03 09:22:12 -07:00
committed by GitHub
parent 5af08e0719
commit 042d4d55d9
6 changed files with 99 additions and 67 deletions

View File

@@ -72,7 +72,7 @@ pub struct Cli {
pub include_plan_tool: bool, pub include_plan_tool: bool,
/// Specifies file where the last message from the agent should be written. /// Specifies file where the last message from the agent should be written.
#[arg(long = "output-last-message")] #[arg(long = "output-last-message", short = 'o', value_name = "FILE")]
pub last_message_file: Option<PathBuf>, pub last_message_file: Option<PathBuf>,
/// Initial instructions for the agent. If not provided as an argument (or /// Initial instructions for the agent. If not provided as an argument (or

View File

@@ -60,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
show_raw_agent_reasoning: bool, show_raw_agent_reasoning: bool,
last_message_path: Option<PathBuf>, last_message_path: Option<PathBuf>,
last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>, last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>,
final_message: Option<String>,
} }
impl EventProcessorWithHumanOutput { impl EventProcessorWithHumanOutput {
@@ -84,6 +85,7 @@ impl EventProcessorWithHumanOutput {
show_raw_agent_reasoning: config.show_raw_agent_reasoning, show_raw_agent_reasoning: config.show_raw_agent_reasoning,
last_message_path, last_message_path,
last_total_token_usage: None, last_total_token_usage: None,
final_message: None,
} }
} else { } else {
Self { Self {
@@ -99,6 +101,7 @@ impl EventProcessorWithHumanOutput {
show_raw_agent_reasoning: config.show_raw_agent_reasoning, show_raw_agent_reasoning: config.show_raw_agent_reasoning,
last_message_path, last_message_path,
last_total_token_usage: None, last_total_token_usage: None,
final_message: None,
} }
} }
} }
@@ -109,11 +112,10 @@ struct PatchApplyBegin {
auto_approved: bool, auto_approved: bool,
} }
// Timestamped println helper. The timestamp is styled with self.dimmed. /// Timestamped helper. The timestamp is styled with self.dimmed.
#[macro_export] macro_rules! ts_msg {
macro_rules! ts_println {
($self:ident, $($arg:tt)*) => {{ ($self:ident, $($arg:tt)*) => {{
println!($($arg)*); eprintln!($($arg)*);
}}; }};
} }
@@ -128,7 +130,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
session_configured_event: &SessionConfiguredEvent, session_configured_event: &SessionConfiguredEvent,
) { ) {
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
ts_println!( ts_msg!(
self, self,
"OpenAI Codex v{} (research preview)\n--------", "OpenAI Codex v{} (research preview)\n--------",
VERSION VERSION
@@ -141,15 +143,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
)); ));
for (key, value) in entries { for (key, value) in entries {
println!("{} {}", format!("{key}:").style(self.bold), value); eprintln!("{} {}", format!("{key}:").style(self.bold), value);
} }
println!("--------"); eprintln!("--------");
// Echo the prompt that will be sent to the agent so it is visible in the // Echo the prompt that will be sent to the agent so it is visible in the
// transcript/logs before any events come in. Note the prompt may have been // transcript/logs before any events come in. Note the prompt may have been
// read from stdin, so it may not be visible in the terminal otherwise. // read from stdin, so it may not be visible in the terminal otherwise.
ts_println!(self, "{}\n{}", "user".style(self.cyan), prompt); ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt);
} }
fn process_event(&mut self, event: Event) -> CodexStatus { fn process_event(&mut self, event: Event) -> CodexStatus {
@@ -157,21 +159,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
match msg { match msg {
EventMsg::Error(ErrorEvent { message }) => { EventMsg::Error(ErrorEvent { message }) => {
let prefix = "ERROR:".style(self.red); let prefix = "ERROR:".style(self.red);
ts_println!(self, "{prefix} {message}"); ts_msg!(self, "{prefix} {message}");
} }
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed)); ts_msg!(self, "{}", message.style(self.dimmed));
} }
EventMsg::StreamError(StreamErrorEvent { message }) => { EventMsg::StreamError(StreamErrorEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed)); ts_msg!(self, "{}", message.style(self.dimmed));
} }
EventMsg::TaskStarted(_) => { EventMsg::TaskStarted(_) => {
// Ignore. // Ignore.
} }
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
let last_message = last_agent_message.as_deref();
if let Some(output_file) = self.last_message_path.as_deref() { if let Some(output_file) = self.last_message_path.as_deref() {
handle_last_message(last_agent_message.as_deref(), output_file); handle_last_message(last_message, output_file);
} }
self.final_message = last_agent_message;
return CodexStatus::InitiateShutdown; return CodexStatus::InitiateShutdown;
} }
EventMsg::TokenCount(ev) => { EventMsg::TokenCount(ev) => {
@@ -182,11 +188,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
if !self.show_agent_reasoning { if !self.show_agent_reasoning {
return CodexStatus::Running; return CodexStatus::Running;
} }
println!(); eprintln!();
} }
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if self.show_raw_agent_reasoning { if self.show_raw_agent_reasoning {
ts_println!( ts_msg!(
self, self,
"{}\n{}", "{}\n{}",
"thinking".style(self.italic).style(self.magenta), "thinking".style(self.italic).style(self.magenta),
@@ -195,7 +201,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} }
} }
EventMsg::AgentMessage(AgentMessageEvent { message }) => { EventMsg::AgentMessage(AgentMessageEvent { message }) => {
ts_println!( ts_msg!(
self, self,
"{}\n{}", "{}\n{}",
"codex".style(self.italic).style(self.magenta), "codex".style(self.italic).style(self.magenta),
@@ -203,7 +209,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
); );
} }
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => { EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
print!( eprint!(
"{}\n{} in {}", "{}\n{} in {}",
"exec".style(self.italic).style(self.magenta), "exec".style(self.italic).style(self.magenta),
escape_command(&command).style(self.bold), escape_command(&command).style(self.bold),
@@ -227,20 +233,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
match exit_code { match exit_code {
0 => { 0 => {
let title = format!(" succeeded{duration}:"); let title = format!(" succeeded{duration}:");
ts_println!(self, "{}", title.style(self.green)); ts_msg!(self, "{}", title.style(self.green));
} }
_ => { _ => {
let title = format!(" exited {exit_code}{duration}:"); let title = format!(" exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(self.red)); ts_msg!(self, "{}", title.style(self.red));
} }
} }
println!("{}", truncated_output.style(self.dimmed)); eprintln!("{}", truncated_output.style(self.dimmed));
} }
EventMsg::McpToolCallBegin(McpToolCallBeginEvent { EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _, call_id: _,
invocation, invocation,
}) => { }) => {
ts_println!( ts_msg!(
self, self,
"{} {}", "{} {}",
"tool".style(self.magenta), "tool".style(self.magenta),
@@ -265,7 +271,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_mcp_invocation(&invocation) format_mcp_invocation(&invocation)
); );
ts_println!(self, "{}", title.style(title_style)); ts_msg!(self, "{}", title.style(title_style));
if let Ok(res) = result { if let Ok(res) = result {
let val: serde_json::Value = res.into(); let val: serde_json::Value = res.into();
@@ -273,13 +279,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string()); serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) { for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
println!("{}", line.style(self.dimmed)); eprintln!("{}", line.style(self.dimmed));
} }
} }
} }
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {} EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {}
EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => { EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
ts_println!(self, "🌐 Searched: {query}"); ts_msg!(self, "🌐 Searched: {query}");
} }
EventMsg::PatchApplyBegin(PatchApplyBeginEvent { EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id, call_id,
@@ -296,7 +302,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}, },
); );
ts_println!( ts_msg!(
self, self,
"{}", "{}",
"file update".style(self.magenta).style(self.italic), "file update".style(self.magenta).style(self.italic),
@@ -312,9 +318,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_file_change(change), format_file_change(change),
path.to_string_lossy() path.to_string_lossy()
); );
println!("{}", header.style(self.magenta)); eprintln!("{}", header.style(self.magenta));
for line in content.lines() { for line in content.lines() {
println!("{}", line.style(self.green)); eprintln!("{}", line.style(self.green));
} }
} }
FileChange::Delete { content } => { FileChange::Delete { content } => {
@@ -323,9 +329,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_file_change(change), format_file_change(change),
path.to_string_lossy() path.to_string_lossy()
); );
println!("{}", header.style(self.magenta)); eprintln!("{}", header.style(self.magenta));
for line in content.lines() { for line in content.lines() {
println!("{}", line.style(self.red)); eprintln!("{}", line.style(self.red));
} }
} }
FileChange::Update { FileChange::Update {
@@ -342,20 +348,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} else { } else {
format!("{} {}", format_file_change(change), path.to_string_lossy()) format!("{} {}", format_file_change(change), path.to_string_lossy())
}; };
println!("{}", header.style(self.magenta)); eprintln!("{}", header.style(self.magenta));
// Colorize diff lines. We keep file header lines // Colorize diff lines. We keep file header lines
// (--- / +++) without extra coloring so they are // (--- / +++) without extra coloring so they are
// still readable. // still readable.
for diff_line in unified_diff.lines() { for diff_line in unified_diff.lines() {
if diff_line.starts_with('+') && !diff_line.starts_with("+++") { if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
println!("{}", diff_line.style(self.green)); eprintln!("{}", diff_line.style(self.green));
} else if diff_line.starts_with('-') } else if diff_line.starts_with('-')
&& !diff_line.starts_with("---") && !diff_line.starts_with("---")
{ {
println!("{}", diff_line.style(self.red)); eprintln!("{}", diff_line.style(self.red));
} else { } else {
println!("{diff_line}"); eprintln!("{diff_line}");
} }
} }
} }
@@ -392,18 +398,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}; };
let title = format!("{label} exited {exit_code}{duration}:"); let title = format!("{label} exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(title_style)); ts_msg!(self, "{}", title.style(title_style));
for line in output.lines() { for line in output.lines() {
println!("{}", line.style(self.dimmed)); eprintln!("{}", line.style(self.dimmed));
} }
} }
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => { EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
ts_println!( ts_msg!(
self, self,
"{}", "{}",
"file update:".style(self.magenta).style(self.italic) "file update:".style(self.magenta).style(self.italic)
); );
println!("{unified_diff}"); eprintln!("{unified_diff}");
} }
EventMsg::ExecApprovalRequest(_) => { EventMsg::ExecApprovalRequest(_) => {
// Should we exit? // Should we exit?
@@ -413,7 +419,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} }
EventMsg::AgentReasoning(agent_reasoning_event) => { EventMsg::AgentReasoning(agent_reasoning_event) => {
if self.show_agent_reasoning { if self.show_agent_reasoning {
ts_println!( ts_msg!(
self, self,
"{}\n{}", "{}\n{}",
"thinking".style(self.italic).style(self.magenta), "thinking".style(self.italic).style(self.magenta),
@@ -432,40 +438,40 @@ impl EventProcessor for EventProcessorWithHumanOutput {
rollout_path: _, rollout_path: _,
} = session_configured_event; } = session_configured_event;
ts_println!( ts_msg!(
self, self,
"{} {}", "{} {}",
"codex session".style(self.magenta).style(self.bold), "codex session".style(self.magenta).style(self.bold),
conversation_id.to_string().style(self.dimmed) conversation_id.to_string().style(self.dimmed)
); );
ts_println!(self, "model: {}", model); ts_msg!(self, "model: {}", model);
println!(); eprintln!();
} }
EventMsg::PlanUpdate(plan_update_event) => { EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event; let UpdatePlanArgs { explanation, plan } = plan_update_event;
// Header // Header
ts_println!(self, "{}", "Plan update".style(self.magenta)); ts_msg!(self, "{}", "Plan update".style(self.magenta));
// Optional explanation // Optional explanation
if let Some(explanation) = explanation if let Some(explanation) = explanation
&& !explanation.trim().is_empty() && !explanation.trim().is_empty()
{ {
ts_println!(self, "{}", explanation.style(self.italic)); ts_msg!(self, "{}", explanation.style(self.italic));
} }
// Pretty-print the plan items with simple status markers. // Pretty-print the plan items with simple status markers.
for item in plan { for item in plan {
match item.status { match item.status {
StepStatus::Completed => { StepStatus::Completed => {
ts_println!(self, " {} {}", "".style(self.green), item.step); ts_msg!(self, " {} {}", "".style(self.green), item.step);
} }
StepStatus::InProgress => { StepStatus::InProgress => {
ts_println!(self, " {} {}", "".style(self.cyan), item.step); ts_msg!(self, " {} {}", "".style(self.cyan), item.step);
} }
StepStatus::Pending => { StepStatus::Pending => {
ts_println!( ts_msg!(
self, self,
" {} {}", " {} {}",
"".style(self.dimmed), "".style(self.dimmed),
@@ -485,7 +491,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// Currently ignored in exec output. // Currently ignored in exec output.
} }
EventMsg::ViewImageToolCall(view) => { EventMsg::ViewImageToolCall(view) => {
ts_println!( ts_msg!(
self, self,
"{} {}", "{} {}",
"viewed image".style(self.magenta), "viewed image".style(self.magenta),
@@ -494,13 +500,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} }
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason { EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => { TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted"); ts_msg!(self, "task interrupted");
} }
TurnAbortReason::Replaced => { TurnAbortReason::Replaced => {
ts_println!(self, "task aborted: replaced by a new task"); ts_msg!(self, "task aborted: replaced by a new task");
} }
TurnAbortReason::ReviewEnded => { TurnAbortReason::ReviewEnded => {
ts_println!(self, "task aborted: review ended"); ts_msg!(self, "task aborted: review ended");
} }
}, },
EventMsg::ShutdownComplete => return CodexStatus::Shutdown, EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
@@ -517,13 +523,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
fn print_final_output(&mut self) { fn print_final_output(&mut self) {
if let Some(usage_info) = &self.last_total_token_usage { if let Some(usage_info) = &self.last_total_token_usage {
ts_println!( eprintln!(
self,
"{}\n{}", "{}\n{}",
"tokens used".style(self.magenta).style(self.italic), "tokens used".style(self.magenta).style(self.italic),
format_with_separators(usage_info.total_token_usage.blended_total()) format_with_separators(usage_info.total_token_usage.blended_total())
); );
} }
// If the user has not piped the final message to a file, they will see
// it twice: once written to stderr as part of the normal event
// processing, and once here on stdout. We print the token summary above
// to help break up the output visually in that case.
#[allow(clippy::print_stdout)]
if let Some(message) = &self.final_message {
if message.ends_with('\n') {
print!("{message}");
} else {
println!("{message}");
}
}
} }
} }

View File

@@ -428,6 +428,7 @@ impl EventProcessor for EventProcessorWithJsonOutput {
}); });
} }
#[allow(clippy::print_stdout)]
fn process_event(&mut self, event: Event) -> CodexStatus { fn process_event(&mut self, event: Event) -> CodexStatus {
let aggregated = self.collect_thread_events(&event); let aggregated = self.collect_thread_events(&event);
for conv_event in aggregated { for conv_event in aggregated {

View File

@@ -1,3 +1,9 @@
// - In the default output mode, it is paramount that the only thing written to
// stdout is the final message (if any).
// - In --json mode, stdout must be valid JSONL, one event per line.
// For both modes, any other output must be written to stderr.
#![deny(clippy::print_stdout)]
mod cli; mod cli;
mod event_processor; mod event_processor;
mod event_processor_with_human_output; mod event_processor_with_human_output;

View File

@@ -229,14 +229,14 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
assert!(output.status.success(), "resume run failed: {output:?}"); assert!(output.status.success(), "resume run failed: {output:?}");
let stdout = String::from_utf8(output.stdout)?; let stderr = String::from_utf8(output.stderr)?;
assert!( assert!(
stdout.contains("model: gpt-5-high"), stderr.contains("model: gpt-5-high"),
"stdout missing model override: {stdout}" "stderr missing model override: {stderr}"
); );
assert!( assert!(
stdout.contains("sandbox: workspace-write"), stderr.contains("sandbox: workspace-write"),
"stdout missing sandbox override: {stdout}" "stderr missing sandbox override: {stderr}"
); );
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)

View File

@@ -10,12 +10,18 @@ In non-interactive mode, Codex does not ask for command or edit approvals. By de
Use `codex exec --full-auto` to allow file edits. Use `codex exec --sandbox danger-full-access` to allow edits and networked commands. Use `codex exec --full-auto` to allow file edits. Use `codex exec --sandbox danger-full-access` to allow edits and networked commands.
### Default output mode
By default, Codex streams its activity to stderr and only writes the final message from the agent to stdout. This makes it easier to pipe `codex exec` into another tool without extra filtering.
To write the output of `codex exec` to a file, in addition to using a shell redirect like `>`, there is also a dedicated flag to specify an output file: `-o`/`--output-last-message`.
### JSON output mode ### JSON output mode
`codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs. `codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs.
Supported event types: Supported event types:
- `thread.started` - when a thread is started or resumed. - `thread.started` - when a thread is started or resumed.
- `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response. - `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response.
- `turn.completed` - when a turn completes; includes token usage. - `turn.completed` - when a turn completes; includes token usage.
@@ -23,6 +29,7 @@ Supported event types:
- `item.started`/`item.updated`/`item.completed` - when a thread item is added/updated/completed. - `item.started`/`item.updated`/`item.completed` - when a thread item is added/updated/completed.
Supported item types: Supported item types:
- `assistant_message` - assistant message. - `assistant_message` - assistant message.
- `reasoning` - a summary of the assistant's thinking. - `reasoning` - a summary of the assistant's thinking.
- `command_execution` - assistant executing a command. - `command_execution` - assistant executing a command.
@@ -33,6 +40,7 @@ Supported item types:
Typically, an `assistant_message` is added at the end of the turn. Typically, an `assistant_message` is added at the end of the turn.
Sample output: Sample output:
```jsonl ```jsonl
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"} {"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"} {"type":"turn.started"}
@@ -54,13 +62,13 @@ Sample schema:
```json ```json
{ {
"type": "object", "type": "object",
"properties": { "properties": {
"project_name": { "type": "string" }, "project_name": { "type": "string" },
"programming_languages": { "type": "array", "items": { "type": "string" } } "programming_languages": { "type": "array", "items": { "type": "string" } }
}, },
"required": ["project_name", "programming_languages"], "required": ["project_name", "programming_languages"],
"additionalProperties": false "additionalProperties": false
} }
``` ```
@@ -77,17 +85,16 @@ Combine `--output-schema` with `-o` to only print the final JSON output. You can
Codex requires a Git repository to avoid destructive changes. To disable this check, use `codex exec --skip-git-repo-check`. Codex requires a Git repository to avoid destructive changes. To disable this check, use `codex exec --skip-git-repo-check`.
### Resuming non-interactive sessions ### Resuming non-interactive sessions
Resume a previous non-interactive session with `codex exec resume <SESSION_ID>` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent. Resume a previous non-interactive session with `codex exec resume <SESSION_ID>` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent.
```shell ```shell
codex exec "Review the change, look for use-after-free issues" codex exec "Review the change, look for use-after-free issues"
codex exec resume --last "Fix use-after-free issues" codex exec resume --last "Fix use-after-free issues"
``` ```
Only the conversation context is preserved; you must still provide flags to customize Codex behavior. Only the conversation context is preserved; you must still provide flags to customize Codex behavior.
```shell ```shell
codex exec --model gpt-5-codex --json "Review the change, look for use-after-free issues" codex exec --model gpt-5-codex --json "Review the change, look for use-after-free issues"