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:
@@ -72,7 +72,7 @@ pub struct Cli {
|
||||
pub include_plan_tool: bool,
|
||||
|
||||
/// 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>,
|
||||
|
||||
/// Initial instructions for the agent. If not provided as an argument (or
|
||||
|
||||
@@ -60,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
|
||||
show_raw_agent_reasoning: bool,
|
||||
last_message_path: Option<PathBuf>,
|
||||
last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>,
|
||||
final_message: Option<String>,
|
||||
}
|
||||
|
||||
impl EventProcessorWithHumanOutput {
|
||||
@@ -84,6 +85,7 @@ impl EventProcessorWithHumanOutput {
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
last_message_path,
|
||||
last_total_token_usage: None,
|
||||
final_message: None,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
@@ -99,6 +101,7 @@ impl EventProcessorWithHumanOutput {
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
last_message_path,
|
||||
last_total_token_usage: None,
|
||||
final_message: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,11 +112,10 @@ struct PatchApplyBegin {
|
||||
auto_approved: bool,
|
||||
}
|
||||
|
||||
// Timestamped println helper. The timestamp is styled with self.dimmed.
|
||||
#[macro_export]
|
||||
macro_rules! ts_println {
|
||||
/// Timestamped helper. The timestamp is styled with self.dimmed.
|
||||
macro_rules! ts_msg {
|
||||
($self:ident, $($arg:tt)*) => {{
|
||||
println!($($arg)*);
|
||||
eprintln!($($arg)*);
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -128,7 +130,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
session_configured_event: &SessionConfiguredEvent,
|
||||
) {
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"OpenAI Codex v{} (research preview)\n--------",
|
||||
VERSION
|
||||
@@ -141,15 +143,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
));
|
||||
|
||||
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
|
||||
// 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.
|
||||
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 {
|
||||
@@ -157,21 +159,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
match msg {
|
||||
EventMsg::Error(ErrorEvent { message }) => {
|
||||
let prefix = "ERROR:".style(self.red);
|
||||
ts_println!(self, "{prefix} {message}");
|
||||
ts_msg!(self, "{prefix} {message}");
|
||||
}
|
||||
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
|
||||
ts_println!(self, "{}", message.style(self.dimmed));
|
||||
ts_msg!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::StreamError(StreamErrorEvent { message }) => {
|
||||
ts_println!(self, "{}", message.style(self.dimmed));
|
||||
ts_msg!(self, "{}", message.style(self.dimmed));
|
||||
}
|
||||
EventMsg::TaskStarted(_) => {
|
||||
// Ignore.
|
||||
}
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
EventMsg::TokenCount(ev) => {
|
||||
@@ -182,11 +188,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
if !self.show_agent_reasoning {
|
||||
return CodexStatus::Running;
|
||||
}
|
||||
println!();
|
||||
eprintln!();
|
||||
}
|
||||
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
if self.show_raw_agent_reasoning {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"thinking".style(self.italic).style(self.magenta),
|
||||
@@ -195,7 +201,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
}
|
||||
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"codex".style(self.italic).style(self.magenta),
|
||||
@@ -203,7 +209,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
);
|
||||
}
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
|
||||
print!(
|
||||
eprint!(
|
||||
"{}\n{} in {}",
|
||||
"exec".style(self.italic).style(self.magenta),
|
||||
escape_command(&command).style(self.bold),
|
||||
@@ -227,20 +233,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
match exit_code {
|
||||
0 => {
|
||||
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}:");
|
||||
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 {
|
||||
call_id: _,
|
||||
invocation,
|
||||
}) => {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"tool".style(self.magenta),
|
||||
@@ -265,7 +271,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
format_mcp_invocation(&invocation)
|
||||
);
|
||||
|
||||
ts_println!(self, "{}", title.style(title_style));
|
||||
ts_msg!(self, "{}", title.style(title_style));
|
||||
|
||||
if let Ok(res) = result {
|
||||
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());
|
||||
|
||||
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::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
|
||||
ts_println!(self, "🌐 Searched: {query}");
|
||||
ts_msg!(self, "🌐 Searched: {query}");
|
||||
}
|
||||
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
|
||||
call_id,
|
||||
@@ -296,7 +302,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
},
|
||||
);
|
||||
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}",
|
||||
"file update".style(self.magenta).style(self.italic),
|
||||
@@ -312,9 +318,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
format_file_change(change),
|
||||
path.to_string_lossy()
|
||||
);
|
||||
println!("{}", header.style(self.magenta));
|
||||
eprintln!("{}", header.style(self.magenta));
|
||||
for line in content.lines() {
|
||||
println!("{}", line.style(self.green));
|
||||
eprintln!("{}", line.style(self.green));
|
||||
}
|
||||
}
|
||||
FileChange::Delete { content } => {
|
||||
@@ -323,9 +329,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
format_file_change(change),
|
||||
path.to_string_lossy()
|
||||
);
|
||||
println!("{}", header.style(self.magenta));
|
||||
eprintln!("{}", header.style(self.magenta));
|
||||
for line in content.lines() {
|
||||
println!("{}", line.style(self.red));
|
||||
eprintln!("{}", line.style(self.red));
|
||||
}
|
||||
}
|
||||
FileChange::Update {
|
||||
@@ -342,20 +348,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
} else {
|
||||
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
|
||||
// (--- / +++) without extra coloring so they are
|
||||
// still readable.
|
||||
for diff_line in unified_diff.lines() {
|
||||
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('-')
|
||||
&& !diff_line.starts_with("---")
|
||||
{
|
||||
println!("{}", diff_line.style(self.red));
|
||||
eprintln!("{}", diff_line.style(self.red));
|
||||
} else {
|
||||
println!("{diff_line}");
|
||||
eprintln!("{diff_line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -392,18 +398,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
};
|
||||
|
||||
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() {
|
||||
println!("{}", line.style(self.dimmed));
|
||||
eprintln!("{}", line.style(self.dimmed));
|
||||
}
|
||||
}
|
||||
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}",
|
||||
"file update:".style(self.magenta).style(self.italic)
|
||||
);
|
||||
println!("{unified_diff}");
|
||||
eprintln!("{unified_diff}");
|
||||
}
|
||||
EventMsg::ExecApprovalRequest(_) => {
|
||||
// Should we exit?
|
||||
@@ -413,7 +419,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
EventMsg::AgentReasoning(agent_reasoning_event) => {
|
||||
if self.show_agent_reasoning {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{}\n{}",
|
||||
"thinking".style(self.italic).style(self.magenta),
|
||||
@@ -432,40 +438,40 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
rollout_path: _,
|
||||
} = session_configured_event;
|
||||
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"codex session".style(self.magenta).style(self.bold),
|
||||
conversation_id.to_string().style(self.dimmed)
|
||||
);
|
||||
|
||||
ts_println!(self, "model: {}", model);
|
||||
println!();
|
||||
ts_msg!(self, "model: {}", model);
|
||||
eprintln!();
|
||||
}
|
||||
EventMsg::PlanUpdate(plan_update_event) => {
|
||||
let UpdatePlanArgs { explanation, plan } = plan_update_event;
|
||||
|
||||
// Header
|
||||
ts_println!(self, "{}", "Plan update".style(self.magenta));
|
||||
ts_msg!(self, "{}", "Plan update".style(self.magenta));
|
||||
|
||||
// Optional explanation
|
||||
if let Some(explanation) = explanation
|
||||
&& !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.
|
||||
for item in plan {
|
||||
match item.status {
|
||||
StepStatus::Completed => {
|
||||
ts_println!(self, " {} {}", "✓".style(self.green), item.step);
|
||||
ts_msg!(self, " {} {}", "✓".style(self.green), item.step);
|
||||
}
|
||||
StepStatus::InProgress => {
|
||||
ts_println!(self, " {} {}", "→".style(self.cyan), item.step);
|
||||
ts_msg!(self, " {} {}", "→".style(self.cyan), item.step);
|
||||
}
|
||||
StepStatus::Pending => {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
" {} {}",
|
||||
"•".style(self.dimmed),
|
||||
@@ -485,7 +491,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
// Currently ignored in exec output.
|
||||
}
|
||||
EventMsg::ViewImageToolCall(view) => {
|
||||
ts_println!(
|
||||
ts_msg!(
|
||||
self,
|
||||
"{} {}",
|
||||
"viewed image".style(self.magenta),
|
||||
@@ -494,13 +500,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
}
|
||||
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
|
||||
TurnAbortReason::Interrupted => {
|
||||
ts_println!(self, "task interrupted");
|
||||
ts_msg!(self, "task interrupted");
|
||||
}
|
||||
TurnAbortReason::Replaced => {
|
||||
ts_println!(self, "task aborted: replaced by a new task");
|
||||
ts_msg!(self, "task aborted: replaced by a new task");
|
||||
}
|
||||
TurnAbortReason::ReviewEnded => {
|
||||
ts_println!(self, "task aborted: review ended");
|
||||
ts_msg!(self, "task aborted: review ended");
|
||||
}
|
||||
},
|
||||
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
|
||||
@@ -517,13 +523,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
|
||||
|
||||
fn print_final_output(&mut self) {
|
||||
if let Some(usage_info) = &self.last_total_token_usage {
|
||||
ts_println!(
|
||||
self,
|
||||
eprintln!(
|
||||
"{}\n{}",
|
||||
"tokens used".style(self.magenta).style(self.italic),
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +428,7 @@ impl EventProcessor for EventProcessorWithJsonOutput {
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::print_stdout)]
|
||||
fn process_event(&mut self, event: Event) -> CodexStatus {
|
||||
let aggregated = self.collect_thread_events(&event);
|
||||
for conv_event in aggregated {
|
||||
|
||||
@@ -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 event_processor;
|
||||
mod event_processor_with_human_output;
|
||||
|
||||
@@ -229,14 +229,14 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
|
||||
|
||||
assert!(output.status.success(), "resume run failed: {output:?}");
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
assert!(
|
||||
stdout.contains("model: gpt-5-high"),
|
||||
"stdout missing model override: {stdout}"
|
||||
stderr.contains("model: gpt-5-high"),
|
||||
"stderr missing model override: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("sandbox: workspace-write"),
|
||||
"stdout missing sandbox override: {stdout}"
|
||||
stderr.contains("sandbox: workspace-write"),
|
||||
"stderr missing sandbox override: {stderr}"
|
||||
);
|
||||
|
||||
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)
|
||||
|
||||
27
docs/exec.md
27
docs/exec.md
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
`codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs.
|
||||
|
||||
Supported event types:
|
||||
|
||||
- `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.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.
|
||||
|
||||
Supported item types:
|
||||
|
||||
- `assistant_message` - assistant message.
|
||||
- `reasoning` - a summary of the assistant's thinking.
|
||||
- `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.
|
||||
|
||||
Sample output:
|
||||
|
||||
```jsonl
|
||||
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
|
||||
{"type":"turn.started"}
|
||||
@@ -54,13 +62,13 @@ Sample schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_name": { "type": "string" },
|
||||
"programming_languages": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["project_name", "programming_languages"],
|
||||
"additionalProperties": false
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_name": { "type": "string" },
|
||||
"programming_languages": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["project_name", "programming_languages"],
|
||||
"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`.
|
||||
|
||||
|
||||
### 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
|
||||
codex exec "Review the change, look for 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
|
||||
codex exec --model gpt-5-codex --json "Review the change, look for use-after-free issues"
|
||||
|
||||
Reference in New Issue
Block a user