Skip to content

Codex 产品形态全景

说明 npm launcher、Rust CLI、TUI、Exec、App Server、Daemon、Exec Server 与两种 SDK 的交付和运行关系。

基于rust-v0.150.0
CodexArchitectureSDK

Codex 产品形态全景 ​

用户在不同场景中会看到 codex、codex exec、codex app-server、 codex exec-server、@openai/codex-sdk 和 openai-codex 等名称。它们不是多套 相互独立的 Agent 引擎,也不能因为都能“运行 Codex”就当成同一个入口。

要看清它们的关系,需要先分开四个维度:

维度回答的问题典型对象
交付载体二进制如何到达用户机器安装脚本、npm package、Homebrew、Python wheel
产品入口谁接收用户或程序的请求TUI、Exec、App Server、SDK
通信边界请求是函数调用、进程内消息还是跨进程协议bounded channel、JSONL、JSON-RPC、WebSocket
执行平面命令、文件和网络副作用在哪里发生本地 runtime、Exec Server、远程 environment

同一个 Rust runtime 可以被多个入口复用;同一个入口也可以通过不同交付载体安装。 这是理解后面所有进程关系的前提。

阅读前建议先看 Codex 项目概览,理解 Thread/Turn 和本地 runtime。 本文只比较交付、连接寿命、协议与执行平面,不展开每个入口内部的 Session/Task 实现;同一张拓扑图中的 两个箭头,也不代表它们拥有相同的失败和资源回收语义。

1. 安装包与二进制 ​

1.1 npm包Launcher ​

@openai/codex 的 package.json 只把 codex 命令指向 bin/codex.js。发布脚本 则为 Linux、macOS 和 Windows 的 x64/arm64 组合生成平台包,再把它们写成主包的 optionalDependencies。

Node launcher 根据 process.platform 和 process.arch 选择目标三元组,从对应平台包的 vendor/<target>/bin/ 目录定位原生程序:

源码位置:codex-cli/bin/codex.js :: findCodexExecutable

javascript
// JS launcher 只解析平台包和原生 binary,不实现 agent runtime。
function findCodexExecutable() {
  let vendorRoot;
  try {
    const packageJsonPath = require.resolve(`${platformPackage}/package.json`);
    vendorRoot = path.join(path.dirname(packageJsonPath), "vendor");
  } catch {
    vendorRoot = path.join(__dirname, "..", "vendor");
  }

  const codexExecutable = path.join(
    vendorRoot,
    targetTriple,
    "bin",
    process.platform === "win32" ? "codex.exe" : "codex",
  );
  if (existsSync(codexExecutable)) {
    return codexExecutable;
  }

  const packageManager = detectPackageManager();
  const updateCommand =
    packageManager === "bun"
      ? "bun install -g @openai/codex@latest"
      : packageManager === "pnpm"
        ? "pnpm add -g @openai/codex@latest"
        : "npm install -g @openai/codex@latest";
  throw new Error(
    `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`,
  );
}

定位成功后,launcher 把原参数不变地传给原生二进制,继承终端的 stdin/stdout/stderr:

源码位置:codex-cli/bin/codex.js :: child spawn

javascript
// stdio 继承让原生进程直接成为用户可交互的前台程序。
const child = spawn(binaryPath, process.argv.slice(2), {
  stdio: "inherit",
  env,
});

launcher 还转发 SIGINT、SIGTERM 和 SIGHUP,并镜像子进程的退出码或信号。 因此,通过 npm 运行 codex exec 时,Node 只负责定位和托管子进程;命令解析、 会话、模型请求和工具执行都在 Rust 程序中。

1.2 统一安装入口 ​

仓库根 README 同时提供独立安装脚本、npm、Homebrew 和 GitHub Release 压缩包。 这些是不同的交付通道,并不会选择不同的 Agent 引擎。

发布工程还可单独生成以 codex-app-server 为 entrypoint 的原生包。它与 codex app-server 共用 codex-app-server crate,但交付形式不同:前者是独立二进制, 后者是 codex multitool 的子命令。

2. codex 多入口 ​

2.1 顶层命令 ​

codex-rs/cli 只定义一个默认二进制 codex。MultitoolCli 把 TUI 参数作为 默认分支,同时通过 Subcommand 选择 Exec、App Server、MCP Server、Exec Server 等入口。

