跳转至

第 15 章 运行时与协议

我们从最内圈的循环,一层层拆到了评估和观测。现在到最外层——运行时(runtime)。前面造好的这一整套 core,到底怎么被调用起来?是你在终端敲命令、在 IDE 里点按钮、在 CI 流水线里跑脚本,还是被另一个 agent 当工具调用?这一章讲这个关口。

引子:core 造好了,谁来按下启动键

到上一章为止,我们拆的都是 codex-core 内部的事——循环、上下文、工具、安全、长程……但 core 本身只是一个"业务逻辑库"(还记得第 2 章吗,它的目标是成为一个可被各种 UI 复用的库)。它不自带界面,也不自己启动。

真正"按下启动键"的,是外面那一圈宿主:命令行 TUI、IDE 扩展、自动化脚本、甚至另一个 agent。这一章的核心洞见是:这些宿主形态各异,但它们和 core 对话的方式是统一的——而这统一,正建立在第 3 章那个 Op / Event 双队列之上。

但"统一"不会从天上掉下来。把一个内存里的双队列,变成一个能跨进程、跨语言、跨版本演化、还能同时服务多个客户端的协议,中间有大量工程纪律。这一章就把这层"产品化"拆开:app-server 那一整窝 crate 各自管什么、握手怎么做、通知怎么退订、版本怎么冻结;headless 的 exec 凭什么知道"我干完了"可以退出;以及同一套 core 怎么用一行提示词切换"性格"。

这是全书把 harness "装回外壳"的一章——前面拆的是引擎,这章拆的是接口与外壳

一、framework、runtime、harness:先分清三个词

LangChain 在《Agent Frameworks, Runtimes, and Harnesses, Oh My!》里提过一个有用的区分,帮你定位这一章在讲什么:

  • framework(框架):你把代码填进它的骨架、按它的约定写(经典的"别打电话给我们,我们会打给你"——控制权在框架手里)。
  • runtime(运行时):真正把 agent 跑起来的那层——管会话、状态、重试、并发、进程生命周期、跨进程通信。
  • harness(挽具):围绕模型的整套可控环境与反馈回路(也就是本书一直在拆的东西)——上下文怎么拼、工具怎么给、沙箱怎么围、循环怎么停。

这三者不是同义词,也不是层层包含的关系。一个产品可以"是 harness、也提供 runtime、但不强迫你用 framework"。Codex 就是这样:它不是一个让你继承基类、填回调的 framework;它是一套强观点的 harness,外加一层把这套 harness "跑起来并暴露出去"的 runtime

这一章讲的 app-server、exec,就是 Codex 的 runtime 面——把 harness 产品化成"可被各种前端调用的服务"。换句话说,前 13 章拆的是 harness 的内涵(喂什么、围什么、怎么循环),这一章拆的是 harness 的接口(谁来调、怎么调、调用契约怎么不腐烂)。

〔配图 1:framework / runtime / harness 三词定位〕——一张三栏对照手绘卡。左栏 framework:画一个"模板挖空"的骨架,箭头从框架指向你的代码(标"别打给我们");中栏 runtime:画一个齿轮+进程框+重试环,标"把 agent 跑起来:会话/状态/并发/跨进程";右栏 harness:复用第 2 章同心圈母题,标"围绕模型的可控环境与反馈回路"。底部一行橙字:"Codex = 强观点 harness + 一层 runtime,而不是让你填空的 framework。本章讲 runtime 面。" 中文清晰。

二、app-server:把双队列变成一套协议

core 怎么被一个图形界面(比如官方的 Codex VS Code 扩展)驱动?答案是 app-server——它是一个长驻的 JSON-RPC 2.0 进程,前端把它当后端:发请求、收通知、流式渲染 agent 的每一步。OpenAI 官方文档把它定位为"power rich clients like the Codex VS Code extension";而 OpenAI 官方博客《Unlocking the Codex harness: how we built the App Server》强调的是:Web、CLI、IDE 扩展、macOS App 这些 surface 共享同一套 Codex harness。这两句话不要混为一谈:共享同一 harness ≠ 全都直接连同一个独立 app-server 进程。rich clients 主要走 app-server;CLI / exec 则还能复用进程内app-server-client 语义。

