雨天小六

读懂 Codex(2.1):CLI、TUI、Exec 与 App Server 的进程入口

· 更新于 2026-08-02 · 专栏:读懂 Codex

#Codex#Agent Runtime#软件架构#CLI#TUI

第一次阅读 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 ClientThread/Turn APICore Session
codex exec非交互参数、输出格式、退出码、事件投影Thread/Turn API另一套简化 Agent Loop
App ServerJSON-RPC 连接、请求处理器、ThreadManagerCore Protocol终端渲染、执行环境 RPC 实现
Exec Server进程、PTY、文件系统、HTTP、能力 RPC执行环境协议Agent Thread 与模型循环

这里最重要的一点是:execexec-server 完全不同。前者是“一次非交互 Agent 任务”的用户 入口,后者是“给 Agent 提供可远程调用执行环境”的服务入口。

Codex 根命令将无子命令、exec、app-server 和 exec-server 分派到不同产品入口的流程图
图 2.1-1:根命令先统一解析 Feature 和配置覆盖,再分派进程角色。入口不同不只是输出界面不同,而是进入了不同协议和生命周期。

根命令先做参数归一化,再做分派

根解析器同时接收五类输入:通用配置覆盖、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 在选择远程执行环境后,才可能通过它启动进程、读写文件或发现能力。

TUI 与 Exec 经 App Server 到 Core,而 Core 按需调用 Exec Server 的时序图
图 2.1-2:TUI 与 Exec 位于 Agent 客户端侧;Exec Server 位于执行环境侧。两条协议只在 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 将拆开这条配置流水线。

评论


← 返回文章列表