命令直接调用对象主要输入输出典型调用方
codexcodex_tui终端交互 UI 与持续事件流人类用户
codex execcodex_exec::run_main单次非交互任务,文本或 JSONLshell 脚本、CI、TypeScript SDK
codex review复用 codex_exec非交互审查事件审查自动化
codex app-servercodex_app_server双向 JSON-RPCIDE、Python SDK、长连接客户端
codex app-server daemon ...codex_app_server_daemon机器可读的生命周期结果SSH 和远程控制工具
codex mcp-servercodex_mcp_serverstdio MCP 服务MCP client
codex exec-servercodex_exec_server进程、文件、HTTP 的 JSON-RPC本地或远程执行环境
codex appapp_cmd启动或安装桌面 AppmacOS/Windows 用户

codex app 只在 macOS 和 Windows 编译。app-server、remote-control 和 exec-server 在这个版本的 CLI 帮助中仍被标记为 experimental,不应把它们的当前参数当成永久不变的稳定接口。

2.2 TUI与Exec入口 ​

顶层分派图只表明“进入哪个前端”,不表示它们各自实现了一套会话协议。 当前 TUI 和 Exec 都使用 codex-app-server-client 的类型化请求、通知和事件模型。

这个分层带来一个很重要的区别:复用 App Server 协议模型,不等于一定启动 App Server 子进程。进程内客户端通过有界 channel 与嵌入式 runtime 通信,仍保留 JSON-RPC 的 request/notification/event 形状,但不经过 stdio 或 socket。

3. TUI与Exec ​

3.1 TUI服务目标 ​

TUI 用 AppServerTarget 区分嵌入式 runtime、本地 daemon 和显式远程 endpoint。 选择规则很具体:显式远程 endpoint 优先;否则,只有当本次启动配置可以被现有 daemon 复用,且默认 Unix Socket 可连接时,才使用本地 daemon;其余情况回退到嵌入式 runtime。

源码位置:codex-rs/tui/src/lib.rs :: app_server_target_for_launch

rust
// 显式 endpoint 优先;只有满足复用条件才选择隐式本地 daemon。
fn app_server_target_for_launch(
    explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
    default_daemon_socket: Option<AbsolutePathBuf>,
    can_reuse_implicit_local_daemon: bool,
) -> AppServerTarget {
    match explicit_remote_endpoint {
        Some(endpoint) => AppServerTarget::Remote { endpoint },
        None if can_reuse_implicit_local_daemon => {
            default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| {
                AppServerTarget::LocalDaemon {
                    endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path },
                }
            })
        }
        None => AppServerTarget::Embedded,
    }
}

“可以复用”并不只是检查 socket。如果本次带有 CLI 配置覆盖、非默认 loader、 strict-config 或无法重放的启动覆盖,TUI 会使用嵌入式 runtime,避免把本次参数 错配给一个早已启动的 daemon。

3.2 Exec 客户端 ​

codex exec 创建 InProcessClientStartArgs,把 SessionSource::Exec、客户端名称、 配置、状态库和 environment manager 交给 InProcessAppServerClient::start。然后它使用 thread/start、thread/resume、turn/start 和 turn/interrupt 等类型化 App Server 请求驱动任务。

源码位置:codex-rs/exec/src/lib.rs :: run_exec_session

rust
// Exec 复用 in-process App Server client,而不是另建一条 Core 控制协议。
let mut request_ids = RequestIdSequencer::new();
let mut client = InProcessAppServerClient::start(in_process_start_args)
    .await
    .map_err(|err| {
        anyhow::anyhow!("failed to initialize in-process app-server client: {err}")
    })?;

Exec 的特殊之处是产品行为:它从 stdin/参数读取任务,以人类文本或 JSONL 输出事件, 遇到未恢复的失败时使用非零退出码。它的 runtime 仍在当前 codex 进程中,不会因为 名字包含 exec 就自动连接一个 exec-server 进程。

4. App Server协议 ​

4.1 Transport适配 ​

App Server 面向需要长会话、双向请求和细粒度事件的客户端。仓库 README 明确说明 VS Code 扩展使用该界面;Python SDK 也直接作为它的 typed JSON-RPC client。