它的灵魂你在第 3 章已经见过——提交 Op、接收 Event。app-server 没发明新范式,它只是把这套"双队列"产品化、规范化、跨进程化app-server/README.md 开篇就把这层关系点明了:"Similar to MCP, codex app-server supports bidirectional communication using JSON-RPC 2.0 messages(线上省略 "jsonrpc":"2.0" 头)。"——和第 9 章的 MCP 是同一种通信底座,只是方向反过来:MCP 是 Codex 当客户端去调别人,app-server 是别人当客户端来调 Codex。

JSON-RPC 2.0 本身极简:带 id 的是请求(要回包),不带 id 的是通知(不回包)。app-server 把这两类映射得很自然——前端的操作(开会话、发一轮输入、打断)是带 id 的请求;core 流出来的一串串中间状态(模型在说话、工具在跑、推理笔记)是不带 id 的通知。请求 ≈ 上行 Op,通知 ≈ 下行 Event

2.1 六个 crate,各管一段

app-server 不是一个大文件,而是一窝分工明确的 crate。把它们摆开看,正好对应"一个跨进程服务"要操心的几件事:

crate 职责 一句话
app-server-protocol 协议类型与契约 定义所有 *Params/*Response/*Notification、版本 v1/v2、camelCase 序列化
app-server 协议服务端实现 真正接请求、驱动 core、流出通知;codex app-server 命令的本体
app-server-transport 连接生命周期 stdio / websocket / unix-socket 三种传输;每条连接一个 ConnectionId、带背压的出站队列
app-server-daemon 多客户端 / 远程管理 把 app-server 作为后台进程托管,供桌面/移动端经 SSH 远程接入
app-server-client 进程内客户端 给 TUI、exec 复用的"内嵌 app-server"启动+握手+收发封装
app-server-test-client 调试/测试客户端 命令行打 app-server、watch 原始流量、测试断线重连

这套切分的妙处在于:协议(contract)和实现(impl)分开,传输(transport)和业务(business)分开。前端只依赖 app-server-protocol 里的类型(还能从它生成 TypeScript 定义),就能稳定对接;至于底层走 stdio 还是 websocket、是本地拉起还是 SSH 远程托管,都被 transport / daemon 吸收掉了。

2.2 transport:一条连接的一生

app-server-transport 管的是"连接生命周期"——这正是 runtime 区别于 framework 的地方。它支持三种传输(见 app-server/README.md):

  • stdio(默认,--stdio):换行分隔的 JSON(JSONL),VS Code 扩展就是 spawn 出 app-server 子进程,然后在它的 stdin/stdout 上读写 JSONL。
  • websocket--listen ws://IP:PORT实验性/不支持生产):一条 JSON-RPC 消息一个文本帧;同一监听端口还顺带提供 /readyz/healthz 健康探针。
  • unix socket--listen unix://):本地控制面客户端用,走 $CODEX_HOME/app-server-control/ 下的 socket。

每来一条连接,transport 就发一个单调递增的 ConnectionId,并为它挂一条有界的出站队列——这意味着 app-server 天然是多客户端的:多个前端可以同时连同一个 app-server,各自有独立的订阅与背压。队列"有界"是个关键细节:如果某个客户端消费太慢,系统不会无限堆积内存,而是触发明确的过载/滞后信号(后面 2.6 还会提到进程内版的 Lagged)。

// app-server-transport:每条连接一个递增 ID
fn next_connection_id() -> ConnectionId {
    ConnectionId(CONNECTION_ID_COUNTER.fetch_add(1, Ordering::Relaxed))
}

解读:就这一行原子自增,就把"多客户端"这件事变成了一等公民——连接不是隐式假设,而是被显式编号、各自治理。

2.3 daemon:让 app-server 在别的机器上也能被"按下启动键"

app-server-daemon 是这窝里最"运维"的一个 crate(它自己标注为 experimental)。它解决的问题是:当 app-server 要跑在一台远程开发机上、被桌面或移动端经 SSH 接管时,谁来负责"把它当后台服务启动/重启/升级"?

它给出一套机器可读的生命周期命令——codex app-server daemon start / restart / stop / enable-remote-control / bootstrap …——每条命令成功后只往 stdout 写一个 JSON 对象(消费者解析 JSON,不要去解析人类可读文本)。daemon 用 pidfile 做守护化、用文件锁把所有改动型生命周期操作CODEX_HOME 串行化(避免并发 start/stop 打架),还能跑一个分离的 updater 循环定时拉新二进制、热替换正在运行的 app-server。

这一段的意义对应主线那句"harness 随模型演化"的运维侧:模型在变,harness 二进制也在变,daemon 就是让"远程那台一直跑着的 agent"能平滑跟上版本的机制。

2.4 握手:initialize / initialized,以及"通知退订"

跨进程协议必须先握手,否则双方连对方支持什么都不知道。app-server 的握手是两步(见 app-server/README.md 的 Initialization 节):

  1. 客户端发 initialize 请求,带上 clientInfoname/title/version)——这个 name 还用于 OpenAI 合规日志识别客户端。
  2. 客户端回一个 initialized 通知确认。

在这之前,任何其它请求都会被拒(返回 "Not initialized");重复 initialize 也会被拒("Already initialized")。服务端在握手响应里回报它将向上游呈现的 user agent、codexHome 目录、以及 platformFamily/platformOs 等运行时信息。这是典型的"能力协商"——一次性把双方的身份和环境对齐。

握手里还藏着一个很能体现"对 agent 可见的才存在"反面的设计:通知退订 optOutNotificationMethods。前端在 initialize.params.capabilities 里可以列出一串精确方法名,声明"这些通知别发给我了":

"capabilities": {
  "experimentalApi": true,
  "optOutNotificationMethods": ["thread/started", "item/agentMessage/delta"]
}

解读:匹配是精确匹配(无通配/前缀),未知方法名会被接受并忽略。它的价值是按连接裁剪噪声——比如一个只想要最终结果、不想要逐字 delta 的脚本式客户端,可以把高频的 item/agentMessage/delta 退订掉,省带宽也省自己的解析开销。同一个 core,不同前端按需订阅不同的事件子集,这正是"双队列产品化"后多出来的弹性。

2.5 一次对话的生命周期:thread / turn / item

握手完,真正的工作流是三层嵌套的对象模型(README 的 Lifecycle Overview 讲得很清楚):

  • thread(线程/会话)thread/start 开新会话、thread/resume 续上旧的、thread/fork 从已有历史分叉。ephemeral: true 表示纯内存、不落盘(此时 thread.pathnull)——这和 exec 的 --ephemeral 是同一个概念。
  • turn(一轮)turn/start 把用户输入挂到 thread 上、触发一次生成;turn/interrupt 打断;turn/steer 在不另起一轮的情况下追加输入。
  • item(一项):一轮里流出来的各种增量——item/starteditem/completeditem/agentMessage/delta(逐字)、工具进度等。

收尾事件 turn/completed 带回这一轮的最终状态和 token 用量——记住这个事件,它是下一节 headless 完成判定的关键。订阅模型也有讲究:thread/start 会自动把你订阅到该 thread 的 turn/item 事件;thread/unsubscribe 退订后若没有别的订阅者,服务端不会立刻卸载,而是"无订阅且无活动满 30 分钟"才卸载并发 thread/closed——这是为"前端断开重连"留的窗口。

〔配图 2:app-server 一次对话的生命周期〕——一条横向泳道时间线。左端"打开连接"→ initialize(请求,带 clientInfo)→ initialized(通知)→ 一道竖虚线标"握手前任何请求被拒"。之后 thread/start(请求)→ thread/started(通知)→ turn/start(请求)→ 中段密集流出一串通知 item/started · item/agentMessage/delta · item/completed(标"≈ 下行 Event 流")→ turn/completed(通知,标"带 token 用量 · headless 据此收尾")。泳道上方一根回流箭头标 turn/interrupt / turn/steer。底部橙字:"请求≈上行 Op,通知≈下行 Event——第 3 章的双队列,被握手、版本与订阅纪律包了起来。" 中文清晰。

2.6 进程内的同一套协议:app-server-client

有意思的是,TUI 和 exec 自己也是 app-server 的客户端——只不过是"进程内"的。app-server-client 这个 crate 就是给它们复用的封装:启动一个进程内的 app-server runtime,做完 initialize 握手,把请求/事件用类型化通道接起来(client→server 是 ClientRequest/ClientNotification,server→client 是 ServerRequest/ServerNotification/LegacyNotification),再管好优雅关停。

它特意强调:"in-process path is meant to preserve app-server semantics while removing the process boundary, not to introduce a second response contract."——进程内只是省掉了进程边界,语义和契约和跨进程版完全一致。这是个漂亮的统一:TUI 内嵌跑、exec 内嵌跑、VS Code 跨进程跑,走的是同一套协议、同一套 thread/turn 流程,只是传输层不同。队列同样有界,落后了就发 InProcessServerEvent::Lagged——和跨进程版的背压设计一脉相承。

三、协议演进治理:v1 冻结、v2 开发,以及一堆"接口纪律"

一套要被外部产品长期对接的协议,最怕的不是写得不够多,而是改得太随意。Codex 在根目录 AGENTS.md(第 242–256 行)里写下了一份相当硬核的"App-server API 开发最佳实践",可以当成"协议演进治理"的范本来读:

  • v1 冻结,v2 开发AGENTS.md 第 242 行逐字):"All active API development should happen in app-server v2. Do not add new API surface area to v1."——老接口保持兼容、不再加新表面积,新功能一律去 v2。这是典型的"版本即承诺":已经发出去被各路前端依赖的 v1,宁可冻结也不动。
  • 命名约定(第 243–245 行):请求载荷叫 *Params、响应叫 *Response、通知叫 *Notification;RPC 方法名形如 <resource>/<method> 且 resource 用单数——thread/readapp/list
  • 线上 camelCase(第 246 行):字段统一用 #[serde(rename_all = "camelCase")] 暴露成 camelCase(例外:config 类 RPC 用 snake_case 以对齐 config.toml 的 key)。
  • 时间戳是整数 Unix 秒(第 256 行):i64、命名 *_atcreated_atupdated_atresets_at)——不传字符串日期、不传毫秒,消除跨语言歧义。

这些规则在 app-server-protocol/src/protocol/common.rs 里用宏强制落地:请求/响应/通知都按 #[serde(tag = "method", rename_all = "camelCase")] 生成,并自带一个 method() 方法把内部枚举映射回线上的方法字符串。还有更细的纪律——v2 payload 字段禁止skip_serializing_if = "Option::is_none"(避免"字段消失"引起的歧义)、可选字段统一用 Option<...> + #[ts(optional = nullable)]、列表方法默认实现 cursor/limit 游标分页……

解读:这一整套不是洁癖,而是"契约稳定性"的工程化。前端可能是 TypeScript、Swift、Kotlin 写的,跨语言对接最怕"同一个字段这版有下版没"。把"省略"和"显式 null"的语义钉死、把时间戳钉成整数秒、把版本演进路径钉成"只在 v2 加",本质都是在保护外部依赖者不被你的内部重构波及

这层协议的价值:core 的内部怎么演进(循环换实现、上下文换策略),都不影响前端——只要协议稳定,IDE、桌面 App、Web、CLI 都能接同一个 core。这就是"把 harness 做成服务"的真正意义,也是"模型是商品、harness 是杠杆"在接口层的体现:你把杠杆做成稳定 API,它才能被尽可能多的产品复用、放大。

〔配图 3:协议演进治理〕——画两条平行轨道。上轨 "v1" 标一把锁 + "FROZEN:不加新表面积,只保兼容";下轨 "v2" 标一个"施工中"图标 + "all active development",几个新方法(thread/goal/setthread/realtime/start…)正往里加。两轨之间一道"兼容墙"。轨道下方贴四张小约束卡:*Params/*Response/*Notification<resource>/<method> 单数、camelCase(snake_case 例外=config)、时间戳=整数 Unix 秒 *_at。底部橙字:"版本即承诺——内部随便重构,外部契约不腐烂。出处:AGENTS.md L242–256。" 中文清晰。

四、headless(exec):把 agent 嵌进自动化,以及它凭什么知道"干完了"

不是所有场景都有人坐在界面前。CI 流水线、定时任务、批处理——这些要的是无界面(headless)地跑 agent。这就是 exec crate 干的事,命令是 codex exec

别和 codex exec-server 搞混。codex exec-serverexec-server crate)是另一回事:它是一个通过 codex-utils-pty 派生并控制子进程的 JSON-RPC 服务(用于沙箱化的进程执行、可本地或远程注册环境),README 原话是"a small JSON-RPC server for spawning and controlling subprocesses through codex-utils-pty"。它不是 headless 跑 agent 的东西。本节讲的是 codex exec(headless agent),不是 codex exec-server(子进程执行服务)。

codex exec "把这个 bug 修了" 就能非交互地跑:它自己干到认为完成为止,最终消息打到 stdout、进度流到 stderr,方便管道拼接。几个为自动化设计的细节(全部来自 exec/src/cli.rs,逐字核对):

  • --ephemeralRun without persisting session files to disk.——跑完不留会话文件,适合一次性任务(和 app-server thread 的 ephemeral 同义)。
  • 结构化输出 --json(别名 --experimental-jsonPrint events to stdout as JSONL.——把事件流以 JSONL 打到 stdout,让下游脚本机器解析每个事件。这又是第 3 章那个 Event 流,只不过这次消费者是脚本、不是人眼(由 exec/src/event_processor_with_jsonl_output.rs 负责把内部通知翻译成对外的 thread.started/turn.completed/item.* 等 JSONL 事件)。
  • stdin 喂内容:prompt 不作为参数给、或写成 - 时,从 stdin 读;若 prompt 也给了、stdin 又被管道喂入,则把 stdin 当作 <stdin>追加在 prompt 后。所以 echo "..." | codex exec "总结一下" 是合法的组合用法。
  • 还有 --output-schema(用 JSON Schema 约束最终回答形状)、-o/--output-last-message(把最后一条消息写到文件)等,都是为"机器消费"准备的旋钮。

4.1 关键机制:headless 靠 turn/completed 自己收尾

交互式 TUI 永远不用考虑"什么时候退出"——人不关它就一直开着。但 headless 必须自己判断"我干完了,可以退出了",否则 CI 任务永远挂着。这是交互式与 headless 最根本的机制差异。

Codex 的做法是:exec 内部其实跑着一个进程内 app-server 客户端(就是上面 §2.6 的 app-server-client),它在一个事件循环里收 ServerNotification,交给 event processor 处理,processor 返回一个 CodexStatus 信号决定要不要停:

// exec/src/event_processor.rs:只有两种状态
pub enum CodexStatus {
    Running,
    InitiateShutdown,
}

而把状态从 Running 翻成 InitiateShutdown 的,正是那个 turn/completed 通知——当这一轮跑完(无论成功、失败还是被打断),processor 就返回 InitiateShutdown

// exec/src/event_processor_with_jsonl_output.rs(节选)
ServerNotification::TurnCompleted(notification) => {
    // ……把它翻译成对外的 TurnCompleted JSONL 事件……
    CodexStatus::InitiateShutdown
}

主循环收到 InitiateShutdown 后,发起 thread/unsubscribe 退订、break 跳出循环、优雅关停进程内 app-server,最后打印结尾输出;如果中途见过失败/打断(error_seen),还会 std::process::exit(1) 让 CI 拿到非零退出码:

// exec/src/lib.rs(主循环节选)
CodexStatus::Running => {}
CodexStatus::InitiateShutdown => {
    request_shutdown(/* 发 thread/unsubscribe */).await?;
    break;
}
// ……循环外……
if error_seen { std::process::exit(1); }

