codex exec 与 SDK · 没有界面的用法
codex exec 是一个把 app-server 嵌在自己进程里的无界面客户端:发 thread/start 与 turn/start,把 v2 通知翻译成给人看的输出或一套更简单的 JSONL 事件,遇到审批请求一律拒绝,本轮结束就退出。TypeScript SDK 每一轮都拉起一次 codex exec 读它的 JSONL;Python SDK 则直接经 stdio 连 codex app-server,讲完整的 v2 协议。
codex exec 与 SDK · 没有界面的用法
前面几篇的主角是交互式前端背后的 app-server。这一篇看三种不带界面的用法:codex exec 命令(codex-rs/exec,crate 名 codex-exec),以及仓库 sdk/ 目录下的 TypeScript 与 Python 两个 SDK。它们接入 Codex 的方式各不相同。
用户看到的样子
codex exec "任务" 跑完一轮就退出:进度写 stderr,最终回复写 stdout,--json 把 stdout 换成 JSONL 事件流,失败时退出码为 1。参数、默认的沙箱与审批、事件类型与结构化输出见手册非交互模式 codex exec;两个 SDK 的安装与示例见SDK 与 App Server。
codex exec:一个无界面的 app-server 客户端
codex exec 与 TUI 一样不直接调用内核,而是用 InProcessAppServerClient 在进程里嵌一个 app-server(第 6 篇),客户端名 codex_exec,会话来源 SessionSource::Exec,并且允许用环境变量 CODEX_API_KEY 提供凭据。构建配置时,它先把审批策略固定为 AskForApproval::Never,注释称这是 headless 模式的默认值;只有当最终生效的审批人是自动审查(AutoReview)时,才撤掉这个覆盖、沿用配置里的策略,而加了 --dangerously-bypass-approvals-and-sandbox 时始终保持 Never。
run_exec_session(codex-rs/exec/src/lib.rs:833)的流程是:
- 按
--json选事件处理器:EventProcessorWithJsonOutput或EventProcessorWithHumanOutput,两者实现同一个EventProcessortrait,输入都是 v2 的ServerNotification。 - 新建、恢复或分叉线程:分别发
thread/start、thread/resume、thread/fork。新建的非临时线程会请求 paginated 历史格式,服务端不支持时去掉该参数重试。注释特意说明,直接拿 start、resume 的响应当启动信息,不再等之后推来的SessionConfigured事件,因为在进程内路径上那样做会白白多等最多 10 秒。 - 发
turn/start,turn_trigger填"exec";codex exec review改发review/start;codex exec fork不带提示词时只分叉、不开轮次。 - 进入事件循环:Ctrl-C 被转成
turn/interrupt;通知只保留属于这个线程、这一轮的(should_process_notification),交给事件处理器;处理器在turn/completed时返回InitiateShutdown,于是发thread/unsubscribe、关闭内嵌服务端,出现过不再重试的错误,或本轮以失败、中断结束,就以退出码 1 退出。
没有人可以点“同意”,服务端请求在 handle_server_request 里统一处理:MCP 的 elicitation 回“取消”,其余请求(命令与文件修改的审批、request_user_input、动态工具调用、权限申请等)全部以 -32000 拒绝:
ServerRequest::CommandExecutionRequestApproval { request_id, params } => {
reject_server_request(
client,
request_id,
&method,
format!(
"command execution approval is not supported in exec mode for thread `{}`",
params.thread_id
),
)
.await
}(codex-rs/exec/src/lib.rs:2014)
结合第 6 篇讲的审批往返:客户端回了错误,app-server 就以 ReviewDecision::denied 把决定交回 core,那一步操作不会执行。
还有一处能看出分层之间的配合。进程内传输在队列满时可能丢掉普通通知,但 turn/completed 保证送达;而且 app-server 目前发出的 turn/completed 里 items 是空的。exec 因此在收尾时补读一次:
async fn maybe_backfill_turn_completed_items(
thread_ephemeral: bool,
client: &InProcessAppServerClient,
request_ids: &mut RequestIdSequencer,
notification: &mut ServerNotification,
) {
// In-process delivery may drop non-terminal item notifications under backpressure while still
// guaranteeing `turn/completed`. Because app-server currently emits that completion with an
// empty `turn.items`, exec does one last `thread/read` here so human/json output can recover
// the final message and reconcile any still-running items before shutdown.
if !should_backfill_turn_completed_items(thread_ephemeral, notification) {
return;
}(codex-rs/exec/src/lib.rs:1655)
临时线程没有落盘的历史可读,所以跳过这一步。
两种输出
给人看的处理器把进度写到 stderr,收尾时在 stderr 报出这一轮用掉的 token 数;stdout 与 stderr 只要有一个不是终端,就把最终回复单独打印到 stdout,因此 codex exec "修复测试" > out.txt 拿到的正好是回复本身。
JSONL 处理器输出的不是 v2 协议,而是 exec_events.rs 定义的另一套更简单、字段为 snake_case 的事件:
/// Top-level JSONL events emitted by codex exec
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
#[serde(tag = "type")]
pub enum ThreadEvent {
/// Emitted when a new thread is started as the first event.
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),(codex-rs/exec/src/exec_events.rs:8)
顶层事件只有 thread.started、turn.started、turn.completed(带 token 用量)、turn.failed、item.started、item.updated、item.completed 与 error 八种;条目类型也收窄为 agent_message、reasoning、command_execution、file_change、mcp_tool_call、collab_tool_call、web_search、todo_list、error 九种。处理器把 v2 通知逐个翻译过来:条目 id 重新编号为 item_0、item_1……;turn/plan/updated 变成一个 todo_list 条目的开始与更新,本轮结束时再补一个完成事件;turn/completed 按状态变成 turn.completed 或 turn.failed。这层翻译让脚本面对的是一个稳定的小接口,而不是随版本增长的完整协议。
TypeScript SDK:每一轮一个 codex exec
sdk/typescript 不讲 app-server 协议,它包装的是 codex exec:
async *run(args: CodexExecArgs): AsyncGenerator<string> {
const commandArgs: string[] = ["exec", "--experimental-json"];
// ...
if (args.threadId) {
commandArgs.push("resume", args.threadId);
}
// ...
const child = spawn(this.executablePath, commandArgs, {
env,
signal: args.signal,
});
// ...
child.stdin.write(args.input);
child.stdin.end();(sdk/typescript/src/exec.ts:91)
--experimental-json 是 --json 的别名。每次 thread.run() 或 runStreamed() 都拉起一个新进程:提示词写进 stdin,stdout 按行解析成上面那套事件;Thread 从第一个 thread.started 事件里记下 thread id,下一轮就带上 resume <id>。模型、沙箱、审批策略等选项被翻译成命令行参数或 --config 覆盖;环境变量里注入 CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codex_sdk_ts 标明调用方,传了 apiKey 就设 CODEX_API_KEY。进程以非零状态退出时,SDK 把收集到的 stderr 连同退出码抛成异常。可执行文件默认从 npm 包 @openai/codex 对应平台的子包里找。
Python SDK:直接讲 v2 协议
sdk/python 走的是另一条路:CodexClient 启动 codex app-server --listen stdio://,发 initialize(客户端名 codex_python_sdk,默认打开 experimentalApi),之后用 v2 方法建线程、开轮次。它的类型定义 generated/v2_all.py 就是从第 7 篇讲的 v2 JSON Schema 生成的 pydantic 模型。一个后台线程独占 stdout:带 method 和 id 的是服务端请求,交给审批处理函数并立即写回结果;只有 method 的是通知,按轮次分发;其余是响应。thread.run() 收集本轮的 item/completed 与用量通知,直到 turn/completed;最终回复优先取最后一条 phase 为 final_answer 的 agent 消息。
审批有两层。高层 API 的 approval_mode 默认是 auto_review,映射成审批策略 on-request 加自动审查人,由服务端的自动审查代为裁决;deny_all 映射成 never。低层 CodexClient 没传审批处理函数时,默认的处理函数会批准命令与文件修改:
def _default_approval_handler(self, method: str, params: JsonObject | None) -> JsonObject:
"""Accept approval requests when the caller did not provide a handler."""
if method == "item/commandExecution/requestApproval":
return {"decision": "accept"}
if method == "item/fileChange/requestApproval":
return {"decision": "accept"}
return {}(sdk/python/src/openai_codex/client.py:833)
这与 codex exec 的一律拒绝正好相反,自己用 CodexClient 接入时值得留意。
和其它 agent 对照
Grok Build 的 Headless 模式 把 grok -p 称作“TUI 减去渲染”:复用 TUI 的会话启动与同一个会话 actor,只是不套界面。codex exec 是同一个思路,复用的层次更往外一层,是整个 app-server。OpenCode 的其他入口一篇里,opencode run 在本地走进程内的 fetch,与 codex exec 用进程内客户端访问同一套服务端的做法相近。
上一篇:会话持久化 · rollout、thread-store 与 SQLite · 下一篇:Session 与 TurnContext · 会话状态与每轮配置