协议采用 JSON-RPC 2.0 的消息模型,但 wire 上省略 "jsonrpc":"2.0" 字段。每条连接 必须先发送一次 initialize,再发送 initialized 通知;然后才能通过 thread/*、 turn/* 和其他方法工作。

源码位置:codex-rs/app-server-transport/src/transport/mod.rs :: AppServerTransport

rust
// transport 只描述连接承载方式,request/notification 语义保持一致。
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum AppServerTransport {
    Stdio,
    UnixSocket { socket_path: AbsolutePathBuf },
    WebSocket { bind_address: SocketAddr },
    Off,
}
Transport连接模型当前用途重要限制
stdio://每行一个 JSON-RPC 消息父进程直接托管,默认值服务生命周期通常跟随父客户端
unix://Unix Stream 上的 WebSocket Upgrade本机 daemon 控制面依赖 Unix Socket
ws://IP:PORTWebSocket text frame实验性网络客户端文档明确标记 unsupported,不应用于生产
off不对外监听仅需内部功能的启动形态没有本地 client transport

App Server 在传输入口、请求处理和输出之间使用有界队列。输入过载时返回可重试的 JSON-RPC -32001 错误,而不是无限缓存新请求。长连接 client 因此必须同时处理响应、 服务器主动请求、通知、背压和断连。

4.2 Daemon 管理 ​

codex app-server daemon 是生命周期控制层,不是第二套 App Server 实现。它在 CODEX_HOME/app-server-daemon/ 中保存 settings、PID 记录和操作锁,然后用受管理的 codex 二进制启动 app-server --listen unix://。

start 是幂等的;restart 会停止旧进程并启动新进程;bootstrap 还可启动 独立更新循环。但这一生命周期实现当前只支持 Unix,Windows 上会直接返回 “daemon lifecycle is only supported on Unix platforms”。daemon 也不会接管一个已经使用同一 socket、但不受它管理的 App Server 进程。

5. SDK运行拓扑 ​

5.1 TypeScript模式 ​

@openai/codex-sdk 的 Thread 保存 thread id,但不保持一个长期运行的 Rust 子进程。 每次 run() 或 runStreamed() 都调用 CodexExec.run(),后者构造 exec --experimental-json;如果 thread id 已存在,再追加 resume <thread-id>。

源码位置:sdk/typescript/src/exec.ts :: CodexExec::run commandArgs

typescript
// TypeScript SDK 通过稳定 CLI 子命令启动一次非交互 Turn。
const commandArgs: string[] = ["exec", "--experimental-json"];

恢复已有 thread 不会切换到另一套 client,只是在同一组命令参数后追加 resume 与 thread id:

源码位置:sdk/typescript/src/exec.ts :: CodexExec::run resume args

typescript
// resume 只是参数分支,仍沿用同一个子进程执行器。
if (args.threadId) {
  commandArgs.push("resume", args.threadId);
}

参数组装完成后,SDK 才为这一次 Turn 创建子进程:

源码位置:sdk/typescript/src/exec.ts :: CodexExec::run spawn

typescript
// AbortSignal 直接绑定子进程,取消不会创建额外常驻服务。
const child = spawn(this.executablePath, commandArgs, {
  env,
  signal: args.signal,
});

SDK 逐行解析子进程 stdout 的 JSONL;thread.started 事件到达后保存 id,下一次 Turn 再由新的 codex exec resume 进程恢复。AbortSignal 会传给 Node spawn,非零退出码 或信号则转换为 SDK 错误。

发布 TypeScript SDK 时,staging 脚本会把同版本 @openai/codex 加入 dependencies, 所以默认 binary resolution 与 CLI npm 包使用相同的平台原生包布局。

5.2 Python子进程 ​

Python openai-codex 选择了另一个边界。CodexClient.start() 从精确依赖的 openai-codex-cli-bin wheel 定位原生二进制,启动 app-server --listen stdio://, 并为 stdout 消息路由和 stderr 排空分别启动线程。

源码位置:sdk/python/src/openai_codex/client.py :: CodexClient.start

python
# Python SDK 维持一个 stdio App Server 子进程,并在重复 start 时保持幂等。
def start(self) -> None:
    if self._proc is not None:
        return

    path_dirs: tuple[Path, ...] = ()
    if self.config.launch_args_override is not None:
        args = list(self.config.launch_args_override)
    else:
        codex_bin = _resolve_codex_bin(self.config)
        if self.config.codex_bin is None:
            path_dirs = _installed_codex_path_dirs()
        args = [str(codex_bin)]
        for kv in self.config.config_overrides:
            args.extend(["--config", kv])
        args.extend(["app-server", "--listen", "stdio://"])

    env = os.environ.copy()
    if self.config.env:
        env.update(self.config.env)
    _prepend_path_dirs(env, path_dirs)

    self._proc = subprocess.Popen(
        args,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
        cwd=self.config.cwd,
        env=env,
        bufsize=1,
    )

Codex() 构造期间就会完成 start、initialize 和 initialized handshake;同一个 client 上的多个 thread/turn 复用这个 App Server 进程。关闭时先 terminate,两秒内未退出 则 kill,因此调用方应使用 context manager 或显式 close()。

两种 SDK 都是本地 Rust runtime 的高层封装,但它们的并发、资源回收和协议错误模型 不同,不能把 TypeScript 的“Thread 对象”理解为一条始终存活的 App Server 连接。

两套 SDK 都叫“Codex client”,但它们持有的运行对象不同。下面的类图用真实类名展示 TypeScript 的 per-Turn Exec 关系,以及 Python 的长寿命 App Server client 关系。

TypeScript 的 Thread 只是参数与 ID handle,真正执行由 CodexExec 每次拉起;Python CodexClient 则拥有可复用的 stdio 进程。两者不能按同一套进程生命周期处理。

5.3 Python运行时版本 ​

Rust workspace 的版本是 0.147.0,但 Python SDK 的 pyproject.toml 此时精确依赖 openai-codex-cli-bin==0.144.4。Python SDK 使用独立的 python-v* beta 发布流程,该流程从 pyproject 读取这个 pin,并用对应 runtime 生成协议类型。

所以,对 Python 应用判定能力边界时,应查看实际安装的 SDK 和 runtime wheel,不能仅凭 Rust 仓库 tag 推导运行时版本。调用方可以用 CodexConfig.codex_bin 显式覆盖二进制, 但这也意味着需要自行承担 SDK 生成类型与 runtime 协议不匹配的风险。

6. Exec Server ​

codex exec-server 与 codex exec 只是名字相似。Exec 是一个会创建 Thread/Turn、 请求模型并消费 Agent 事件的无 UI 客户端;Exec Server 则把进程、文件系统、环境信息和 HTTP 请求暴露成另一套 JSON-RPC 执行协议。

源码位置:codex-rs/exec-server-protocol/src/protocol.rs :: executor method constants

rust
// 方法名是 executor JSON-RPC 的稳定边界,不是 Core 的 Op/Event 协议。
pub const INITIALIZE_METHOD: &str = "initialize";
pub const INITIALIZED_METHOD: &str = "initialized";
pub const EXEC_METHOD: &str = "process/start";
pub const EXEC_READ_METHOD: &str = "process/read";
pub const EXEC_WRITE_METHOD: &str = "process/write";
pub const EXEC_SIGNAL_METHOD: &str = "process/signal";
pub const EXEC_TERMINATE_METHOD: &str = "process/terminate";
pub const ENVIRONMENT_INFO_METHOD: &str = "environment/info";
pub const FS_READ_FILE_METHOD: &str = "fs/readFile";
pub const FS_WRITE_FILE_METHOD: &str = "fs/writeFile";
pub const FS_READ_DIRECTORY_METHOD: &str = "fs/readDirectory";
pub const HTTP_REQUEST_METHOD: &str = "http/request";
pub const HTTP_REQUEST_BODY_DELTA_METHOD: &str = "http/request/bodyDelta";

Exec Server 可以在 stdio 上服务单连接,也可以监听 WebSocket;另一种模式是通过 --remote <URL> --environment-id <ID> 向远程环境注册。它本身不是 TUI、SDK 或模型 client, 而是 Codex runtime 可以选择的副作用落点。

这也解释了为什么 TUI、Exec 和 App Server 都可以通过同一个 EnvironmentManager 选择本地或 远程执行环境:产品入口和工作区所在地是两个独立的选择。

7. 选择入口 ​

需求适合的入口主要原因
人在终端中持续交互codex TUI会话选择、审批、流式输出和本地终端体验
shell/CI 执行一次任务codex exec非交互退出语义,支持 JSONL
Node 应用以 Turn 为单位调用TypeScript SDK封装 codex exec,自动解析事件和恢复 thread
Python 应用维护多个 Thread/TurnPython SDK长生命周期 App Server 客户端与生成类型
IDE 或自定义富客户端App Server双向 JSON-RPC、审批请求和细粒度事件
SSH 机器上持续运行 App ServerApp Server DaemonUnix Socket、幂等生命周期和可选更新循环
把命令、文件和 HTTP 落在其他环境Exec Server为 runtime 提供远程执行平面

最终的判断标准不是“哪个名字看起来更强”,而是调用方需要的连接寿命、协议粒度、 输出形式和执行环境。这四个问题一旦确定,Codex 的产品入口就不再是一组混乱的命令名, 而是一组共用 runtime、却有明确边界的 client 与 service。

8. 入口与握手测试 ​

产品拓扑需要由状态变化反向验证:TUI 何时回退 Embedded,WebSocket connection 何时获得调用资格, 远程连接在什么情况下连 socket 都不能尝试,以及 in-process client 的 shutdown 是否等 worker 完成。

8.1 TUI配置 ​

app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable 提供了默认 daemon socket,却把 can_reuse_implicit_local_daemon 设为 false,最终必须选择 Embedded。测试证明“socket 存在”不是复用 daemon 的充分条件。

源码位置:codex-rs/tui/src/lib.rs

rust
// :: app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable
#[test]
fn app_server_target_for_launch_skips_local_daemon_when_launch_config_is_not_replayable()
-> color_eyre::Result<()> {
    let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?;
    let target = app_server_target_for_launch(
        /*explicit_remote_endpoint*/ None,
        Some(socket_path),
        // 当前启动参数不能由既有daemon重放,因此禁止隐式复用。
        /*can_reuse_implicit_local_daemon*/ false,
    );

    assert_eq!(target, AppServerTarget::Embedded);
    Ok(())
}