解读:这就把"完成判定"建立在协议事件而不是"猜模型停没停"上。turn/completed 是 core 那条循环(第 3 章)走到尽头才发的权威信号;headless 只是订阅它、收到就收尾。交互式靠人决定何时退出,headless 靠 turn/completed 决定何时退出——同一套 core、同一个事件,换个消费者就从"陪聊助手"变成"CI 里的一个工序",而且能给出正确的退出码供流水线判定成败。这正是 OpenAI 官方文档推荐用 codex exec 做 CI/预合并检查/定时任务的底气。

〔配图 4:headless 的完成判定〕——左边一个 CI/齿轮图标,一根管子连到中间"codex exec(内嵌 app-server)"方块;方块里画一个小循环"收 ServerNotification → event processor → CodexStatus?"。当 turn/completed 这颗事件流进来,循环判出 InitiateShutdown,箭头走向右边"unsubscribe → break → 优雅关停 → exit(0/1)"。底部对比一行:"交互式:人来关。headless:turn/completed 来关。" 旁注"--json 把 Event 流喷成 JSONL 给脚本吃"。中文清晰。

五、MCP 接口:core 也能被另一个 agent 调用

第 9 章讲过,codex mcp-server 能把整个 Codex 暴露成一个 MCP 工具,让另一个 agent 把"调用 Codex"当成自己的一个工具。

