第 15 章:SDK、Daemon 与远程控制
阅读契约: 本章回答一个具体问题:当 core runtime 之外的代码访问 Codex 时,协议语义、进程生命周期、restart/reconnect 行为和恢复分别由哪一层负责?阅读时抓住三个 owner:SDK stream routing、daemon process supervision、remote-control cursor replay。读完后应该能分清一个“client”到底是 protocol client、process wrapper、lifecycle manager,还是 transport bridge。

源码边界: 本章只有在链接到固定 Codex commit 569ff6a1c400bd514ff79f5f1050a684dc3afde3 或本章 Source Map 时,才把 source-level 说法视为 verified source。像 runtime owner、transport bridge、semantic boundary、client reach 这类设计语言,是从可见源码锚点得出的 surrounding contract inference。本章不推断 OpenAI 服务内部实现。
第 14 章把 app-server 解释为 shared thread contract:多个 connection 可以 start turn、observe notification、回答 server request,并重新接入正在运行的 work,而不用直接拥有 core session loop。第 15 章往外走一层,看同一份 contract 怎样穿过 SDK ergonomics、process supervision 和网络间隙。真实用户通常不会手写每条 JSON-RPC message。他们会调用 SDK,让 daemon 找到本地 server,或者通过 remote-control bridge 连接。
容易误判的地方,是把这些都叫做“client”然后停住。这个词会遮住真正重要的工程分工:
- Protocol client 必须保留 request、response、notification 和 server-request 语义。
- Process wrapper 可能只是运行一个命令并解析 JSON event stream。
- Daemon 不是 SDK;它拥有进程生命周期、pid 状态、健康探测、restart 和 update loop。
- Remote-control bridge 不是普通 WebSocket;网络失败会把 cursor、buffering、replay 和 connection identity 变成正确性的一部分。
因此,本章的 mental model 不是“SDK 调 app-server”,而是“每个外部 surface 要么保留 app-server contract,要么明确暴露一个更窄的 contract”。
一、Client reach 是 taxonomy,不是一条路径
Codex 有几种被外部代码触达的方式,它们的边界刻意不对称。
| Surface | Primary boundary | Verified anchor | 本地 owner / invariant |
|---|---|---|---|
| Rust app-server transport | App-server message transport | AppServerTransport | Normalize listener forms,携带 ConnectionOrigin metadata,但不改变 message meaning。 |
| Python SDK | App-server v2 over stdio | Codex、MessageRouter | 保持 single stdout reader、typed calls、per-request queues、turn streams 和 result projection。 |
| TypeScript SDK | codex exec process stream | Codex、Thread.runStreamedInternal | 暴露围绕 line-delimited JSON events 的 process-oriented thread API。 |
| Daemon | Local app-server lifecycle | Daemon::run、PidBackend | 串行化 lifecycle changes、probe socket health、发布 pid state,并管理 restart/update loops。 |
| Remote control | Backend-mediated app-server stream | start_remote_control、ClientTracker | 保留 remote connection identity、chunking、ack cursor、reconnect 和 replay。 |
这张表先把 contract shape 和 client ergonomics 拆开,避免两个错误结论。第一,TypeScript SDK 不是“没有 Python SDK 完整所以错了”。它包装 codex exec,把 input 写给 child process,读取 line-delimited stdout,然后解析 ThreadEvent。第二,daemon 也不是 codex app-server 外面的一层 convenience flag;它是让 long-lived local server 可以被 short-lived tools 安全依赖的边界。
1.1 Schema 先给 clients 一套共享语言
SDK 做 ergonomic 之前,app-server protocol surface 已经有 generated types 和 transport normalization。Transport module 通过 AppServerTransport::from_listen_url 接受 stdio://、unix://、ws://IP:PORT 或 off。同一个 module 又通过 TransportEvent 表示 incoming work,并用 ConnectionOrigin 区分 Stdio、InProcess、WebSocket 和 RemoteControl。
这个 enum 是一个小但关键的线索。Transport boundary 之上,runtime 可以知道 connection 从哪里来,但不应该因此改变 protocol message 的含义。Origin 是 connection handling 和 disconnection path 的 metadata;protocol message 本身仍然是语义 contract。它不是偷偷改写 turn/start 或 server request 语义的许可。
1.2 Public client surface 不应该泄漏所有 protocol 细节
Python SDK 的 public class 会启动 app-server client,并在暴露 generated method facade 前校验 initialize metadata。源码锚点是 Codex.__init__,后面接着 thread_start 这类 generated method。这是用户面对的 surface:caller 应该看到 thread API,而不是被要求自己写 response router。
TypeScript SDK 的 tradeoff 不同。Codex.startThread() 和 resumeThread() 返回 Thread,但实现运行的是 codex 可执行文件。在 exec.ts 里,threadId 变成 resume <id>,stdin 收到 prompt,stdout 被 readline 读成 stream,每一行再交给 thread 层。下面的摘录是缩略版;中间省略的是 image arguments、env construction、API key injection、stderr collection 和 exit handling。
if (args.threadId) {
commandArgs.push("resume", args.threadId);
}
// ... image arguments and environment setup omitted ...
const child = spawn(this.executablePath, commandArgs, {
env,
signal: args.signal,
});
child.stdin.write(args.input);
child.stdin.end();
const rl = readline.createInterface({
input: child.stdout,
crlfDelay: Infinity,
});
for await (const line of rl) {
yield line as string;
}
// ... spawn errors and non-zero exit handling omitted ...
这是有效的 SDK 边界,但它比“app-server client”窄。它是 command wrapper with structured events。谈 server-request replay、daemon-managed socket、remote-control cursor 时,不能把那些语义自动投射到这个 SDK 上,除非 pinned source 显示它接了这些 hook。
二、SDK routing:一条 ordered stream,多个本地 owner

