数据截至 (上游 commit 60706feb348c)
归一层:把五家异构 agent CLI 变成同一种 Session
30 秒导读: Paseo 要同时管 Claude Code、Codex、Copilot、Cursor、OpenCode、Pi 等一堆命令行 agent,但它们的进程模型、协议、事件名、工具名全不一样。这一层的任务只有一件:把它们全部压成同一个
AgentSession,让上面的 AgentManager 和手机客户端根本不需要知道背后跑的是谁。
本章是 Paseo 工程含量最高的一层。上一层的连接与线协议见 一条连接;本层产出的会话怎么被调度、时间线怎么落库,见 AgentManager。
1. 这是什么(零基础也能懂)
一句话定义: 归一层是一组适配器,把五种互不兼容的 agent 进程,包装成一套完全相同的 TypeScript 接口。
为什么需要它
假设你要写一个手机 App,能同时控制你电脑上的 Claude Code 和 Codex。你会立刻撞上这些事实:
| 厂商 | 进程怎么起 | 说什么话 | 一次工具调用长什么样 |
|---|---|---|---|
| Claude Code | 引 Anthropic 的 Agent SDK | SDK 的 SDKMessage 异步迭代 | {name:"Bash", input:{command}} |
| Codex | 起子进程 codex app-server | 自家 JSON-RPC | {name:"shell", input:{command:[...]}} |
| Copilot / Cursor | 起子进程 + --acp | ACP(Agent Client Protocol) | {kind:"execute", rawInput:{...}} |
| OpenCode | 起一个本地 HTTP server | REST + SSE 事件流 | 自家 part 结构 |
| Pi / OMP | 起子进程 pi --mode rpc | 换行分隔的 JSON(JSONL-RPC) | 自家 event |
四种传输、五套词汇。如果不归一,客户端每加一家厂商就要改一遍 UI。
归一层做什么
它规定两个接口和三种形状,谁都得遵守:
AgentClient—— 厂商级的工厂。负责"这个 CLI 装了没""有哪些模型和模式""给我造一个会话/恢复一个会话"。AgentSession—— 一次会话。负责"跑一轮对话""订阅事件流""切模式""回答权限询问""告诉我怎么把你救活"。- 三种统一形状 —— 事件用
AgentStreamEvent,时间线条目用AgentTimelineItem,工具调用的可视化细节用ToolCallDetail。
用起来什么样
上层代码长这样——完全看不出背后是谁:
// 示意,非源码
const client = registry["codex"].createClient(logger); // 换成 "claude" 也一样跑
const session = await client.createSession({ provider: "codex", cwd: "/repo" });
session.subscribe((event) => {
// 无论哪家 provider,这里收到的都是同一个事件联合
if (event.type === "timeline" && event.item.type === "tool_call") {
console.log(event.item.detail.type); // "shell" | "read" | "edit" | ...
}
});
await session.startTurn("把 README 翻译成中文"); // 只发起,不等待
const handle = session.describePersistence(); // 存下来,重启后还能复活这场对话
一句话直觉: 把它想成显卡驱动。游戏(客户端)只会说 OpenGL,五家显卡(agent CLI)各说各的指令集,驱动层负责翻译——而且当某张卡不支持某个特性时,驱动不是崩溃,是降级。
2. 顶层全景(它大概怎么转)
怎么读这张图
从上往下是"从配置到一次真实对话"的顺序;左边是注册与发现,右边是运行时。
┌──────────────────────────────────────────────────────────────┐
│ ① 清单:每家 provider 的静态身份(名字/描述/模式/图标) │
│ packages/protocol/src/provider-manifest.ts │
└───────────────────────────┬──────────────────────────────────┘
│ + 用户覆写(自定义 provider / 换二进制 / 换模型)
▼
┌──────────────────────────────────────────────────────────────┐
│ ② 注册表:清单 × 工厂 → ProviderDefinition │
│ buildProviderRegistry() provider-registry.ts │
└───────────────┬──────────────────────────┬───────────────────┘
│ createClient(logger) │ fetchCatalog(...)
▼ ▼
┌───────────────────────────┐ ┌────────────────── ────────────┐
│ ③ AgentClient(每家一个) │ │ ④ 目录缓存:哪些模型/模式可用 │
│ 造会话 / 恢复会话 │ │ ProviderSnapshotManager │
└───────────────┬───────────┘ └──────────────────────────────┘
│ createSession / resumeSession
▼
┌──────────────────────────────────────────────────────────────┐
│ ⑤ AgentSession(统一会话) │
│ startTurn ─► 厂商原生协议 ─► 原生事件 ─► 翻译 ─► 统一事件 │
└───────────────────────────┬──────────────────────────────────┘
│ subscribe(AgentStreamEvent)
▼
AgentManager(见第 03 章)
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
AgentProviderDefinition | 一家 provider 的静态身份:标签、模式、图标、默认模式 | packages/protocol/src/provider-manifest.ts:24 |
buildProviderRegistry | 把清单 + 用户覆写 + 客户端工厂拼成可用注册表 | packages/server/src/server/agent/provider-registry.ts:855 |
AgentClient | 厂商级接口:可用性探测、目录发现、造/恢复会话 | packages/server/src/server/agent/agent-sdk-types.ts:708 |
AgentSession | 会话级接口:跑轮次、订阅、模式、权限、持久化句柄 | packages/server/src/server/agent/agent-sdk-types.ts:636 |
AgentCapabilityFlags | 能力位,决定上层允许调用哪些可选方法 | packages/server/src/server/agent/agent-sdk-types.ts:180 |
ProviderSnapshotManager | 按 cwd 缓存"这家 provider 现在有哪些模型/模式/状态" | packages/server/src/server/agent/provider-snapshot-manager.ts:206 |