放到这一章的框架里看,这其实就是第四种宿主:前三种宿主(TUI、IDE、自动化脚本)背后是人或机器,而这一种背后是另一个 agent。但对 core 来说没区别——它依旧只是"收 Op、吐 Event"。值得点明的是底座的对称性:MCP 和 app-server 都建立在 JSON-RPC 2.0(请求带 id、通知不带 id)之上,MCP 官方也明确自己用 JSON-RPC 2.0 当线协议;区别只是方向——

  • app-server:别人当客户端,调进 Codex(Codex 是服务端)。
  • codex mcp-server:把 Codex 包成 MCP 服务,让别的 agent 调进 Codex(Codex 是 MCP 服务端)。
  • 第 9 章的 MCP 客户端面:Codex 当客户端,调出去用别人的 MCP 工具(Codex 是 MCP 客户端)。

同一种 JSON-RPC 底座,三个方向,拼出了 Codex 既能"被各种前端驱动"、又能"互相当工具"的生态位。接口细节记录在 codex-rs/docs/codex_mcp_interface.mdcodex-rs/docs/protocol_v1.md(exec / 非交互模式没有单独的仓库内文档,用法见文末 OpenAI 官方"Non-interactive mode"链接)。

六、协作模式:同一套 harness 的几个"挡位"