这条 fallback 不代表启动失败被吞掉:它发生在目标选择阶段,Embedded runtime 随后的 initialize 或 Session 构造仍可能返回错误。

8.2 Initialize资格 ​

websocket_transport_routes_per_connection_handshake_and_responses 同时连接两个 client。ws1 完成 initialize 不会让 ws2 获得资格;ws2 在 initialize 前调用 config/read 必须得到 Not initialized。两条连接之后 即使复用相同 request id,响应也必须分别路由。

源码位置:codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs

rust
// :: websocket_transport_routes_per_connection_handshake_and_responses(关键路径)
send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?;
let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?;
assert_eq!(first_init.id, RequestId::Integer(1));

// ws1的initialize response不能泄漏到尚未初始化的ws2。
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;

send_config_read_request(&mut ws2, /*id*/ 2).await?;
let not_initialized = read_error_for_id(&mut ws2, /*id*/ 2).await?;
assert_eq!(not_initialized.error.message, "Not initialized");

send_initialize_request(&mut ws2, /*id*/ 3, "ws_client_two").await?;
let second_init = read_response_for_id(&mut ws2, /*id*/ 3).await?;
assert_eq!(second_init.id, RequestId::Integer(3));

// request id只在connection内唯一,不是App Server全局主键。
send_config_read_request(&mut ws1, /*id*/ 77).await?;
send_config_read_request(&mut ws2, /*id*/ 77).await?;
let ws1_config = read_response_for_id(&mut ws1, /*id*/ 77).await?;
let ws2_config = read_response_for_id(&mut ws2, /*id*/ 77).await?;
assert_eq!(ws1_config.id, RequestId::Integer(77));
assert_eq!(ws2_config.id, RequestId::Integer(77));

