同一个 codex,为什么会启动几套不同的系统
从 CLI 路由器追到 TUI、exec、app-server 与 mcp-server,分清 client model、transport、SessionSource、core 汇合点和各自终态。
在 shell 里输入的都叫 codex,很容易让人形成一个过于整齐的模型:CLI 解析参数,然后启动同一套 Agent runtime,只是输出方式不同。这个模型能解释命令名,却解释不了几个很实际的问题:为什么 TUI 可以连接另一进程里的 app-server,为什么 exec 在 turn 完成后退出,而 app-server 还在等下一条 JSON-RPC;为什么 codex mcp-server 能让外部 Model Context Protocol(MCP)客户端调用 Codex,codex mcp 却分到配置 / OAuth 路径,只有部分读取与认证命令进入 MCP manager。
源码里的 MultitoolCli 已经给出第一条边界。顶层参数同时包含交互 TUI 参数和可选 Subcommand;没有子命令时,参数转给 interactive CLI。Subcommand 则把 exec、mcp-server、app-server、mcp 等入口分开。这里的 codex 更接近进程路由器,不代表路由之后仍是一套协议、一种 client 或一个退出条件。
验证锚点:先看这个版本公开了哪些入口
先按第一部导读创建 disposable archive,再在它的 codex-rs 目录运行下面的命令。它会构建本次指定的 codex-cli package / codex binary target,然后打印这个 tag 的公开帮助;这里不把构建耗时、缓存状态或机器目录当实验结果。
: "${ARCHIVE_CODEX_RS:?先执行第一部导读的 archive 准备脚本}"
host="$(rustc -vV | sed -n 's/^host: //p')"
test "$host" = aarch64-apple-darwin
cd "$ARCHIVE_CODEX_RS"
cargo run --locked -q -p codex-cli --bin codex -- --help
在本章固定版本、目标 host aarch64-apple-darwin 上,Commands 的可见名称依次是:
exec, review, login, logout, mcp, plugin, mcp-server, app-server,
remote-control, app, completion, update, doctor, sandbox, debug, apply,
resume, archive, delete, unarchive, fork, cloud, exec-server, features, help
这是一份确定的 visible Commands list,不是完整 help:同一输出后面还有 arguments 与 options。命令里的 host 断言会在其他 target 上直接失败,因为下面的精确列表只对应本章记录的 aarch64-apple-darwin 构建;它不能被当作跨平台快照。若只想观察当前平台,可以保留 host 输出并运行最后一条 cargo run,但不要把结果与本章列表逐项对账。源码中 app 仅在 macOS / Windows cfg 下编译,app-server、remote-control、cloud、exec-server 属于 experimental 入口;另有被 Clap 标为 hidden 的内部入口。命令在 help 中可见不等于 stable 或 supported,不同 OS、编译配置和后续版本都可能改变帮助输出。本章只把这次目标构建公开显示的命令当作实验观察,不把 hidden 入口改写成公开用法。
入口矩阵:相同 binary 之后,合同已经分叉
先把主要入口放在同一张表里。六列分别记录路由、客户端、线协议、来源元数据、完成或退出的观察层,以及第一次碰到的 core 汇合点。表里的 JSONL 指 JSON Lines,也就是一行一个 JSON 对象的输出格式。session source 不是 transport,也不是唯一 client identity。
| binary / route | client | protocol / wire | session source | 完成 / 退出边界 | shared-core boundary |
|---|---|---|---|---|---|
codex(无子命令) | TUI -> InProcess 或 RemoteAppServerClient | typed app-server;remote client 用 WS frames over TCP 或 Unix domain socket(UDS) | embedded 新 thread 默认 Cli;daemon/remote 用 server 默认;resume 按 history 形态决定 source | TUI run_interactive_tui: io::Result<AppExitInfo>;顶层 handle_app_exit 映射 user/fatal | app-server runtime -> ThreadManager -> CodexThread |
codex exec / review | exec human / JSONL projector | in-process app-server client model | 新 thread 默认 Exec;stored resume 可保留 metadata,Forked history 用 manager defaults | turn: 目标 turn 的 terminal notification 可让 projector 收束;process: stream closure 也可退出 | app-server runtime -> ThreadManager -> CodexThread |
codex app-server | 本地或 Remote Control client | 本地 stdio / WebSocket / Unix selector;Remote Control 独立 | 新 thread 默认 VSCode metadata;resume 按 stored/Forked history 分支 | process: stdio 最后连接关闭;非 stdio signal drain;transport channel / outbound router closure | app-server runtime -> ThreadManager -> CodexThread |
codex mcp-server | 外部 MCP client | MCP JSON-RPC over stdio,另发 codex/event notification | codex 新 thread 默认 Mcp;codex-reply 沿用既有 thread | request: tools/call result;process: stdin EOF / channel closure | 自己创建 ThreadManager -> CodexThread |
codex mcp list/get/add/... | CLI 配置、认证与 OAuth 命令 | 配置读写、OAuth discovery / login 与 auth status | management handlers 覆盖 config / OAuth / McpManager;不创建 Codex thread;SessionSource/ThreadSource: N/A | command: 对应配置、认证或 OAuth 动作完成 | config / OAuth / McpManager;与 codex mcp-server 的 server route 分开,不进入 Agent runtime |
表外只补一条恢复规则:stored rollout/path 恢复可以从 SessionMeta 保留原 source;显式 ThreadResumeParams.history 则走 InitialHistory::Forked,但 resumed-source lookup 不读取 Forked metadata,因此使用 manager 默认值(SessionSource,且 ThreadSource=None)。codex-reply 使用既有 live thread,但不能据此断言它与 codex 必然有不同的 thread source。
TUI 仍然保留 app-server client model
顶层 dispatch 的 None 分支进入 run_interactive_tui,后者调用 codex_tui::run_main。中间函数的合同是 io::Result<AppExitInfo>;返回顶层后由 handle_app_exit 把 UserRequested(user)映射为正常返回,把 Fatal 映射为进程退出。不能把顶层 route 本身写成 Result<AppExitInfo>。thread routing、buffer、replay filter、ChatWidget 状态和审批 overlay 属于第 31 章,本章不展开 UI 如何还原事件流。
这个 tag 的 TUI 先在 launch target 层选择 Embedded、LocalDaemon 或 Remote。显式 endpoint 产生 Remote;仅 Unix 平台会对可复用的 launch config 做隐式 probe,而且只探测默认 UDS socket,探测不完成 app-server/WebSocket handshake,命中后才产生 LocalDaemon;两者都没有时才是 Embedded。这里的 target 描述启动选择,不是 client 类型或 wire 名称。
客户端实现(client implementation)层只有 InProcess 与 RemoteAppServerClient 两种。Embedded target 映射到 InProcessAppServerClient;LocalDaemon 和 Remote target 都映射到同一个 remote client implementation。两边最终被包装为 AppServerClient,所以进程边界可以变化,server 的 request / notification / event model 仍然存在。
远端客户端的线协议(wire)统一承载 WebSocket frames,底层连接才分成 TCP WebSocket URL 与 Unix socket。Unix socket 分支仍会做 WebSocket handshake;因此 target 名里的 Remote、client enum 里的 Remote 和 wire 层的 TCP / UDS 是三个不同问题。
InProcessAppServerClient 自己的注释把这条边界说得很清楚:它桥接 TUI / exec 使用的 async channel 与 embedded app-server,刻意保留 server 的请求、通知和事件模型,并不暴露 core runtime handle。因而“TUI 直接调用 core”在这个版本上是不准确的;它跳过了 app-server runtime 这一层真实存在的合同。
embedded 启动参数里,TUI 为新 thread 提供 SessionSource::Cli 默认值,同时另写 client_name = codex-tui。这两个字段并列,已经说明来源元数据和客户端身份不是同一个概念。daemon / remote target 则由所连接的 server 提供新 thread 默认值;resume 的来源例外以入口矩阵为准,完整重建边界留到第 27 章。
remote transport 的连接关闭、读取错误或 EOF 会先产生 Disconnected。在主 App 事件处理路径中,TUI 再把 Disconnected 转成 FatalExitRequest,event dispatch 最终形成 ExitReason::Fatal。这条链只证明远端断连在这条路径上可以成为 TUI 的 fatal route 结果,不代表本章需要展开普通 notification 的 UI 投影。
exec 选择目标工作,然后主动收束生命周期
exec 保留 app-server client model,但只走 in-process 路径,并自行选择工作对象。新 thread 的 source 默认是 Exec,client name 是 codex_exec;resume 先解析目标 thread,再发 thread/resume,没有可恢复目标或普通执行时才发 thread/start。
选好 thread 后,普通 prompt 发 turn/start,review 发 review/start,并保存 response 中的目标 turn id。输出层也由 exec 决定:--json 选择 JSONL projector,否则选择 human projector。两者消费的是 typed app-server notifications,不是 TUI widget event。
进入 projector 前,exec 先处理 error_seen,并为非 ephemeral 的 turn/completed 做 backfill;随后才按目标 thread/turn 范围过滤 notification。ConfigWarning 与 DeprecationNotice 无条件通过;Warning 没有 thread id 时通过,带 id 时匹配 primary thread。HookStarted 与 HookCompleted 必须匹配 primary thread;turn id 缺失时仍通过,存在时匹配目标 turn。其余 relevant notifications 要求 primary thread 与目标 turn exact match,未列出的 variant 返回 false。按目标 thread/turn 过滤进入 projector 是最后一道门。
目标 turn/completed 进入 projector 后,human projector 对 completed、failed、interrupted 都返回 InitiateShutdown。JSONL projector 对 completed 发 TurnCompleted,对 failed 发 TurnFailed;interrupted 不发 dedicated terminal JSONL record,但同样返回 InitiateShutdown。失败或中断会设置 error_seen,最后映射成退出码 1。
process 也可能在没有 projector 决议时结束。event stream 的 next_event() == None 会直接 break,并跳过 thread/unsubscribe,但仍执行 best-effort client.shutdown 与 print_final_output。关闭本身不设置 error_seen;没有更早错误时返回 Ok(()),对应退出码 0。若 JSONL stream 在目标 terminal notification 之前关闭,输出不会凭空出现 TurnCompleted 或 TurnFailed。
app-server 的进程终止属于外层 runtime,不属于一次 turn
codex app-server 的 CLI 分支把 transport 和 session_source 分开传给 run_main_with_transport_options。当前 tag 为新 thread 默认使用 SessionSource::VSCode;resume 的来源规则已经在入口矩阵统一说明。VSCode 只是来源元数据,不能据此推导 client 一定是 VS Code,也不能把它解释成 stdio、WebSocket 或 Unix socket。
进入 runtime 后,session_source 被放进 MessageProcessorArgs 并传给 MessageProcessor::new;processor 解构该字段,再把它传给新建的 ThreadManager。
AppServerTransport 只选择本地 endpoint。stdio、Unix socket 与 WebSocket 会启动本地 connection / acceptor;Off 不启动本地 endpoint,Remote Control 是独立的 connection source。Remote Control 可与 stdio、Unix socket 或 WebSocket 共存,在 Off 下也可作为唯一入口。没有本地 endpoint 时,Remote Control 必须在 policy / state 条件允许下启用,或从持久配置解析为可用;否则入口返回 no transport configured。这里的入口可用只证明 server 有连接来源,不代表远端 client 已经建立连接。
从 client 边界可以观察到公开 turn/completed method;它是 server notification method,不是 ClientRequest。进入外层 event loop 后,外层退出判定来自共享连接表、signal drain、transport channel closure 或 outbound router closure:graceful drain 通过 watched running-turn count 间接等待 core turn 完成并收束;第二个 forceable signal 可跳过等待直接结束;stdio 还通过 shutdown_when_no_connections 在最后连接关闭时收束。channel 与 router closure 是共同出口。
本章把 app-server 当作入口黑盒;第 30 章解释 initialize、thread/turn/item、双向请求、事件投影和 cleanup。
mcp 与 mcp-server 的方向相反
codex mcp 的 management handlers(管理命令)处理配置与认证:add 和 remove 直接读写配置(config edit),其中 add 还处理 OAuth discovery / login;list、get、login、logout 使用 McpManager。这个 route 定义六项业务动作,Clap 另生成 help。codex mcp-server 是另一条 server route,它让 Codex 成为对外 MCP server。
codex mcp-server 则让 Codex 自己成为对外 MCP server。它从 stdin 读取 MCP JSON-RPC,MessageProcessor 以 SessionSource::Mcp 作为新 thread 默认值创建 ThreadManager。收到 tools/call 后,processor 先按 tool name 分到 codex 或 codex-reply;codex 在参数与配置解析成功后 spawn handler,codex-reply 则在参数解析成功并找到 live thread 后 spawn handler。codex 通过 start_thread 新建 thread,codex-reply 通过 get_thread 复用 live thread;不要把这两条路径概括成必然不同的 thread source。
codex handler 调用 runner 后,由 ThreadManager::start_thread 新建 thread,随后把 MCP request id 转成 string,构造 Submission 并提交初始 prompt。它创建的是一条新 core thread 生命周期。
codex-reply handler 先用 thread_manager.get_thread 取得既有 CodexThread,找不到就直接返回错误;找到后才 spawn 这次长请求的 reply handler,并向该 thread 提交 Op::UserInput。get_thread 只查当前 manager 的 in-memory map,并排除 internal thread,不会 cold-resume rollout;因此 reply 复用已有 thread 及其 source,不调用 start_thread。
这里也有两层完成。tool runner 看见 core TurnComplete 后,把最后一条 agent message(缺失时使用空串)组装成 MCP CallToolResult,回应当前 tools/call 并停止这一个 request 的 event loop;mcp-server 进程仍在处理后续输入。常规进程终止条件是 stdin EOF 引发 channel 逐层关闭,再等待 reader、processor 和 writer task 结束。
此外,MCP server 会把 core Event 序列化为方法名 codex/event 的 notification。这个 wire shape 与 app-server 对外暴露的 typed turn/completed 不同;本章只记录两个可观察 interface,不解释 app-server 内部怎样完成映射。
CodexThread 是共享 conduit,不是统一上层协议
不同入口最后确实会汇合。app-server runtime 与 mcp-server 都创建 ThreadManager,manager 再管理 Arc<CodexThread>。CodexThread 保存 Codex、session_source、首次 SessionConfiguredEvent 与 rollout path;公开操作包括提交 Op、等待 Event、shutdown 和等待底层 session loop 终止。源码注释称它为组成一个 thread 的双向消息 conduit,这个措辞比“统一 runtime API”更准确。
SessionSource enum 里有 Cli、VSCode、Exec、Mcp 等值。这是附着在线程上的来源分类。它不会选择 app-server 的 transport,不足以识别一个具体连接,也不定义 client 如何显示审批请求。尤其是 VSCode:在 app-server 默认入口中它与可选 transport 分开传入,不能把这个 metadata 当成“当前连接一定来自 VS Code”的运行时证明。
因此,在本章比较的入口与终止条件里,CodexThread 提供共享的 Op / Event conduit、来源元数据与 shutdown 能力,却不规定 JSON-RPC / MCP wire shape、human / JSONL / TUI 投影、审批交互或各入口的终止条件。这是本章范围内的边界,不是对 CodexThread 全部职责的穷举。
一张只画入口汇合点的图
下面的图只回答“各入口在哪里汇合”,刻意不把边上的协议画成相同。TUI 和 exec 使用 app-server client model;app-server 对外承担 JSON-RPC transport;mcp-server 则直接持有自己的 ThreadManager。mcp management handlers 分到 config / OAuth 与 McpManager 两路;codex mcp-server 是独立的 server route。
flowchart TD
accTitle: Codex runtime 入口与共享 core 汇合点
accDescr: codex CLI 分派到 TUI、exec、app-server、mcp-server 和 mcp management。TUI 的 Embedded target 映射到 InProcess client,LocalDaemon 或 Remote target 映射到 Remote client,后者以 WS frames 运行在 TCP 或 UDS 上。app-server 的本地 selector 与 Remote Control 可独立进入同一 runtime,Off 只是不创建本地 acceptor。TUI、exec 与 app-server 进入 app-server runtime,再进入 ThreadManager;mcp-server 直接创建 ThreadManager。mcp management handlers 分到 config OAuth 与 McpManager 两路,与 mcp-server 的 server route 分开。
CLI["codex CLI router"] --> TUI["TUI client"]
CLI --> EXEC["exec client / projector"]
CLI --> AS["app-server JSON-RPC service"]
CLI --> MS["mcp-server"]
CLI --> MM["mcp management"]
TUI --> ET["Embedded target"] -->|"InProcess"| AR["app-server runtime"]
TUI --> RT["LocalDaemon/Remote target"] -->|"Remote client"| RW["WS frames"]
RW -->|"TCP or UDS"| AR
EXEC -->|"typed in-process client"| AR
AS -->|"local stdio / WS / Unix"| AR
AS -->|"Remote Control; independent"| AR
AS -.->|"Off: no local acceptor"| OFF["no local endpoint"]
AR --> TM["ThreadManager"]
MS -->|"MCP stdio; direct core event consumer"| TM
TM --> CT["CodexThread: Op / Event conduit"]
MM --> CFG["config edit / OAuth"]
MM -->|"list/get/login/logout"| MCM["codex_core::McpManager"]
CFG -.-> EXT
MCM -.-> EXT["external MCP servers"]
图里没有从 TUI 直接连到 CodexThread,也没有让 mcp-server 经过 app-server runtime。这两条看似对称的简化都会改写当前 tag 的真实 client boundary。
四种“完成”不能互换
这几个入口最容易在日志里制造假结论,因为它们都使用了 complete / completed 之类的词:
| 观察到的状态 | 它能证明什么 | 它不能证明什么 |
|---|---|---|
core EventMsg::TurnComplete | core 为一个 turn 发出了终态事件 | app-server 进程已经退出 |
app-server turn/completed | client 观察到公开的 turn-level completion method | app-server 已 return,或外部 transport 已关闭 |
exec 因目标 turn/completed 的 InitiateShutdown 正常收束 | 目标 thread/turn 的 projector 已决定 shutdown,且退出码逻辑完成 | event-stream closure、启动失败或其他 client 退出 |
MCP tools/call result | 当前 MCP request 得到了成功或错误结果 | mcp-server stdin loop 已终止 |
这些状态来自不同观察边界,名称相近也不存在类型等价。表格只比较可观察结果,不解释各协议内部怎样生成这些结果。
负向边界:入口矩阵不能替你证明什么
第一,InProcessAppServerClient 没有进程间 socket,不等于 TUI 或 exec 直接调用 core。in-process 只消除了进程 transport,facade 仍保留 app-server 的 request、notification 与 event model。
第二,codex mcp 执行成功对应 config、认证或 OAuth handler 的结果,其中读取与认证动作会使用 McpManager。codex mcp-server 是另一条 server route,是否正在接受连接需要沿该 route 单独验证。
从入口矩阵转向配置合并
入口矩阵已经证明不同 route 会携带不同 client、transport 和来源元数据。第 3 章换一个更窄的观察面:以常规 TUI 为可追踪样本,检查 loader/config inputs 怎样进入 shared layer precedence,再看 root flags、profile、配置文件和 typed override 如何合并。它不逐条复刻 exec、app-server 或 mcp-server 的完整配置入口。继续读《Codex 启动时,哪一份配置说了算》。