最后一个有意思的点:同一个 core,还能切换不同的协作模式(collaboration mode)——

  • plan:先规划、不动手;
  • execute:放手执行、自己做假设、不打断你;
  • pair_programming:结对,边做边和你确认;
  • default:默认挡,倾向于做合理假设直接执行。

这些模式不改 core 的循环,只是换一套"开场指令"(还记得第 3 章吗?不同 task 的区别就在初始提示),就让同一套 harness 表现出不同的"性格"和自主度。这是一个优雅的设计:用提示词换挡,而不是用代码分叉。

但这里有一处常见误解需要澄清。模板文本住在 codex-rs/collaboration-mode-templates/templates/plan.md/execute.md/pair_programming.md/default.md,每个就是一段提示词)。真正把模板装配成 preset 的,是 models-manager/src/collaboration_mode_presets.rs

// models-manager/src/collaboration_mode_presets.rs(节选)
fn plan_preset() -> CollaborationModeMask {
    CollaborationModeMask {
        name: ModeKind::Plan.display_name().to_string(),
        mode: Some(ModeKind::Plan),
        model: None,                                  // 内置 preset 不锁定模型
        reasoning_effort: Some(Some(ReasoningEffort::Medium)),  // plan 锁中等推理强度
        developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())),
    }
}

