第一次阅读 Codex 时,很容易把 codex、TUI、codex exec、App Server 和 Exec Server 当成
同一程序的五种叫法。源码给出的边界更清楚:codex 首先是一个多命令分发器;TUI 和 Exec
是客户端形态;App Server 是 Agent Runtime 的公开协议适配层;Exec Server 则是另一条执行环境
协议。
这一区分不是命名问题。它决定配置由谁解析、Thread 由谁拥有、事件怎样跨边界,以及某个进程 退出时究竟要停止 UI、Agent Session,还是正在执行的远程命令。
先看四种入口各自拥有的东西
| 入口 | 直接拥有 | 通过协议访问 | 不直接拥有 |
|---|---|---|---|
codex 根命令 | 参数解析与子命令分派 | 无 | Thread、模型采样、工具执行 |
| TUI | 终端状态、输入、渲染、App Server Client | Thread/Turn API | Core Session |
codex exec | 非交互参数、输出格式、退出码、事件投影 | Thread/Turn API | 另一套简化 Agent Loop |
| App Server | JSON-RPC 连接、请求处理器、ThreadManager | Core Protocol | 终端渲染、执行环境 RPC 实现 |
| Exec Server | 进程、PTY、文件系统、HTTP、能力 RPC | 执行环境协议 | Agent Thread 与模型循环 |
这里最重要的一点是:exec 和 exec-server 完全不同。前者是“一次非交互 Agent 任务”的用户
入口,后者是“给 Agent 提供可远程调用执行环境”的服务入口。
根命令先做参数归一化,再做分派
根解析器同时接收五类输入:通用配置覆盖、Feature 开关、远程连接参数、默认交互参数和可选 子命令。没有子命令时,剩余参数属于 TUI;存在子命令时,默认交互参数的必填约束不再阻塞解析。
--enable 与 --disable 并不会直接修改一个全局 Feature 单例。入口先把它们转换成与 -c features.xxx=true/false 等价的配置覆盖,再前置到具体子命令的覆盖列表。这样 Feature 与其他配置
共享同一套来源、优先级和诊断机制。
下面的伪代码保留了这个先后关系:
@dataclass(frozen=True)
class ParsedRootCommand:
root_overrides: list[ConfigOverride]
feature_toggles: list[FeatureToggle]
interactive: TuiArguments
subcommand: Subcommand | None
async def dispatch_codex_process(argv: list[str]) -> ExitStatus:
parsed = parse_multitool_cli(argv)
toggle_overrides = compile_feature_toggles(parsed.feature_toggles)
root_overrides = parsed.root_overrides + toggle_overrides
validate_root_only_options(parsed)
match parsed.subcommand:
case None:
tui_args = parsed.interactive.prepend_overrides(root_overrides)
return await run_tui(tui_args)
case Exec(arguments):
arguments.inherit_shared_options(parsed.interactive)
arguments.prepend_overrides(root_overrides)
return await run_noninteractive_exec(arguments)
case Review(arguments):
exec_args = translate_review_to_exec(arguments, parsed.interactive)
exec_args.prepend_overrides(root_overrides)
return await run_noninteractive_exec(exec_args)
case AppServer(arguments):
return await run_app_server(arguments, root_overrides)
case ExecServer(arguments):
return await run_exec_server(arguments, root_overrides)
case command:
return await dispatch_management_command(command, root_overrides)
这段设计有两个直接收益。第一,Review 不需要复制非交互事件循环;它只需要把 Review 参数翻译成 Exec 的一种命令。第二,Feature 开关不会绕开配置约束。但代价是根级参数、子命令参数和继承规则 必须明确,否则同名选项可能在不同位置产生不同含义。
TUI 是 App Server 的有状态客户端
TUI 负责键盘输入、终端布局、流式文本和审批弹窗,但它不会直接创建 Core Session。TUI 内部的
App Server Session 保存一个客户端、递增请求 ID、远程工作目录覆盖,以及 Embedded/Remote 参数
模式。
Embedded 模式可以把本地配置中已经确定的模型 Provider 等信息发给进程内服务;Remote 模式会 省略部分本地值,让远程 App Server 恢复或决定它自己的权威配置。若把两者混为一谈,本地默认值 可能意外覆盖远端线程已经保存的状态。
TUI 启动时还会并行读取模型列表和配置要求。这个并行不是 Agent Tool 并行,而是 UI bootstrap 为了减少串行网络等待。任何 typed request 失败都在客户端边界形成启动错误,不会改走一条直接 调用 Core 的备用路径。
class TuiAppServerSession:
def __init__(self, client: AppServerClient, mode: ThreadParamsMode) -> None:
self.client = client
self.mode = mode
self.next_request_id = 1
async def bootstrap(self, local_config: Config) -> BootstrapState:
models_task = self.client.request(ModelList(include_hidden=True))
requirements_task = self.client.request(ConfigRequirementsRead())
models, requirements = await gather_required(models_task, requirements_task)
return choose_ui_defaults(local_config, models, requirements)
async def start_thread(self, config: Config) -> StartedThread:
params = build_thread_start_params(
config=config,
include_local_provider=self.mode is EMBEDDED,
)
response = await self.client.request(ThreadStart(params))
return validate_started_thread(response)
async def start_turn(self, thread_id: str, turn: TurnDraft) -> TurnHandle:
return await self.client.request(
TurnStart(thread_id=thread_id, input=turn.items, **turn.runtime_overrides())
)
Exec 没有维护第二套 Agent Loop
codex exec 看起来比 TUI 简单:接收 Prompt,最后输出文本或 JSONL,然后返回退出码。但它并没有
因此跳过 App Server。Exec 在当前进程里启动一个 InProcessAppServerClient,发出 initialize、
thread/start 和 turn/start,再持续消费 Server Notification。
“进程内”只代表客户端与服务端不需要经过外部 socket。请求类型、通知类型和错误边界仍然属于 App Server Protocol。这个选择让 TUI、Exec 和其他客户端可以共享 Thread/Turn 语义,也让非交互 模式更容易被协议测试覆盖。
Exec 自己保留的是界面职责:
- 是否检查当前目录是 Git 仓库;
- 最终消息是否另存文件;
- 输出普通终端文本还是逐行 JSON;
- 什么事件影响 stderr、stdout 和进程退出码;
- Ctrl-C 或关闭信号怎样转换成 Turn/Thread 关闭请求。
async def run_exec_once(arguments: ExecArguments) -> int:
config = await resolve_exec_config(arguments)
client = await InProcessAppServerClient.start(config)
await client.initialize(exec_capabilities(arguments))
thread = await client.request(ThreadStart(from_config=config))
turn = await client.request(
TurnStart(thread_id=thread.id, input=arguments.prompt_items())
)
terminal = ExecTerminalState(turn_id=turn.id)
while not terminal.finished:
event = await client.next_event()
terminal.consume(event)
render_exec_projection(event, json_mode=arguments.json)
await client.request(ThreadClose(thread_id=thread.id))
return terminal.exit_code()
这个循环与 Core Agent Loop 不在同一层。它等待的是公开通知;Core Agent Loop 等待的是模型流和工具 Future。把两者都叫“事件循环”没有错,但不能据此认为它们拥有相同状态。
App Server 是公开协议适配器
App Server 启动后接受 stdio、WebSocket、Unix socket 等传输上的 JSON-RPC 请求。它初始化共享服务和
ThreadManager,把 thread/*、turn/* 请求转换成 Core 操作,再把 Core 事件投影成公开通知。
App Server 进程可以服务一个富客户端,也可以同时持有多个连接和 Thread。它的退出意味着公开 协议服务终止,并触发其所拥有 Runtime 的关闭;这与关闭一个 TUI 页面不是同一件事。
Exec Server 只回答执行环境问题
Exec Server 的独立监听模式建立执行环境 RPC 服务。远程注册模式还需要认证、父进程生命周期和 优雅关闭控制。Core 在选择远程执行环境后,才可能通过它启动进程、读写文件或发现能力。
如果把 Exec Server 当成 codex exec 的服务端,会得到三个错误推论:以为 Exec Server 能创建
Thread、以为它会调用模型、以为关闭一个执行 RPC 会自动产生 TurnComplete。当前协议都不保证这些
行为。
四类入口失败应在哪一层结束
| 失败 | 所有者 | 正确结果 |
|---|---|---|
| CLI 参数互斥、非法 Feature key | 根分发器 | 在任何 Runtime 启动前报错 |
| TUI 没有可用终端或 bootstrap 请求失败 | TUI | 返回启动失败,不创建可交互页面 |
| Exec 配置、工作目录或 Git 前置检查失败 | Exec | 输出诊断并给出非零退出码 |
| App Server transport 绑定失败 | App Server | 服务未就绪,不宣称 initialize 成功 |
| Remote TUI 连接/认证失败 | App Server Client | 保留远程错误,不偷偷切成本地 Thread |
| Exec Server 监听、注册或认证失败 | Exec Server | 执行环境不可用,不伪装成 Agent Turn 错误 |
特别要注意“自动回退”的范围。TUI 本地状态数据库有自己的恢复路径,模型运输也可能有 WebSocket→HTTP 回退,但这不等于任何远程 App Server 连接失败都可以改成本地执行。那会改变数据和 权限边界,必须由明确产品逻辑决定。
怎样测试进程入口而不启动完整模型任务
入口测试首先应保护参数和边界:
def test_review_reuses_exec_entrypoint() -> None:
parsed = parse_cli(["review", "--uncommitted"])
dispatched = plan_dispatch(parsed)
assert dispatched.runtime == "exec"
assert dispatched.exec_command.kind == "review"
def test_remote_options_do_not_leak_into_noninteractive_exec() -> None:
parsed = parse_cli(["--remote", "wss://host", "exec", "fix tests"])
with raises(UsageError):
plan_dispatch(parsed)
async def test_exec_observes_app_server_notifications() -> None:
fake = FakeInProcessAppServer(
notifications=[TurnStarted("t1"), TurnCompleted("t1")]
)
status = await run_exec_with_client(fake, prompt="inspect")
assert fake.requests.names() == ["initialize", "thread/start", "turn/start", "thread/close"]
assert status == 0
生产测试还需要分别验证 Embedded 与 Remote 参数投影、旧 App Server capability 缺失时的兼容处理,
以及进程收到关闭信号后的资源收尾。它们共同保护的不是某个 main 函数,而是“入口不越权接管
下一层状态”的架构边界。
小结
Codex 不是从一个 main 函数直接一路调用到模型。根 CLI 先选择产品角色;TUI 和 Exec 通过 App
Server Client 使用同一套公开 Thread/Turn 合同;App Server 适配 Core;Exec Server 则只在工具需要
真实执行环境时出现。
入口确定之后,下一项问题是“相同入口为什么会组装出不同 Runtime”。答案不在 match subcommand,而在配置层、Profile、运行时覆盖和受管要求怎样合并。2.2 将拆开这条配置流水线。
评论
登录后即可评论