Python SDK 最重要的内部 invariant 写在 MessageRouter 里:only the reader thread should consume stdout。这句话解释了整个 class。Stdio 是 ordered,但不等于天然有 owner。如果两个高层 SDK method 都直接读 process output stream,一个 response 就可能被错误 caller 拿走。
所以 router 创造了本地 ownership:
class MessageRouter:
"""Route reader-thread messages to the SDK operation waiting for them."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._response_waiters: dict[str, queue.Queue[ResponseQueueItem]] = {}
self._turn_notifications: dict[str, queue.Queue[NotificationQueueItem]] = {}
self._pending_turn_notifications: dict[str, deque[Notification]] = {}
self._global_notifications: queue.Queue[NotificationQueueItem] = queue.Queue()
这四个 routing structures 不是实现细节堆砌,而是 app-server contract 在 SDK 本地的镜像:
_response_waiters把 request id 映射到 one-shot JSON-RPC response queues。_turn_notifications把 turn id 映射到 caller 已经开始消费的 stream queues。_pending_turn_notifications把 turn id 映射到 deques,用来保留 turn/start 后、caller 注册 stream 前就到达的早期 events。_global_notifications接收不属于某个 turn 的 notifications。
最容易破坏 naive SDK 的就是早期 event 场景。源码在 register_turn 里处理它:为 turn 注册 queue 时,把 pending events 弹出来再放进新 queue。route_notification 做另一半:当 turn queue 还不存在时,先 buffer 非 completed 的 turn notifications。
def register_turn(self, turn_id: str) -> None:
turn_queue: queue.Queue[NotificationQueueItem] = queue.Queue()
with self._lock:
if turn_id in self._turn_notifications:
return
pending = self._pending_turn_notifications.pop(turn_id, deque())
self._turn_notifications[turn_id] = turn_queue
for notification in pending:
turn_queue.put(notification)
这就是 runtime recovery rule 的 client-side 版本:event 可以早于 consumer readiness 到达,但仍然属于同一个 turn。没有这个机制,快速 turn 会只在 timing pressure 下偶发丢事件。
2.1 Failure 必须唤醒每个 blocked owner
Router 同时拥有 failure propagation。fail_all 会 snapshot 已注册的 turn queues,清空 response waiters 和 pending-turn buffer,然后把同一个 exception 注入每个 response waiter、已注册 turn queue,以及 global-notification queue。源码注释直接写出 invariant:没有 SDK call 应该永远阻塞在一个不可能到来的 response 上。
所以 stream router 不只是 multiplexer。它是本地 failure boundary。reader thread 一旦退出,只有 router 知道哪些 user-facing operation 可能还在 sleep。
2.2 Result collection 是 projection,不是整条 stream
高层 run() result 是从 notifications 投影出来的。_collect_run_result 收集 target turn 的 completed items、token usage 和 turn/completed notification。如果一直没有 turn completion event,就 raise。
这意味着 public return value 是 stream 的 projection,而不是 stream 的替代品。只需要最终文本的 caller 可以用 run();需要 progressive items、approvals 或自定义 UI 的 caller,则应该把 notification stream 当成更丰富的 contract。
2.3 Python 和 TypeScript 的差异是有意的
| 问题 | Python SDK | TypeScript SDK |
|---|---|---|
| Primary input/output | App-server protocol over stdio。 | codex exec process stdin/stdout。 |
| Stream owner | MessageRouter 拥有 single reader 和 per-operation queues。 | readline 逐行 yield process output。 |
| Turn identity | Protocol methods 和 generated models 暴露 thread/turn operations。 | thread.started 可以设置 _id;resume <id> 被传给 executable。 |
| Failure boundary | Router 唤醒 waiters 和 streams。 | Child process errors、non-zero exit、JSON parse failures 变成 SDK errors。 |
| Best fit | 需要 app-server semantics 的 clients。 | 需要 structured execution events 的 scripts。 |
这个区别能保持文章诚实。需要 shared app-server thread ownership、server-request handling、remote-control semantics,就要使用真正暴露这些 contract 的 surface。只需要围绕命令做一个语言 wrapper,较窄的 event-stream surface 反而更合适。
三、Daemon lifecycle:可靠性不是 pid file

本地 daemon 听起来像小工具,直到两个 caller 同时 start、restart、update 或 stop 同一个 app-server。源码明确暴露了这个风险。Daemon 定义了 app-server.pid、app-server-updater.pid 和 daemon.lock 等 state files,然后通过 Daemon::run 分发 lifecycle commands。
关键模式是:Start、Restart 和 Stop 在修改 process state 前都会 acquire operation lock。Version 不需要,因为它是 probe-style read。
async fn run(&self, command: LifecycleCommand) -> Result<LifecycleOutput> {
match command {
LifecycleCommand::Start => {
let _operation_lock = self.acquire_operation_lock().await?;
self.start().await
}
LifecycleCommand::Restart => {
let _operation_lock = self.acquire_operation_lock().await?;
self.restart().await
}
LifecycleCommand::Stop => {
let _operation_lock = self.acquire_operation_lock().await?;
self.stop().await
}
LifecycleCommand::Version => self.version().await,
}
}
这个 lock 不是隐藏在进程内存里的抽象 mutex。acquire_operation_lock 会打开 daemon lock file,在 timeout 前反复尝试文件锁,并在尝试之间 sleep。file-backed 设计很重要,因为不同 CLI invocation 和 update loop 可以通过 filesystem 协调。
3.1 先 probe,再信任 process record
Daemon 不把“pid file 存在”等同于“server 可用”。start 会先 load settings,然后在 control socket 上调用 client::probe。Probe 会连接 socket、升级 WebSocket、发送 initialize、等待匹配 response、发送 initialized、关闭连接,并从 response user agent 解析 app-server version。
这比 pid file 是更强的信号。Crash 之后可能留下 stale pid record;能对 initialize 作出 response 的健康 socket,才证明 app-server 正在接受协议。
Daemon 在 restart 里也遵循这个纪律:如果有 server 正在运行但不是 daemon 管理的 backend,restart 会返回错误,而不是杀掉未知进程。wait_until_ready 则持续 probe,直到 app-server ready 或 start timeout。
3.2 PID reservation 保护 startup gap
Pid backend 还有自己的 lock,因为 startup 有危险的中间状态:某个 process 已经决定启动 server,但 pid record 还没完整发布。PidBackend::start 会创建 pid directory、acquire reservation lock、用 create_new 创建 pid file、移除 stale records、spawn detached process、读取 process start time、写 temp record,并 rename 到最终位置。
读取侧也会区分 missing、empty、starting、running 和 stale。read_pid_file_state 在 pid file 缺失但 reservation lock active 时返回 Starting。refresh_after_stale_record 会重新拿 reservation lock,再移除 stale record。
这就是 daemon 的 invariant ledger:
| Pressure | 会失败的简单做法 | Source mechanism | 保护的 invariant |
|---|---|---|---|
| 两个 command 同时 start。 | 两边都 spawn server。 | Daemon::run 的 operation lock。 | 同一时间只有一个 lifecycle mutation。 |
| PID 存在但 server 已死。 | 信 pid file。 | Control socket initialize probe。 | Readiness 必须意味着 protocol acceptance。 |
| 进程在 reservation 后死亡。 | 把 empty pid 当 running。 | Reservation lock 和 starting state。 | Startup gap 可观测。 |
| Update loop 遇到 busy daemon。 | 直接 restart。 | try_restart_if_running 在无法拿到 lock 时返回 Busy。 | Update 不与 user lifecycle commands 竞争。 |
| Server 属于另一个 owner。 | 杀掉任何响应该 socket 的进程。 | Restart/stop 前检查 managed backend。 | Daemon 只管理自己拥有的 backend。 |
因此,daemon 不只是“server 从这里启动”。它让 local app-server reach 稳定到足以被 SDK、UI 和 update flows 依赖。
四、Remote control:网络失败变成 runtime state

Remote control 是本章最尖锐的边界,因为它跨网络。本地 stdio client 在 child process 退出时通常可以 fail fast;remote client 可能在本地 runtime 继续发 server messages 时断线。如果 bridge 不记得 remote side 已经 ack 了什么,reconnect 后 client 就可能悄悄漏掉 notifications。
Remote-control module 先处理 enablement 和 status。RemoteControlStartConfig 携带 remote-control URL 和 installation id。RemoteControlHandle 可以 enable/disable bridge 并暴露 status updates。start_remote_control 只有在 remote control 初始启用时才会立即 normalize target;否则后续 connect path 再 normalize。它还会把 initial status 设为 Connecting 或 Disabled,创建 websocket runner,并返回 handle。
Recovery fields 在 protocol 里是可见的。ClientEnvelope 包含 client_id、optional stream_id、optional seq_id 和 optional cursor。源码注释说明 seq_id 是用于 acknowledgement 的 backend-generated per-stream cursor。ServerEvent 可以携带 ServerMessage、ServerMessageChunk、Ack 或 Pong。
pub(crate) struct ClientEnvelope {
pub(crate) event: ClientEvent,
pub(crate) client_id: ClientId,
pub(crate) stream_id: Option<StreamId>,
/// For `Ack`, this is the backend-generated per-stream cursor over
/// `ServerEnvelope.seq_id`.
pub(crate) seq_id: Option<u64>,
pub(crate) cursor: Option<String>,
}
URL normalizer 也刻意收窄。normalize_remote_control_url 接受 allowed ChatGPT hosts 的 HTTPS URL,以及 localhost 的 HTTP/HTTPS URL,然后派生 enroll 和 websocket endpoints。这是 transport contract,不是任意 tunnel。
4.1 ClientTracker 把 remote messages 变成 app-server connections
ClientTracker::handle_message 会把 incoming remote envelopes 映射成本地 app-server connection events。遇到 initialize-style message 时,它用 ConnectionOrigin::RemoteControl 打开一个新 connection。之后同一 (client_id, stream_id) 的 messages 会变成 TransportEvent::IncomingMessage。
这一步是 bridge 的 semantic hinge。Remote control 不是“把 JSON 写到 websocket”。它创建一个正常 app-server connection origin,然后让 transport boundary 之上的 message processor 继续处理 app-server semantics。
4.2 OutboundBuffer 是 replay ledger
Outbound side 会按 (client_id, stream_id) 存储尚未 ack 的 server envelopes。BoundedOutboundBuffer 插入每个 server envelope,只移除 ack cursor 已覆盖的部分。
fn ack(
&mut self,
client_id: &ClientId,
stream_id: &StreamId,
acked_seq_id: u64,
acked_segment_id: Option<usize>,
) {
let key = (client_id.clone(), stream_id.clone());
let Some(buffer) = self.buffer_by_stream.get_mut(&key) else {
return;
};
let acked_cursor = (acked_seq_id, acked_segment_id.unwrap_or(usize::MAX));
buffer.retain(|server_envelope| {
let envelope_cursor = (
server_envelope.seq_id,
server_envelope.event.segment_id().unwrap_or_default(),
);
let is_acked = envelope_cursor <= acked_cursor;
!is_acked
});
}
完整源码还会更新 usage watch channel 并删除 empty buffers。文章层面最重要的是 comparison key:sequence id 加 optional segment id。这让 segment acknowledgement 可以按 wire-chunk granularity 前进,而不是把大 server message 假装成不可分割的 atom。
run_server_writer_inner 在 websocket writer 启动时 replay outbound-buffer 中已有的 envelopes,然后给新的 server events 分配 per-stream contiguous sequence ids,必要时 split large messages,再把 envelopes 插入 buffer 并发送 JSON payload。run_websocket_reader_inner 会读 client envelopes,保存 subscribe cursor,并在看到带 seq_id 和 stream_id 的 ack 时调用 outbound_buffer.ack(...)。
Shape-level sequence:
connect with subscribe cursor C
-> writer replays unacked server envelopes still in outbound buffer
-> new server event gets next per-stream sequence id
-> large message may split into chunks
-> remote client sends ack cursor
-> outbound buffer drops covered sequence/segment entries
解释这条链路不需要任何 provider-internal behavior。可见源码 contract 已经说明 cursor 和 replay 为什么存在:remote control 必须在 websocket reconnect 后,不丢失 remote side 尚未 acknowledge 的 app-server messages。
4.3 Chunk reassembly 是 safety boundary
Remote clients 也可以发送 chunked messages。REMOTE_CONTROL_SEGMENT_* constants 定义 target、max segment、max reassembled size 和 max segment count;ClientSegmentReassembler 拥有 in-progress assemblies。observe 会丢弃缺少 required seq_id 或 stream_id 的 segmented envelope,拒绝非法 counts 和 sizes,并在 stream change 时 reset state。AssemblyUpdate enum 命名这些 outcome;observe branch 会在 forward reassembled ClientMessage 前,拒绝 old、mismatched、out-of-order、oversized、invalid-base64 和 invalid-JSON chunks。
这不只是 defensive parsing。它保护 app-server 不会在网络中断后收到 half-assembled 或 replay-confused protocol messages。
五、Compatibility 应该留在边界
SDK、daemon supervision 和 remote control 都会带来兼容性成本,但源码倾向于把这些成本留在边界:
| Boundary | Compatibility pressure | Edge response |
|---|---|---|
| Python SDK | Initialize metadata 可能来自 serverInfo,也可能来自 user-agent shape。 | _validate_initialize 在暴露 SDK 前 normalize required metadata。 |
| TypeScript SDK | Existing session 通过 command-line surface resume。 | exec.ts 把 resume <threadId> 传给 executable。 |
| Daemon | Server 可能已经运行,但不是 daemon-managed。 | start、restart 和 stop 区分 healthy unmanaged server 与 daemon-owned backend。 |
| Remote control | 旧 clients 可能在 initialize 时省略 stream_id。 | ClientTracker 包含有注释的 legacy stream-id fallback。 |
| Remote reconnect | Remote side 可能只收到 stream 的一部分。 | BoundedOutboundBuffer 按 client 和 stream 保留 unacked envelopes。 |
这条模式可以迁移:compatibility 应该靠近制造它的 surface。Stream-id fallback 属于 remote tracker,不属于 core turn loop。User-agent normalization 属于 SDK initialize handling,不应该散落到每个 downstream call。Daemon ownership checks 属于 process supervision,而不是 protocol message routing。
常见误读
| 误读 | 修正 |
|---|---|
| “所有 SDK 都是 app-server clients。” | Python SDK 是 app-server protocol client;TypeScript SDK 包装 codex exec 并解析 JSON event lines。 |
| “Daemon 只是 pid file。” | Daemon 使用 operation locks、socket probes、pid reservation locks、stale-record cleanup 和 readiness polling。 |
| “Remote control 只是 WebSocket。” | Remote control 增加 enrollment、client/stream identity、chunking、ack cursor、outbound buffering、reconnect 和 replay。 |
| “Transport 差异会改变 turn semantics。” | Transport origin 可见,但 app-server protocol messages 在 transport boundary 之上应保持同一含义。 |
| “SDK final result 就是完整 stream。” | run() projection 收集 completed items、final response、usage 和 failure status;notification stream 仍然更丰富。 |
应用到实践
- 先命名 client boundary,再评估它。 Protocol client、process wrapper、daemon、remote bridge 的义务不同。
- 每条 ordered stream 只设一个 reader。 内部路由 responses 和 notifications,避免并发 user code 偷走 protocol bytes。
- Probe health,而不是只看存在性。 Pid file 是证据;control socket 的 initialize response 是更强证据。
- 把 reconnect 显式化。 如果 transport 会在 runtime 继续运行时断开,那么 cursor、buffer 和 replay 就是 correctness 的一部分。
- Compatibility 留在 owner 附近。 SDK metadata 在 SDK normalize,process ownership 在 daemon 判断,stream-id fallback 在 remote tracking 处理。
收束
SDK、daemon 和 remote control 不是“真正 runtime”旁边的附属代码。它们是 app-server contract 进入程序、脚本、本地 supervisor 和远程 client 的地方。源码层面的主题是一致的:保留同一份 semantic contract,但让每个边界拥有自己独特负责的 mechanics。第 16 章转向这份 contract 最可见的本地消费者:terminal UI。
Source Map
| 概念 | 源码锚点 |
|---|---|
| Transport modes 与 connection origin | transport/mod.rs |
| Python SDK public API 与 initialize normalization | api.py |
| Python SDK message routing | _message_router.py |
| Python SDK run-result projection | _run.py |
| TypeScript SDK public API | codex.ts |
TypeScript event stream 与 run() collection | thread.ts |
| TypeScript process wrapper | exec.ts |
| Daemon lifecycle commands、probe、bootstrap 与 operation lock | app-server-daemon/src/lib.rs |
| Daemon control-socket probe | client.rs |
| PID backend reservation、stale cleanup 与 process start | backend/pid.rs |
| Remote-control start handle | remote_control/mod.rs |
| Remote-control envelope、chunks、ack cursor 与 URL normalization | protocol.rs |
| Remote-control client tracking 与 connection origin | client_tracker.rs |
| Remote-control websocket buffering、reconnect writer 与 ack handling | websocket.rs |
| Remote-control segment reassembly 与 drop rules | segment.rs |