服务端的 ConnectionSessionState 用 OnceLock 保存初始化结果;非 initialize request 在该状态为空时由 dispatch_initialized_client_request() 返回 invalid request。这是连接级协议 gate,不是 Thread 是否存在 的运行时 gate。

8.3 明文WS边界 ​

remote_connect_rejects_non_loopback_ws_when_auth_configured 使用 ws://example.com 和 bearer token。 客户端必须在建立网络连接前返回 InvalidInput,避免把凭据放进非加密的远端 WebSocket handshake; loopback ws:// 与远端 wss:// 才是允许组合。

源码位置:codex-rs/app-server-client/src/lib.rs

rust
// :: remote_connect_rejects_non_loopback_ws_when_auth_configured(完整断言)
// URL与token组合在connect内部先通过transport policy,再尝试网络握手。
let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
    endpoint: RemoteAppServerEndpoint::WebSocket {
        websocket_url: "ws://example.com:4500".to_string(),
        auth_token: Some("remote-bearer-token".to_string()),
    },
    client_name: "codex-app-server-client-test".to_string(),
    client_version: "0.0.0-test".to_string(),
    experimental_api: true,
    mcp_server_openai_form_elicitation: false,
    opt_out_notification_methods: Vec::new(),
    channel_capacity: 8,
})
.await;
let err = match result {
    Ok(_) => panic!("non-loopback ws should be rejected before connect"),
    Err(err) => err,
};
assert_eq!(err.kind(), ErrorKind::InvalidInput);
assert!(
    err.to_string()
        .contains("remote auth tokens require `wss://` or loopback `ws://` URLs")
);