解读:plan_preset() 做了两件实事——把 plan.md 那段提示词塞进 developer_instructions,并把推理强度锁到 Medium;而 default_preset() 不锁强度。注意它们 model: None——挡位只换"性格"和推理强度,不替你选模型(呼应主线"harness 调的是杠杆,不是替你挑商品")。

core/src/context/collaboration_mode_instructions.rs 是什么?它只是一层约 40 行的薄胶水:从当前 CollaborationMode.settings.developer_instructions 里把那段指令取出来,包上 <collaboration_mode>…</collaboration_mode> 标签、以 developer 角色注入到上下文里。真正"哪个挡位 = 哪段提示词 + 哪个推理强度"的装配逻辑在 models-manager,不在这个胶水文件——别把胶水当成 preset 本体

挡位最终经协议暴露:app-server 有 collaborationMode/list 列出可用 preset,turn/start 可带 collaborationMode 切换。其中一个约定很能体现接口纪律——传 settings.developer_instructions: null 表示"用该模式的内置指令",传具体字符串则用你自己的。

〔配图 5:用提示词换挡〕——正中一个"codex-core 循环"齿轮(不变);左边一个换挡杆,四个挡位 plan / execute / pair / default。每个挡位用一根线连到一张"提示词卡"(标 templates/.md),plan 卡上额外贴"reasoning=Medium 锁定"。换挡杆下方一行小字:"装配在 models-manager 的 _preset();core 里那 40 行只是把指令包标签注入的胶水。" 底部橙字:"换的是开场指令与推理强度,不是代码分叉,也不替你选模型。" 中文清晰。

