第 3 章:启动、ACP 与 Leader
同一个 grok 二进制既能启动 TUI,也能充当 stdio Agent、无头执行器和常驻 Leader。本章回答:这些模式如何汇聚,ACP 为什么是关键边界,Leader 又解决了什么。
3.1 main 先做什么
xai-grok-pager-bin/src/main.rs 的 main 位于约 1654 行。它不是简单的 tokio::main,而是有意识地把进程级初始化放在异步业务之前。
主要步骤可归纳为:
- 处理渲染子进程等特殊早期分派;
- 解析 CLI,处理 version/doctor 等可提前返回的命令;
- 初始化 allocator、heap profiling、文件描述符上限;
- 校验 managed requirements,安装 telemetry/Sentry 与 crash handler;
- 准备内置 user guide 等资源;
- 创建多线程 Tokio runtime;
- 进入
async_main执行命令分派。
这个顺序体现了两个边界:
- 进程不变量应在启动业务前建立,例如 sandbox、allocator、崩溃处理;
- Agent 业务进入 Tokio 后再由 shell 层负责。
3.2 命令分派不是 Agent Loop
async_main 支持大量管理命令:模型、登录、插件、MCP、session、workspace、leader、share、export、trace、memory、update 等。真正进入 Agent 的分支由 run_agent_command 继续分派:
AgentCmd::Stdio -> run_stdio_agent
AgentCmd::Headless -> run_headless
AgentCmd::Serve -> serve path
AgentCmd::Leader -> run_leader
interactive -> pager app2
3
4
5
不要从这个巨大的 match 中寻找对话循环。它只是在选择“谁来充当客户端、连接和进程宿主”。
3.3 ACP:稳定的 Agent/Client 边界
ACP 在本项目中承担类似“本地 Agent 应用协议”的角色。Client 发送 initialize、session/new、session/load、session/prompt 等请求,Agent 返回 response 和 session update。
它带来三个工程收益:
- TUI 与 Agent 业务可独立演化;
- Leader 可以透明转发同一套消息;
- 重连恢复可以重放 ACP 初始化与 session 状态,而不需要理解每个 UI widget。
为什么 stdio transport 并不简单
xai-acp-lib 为 stdin 使用专门的阻塞读取线程和可取消的行缓冲读取器。原因不是性能炫技,而是持久双向 NDJSON 在不同平台上可能发生标准输入阻塞和关闭死锁。
源码还体现了防御性协议边界:
- 单行有 64 MiB 上限;
- 读取过程可取消;
- 对部分转义斜杠做兼容归一;
- request/notification 通过 gateway/channel 解耦。
协议层必须把“不完整输入、超大输入、对端消失”视为正常运行条件。
3.4 本地模式:Agent 与 Client 在同一进程
spawn_agent_local 构造 MvpAgent、ACP gateway 和 channel,并在 LocalSet 上运行 Agent。Client 仍通过 ACP 交互,只是 transport 留在进程内。
这是一种很有价值的“逻辑远程、本地部署”结构:
好处是本地模式和 Leader/stdio 模式共享协议语义,不会形成两套业务入口。
3.5 Leader 模式:为什么要常驻
如果每个 TUI 都直接拥有 Agent,退出 TUI 就意味着 Agent 进程消失,也很难让 IDE、另一个终端或 headless client 共同观察同一 session。
Leader 模式把关系改为:
Leader 的职责包括:
- 保证一台机器上目标版本/通道的 leader 唯一;
- 接收并登记客户端能力;
- 把请求路由到共享
MvpAgent; - 按 session 和 driver/viewer 身份路由更新;
- 处理 client detach、重连和版本替换;
- 暴露 workspace/control 等管理能力。
它不应该拥有 SessionActor 的对话语义,否则本地模式和 Leader 模式会分叉。
3.6 Request ID 为什么需要命名空间
不同客户端都可能发送 JSON-RPC id 1。Leader 把客户端 id 与连接身份组合成上游 id,再在响应返回时拆回。
client A: id=1 ─┐
├─ leader namespace ─> unique upstream ids
client B: id=1 ─┘2
3
源码使用分隔符构造命名空间,并对热路径使用共享字符串,减少路由期间重复分配。这个细节揭示了 Leader 的本质:它是一个保持协议相关性的多路复用器。
3.7 Load 时的 replay/live 竞态
一个新客户端附着 session 时,Leader 要先发送历史重放;但 SessionActor 可能同时产生新 token。如果直接并发转发,客户端可能看到:
历史 A -> 实时 C -> 历史 BLeader 在 session/load 期间暂存 live update,等 replay 完成再按序释放。缓冲有上限(源码中为 4096 条),避免慢客户端造成无限内存增长。
这是恢复系统的通用原则:
重放与实时流必须有明确切换屏障,否则“每条消息都没丢”仍然可能得到错误状态。
3.8 交互提示的多客户端语义
权限提示、选择框等 modal 不能简单广播后接受所有回复。Leader 的做法是:
- 可把交互提示广播给已附着客户端;
- 第一份有效回答获胜;
- 后续重复回答不再改变决定;
- scheduled prompt 则路由给单一 driver,避免多个客户端重复执行。
这说明“多个 viewer”与“多个 driver”不是同一概念。可观察性可以复制,产生副作用的控制权必须唯一化。
3.9 版本切换与重连
Leader 对版本有方向性策略:较新版本可促使旧 Leader 退出或 relaunch,但旧客户端不应反向驱逐新 Leader。TUI 重连可保持长期等待并允许取消;headless 重连则需要有界,否则脚本可能永久挂住。
stdio 侧还维护最小 replay cache,保存 initialize、session/new 或 session/load 所需信息。连接恢复后,它重放这些控制请求,再继续转发后续 ACP 消息。
注意这里恢复的不是完整会话历史——完整历史由 Session/Persistence 负责。stdio cache 只恢复“如何重新建立协议关系”。
3.10 启动时的沙箱边界
binary 在大多数应用逻辑之前应用进程沙箱。这种顺序很重要:OS 级限制往往不可逆,若先启动线程或打开资源,再套沙箱,就会产生难以推理的继承状态。
然而,Leader、权限策略和沙箱解决的是不同问题:
| 机制 | 回答的问题 |
|---|---|
| Leader | 哪个客户端控制哪个 session? |
| Permission | 这个具体动作是否被策略/用户允许? |
| Sandbox | 即使代码尝试越界,操作系统是否允许? |
不能用其中一个替代另外两个。
3.11 源码阅读卡
| 目的 | 文件 |
|---|---|
| 进程初始化与命令分派 | xai-grok-pager-bin/src/main.rs |
| 本地、stdio、headless、leader 启动 | xai-grok-shell/src/agent/app.rs |
| ACP transport | xai-acp-lib |
| Leader 总体设计 | leader/mod.rs |
| 路由、缓冲与 modal | leader/server.rs |
| TUI 启动 | pager/src/app/mod.rs |
3.12 本章结论
ACP 把“交互表面”与“Agent 状态”分离;Leader 又把“客户端进程寿命”与“会话寿命”分离。两层分离共同允许:
- 同一 Agent 支持 TUI、IDE 和自动化;
- 一个 session 被多个客户端观察;
- UI 退出、连接断开后,Agent 工作与历史仍可恢复;
- 本地模式不必付出远程部署成本,却保留同样的协议结构。
下一章进入 SessionActor,还原真正的 Agent Loop。