这组测试验证 remote client 的 transport policy,不代表文档把实验性网络 WebSocket提升为生产承诺。

8.4 进程内关闭 ​

shutdown_waits_for_in_process_drain 使用暂停时间的 Tokio test:worker 收到 Shutdown 后故意等待 30 秒, 再设置 completion flag 并响应。client.shutdown().await 返回时 flag 必须已经为 true,证明 shutdown 不是 只把 command 放入 channel 就提前结束。

源码位置:codex-rs/app-server-client/src/lib.rs

rust
// :: shutdown_waits_for_in_process_drain(完整核心路径)
let (command_tx, mut command_rx) = mpsc::channel(1);
let (_event_tx, event_rx) = mpsc::channel(1);
let completed = Arc::new(AtomicBool::new(false));
let worker_completed = Arc::clone(&completed);
let worker_handle = tokio::spawn(async move {
    let response_tx = match command_rx.recv().await {
        Some(ClientCommand::Shutdown { response_tx }) => response_tx,
        _ => panic!("expected shutdown command"),
    };
    tokio::time::sleep(Duration::from_secs(30)).await;
    worker_completed.store(true, Ordering::Release);
    let _ = response_tx.send(Ok(()));
});
let client = InProcessAppServerClient {
    command_tx,
    event_rx,
    worker_handle,
};

// start_paused让测试虚拟推进30秒,不把真实CI拖慢。
client.shutdown().await.expect("shutdown should complete");
assert!(completed.load(Ordering::Acquire));

Python SDK 的 CodexClient.close() 还实现了 stdin close → terminate → 2 秒 wait → exception 时 kill,并各 给 reader/stderr thread 0.5 秒 join。当前测试没有直接模拟 wait(timeout=2) 失败并断言 kill() 被 调用的 Python 场景,因此这条升级关闭路径有源码控制流,但仍缺直接回归测试。

8.5 连接生命周期 ​

bash
# 找出哪些入口持有长期进程,哪些只持有一次Turn子进程。
rg -n "subprocess.Popen|spawn\(|InProcessAppServerClient::start|RemoteAppServerClient::connect" \
  sdk codex-rs/tui codex-rs/exec

# 找出initialize gate和不同transport的关闭入口。
rg -n "Not initialized|ClientNotification::Initialized|shutdown_waits_for" \
  codex-rs/app-server codex-rs/app-server-client

读者应能回答:

  1. 为什么 TUI 复用 App Server protocol 不等于一定启动 App Server 子进程?
  2. 为什么 ws1 initialize 成功不能使 ws2 调用 config/read?
  3. TypeScript SDK 与 Python SDK 的进程 owner 和关闭时机有什么差异?
  4. codex exec 与 codex exec-server 为什么不能因为名称相似而混用?

接下来可阅读 Core运行时架构总览 进入共享 runtime,或用 CodexThread背压 深入不同 transport 的队列语义。