diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 57217b1f84..323cc879fe 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -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, /// Initial instructions for the agent. If not provided as an argument (or diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a78139eeed..537512ff90 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -59,6 +59,7 @@ pub(crate) struct EventProcessorWithHumanOutput { show_raw_agent_reasoning: bool, last_message_path: Option, last_total_token_usage: Option, + final_message: Option, } impl EventProcessorWithHumanOutput { @@ -83,6 +84,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 { @@ -98,6 +100,7 @@ impl EventProcessorWithHumanOutput { show_raw_agent_reasoning: config.show_raw_agent_reasoning, last_message_path, last_total_token_usage: None, + final_message: None, } } } @@ -108,11 +111,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)*); }}; } @@ -127,7 +129,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 @@ -140,15 +142,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 { @@ -156,21 +158,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) => { @@ -181,11 +187,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), @@ -194,7 +200,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } } EventMsg::AgentMessage(AgentMessageEvent { message }) => { - ts_println!( + ts_msg!( self, "{}\n{}", "codex".style(self.italic).style(self.magenta), @@ -202,7 +208,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), @@ -226,20 +232,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), @@ -264,7 +270,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(); @@ -272,13 +278,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, @@ -295,7 +301,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { }, ); - ts_println!( + ts_msg!( self, "{}", "file update".style(self.magenta).style(self.italic), @@ -311,9 +317,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 } => { @@ -322,9 +328,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 { @@ -341,20 +347,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}"); } } } @@ -391,18 +397,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? @@ -412,7 +418,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), @@ -431,27 +437,27 @@ 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. @@ -459,13 +465,13 @@ impl EventProcessor for EventProcessorWithHumanOutput { use codex_core::plan_tool::StepStatus; 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}"); + } + } } } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 51ecd71a9b..be3d493fd1 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -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 { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 488e0fcf3d..cc3797b783 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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; diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 16be995e4d..320c5766da 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -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) diff --git a/docs/exec.md b/docs/exec.md index 5c3588eb5a..a0a7caf9de 100644 --- a/docs/exec.md +++ b/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 ` 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 ` 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"