本章小结

  • core 只是业务逻辑库,运行时才是它的"启动键":真正驱动它的是外圈的宿主——TUI、IDE、自动化、另一个 agent。
  • 三词之分:Codex 偏 runtime + harness,而非让你填空的 framework;app-server / exec 是它把 harness 产品化成"可被各种前端调用的服务"的那一面。
  • app-server 五 crate 分工protocol(契约)/ app-server(实现)/ transport(连接生命周期、多客户端、有界背压)/ daemon(远程托管与热更新)/ client(进程内复用),本质是第 3 章 Op/Event 双队列的跨进程产品化。
  • 握手与退订:每条连接先 initialize+initialized 协商能力,未握手的请求一律被拒;optOutNotificationMethods 让前端按连接精确退订高频通知。
  • 协议演进治理:v1 冻结、v2 开发;*Params/*Response/*Notification<resource>/<method> 单数、camelCase、时间戳整数 Unix 秒——一整套纪律保证外部契约不被内部重构波及(AGENTS.md L242–256)。
  • headless(exec)的完成判定codex exec 内嵌一个进程内 app-server,靠 turn/completedCodexStatus::InitiateShutdown 自己收尾退出(区别于交互式"人来关");--ephemeral 不落盘、--json 喷 JSONL 给脚本、stdin 可喂内容。
  • MCP 接口是第四种宿主:另一个 agent 经 codex mcp-server 调用 core;app-server / mcp-server / MCP 客户端面共享 JSON-RPC 2.0 底座,区别只在调用方向。
  • 协作模式用提示词换挡:plan / execute / pair / default 不改循环、只换开场指令(+ 推理强度),preset 装配在 models-manager、core 里那 40 行只是注入胶水。

下一章是收官:我们把这一路拆开的所有层装回去,谈 agent-first 团队怎么运转、怎么对抗代码熵、以及怎么造你自己的 harness——并诚实地聊聊这件事的未来与局限。


参考来源

解剖标本(codex-rs 源码,HEAD=ad2012d6)

  • app-server/ — 服务端实现、README.md 的 Protocol/Lifecycle
  • app-server-protocol/src/protocol/common.rs — 方法/版本宏、optOutNotificationMethods
  • app-server-transport/ConnectionId、三种传输、有界出站队列
  • app-server-daemon/ — 远程托管生命周期
  • app-server-client/ — 进程内客户端、Lagged 背压
  • ../AGENTS.md L242–256 — 协议演进治理(v1 冻结、命名/时间戳约定)
  • exec/src/cli.rs--ephemeral/--json/stdin/--output-schema
  • exec/src/event_processor.rsCodexStatus 枚举
  • exec/src/event_processor_with_jsonl_output.rsturn/completedInitiateShutdown 完成判定
  • exec/src/lib.rs — 主循环 break/退出码
  • exec-server/ — PTY 派生子进程的 JSON-RPC 服务(非 headless 跑 agent)
  • models-manager/src/collaboration_mode_presets.rs — preset 装配(plan_preset()/default_preset()
  • collaboration-mode-templates/templates/ — plan/execute/pair_programming/default 模板
  • core/src/context/collaboration_mode_instructions.rs — 约 40 行注入胶水
  • codex-rs/docs/codex_mcp_interface.mdcodex-rs/docs/protocol_v1.md — MCP 接口与协议文档(仓库内无 exec.md;exec 用法见文末官方非交互文档)

方法论 / 外部参考

注:app-server 协议面很大(数百个方法);本章只取"core 如何被各种宿主调用"的主干,方法/字段名以你 clone 的版本(HEAD=ad2012d6)为准。websocket 传输官方标注为实验性。

留言