# app-server · 所有前端共用的服务端

> TUI、codex exec、IDE 扩展与桌面端都不直接调用 codex-core，而是经 JSON-RPC 访问同一个 app-server：要么在进程内嵌一份，要么通过 stdio、Unix socket 或 WebSocket 连一份。本篇拆开 MessageProcessor 的分派与按资源串行、线程事件翻译成通知的监听任务，以及审批这类服务端请求怎样一来一回。

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

# app-server · 所有前端共用的服务端

[一条消息的生命周期](https://daiw.net/manual/codex-source/message-lifecycle)里，TUI 把输入交出去之后，接手的并不是 agent 内核，而是一个“服务端”。这一篇看的就是它：`codex-rs/app-server`（crate 名 `codex-app-server`，去掉测试约 4.4 万行），以及前端访问它用的 `codex-app-server-client`。

## 用户看到的样子

对 IDE 扩展、桌面端这类集成方来说，app-server 就是一条命令 `codex app-server`，默认在 stdio 上收发 JSONL，每行一条 JSON-RPC 消息；传输方式、主要方法与守护进程的用法见手册[SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)。终端用户很少直接碰到它：`codex exec` 把它嵌在自己的进程里，交互式 TUI 则默认拉起一个本机后台守护进程再连上去：

| 前端 | 怎么连到 app-server | 代码里的身份 |
| --- | --- | --- |
| TUI | 功能开关 `daemon_auto_start` 默认开启，自动拉起本机守护进程并经 Unix socket 连接；带 `--no-daemon`、`--oss`、多数 `-c` 覆盖等参数时改为进程内嵌；`--remote` 连远程 WebSocket | 客户端名 `codex-tui`，内嵌时会话来源 `cli` |
| `codex exec` | 进程内嵌 | 客户端名 `codex_exec`，`SessionSource::Exec` |
| IDE 扩展、桌面端 | 子进程 `codex app-server`，走 stdio | 会话来源 `SessionSource::VSCode` |
| Python SDK | 子进程 `codex app-server --listen stdio://` | 见[codex exec 与 SDK](https://daiw.net/manual/codex-source/exec-and-sdk) |

IDE 扩展和桌面端的代码不在这个仓库里，表里那一行依据的是仓库内的痕迹：`codex app-server` 子命令固定以 `SessionSource::VSCode` 启动服务端（`codex-rs/cli/src/main.rs:1270`），独立二进制 `codex-app-server` 的 `--session-source` 默认值也是 `vscode`；CLI 参数 `--analytics-default-enabled` 的注释说 VS Code 扩展这类第一方用例会带上它；`initialize` 的处理代码对 stdio 连接上名为 `Codex Desktop` 的客户端有专门分支。

## 为什么要共用一个服务端

答案写在注释里。`in_process.rs` 开头说，进程内模式“preserve app-server semantics while avoiding a process boundary”；客户端门面则明说，它刻意保留服务端的请求、通知、事件模型，而不是把 core 的运行时句柄直接交给调用方：

```rust
/// The facade intentionally preserves the server's request/notification/event
/// model instead of exposing direct core runtime handles. That keeps in-process
/// callers aligned with app-server behavior while still avoiding a process
/// boundary.
pub struct InProcessAppServerClient {
    command_tx: mpsc::Sender<ClientCommand>,
    event_rx: mpsc::UnboundedReceiver<InProcessServerEvent>,
    worker_handle: tokio::task::JoinHandle<()>,
}
// ...
pub enum AppServerClient {
    InProcess(InProcessAppServerClient),
    Remote(RemoteAppServerClient),
}
```

（`codex-rs/app-server-client/src/lib.rs:324`）

于是所有前端面对同一份行为契约：同样的参数校验、同样的线程生命周期、同样的审批往返，差别只在传输。两种客户端向上暴露同一个事件类型 `AppServerEvent`（`Lagged`、`ServerNotification`、`ServerRequest`、`Disconnected`），TUI 在嵌入与远程之间切换不必改会话逻辑。迁移还没有百分之百完成：同一文件里的 `legacy_core` 模块把少量 core 配置类型转手给 TUI，注释称它是过渡用的，新行为“should prefer the app-server protocol methods”。

## 全景

```mermaid
flowchart LR
  FE[TUI、codex exec] --> CL[codex-app-server-client]
  IDE[IDE 扩展、桌面端、Python SDK] -->|stdio JSONL| TR
  CL -->|InProcess 类型化通道| MP
  CL -->|Remote WebSocket 帧| TR[传输层<br/>stdio、Unix socket、WebSocket]
  TR -->|TransportEvent| MP[MessageProcessor<br/>23 个 RequestProcessor]
  MP -->|Op 与 turn 输入| CT[CodexThread<br/>codex-core]
  CT -->|EventMsg| LS[每线程一个监听任务]
  LS --> OUT[OutgoingMessageSender<br/>出站路由按连接过滤]
  MP -->|响应| OUT
  OUT --> TR
  OUT --> CL
```

独立进程的入口是 `run_main_with_transport_options`（`codex-rs/app-server/src/lib.rs:489`）。它起两个任务：处理循环接收连接事件、分派请求；出站循环负责往各连接写消息。`OutboundControlEvent` 的注释解释了这样拆的原因：写连接可能很慢，不能拖住请求分派。进程内模式（`codex-rs/app-server/src/in_process.rs:427` 的 `start_uninitialized`）复用同一个 `MessageProcessor` 和同一套出站路由，只是把传输换成有界的内存通道，连接号固定为 `ConnectionId(0)`。

## 请求：从一行 JSON 到某个 processor

处理循环收到的 `TransportEvent` 有四种：`ConnectionOpened`、`ConnectionClosed`、`IncomingMessage`、`DaemonShutdown`。`IncomingMessage` 里装的是四类 JSON-RPC 消息之一：Request 交给 `MessageProcessor::process_request`；Response 与 Error 是客户端对服务端请求的回答（见下文审批一节）；Notification 只记一行日志。这意味着握手第二步的 `initialized` 通知在服务端并不改变任何状态，连接在 `initialize` 请求处理完就已就绪；`initialize` 的响应带回 `userAgent`、`codexHome`、`platformFamily`、`platformOs`，远程客户端靠它们认识对端。

`MessageProcessor`（`codex-rs/app-server/src/message_processor.rs:140`）按领域持有 23 个 `*RequestProcessor`：线程、轮次、配置、账号、插件、MCP、文件系统、命令执行等，实现都在 `request_processors/` 目录。请求反序列化成 `ClientRequest` 之后，先过几道关：

```rust
        if !session.initialized() {
            return Err(invalid_request("Not initialized"));
        }
// ...
        let serialization_scope = codex_request.serialization_scope();
// ...
        if let Some(scope) = serialization_scope {
            let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope);
            self.request_serialization_queues
                .enqueue(key, access, request)
                .await;
        } else {
            tokio::spawn(async move {
                request.run().await;
            });
        }
```

（`codex-rs/app-server/src/message_processor.rs:971`）

- **握手与实验开关**：没 `initialize` 的连接一律 `Not initialized`；方法或字段标了实验性、而连接没在 `initialize` 里声明 `experimentalApi` 的，同样拒绝。
- **轮次准入**：`thread/start`、`turn/start`、`thread/archive` 等会改变运行状态的请求，要先从 `TurnAdmission` 拿许可（中间删掉的那段）。监听 socket 的服务端收到关停信号或守护进程的关停请求后进入排空期：新许可一律拒发，已经在跑的轮次跑完才真正退出，再来一次信号则强制退出。stdio 模式没有这一步，唯一的连接一断，进程就收尾。
- **按资源串行**：每个方法在协议定义里声明自己的串行化范围（由[下一篇](https://daiw.net/manual/codex-source/app-server-protocol)讲的宏生成），映射成 `RequestSerializationQueueKey`：全局的某个名字、某个线程、某个线程文件路径、某个命令进程、某个文件监视等。同一键上的请求排进同一条队列、依次执行；标成 `SharedRead` 的读请求若连续排在队首，会被一次取出并发执行。没有范围的请求直接 `tokio::spawn`。`turn/start`、`turn/interrupt`、`thread/archive` 的范围都是 `thread_id`，所以同一线程上的操作严格按到达顺序生效，不同线程之间互不阻塞；`thread/start` 没有范围，并发新建互不等待。

过了关的请求进入 `handle_initialized_client_request` 里那个覆盖 170 个请求变体的 `match`，交给对应的 processor。处理函数返回 `Result<Option<ClientResponsePayload>, JSONRPCErrorError>`：`Some` 由 `MessageProcessor` 统一回响应，`None` 表示 processor 自己已经回过，`Err` 变成 JSON-RPC 错误。以 `turn/start` 为例，`TurnRequestProcessor` 最终调用 `CodexThread::start_or_steer_turn`：线程空闲就开新一轮，正有一轮在跑就把输入并入那一轮。响应立刻返回一个状态为 `InProgress` 的 `Turn`，后面发生的一切都靠通知送达。

## 事件：从 EventMsg 到通知

每个被加载的线程都有一个监听任务，由 `ensure_listener_task_running`（`codex-rs/app-server/src/request_processors/thread_lifecycle.rs:214`）启动。它在一个 `select!` 里同时等四样东西：取消信号、发给本线程的内部命令、`CodexThread::next_event()` 吐出的下一个事件，以及“该卸载了”的触发。每拿到一个事件，它先查出此刻订阅了这个线程的连接，包成一个 `ThreadScopedOutgoingMessageSender`，再交给 `apply_bespoke_event_handling`（`codex-rs/app-server/src/bespoke_event_handling.rs:141`）翻译，所以通知只发给订阅者。翻译是一个按 `EventMsg` 分支的大 `match`：`TurnStarted` 变成 `turn/started`；`ItemStarted`、`ItemCompleted` 与各种增量交给 app-server-protocol 里的 `item_event_to_server_notification`，变成 `item/started`、`item/completed`、`item/agentMessage/delta` 等；`ExecCommandBegin`、`McpToolCallBegin` 这些旧式事件在这里被直接忽略，注释说 core 仍发它们只是为了原始事件和 rollout 的兼容，v2 客户端看的是统一的 item 生命周期。

线程状态另有一条线。`ThreadWatchManager`（`codex-rs/app-server/src/thread_status.rs:19`）为每个线程记几项运行事实：是否加载、是否在跑、待决的审批数与提问数、是否出过系统错误，再推导出 `ThreadStatus`：未加载、空闲、系统错误，或带 `WaitingOnApproval`、`WaitingOnUserInput` 标记的活动态；状态一变就广播 `thread/status/changed`。它顺带维护“正在运行的轮次数”，关停排空等的就是这个数归零。监听任务里的卸载触发也依赖它：线程既没有订阅者、又不处于活动态，持续 `thread_unload_delay_secs`（默认 60 秒，`codex-rs/core/src/config/mod.rs:3900`）之后就被卸载。core 自己创建的线程（例如子代理）会经 `ThreadManager` 的广播被发现，处理循环把它们自动挂到所有已初始化的连接上。

所有出站消息最后汇成 `OutgoingEnvelope`：发给某个连接，或广播。出站路由（`codex-rs/app-server/src/transport.rs:204` 的 `route_outgoing_envelope`）逐连接过滤：没开实验开关的连接收不到实验性通知，`initialize` 里 `optOutNotificationMethods` 列出的方法也被跳过。stdio 连接的写队列容量是 128（`CHANNEL_CAPACITY`），WebSocket 与 Unix socket 连接放宽到 `32 * 1024`（`WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY`，注释说客户端在输出突发时可能短暂落后）；这类可以断开的连接一旦队列写满就被直接断开，日志写 `disconnecting slow connection after outbound queue filled`，stdio 连接则原地等待。

## 服务端请求：审批怎么一来一回

模型要跑一条需要批准的命令时，core 发出 `EventMsg::ExecApprovalRequest`，之后的往返是这样的：

```mermaid
sequenceDiagram
  participant C as codex-core 会话
  participant L as 线程监听任务
  participant O as OutgoingMessageSender
  participant F as 前端
  participant H as 回调任务
  C->>L: ExecApprovalRequest 事件
  L->>L: 线程状态记一笔待审批
  L->>O: send_request 命令审批
  O->>O: 分配整数 id，登记 oneshot 回调
  O->>F: item/commandExecution/requestApproval
  L->>H: spawn 等待 oneshot
  F-->>O: 同一个 id 的 JSON-RPC 响应
  O->>H: 回调收到结果
  H->>C: submit Op ExecApproval
```

几个细节：

- 服务端请求的 id 来自 `OutgoingMessageSender` 里一个自增整数；请求发给订阅该线程的所有连接，谁先答复算谁的，回调一旦被取走，后到的答复只会得到一条 `could not find callback` 警告。
- 某个连接通过 `thread/resume` 重新加入一个线程时，服务端会把该线程还没答复的请求重发给它（`replay_requests_to_connection_for_thread`），断线重连不会把审批弄丢。
- 新一轮开始、本轮结束或被中断时，`abort_pending_server_requests` 用一个 `data.reason` 为 `turnTransition` 的错误结束所有悬而未决的请求，回调任务见到这种错误就直接返回，不再向 core 提交决定。
- 答复解析失败或客户端回了错误，都按拒绝处理，宁可不执行，不会默认放行。文件修改审批的映射最直观：

```rust
fn map_file_change_approval_decision(decision: FileChangeApprovalDecision) -> ReviewDecision {
    match decision {
        FileChangeApprovalDecision::Accept => ReviewDecision::Approved,
        FileChangeApprovalDecision::AcceptForSession => ReviewDecision::ApprovedForSession,
        FileChangeApprovalDecision::Decline => ReviewDecision::denied("rejected by user"),
        FileChangeApprovalDecision::Cancel => ReviewDecision::Abort,
    }
}
// ...
    drop(permission_guard);
// ...
    if let Err(err) = codex
        .submit(Op::PatchApproval {
            id: item_id,
            decision,
        })
        .await
```

（`codex-rs/app-server/src/bespoke_event_handling.rs:1908`）

`permission_guard` 是 `ThreadWatchManager` 发的守卫，析构时把待审批计数减一，线程状态随之从 `WaitingOnApproval` 退回普通活动态。向用户提问（`item/tool/requestUserInput`）、MCP 的 elicitation、动态工具调用（`item/tool/call`）都是同一个模式：发请求、登记回调、另起任务等答复，再把结果换成 `Op` 交回 core。

## 进程内与远程两种客户端

`InProcessAppServerClient` 把类型化的 `ClientRequest` 直接送进 `MessageProcessor::process_client_request`，省掉了 JSON 反序列化，但结果仍然是 JSON-RPC 的 result 信封（`RequestResult`）；README 解释说这是有意为之，进程内路径只去掉进程边界，不引入第二套响应契约。背压策略写在 `in_process.rs` 里：运行时队列全部有界，普通通知在队列满时丢弃并记一条日志；`server_notification_requires_delivery`（`codex-rs/app-server/src/in_process.rs:114`）列出的少数通知必须送达，会等队列腾出空位，包括 `turn/completed`、线程队列与设置的变更、附件变更，以及异步投递的 agent 消息完成。服务端请求排不进队列时不会被悄悄丢掉，而是以 `OVERLOADED_ERROR_CODE` 失败回 `MessageProcessor`，免得审批永远挂着。门面这一层再加一个工作任务：请求在独立任务里等待，面向调用方的事件队列是无界的，这样调用方在等某个请求的响应时，排在前面没读的通知不会把响应堵死。另外，进程内客户端不支持 `account/chatgptAuthTokens/refresh`，工作任务收到就直接以 `-32000` 拒绝。

`RemoteAppServerClient`（`codex-rs/app-server-client/src/remote.rs:161`）总是讲 WebSocket 帧：要么是 TCP 上的 `ws://`、`wss://`，要么是本机 Unix socket 上的 WebSocket（握手 URL 固定写成 `ws://localhost/rpc`，字节实际走 socket）。连接与 `initialize` 各有 10 秒超时，单条消息上限 128 MiB，bearer token 只允许用在 `wss://` 或回环地址的 `ws://` 上；对端发来不认识的服务端请求，回 `-32601`。守护进程怎么启动、TUI 何时自动连上它，见[守护进程与传输](https://daiw.net/manual/codex-source/daemon-and-transport)。

## 和《从 LLM 到 Coding Agent》对照

那本书的 [permissions](https://daiw.net/manual/llm-to-agent/permissions) 把权限闸门写在同一进程里：`canUseTool` 判出 `ask`，就 `await` 一次 `promptUser` 等用户点头。Codex 把“问用户”这一步拆成了跨进程的协议往返：core 只负责发出审批事件、等待 `Op`，谁来问、在哪台机器上问、同时有几个窗口可以答，都由 app-server 和前端决定。那本书强调的 fail-closed 在这里更显必要：跨进程的答复可能丢失、超时或格式不对，所以一律按拒绝处理。

OpenCode 走的是同一条路：[HTTP 服务端](https://daiw.net/manual/opencode/server)一篇里，它的终端、Web、桌面端与 SDK 都通过同一套 HTTP API 访问内核，TUI 则把同一棵路由树包成进程内的 `fetch` 调用。Codex 的差别在于协议是双向的 JSON-RPC，服务端可以反过来向客户端发请求。

---

上一篇：[一条消息的生命周期 · 从按下回车到看到回复](https://daiw.net/manual/codex-source/message-lifecycle) · 下一篇：[v2 协议 · thread、turn 与 item](https://daiw.net/manual/codex-source/app-server-protocol)
