# 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 协议。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/exec-and-sdk
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# 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](https://daiw.net/manual/codex/exec)；两个 SDK 的安装与示例见[SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)。

```mermaid
flowchart LR
  TS[TypeScript SDK<br/>每一轮一个子进程] -->|prompt 写入 stdin，读 stdout 的 JSONL| EX[codex exec]
  EX -->|InProcessAppServerClient| A1[进程内的 app-server]
  PY[Python SDK] -->|stdio 上的 v2 JSON-RPC| A2[codex app-server 子进程]
  A1 --> CORE[codex-core]
  A2 --> CORE
```

## codex exec：一个无界面的 app-server 客户端

`codex exec` 与 TUI 一样不直接调用内核，而是用 `InProcessAppServerClient` 在进程里嵌一个 app-server（[第 6 篇](https://daiw.net/manual/codex-source/app-server-architecture)），客户端名 `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`）的流程是：

1. 按 `--json` 选事件处理器：`EventProcessorWithJsonOutput` 或 `EventProcessorWithHumanOutput`，两者实现同一个 `EventProcessor` trait，输入都是 v2 的 `ServerNotification`。
2. 新建、恢复或分叉线程：分别发 `thread/start`、`thread/resume`、`thread/fork`。新建的非临时线程会请求 paginated 历史格式，服务端不支持时去掉该参数重试。注释特意说明，直接拿 start、resume 的响应当启动信息，不再等之后推来的 `SessionConfigured` 事件，因为在进程内路径上那样做会白白多等最多 10 秒。
3. 发 `turn/start`，`turn_trigger` 填 `"exec"`；`codex exec review` 改发 `review/start`；`codex exec fork` 不带提示词时只分叉、不开轮次。
4. 进入事件循环：Ctrl-C 被转成 `turn/interrupt`；通知只保留属于这个线程、这一轮的（`should_process_notification`），交给事件处理器；处理器在 `turn/completed` 时返回 `InitiateShutdown`，于是发 `thread/unsubscribe`、关闭内嵌服务端，出现过不再重试的错误，或本轮以失败、中断结束，就以退出码 1 退出。

没有人可以点“同意”，服务端请求在 `handle_server_request` 里统一处理：MCP 的 elicitation 回“取消”，其余请求（命令与文件修改的审批、`request_user_input`、动态工具调用、权限申请等）全部以 `-32000` 拒绝：

```rust
        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 因此在收尾时补读一次：

```rust
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 的事件：

```rust
/// 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`：

```ts
  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 篇](https://daiw.net/manual/codex-source/app-server-protocol)讲的 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` 没传审批处理函数时，默认的处理函数会批准命令与文件修改：

```python
    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 模式](https://daiw.net/manual/grok-build/headless) 把 `grok -p` 称作“TUI 减去渲染”：复用 TUI 的会话启动与同一个会话 actor，只是不套界面。`codex exec` 是同一个思路，复用的层次更往外一层，是整个 app-server。OpenCode 的[其他入口](https://daiw.net/manual/opencode/frontends)一篇里，`opencode run` 在本地走进程内的 `fetch`，与 `codex exec` 用进程内客户端访问同一套服务端的做法相近。

---

上一篇：[会话持久化 · rollout、thread-store 与 SQLite](https://daiw.net/manual/codex-source/rollout-and-storage) · 下一篇：[Session 与 TurnContext · 会话状态与每轮配置](https://daiw.net/manual/codex-source/session-and-turn-context)
