跳到主要内容

数据截至 (上游 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 SDKSDK 的 SDKMessage 异步迭代{name:"Bash", input:{command}}
Codex起子进程 codex app-server自家 JSON-RPC{name:"shell", input:{command:[...]}}
Copilot / Cursor起子进程 + --acpACP(Agent Client Protocol){kind:"execute", rawInput:{...}}
OpenCode起一个本地 HTTP serverREST + 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

主线走一遍

  1. 守护进程启动,buildProviderRegistry 把六家内置 provider(claude / codex / copilot / opencode / pi / omp)和用户自定义的 provider 拼成一张表。
  2. ProviderSnapshotManager 对每家先问 isAvailable()(二进制装了没),再问 fetchCatalog()(有哪些模型和模式),结果按 cwd 缓存。
  3. 用户在手机上点"新建 agent",AgentManager 调 client.createSession(config, launchContext)
  4. 适配器起进程 / 起 HTTP server / 引 SDK,拿到原生会话,返回一个 AgentSession
  5. 每次发消息走 startTurn();原生事件在适配器里被翻译成 AgentStreamEvent,通过 subscribe 吐出去。

3. 两张契约表(归一层的"宪法")

3.1 AgentClient —— 厂商级

只有五个必选方法,其余全是可选;可选方法缺席就是"这家不支持"。

方法必选干什么
createSession造一场全新会话
resumeSession用持久化句柄复活一场旧会话
fetchCatalog一次调用同时返回模型和模式,禁止外部分别探测
isAvailable二进制/登录态是否就绪
capabilities能力位
listImportableSessions列出"用户自己在终端里开的"会话,供导入
importSession把某条终端会话整场吸收进 Paseo(含历史时间线)
archiveNativeSessionPaseo 归档时,顺手把厂商自己 UI 里的会话也归档
shutdown守护进程退出时释放常驻资源,必须幂等

依据:packages/server/src/server/agent/agent-sdk-types.ts:708-770(AgentClient)。

fetchCatalog 那条注释值得单独念一遍:实现内部爱怎么探测都行(一个进程、两次调用、静态表),但外部只有这一个发现入口——这条约束防止了"模型列表和模式列表来自两次不同的进程启动"这种慢且不一致的写法。

3.2 AgentSession —— 会话级

┌──────────── 发起 ────────────┐
run(prompt) ──►│ startTurn(prompt) → {turnId} │──► 原生协议
└──────────────┬───────────────┘

subscribe(cb) ◄───────────────┤ 翻译后的 AgentStreamEvent
streamHistory() ◄─────────────┘ 恢复会话时的历史回放(一次性)

run 不是另一条路径,而是 startTurn 的糖。 所有六家 provider 的 run() 都调同一个 runProviderTurn:它先订阅、再 startTurn、把 timeline 事件攒成数组、等 turn_completed / turn_failed / turn_canceled 结束。依据:packages/server/src/server/agent/providers/provider-runner.ts:27(runProviderTurn),调用点见 claude/agent.ts:2167acp-agent.ts:1592codex-app-server-agent.ts:4040opencode-agent.ts:3369pi/agent.ts:1280omp/agent.ts:939

这里有个容易踩的时序坑,runProviderTurn 专门处理了:startTurn 还没返回 turnId 时事件可能已经到了,所以它先把事件塞进 bufferedEvents,拿到 turnId 后再补放一遍(provider-runner.ts:78-91)。

会话接口的其余部分按职责分四组:

方法备注
事件subscribe / streamHistorystreamHistory 只在恢复会话时吐一次历史,吐完清空
模式与模型getAvailableModes / getCurrentMode / setMode / setModel?setMode 可返回 AgentProviderNotice(软失败提示)
权限getPendingPermissions / respondToPermission见 §6
生命周期describePersistence / interrupt / closeclose 只释放运行时,不删厂商那边的持久会话

依据:agent-sdk-types.ts:636-677(AgentSession)。

3.3 带外提示:不占用轮次的命令

tryHandleOutOfBand 是这套接口里最特别的一个钩子。有些斜杠命令(/compact/goal pause)本质是副作用操作,不该占用一个对话轮次,更不该打断正在跑的那一轮。

约定是:会话返回一个 handler(不支持就返回 null),handler 通过传进来的 emit 直接吐事件;AgentManager 不分配 turnId,但仍把 timeline 事件落库并广播。

// 示意,非源码
tryHandleOutOfBand(prompt) {
if (prompt !== "/compact") return null; // 不认识 → 走正常轮次
return { run: async ({ emit }) => {
const err = await this.compact(); // 真正的副作用
if (err) emit({ type: "timeline", item: { type: "assistant_message", text: err } });
}};
}

真实实现:codex-app-server-agent.ts:4722(tryHandleOutOfBand,处理 /compact/goal)、pi/agent.ts:1555omp/agent.ts:1198。管理侧的落地在 agent-manager.ts:2071(tryRunOutOfBand)。


4. 能力位如何驱动降级

它要解决的小问题

Codex 能回滚对话但不能回滚文件;ACP 类 provider 两样都不能;Claude 三样都行。上层不能为此写一串 if (provider === "claude")——那样每加一家 provider 都要改所有调用点。

思路

把差异数据化:每家 provider 声明一组布尔位,上层只读位、不读厂商名。

export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined; // 开放字典:允许厂商加私有位
supportsStreaming: boolean;
supportsSessionPersistence: boolean;
supportsSessionListing?: boolean;
supportsDynamicModes: boolean;
supportsMcpServers: boolean;
supportsNativePaseoTools?: boolean;
...
}

依据:agent-sdk-types.ts:180-193(AgentCapabilityFlags)。注意首行的索引签名——能力位是开放的,不是封闭枚举。

六家的实际取值

能力位claudecodexACP(默认)copilotopencodepiomp
supportsDynamicModes
supportsMcpServers
supportsRewindConversation
supportsRewindFiles
supportsRewindBoth

依据(逐格):claude/agent.ts:304codex-app-server-agent.ts:212acp-agent.ts:230(DEFAULT_ACP_CAPABILITIES)、copilot-acp-agent.ts:25opencode-agent.ts:115pi/agent.ts:153omp/agent.ts:118

OpenCode 那一行有意思:它 supportsRewindBoth 为真,但 conversation / files 单独都为假——回滚对它是原子的,只能一起回。这正是三个位而不是一个位的原因。

降级发生在三个地方

① 发现期 ② 服务端调用期 ③ 客户端渲染期
┌──────────┐ ┌────────────────────┐ ┌────────────────────┐
│isAvailable│──►│ 能力位为假 → 抛错 │──►│ 能力位为假 → 不画 │
│ 二进制没装│ │ 或干脆不问 provider │ │ 那个菜单项 │
│ → unavail │ │ │ │ │
└──────────┘ └────────────────────┘ └────────────────────┘

三处的真实落点:

层级代码行为
provider-snapshot-manager.ts:940client.isAvailable()返回假 → 快照标 status: "unavailable",前端灰掉这家
agent/rewind/rewind.ts:12(invokeRewindCapability)位为假或方法缺席 → 抛 RewindCapabilityError
agent-manager.ts:921supportsSessionListing 为假 → 压根不问这家有没有可导入会话
agent-manager.ts:4783supportsNativePaseoTools 为真 → 直接注入原生工具目录,并从启动配置里剥掉 Paseo 自己的 MCP server,避免同一批工具进两遍
packages/app/src/components/rewind/use-rewind-capabilities.ts:24resolveRewindMenuItems 按三个位逐个 push 菜单项

②③ 是双保险:客户端不画按钮,服务端也不信客户端。

能力位还能被用户配置改写

自定义 ACP provider 的能力位不是写死的,而是从用户配置的 params 里读:

function buildGenericACPCapabilities(params: GenericACPProviderParams): AgentCapabilityFlags {
return {
...DEFAULT_ACP_CAPABILITIES,
supportsMcpServers: params.supportsMcpServers ?? DEFAULT_ACP_CAPABILITIES.supportsMcpServers,
};
}

packages/server/src/server/agent/providers/generic-acp-agent.ts:164。这样接一个新的 ACP agent 不需要改 Paseo 代码,写一段配置就行。


5. 统一形状:事件、时间线、工具细节

5.1 事件联合 AgentStreamEvent

所有 provider 只能吐这一个联合里的东西。按用途分四类:

类别事件说明
会话生命thread_started拿到厂商侧会话 id 时发一次
轮次生命turn_started / turn_completed / turn_failed / turn_canceled都带可选 turnId,用来把并发事件对号入座
内容timeline / usage_updated / provider_subagenttimeline 承载所有可见内容
交互permission_requested / permission_resolved / attention_requiredattention_required 是给推送通知用的信号
配置变更mode_changed / model_changed / thinking_option_changed厂商侧被外部改了配置时反向通知客户端

依据:agent-sdk-types.ts:402-458(AgentStreamEvent)。取 turnId 的小工具在同文件 :437(getAgentStreamEventTurnId)——它用 "turnId" in event 而不是逐个分支判断,是这个联合的类型收窄技巧。

provider_subagent 是唯一一个"事件里套事件"的成员:它携带 ProviderSubagentInputEvent,用来表达"厂商自己启动的子 agent"的增删改和它自己的时间线(provider-subagents/store.ts:27)。

5.2 时间线条目 AgentTimelineItem

AgentTimelineItem
├── user_message 用户说的话(带 messageId / clientMessageId)
├── assistant_message 模型说的话
├── reasoning 思考流
├── tool_call ──────────┐ status: running|completed|failed|canceled
├── todo │ error: 失败时非空,其余恒为 null
├── error │ detail: ToolCallDetail ← 真正的展示数据
└── compaction ┘

依据:agent-sdk-types.ts:393-400。注意 ToolCallTimelineItem四个状态各一个类型的联合(:336-360),不是一个带可选 error 的对象——这样"completed 却带 error"在类型层就写不出来。

5.3 ToolCallDetail —— 展示层的最小公约数

这是整层最关键的一个类型。它不描述"工具是什么",而描述"该怎么画它":

type承载字段(节选)客户端画成
shellcommand / cwd / output / exitCode终端块
readfilePath / content / offset / limit文件片段带行号
editfilePath / oldString / newString / unifiedDiffdiff 视图
writefilePath / content新文件预览
searchquery / filePaths / numMatches / webResults命中列表
fetchurl / code / bytes / durationMs请求卡片
sub_agentchildSessionId / actions[] / log可展开的子 agent 面板
plantext计划卡片
plain_textlabel / text / icon通用文字卡片(兜底可读)
unknowninput / output原始 JSON(最终兜底)

依据:agent-sdk-types.ts:251-348(ToolCallDetail)。

关键设计:兜底不是抛错,是 unknown。任何一家 provider 冒出没见过的工具,UI 仍然能把原始 input/output 摊开给用户看,不会白屏。


6. 工具调用如何映射成统一 ToolCallDetail

它要解决的小问题

同一件事——跑一条 shell 命令——五家的叫法是 Bashshellexec_commandcommandexecute;参数键是 commandcmd;值可能是字符串也可能是字符串数组;输出可能在 outputtextcontentaggregated_outputstructuredContent.outputresult.output 任意一处。

思路:两次归一 + 两遍解析

厂商原生工具调用


① 名字归一:provider 的名字 → 规范种类(shell/read/edit/…)
│ CODEX_SHELL_NAMES / SHELL_NAMES 这类 Set

② 名字改写:规范种类 → 规范 detail 名(shell / read_file / apply_patch)
│ resolveDetailName()

③ 第一遍 zod:信封校验(name 非空 + input/output 允许为 null)
│ 解析失败 → detail = unknown,结束

④ 第二遍 zod:按规范名匹配分支,分支内做形状容错
│ 解析失败或返回 undefined → detail = unknown

ToolCallDetail

第 ①② 步:名字归一

Claude 侧把 Bash|bash|shell|exec_command 收成 shell 种类,再统一改写成规范名:

function resolveDetailName(toolKind: ClaudeToolKind, name: string): string {
switch (toolKind) {
case "shell": return "shell";
case "read": return "read_file";
case "write": return "write_file";
case "edit": return "apply_patch";
...
}
}

packages/server/src/server/agent/providers/claude/tool-call-mapper.ts:80(resolveDetailName);种类判定在同文件 :69(resolveClaudeToolKind),名字集合在 :40-67。Codex 侧的对应物是 codex/tool-call-mapper.ts:57(resolveCodexToolKind)与 :44(CODEX_SHELL_NAMES)。

各家名字到统一 detail.type 的对照:

统一 detail.typeClaude 认的名字Codex 认的名字ACP 的 kind
shellBash bash shell exec_commandBash shell bash exec exec_command commandexecute
readRead read read_file view_fileread read_fileread
editEdit MultiEdit multi_edit edit apply_patch apply_diff str_replace_editoredit apply_patch apply_diffedit / delete
writeWrite write write_file create_filewrite write_file create_file(并入 edit)
searchWebSearch web_search search Grep grep Glob globsearch web_searchsearch
fetchWebFetch web_fetch WebFetchTool web_fetch_tool webfetch(无独立分支,落 unknown)fetch

依据:claude/tool-call-mapper.ts:40-67codex/tool-call-mapper.ts:44-55acp-agent.ts:3338-3367(mapToolDetail 的 switch)。

第 ③④ 步:两遍 zod 与共享分支

第二遍解析用的分支是跨 provider 共享的:一个工厂函数把"规范名 + 输入 schema + 输出 schema + 映射函数"打包成一个 zod 分支。

export function toolDetailBranchByName(name, inputSchema, outputSchema, mapper) {
const schema = z.object({
name: z.literal(name),
input: z.nullable(inputSchema),
output: z.nullable(outputSchema),
});
return schema.transform((value) => mapper(value.input, value.output));
}

providers/tool-call-detail-primitives.ts:976(toolDetailBranchByName);Codex 用的是带 cwd 的变体 :1027(toolDetailBranchByNameWithCwd),因为 Codex 给的是绝对路径,需要用 cwd 裁成相对路径(codex/tool-call-detail-parser.ts:65 normalizeCodexFilePathstripCwdPrefix)。

容错全部塞进共享 schema,而不是每家各写一遍。shell 输出的取值顺序就是个好例子——它一口气尝试 13 个位置:

return firstNonEmptyString([
value.output, value.text, value.content,
value.aggregated_output, value.aggregatedOutput,
value.structuredContent?.output, ... value.result?.content,
]);

tool-call-detail-primitives.ts:163(resolveShellOutputRawText);同文件 :69(ToolShellInputSchema)对 command / cmd、字符串 / 数组做同样的兼容。

最终装配函数长这样(Claude 与 Codex 结构完全一致):

export function deriveCodexToolDetail(params): ToolCallDetail {
const pass1 = CodexToolEnvelopeSchema.safeParse({...});
if (!pass1.success) return { type: "unknown", input, output };
const pass2 = CodexToolDetailPass2Schema.safeParse(pass1.data);
if (pass2.success && pass2.data) return pass2.data;
return { type: "unknown", input: pass1.data.input, output: pass1.data.output };
}

codex/tool-call-detail-parser.ts:188(deriveCodexToolDetail);Claude 对应 claude/tool-call-detail-parser.ts:217(deriveClaudeToolDetail)。

关键细节:编辑类的"宁可不画"

Codex 的 edit 有个额外闸门。如果一条 edit 已经不是 running 状态,却解析不出可渲染的 diff,它主动降级成 unknown,而不是画一个空 diff:

const detail =
envelope.toolKind === "edit" && envelope.status !== "running" &&
!hasRenderableEditDetail(parsedDetail)
? { type: "unknown", input: envelope.input, output: envelope.output }
: parsedDetail;

codex/tool-call-mapper.ts:97-107(toToolCallTimelineItem)。判断逻辑在同文件 :552(hasRenderableEditDetail)。空 diff 比原始 JSON 更没信息量,这是取舍。

ACP 走的是另一条路

ACP 协议本身就带 kind 字段,所以它不需要名字归一,直接按 kind 分派;状态也只需三值映射:

function mapToolStatus(status) {
switch (status) {
case "completed": return "completed";
case "failed": return "failed";
case "pending":
case "in_progress":
default: return "running"; // 未知状态一律当 running
}
}

acp-agent.ts:3303(mapToolStatus)。

工具名的另一半:归属判定

除了"这是什么工具",还有"这工具是谁的"。packages/protocol/src/tool-name-normalization.ts 专管这件事,因为不同 provider 的命名空间分隔符不同(. / : / / / __):

函数回答的问题
getToolLeafName去掉命名空间后的叶子名是什么13
isLikelyNamespacedToolName这名字带命名空间吗(__ 需 ≥3 段才算)22
isPaseoToolName这是 Paseo 自己注入的工具吗(mcp__paseo__*)42
isLikelyExternalToolName这是外部/MCP 工具吗85

__ 那条规则写得很小心:两段的 foo__bar 不算命名空间,只有三段以上、或第二段自己还含 _ 才算——注释直言是"避免误伤任意自定义名"(:31)。


7. 权限:把厂商的"请求"翻转成 Paseo 的"待办"

它要解决的小问题

厂商 agent 想执行危险操作时会同步阻塞地问"能不能干"。但 Paseo 的用户在手机上,可能十分钟后才回答。

思路:把回调翻成一个悬挂的 Promise

厂商进程 适配器 客户端
│ │ │
│─ 我要跑 rm -rf ? ────►│ │
│ │ 造 requestId │
│ │ new Promise(resolve => { │
│ │ pending.set(id, ...) │
│ │ }) │
│ │── permission_requested ──►│
│ (阻塞等待) │ │ 用户思考 10 分钟
│ │◄── respondToPermission ───│
│◄──── allow/deny ──────│ pending.resolve(...) │

三家的实现结构完全一样,只是入口不同:

provider入口代码
ClaudeSDK 的 canUseTool 回调claude/agent.ts:4569(handlePermissionRequest)
ACPACP 的 session/request_permission RPCacp-agent.ts:2233(requestPermission)
Codexapp-server 的审批通知codex-app-server-agent.ts:4450(handlePlanPermissionResponse)

统一出口都是 respondToPermission:claude/agent.ts:2574acp-agent.ts:2116

统一的请求形状

interface AgentPermissionRequest {
id: string;
kind: "tool" | "plan" | "question" | "mode" | "other";
title?: string;
detail?: ToolCallDetail; // 复用工具细节,UI 不用另写渲染器
actions?: AgentPermissionAction[]; // 厂商自定义按钮
suggestions?: AgentPermissionUpdate[];
}

agent-sdk-types.ts:476(AgentPermissionRequest)。detail 复用 ToolCallDetail 是个省事的设计:权限弹窗里那段"它要跑什么命令"的预览,和时间线里工具卡片用的是同一套渲染代码。

动作选择:精确匹配优先,否则按偏好顺序

ACP 的选项种类是 allow_once / allow_always / reject_once / reject_always。用户如果点了具体按钮就精确匹配,并且校验行为一致(不允许一个标着 allow 的按钮返回 deny);没点具体按钮就按偏好顺序挑第一个:

const order = response.behavior === "allow"
? ["allow_once", "allow_always"]
: ["reject_once", "reject_always"];

acp-agent.ts:3548(selectPermissionOption)。

两个真实的边角

选择器伪装成权限。 有些 ACP agent 会用"多个同为 allow 种类的选项"来表达一道单选题。Paseo 认出这种形状(isACPChooserRequest,acp-agent.ts:3572),并且自动批准对它无效——这类请求必须等真人回答(:2168)。

批准计划要触发下一轮。 Codex 的计划审批通过后需要立刻开始实施,所以 respondToPermission 可以返回 { followUpPrompt },由管理层再发一轮:

export interface AgentPermissionResult {
followUpPrompt?: AgentPromptInput;
}

agent-sdk-types.ts:632,产生点在 codex-app-server-agent.ts:4457-4467


8. 注册与差异适配

8.1 清单:静态身份住在 protocol 包

AgentProviderDefinition 描述一家 provider 的静态部分:id、标签、描述、默认模式、模式列表(带图标和颜色档位)。它放在 packages/protocol 而不是 server,是因为客户端也要用同一份图标/颜色。

export type AgentModeColorTier =
| "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;

provider-manifest.ts:4。四档语义 + 逃生舱(自定义十六进制色)。

模式定义里最重要的一位是 isUnattended:

// Marks the provider's most-permissioned no-prompt mode.
isUnattended?: boolean;

provider-manifest.ts:15-19(AgentProviderModeDefinition)。它标出"这家最放权、不弹窗的模式"——claude 的 bypassPermissions、codex 的 full-access、copilot 的 allow-all、omp 的 full。有了这一位,"让子 agent 继承父 agent 的无人值守状态"就能跨 provider做:父 agent 是无人值守的,子 agent 换了一家 provider,也能查到那家自己的对应模式(getUnattendedModeId,provider-manifest.ts:304;消费点 create-agent-mode.ts:95)。

清单顶上挂着一条诚实的 TODO,值得抄进设计笔记:

modes 不该是静态的。ACP 类 provider 在 session/new 时会自己报模式,应该以 provider 为准、只在上面叠加 UI 元数据。

provider-manifest.ts:21-23。注册表里的 decorateModes 已经是在往这个方向走了:运行期拿到的模式如果缺 icon/colorTier,就从静态清单里补(provider-registry.ts:499)。

8.2 注册表:三层组装

静态清单 AGENT_PROVIDER_DEFINITIONS
+
用户覆写 providerOverrides(换二进制/换模型/改标签/派生新 provider)
+
客户端工厂 PROVIDER_CLIENT_FACTORIES(id → new XxxAgentClient)

ProviderDefinition { enabled, createClient, fetchCatalog, ... }

入口是 provider-registry.ts:731(buildProviderRegistry),工厂表在 :118(PROVIDER_CLIENT_FACTORIES),组装单条在 :487(createRegistryEntry)。取客户端集合用 :776(createClientsFromRegistry),守护进程退出时逐个调 shutdown 且吞掉异常的是 :796(shutdownAgentClients)。

PROVIDER_REGISTRY 是个陷阱名。 它还在导出,但值是 null as unknown as ...,并标了 "Deprecated: Use buildProviderRegistry instead"(provider-registry.ts:765-767)。任何以为它是可用常量的代码都会在运行时炸——读源码时别被名字骗了。

8.3 派生 provider:两条路

用户可以自定义 provider,分两种:

场景extends结果
接一个全新的 ACP agent"acp"(哨兵值)按 id 挑 Cursor/Kiro/Trae 特化类,否则用 GenericACPAgentClient
给已有 provider 做一个配置档已注册的 provider id复用那家的工厂,叠加环境变量/命令/模型覆写

依据:provider-registry.ts:628(addDerivedProviders),ACP 分支在 :642-689,继承分支在 :692-727。第二条路就是 Z.AI / Qwen 这类"Claude 兼容端点"的接入方式:extends: "claude" + 换 env + 换模型列表。

8.4 会话包装器:把身份贴回去

派生 provider 有个隐蔽问题:内层客户端只知道自己是 "claude",但外层这个档案叫 "zai"wrapSessionProvider 负责在每个出口把 provider 字段改写成外层 id:

subscribe: (callback) => inner.subscribe((event) => callback(mapStreamEvent(provider, event))),
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),

provider-registry.ts:350(wrapSessionProvider),客户端级的对应物在 :386(wrapClientProvider)。可选方法一律 ?.bind(inner)——方法缺席必须继续缺席,不能被包装成"存在但会抛错",否则上面的能力探测就失灵了。

8.5 目录缓存:按 cwd 分桶

ProviderSnapshotManager 缓存"这家 provider 在这个工作目录下有哪些模型和模式"。为什么按 cwd 分?因为 OpenCode 之类的 provider,模型和 agent 定义来自仓库里的配置文件。

刷新一家的流程,四种结局各对应一种快照状态:

definition.enabled 为假 ──────────────► status: "unavailable"
│否

isAvailable() 超时/为假 ──────────────► status: "unavailable"
│真

fetchCatalog() 抛错或超时 ────────────► status: "error" (带 error 文案)
│成功

status: "ready" (models/modes/fetchedAt)

provider-snapshot-manager.ts:902(refreshProvider)。两个探测都套了 withTimeout,默认 60 秒(:38);并且写回前用 isCurrentProviderLoad 检查这次刷新有没有被更新的一次刷新取代(:838)——防止慢的旧结果覆盖快的新结果。


9. 四条接入路线的对比

路线代表 provider进程模型传输代码入口
厂商 SDKclaude引 SDK,由 SDK 自己管子进程SDK 的异步消息迭代providers/claude/agent.ts:1476
专有 JSON-RPCcodex自己 spawn codex app-serverstdio 上的 JSON-RPCproviders/codex-app-server-agent.ts:6709
ACP 标准协议copilot / cursor / kiro / traecli自己 spawn xxx --acpstdio 上的 ACP(NDJSON)providers/acp-agent.ts:788
本地 HTTP serveropencodespawn 一个常驻 server,多会话共享HTTP + 事件流providers/opencode-agent.ts:1349
JSONL-RPCpi / omp自己 spawn pi --mode rpc换行分隔 JSONproviders/pi/agent.ts:2396omp/agent.ts:2183

下面各引一处最能说明该路线特点的实现。

9.1 Claude:SDK 路线,顺手拿到子 agent 与回滚

Claude 是唯一能力位全绿的一家,因为 Agent SDK 直接提供了会话分叉和文件检查点:

export async function revertClaudeConversation(input) {
const fork = await input.sdk.forkSession(input.sessionId, { upToMessageId: messageId });
input.setSessionId(fork.sessionId); // 回滚 = 分叉到新会话 id
}

providers/claude/rewind.ts:14(revertClaudeConversation);文件回滚走 query.rewindFiles(:31),两样一起时先回文件再回对话(:43)。前提是启动参数里开了 enableFileCheckpointing: true(claude/agent.ts:3282)。

子 agent 追踪靠 ClaudeSidechainTracker:SDK 把子 agent 的消息以 parentToolUseId 标记发过来,tracker 按这个 id 聚合成一条 sub_agent 细节。providers/claude/sidechain-tracker.ts:58(类)、:76(handleMessage)。

它的构造参数里有条注释,记录了一次真实的踩坑:

当 provider 已经能声明式地知道子 agent 身份与状态时,tracker 就停止写描述符,只做时间线映射。两个主人用不同证据写同一个描述符,正是 live 路径和回放路径当初漂移的原因。

sidechain-tracker.ts:64-68

9.2 Codex:JSON-RPC 路线,持久化句柄最重

Codex 的 describePersistence 把几乎整份配置塞进 metadata:

return {
provider: CODEX_PROVIDER,
sessionId: this.currentThreadId,
nativeHandle: this.currentThreadId, // codex 的 thread id
metadata: { cwd, title, threadId, modeId, model, thinkingOptionId, extra, systemPrompt, mcpServers },
};

codex-app-server-agent.ts:4562。为什么要塞这么多——见 §10。

9.3 ACP:一个基类 + 四个"只填参数"的子类

ACP 路线的价值在于:接一家新 agent 的成本可以低到 30 行。Trae 的整个适配器就是给基类传三个参数:

export class TraeACPAgentClient extends GenericACPAgentClient {
constructor(options) {
super({ ...options,
// traecli publishes slash commands and skills asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS });
}
}

providers/trae-acp-agent.ts:16(全文 30 行)。Cursor 多传两个(clientCapabilityMetaconfigFeatureOptions,cursor-acp-agent.ts:29);Kiro 多传一个自定义通知解析器,因为它用私有扩展方法 _kiro.dev/commands/available 而不是标准的 available_commands_update(kiro-acp-agent.ts:80 parseKiroExtensionCommands)。

差异靠构造器注入,不靠继承里的 override。 基类预留的钩子点有:sessionResponseTransformerconfigOptionsTransformermodeIdTransformerproviderModeWriterbeforeModeWriterextensionCommandsParser

Copilot 是把这些钩子用到极致的例子:它的 "Allow All" 在 ACP 那边根本不是一个模式,而是一个 config option。Paseo 凭空造出这个模式给用户看,再在写模式时翻译回 config 调用:

export async function writeCopilotProviderMode(context) {
if (!requestsAllowAll) return { handled: false }; // 不是我管的,交回基类
const response = await context.connection.setSessionConfigOption({
sessionId: context.sessionId, configId: "allow_all", value: "on",
});
return { handled: true, currentModeId: COPILOT_ALLOW_ALL_MODE_ID, ... };
}

copilot-acp-agent.ts:202;反向的"从 Allow All 切走要先关掉"在 :224(beforeCopilotModeWriter);把 ACP 报的模式列表改造成 Paseo 模式列表在 :127(transformCopilotSessionResponse)。这是"归一层可以创造原始协议里不存在的概念"的典型。

9.4 OpenCode:本地 HTTP server,引用计数管生命周期

OpenCode 不是一进程一会话,而是一个常驻 server 服务多个会话。所以需要一个引用计数器:

方法什么时候用
acquireCurrent普通新会话:复用当前 server140
acquireNew需要一个全新代次(轮换旧的)145
acquireDedicated会话带自定义环境变量 → 必须独占进程150
acquireExisting恢复会话时,按 URL 找回还活着的那个164

providers/opencode/server-manager.ts:80(OpenCodeServerManager)。释放逻辑是标准引用计数:减到 0 且已退休才真杀进程(:200 releaseServer)。

createSession 里的错误处理很关键——建会话失败必须归还引用,否则 server 永远不死:

} catch (error) {
await acquisition.release();
throw error;
}

opencode-agent.ts:1432-1435

启动 server 时那行注释也是踩出来的:server 的 cwd 用中立的 OpenCode home,因为"从用户 home 目录启动会让 OpenCode 把整个 home 树当默认工作区去索引"(server-manager.ts:315-317)。

9.5 Pi / OMP:共享一个 JSONL-RPC 传输

Pi 和 OMP 只共享传输层,业务映射各写各的。传输层 JsonlRpcProcess 做四件事:按行切 stdout、type:"response" 的按 id 回填 pending、其余广播给订阅者、进程死了把所有 pending 一次性 reject。

providers/jsonl-rpc-process.ts:111(类)、:191(handleStdoutChunk 的行缓冲)、:230(handleResponse)、:253(failAll)。

它的超时策略是个值得抄的小设计——允许"无超时"但要求你显式写出来:

/** Pass as `timeoutMs` to wait only for a response, process death, or `close()`. */
export const JSONL_RPC_NO_TIMEOUT = null;

jsonl-rpc-process.ts:13。Pi 的 compact(要跑一次 LLM 压缩)就用它;控制面调用用默认 30 秒(pi/cli-runtime.ts:33-39 的策略注释)。不设墙钟不等于永远挂起:进程死或 close() 都会 reject。


10. 会话恢复句柄如何跨重启复活

它要解决的小问题

守护进程重启了。厂商那边的会话文件还在磁盘上,但内存里的进程、SDK 对象、HTTP 连接全没了。怎么把一场对话原样接回来?

句柄的形状

export interface AgentPersistenceHandle {
provider: AgentProvider;
sessionId: string;
nativeHandle?: string; // Codex thread id、Claude resume token 等
metadata?: AgentMetadata;
}

agent-sdk-types.ts:195。关键在 metadata——它不是备注,是恢复所需的全部配置

复活的四步

重启前 重启后
┌──────────────────┐ ┌────────────────────────────────┐
│ describePersist- │ │ client.resumeSession(handle) │
│ ence() 生成句柄 │ │ ① metadata → 重建 config │
└────────┬─────────┘ │ ② 无 cwd → 直接抛错(拒绝猜) │
│ 存进 agent 记录 │ ③ new Session(config, handle) │
▼ (第 03 章) │ ④ sessionId → 原生 resume 参数│
┌──────────────────┐ 重启 └────────────────────────────────┘
│ $PASEO_HOME/... │────────►
└──────────────────┘

第 ① ② 步在 Claude 侧长这样:

const metadata = coerceSessionMetadata(handle.metadata);
const merged = { ...metadata, ...overrides };
if (!merged.cwd) throw new Error("Claude resume requires the original working directory in metadata");

claude/agent.ts:1527-1530宁可抛错也不猜 cwd——猜错会让 agent 在错误的仓库里动手。Codex 的同一处则选择兜底到 process.cwd()(codex-app-server-agent.ts:6852),两家不一致,是源码里客观存在的分歧。

第 ③ ④ 步:句柄进构造器后,sessionId 被翻译成各家自己的 resume 参数。

if (handle) {
if (!handle.sessionId) throw new Error("Cannot resume: persistence handle has no sessionId");
this.claudeSessionId = handle.sessionId;
this.persistence = handle;
this.loadPersistedHistory(handle.sessionId); // 顺手把磁盘上的历史读进来
}

claude/agent.ts:2108-2114。启动 SDK 时,sessionId 变成 resume 字段:

const sessionBinding: Pick<ClaudeOptions, "resume" | "sessionId"> = {};
if (this.pendingFreshSessionId) sessionBinding.sessionId = this.pendingFreshSessionId;
else if (this.claudeSessionId) sessionBinding.resume = this.claudeSessionId;

claude/agent.ts:3251-3256

历史回放:一次性,不重复

恢复的会话需要把旧时间线补给客户端,这就是 streamHistory 的用途。它有个刻意的"消耗"语义:

async *streamHistory() {
if (!this.historyPending || this.persistedHistory.length === 0) return;
const history = [...this.persistedHistory];
this.persistedHistory.length = 0; // 立刻清空
this.historyPending = false;
for (const item of history) yield { type: "timeline", provider: this.provider, item };
}

acp-agent.ts:1671;Claude 版在 claude/agent.ts:2351(多吐一段子 agent 事件)。第二次调用什么都不吐——防止重连时历史被塞两遍。

Claude 的历史来源是厂商自己的 JSONL 文件,读失败静默忽略(claude/agent.ts:4781 loadPersistedHistory)——历史读不到只是少点上下文,不该让恢复整个失败。

句柄失效怎么办

厂商侧的会话可能已经被用户删了。Claude 认出这种错误后,不是抛给用户,而是把句柄作废、就地降级成一场新会话:

this.logger.warn({ error: staleResumeError },
"Claude resumed session no longer exists; invalidating persisted session");
this.persistence = null;
this.persistedHistory = [];
...

claude/agent.ts:3922-3967(readMissingResumedConversationError 的处理分支)。

恢复的两种意图

AgentResumeSessionOptions.purpose 区分 "interactive"(要能继续对话)和 "history"(只读历史)。类型定义处专门标了 "Never persist this option"——它是运行时意图,不是会话配置(agent-sdk-types.ts:622-626)。Codex 把它一路透传到会话构造器(codex-app-server-agent.ts:6867)。


11. 辅助设施

设施解决什么位置
runProviderTurnrun() 的唯一实现,含 turnId 前的事件缓冲providers/provider-runner.ts:27
JsonlRpcProcess行缓冲 + 请求配对 + 进程死时批量 rejectproviders/jsonl-rpc-process.ts:111
tool-call-detail-primitives.ts跨 provider 共享的 zod 分支与容错 schemaproviders/tool-call-detail-primitives.ts
materializeProviderImage把 base64 图落成内容哈希命名的临时文件providers/provider-image-output.ts:87
renderProviderImageOutputAsAssistantMarkdown把图统一渲染成 ![alt](file://...) 的助手消息providers/provider-image-output.ts:172
tool-name-normalization.ts判断工具名的命名空间归属packages/protocol/src/tool-name-normalization.ts

图片那个设施有两处值得单说:

文件名用内容哈希,所以同一张图在同一进程里反复出现(比如历史回放)不会每次泄一个新临时文件(provider-image-output.ts:84-86 的注释)。目录和文件都锁到 0o700 / 0o600

识别自己产出的 markdown 时匹配完整的 <64位hex>.<ext> 形状,而不是只看开头的 ![——否则用户自己写的图片 markdown 会在历史回放时被误判成 provider 图片(:104-108)。


12. 巧妙之处(可以带走的)

  1. 能力位是开放字典,不是封闭枚举。 [capability: string]: boolean | undefined 让厂商能加私有位而不改公共类型(agent-sdk-types.ts:181)。

  2. 可选方法必须"缺席就是缺席"。 包装器一律 inner.method?.bind(inner),绝不包成"存在但抛错"(provider-registry.ts:375-382)——否则所有 typeof x.y === "function" 的能力探测全部失真。

  3. 兜底永远是 unknown,不是异常。 两遍 zod 的任一遍失败,都退到 {type:"unknown", input, output},UI 至少能摊开原始 JSON(codex/tool-call-detail-parser.ts:200-217)。

  4. 容错代码住在共享层,不住在各家适配器。 shell 输出的 13 处取值、command/cmd 的双写、路径字段的三种拼法,全在 tool-call-detail-primitives.ts 里写一遍(:69:163:213)。

  5. 归一层可以创造上游没有的概念。 Copilot 的 "Allow All" 模式在 ACP 里只是一个 config option,Paseo 把它包装成模式给用户,再在写入时翻译回去(copilot-acp-agent.ts:127/:202)。

  6. 状态用判别联合,不用可选字段。 ToolCallTimelineItem 是四个状态各一个类型,"completed 却带 error" 在类型层就写不出来(agent-sdk-types.ts:359-383)。

  7. 无超时要显式命名。 JSONL_RPC_NO_TIMEOUT = null 比"忘了传超时"安全得多,而且注释写清了它靠什么结束(jsonl-rpc-process.ts:9-13)。

  8. 刷新结果写回前先验"我还是最新那次吗"。 isCurrentProviderLoad 防止慢的旧探测覆盖快的新探测(provider-snapshot-manager.ts:993)。


13. 边界与局限

模式列表还是半静态的。 清单里的 TODO 直说了:ACP provider 会在 session/new 时自己报模式,理想做法是以运行期为准、静态表只提供图标和颜色(provider-manifest.ts:21-23)。当前是 decorateModes 做事后补齐(provider-registry.ts:499)。

PROVIDER_REGISTRY 是个空壳。 它导出的值是 null,只为兼容旧引用留着(provider-registry.ts:765-767)。

persistSession: false 不是人人都认。 接口注释写着"不支持的 provider 应该 no-op"(agent-sdk-types.ts:615-619),Codex 就是这样:只打一行 debug 日志,附一条 TODO 说明将来可能走 codex exec --ephemeral(codex-app-server-agent.ts:6816-6823)。

resume 缺 cwd 时行为不一致。 Claude 抛错,Codex 兜底到 process.cwd()(claude/agent.ts:1530 vs codex-app-server-agent.ts:6852)。

能力位粒度还不够细。 OpenCode 只能原子回滚(supportsRewindBoth 真而另两个假),这个组合能表达,但"部分支持"的更细语义(例如"只能回滚最近三条")就没有位可用。

厂商私有工具会掉进 unknown 名字集合是硬编码的白名单,上游改名或加新工具,在 Paseo 更新前都只能渲染成原始 JSON(claude/tool-call-mapper.ts:40-67)。


14. 代码地图

主题文件路径符号名
厂商级接口packages/server/src/server/agent/agent-sdk-types.tsAgentClient
会话级接口packages/server/src/server/agent/agent-sdk-types.tsAgentSessiontryHandleOutOfBand
能力位packages/server/src/server/agent/agent-sdk-types.tsAgentCapabilityFlags
统一事件packages/server/src/server/agent/agent-sdk-types.tsAgentStreamEventgetAgentStreamEventTurnId
统一时间线与工具细节packages/server/src/server/agent/agent-sdk-types.tsAgentTimelineItemToolCallDetailToolCallTimelineItem
权限形状packages/server/src/server/agent/agent-sdk-types.tsAgentPermissionRequestAgentPermissionResponseAgentPermissionResult
恢复句柄与启动上下文packages/server/src/server/agent/agent-sdk-types.tsAgentPersistenceHandleAgentSessionConfigAgentLaunchContext
注册表组装packages/server/src/server/agent/provider-registry.tsbuildProviderRegistryPROVIDER_CLIENT_FACTORIESaddDerivedProviders
身份包装packages/server/src/server/agent/provider-registry.tswrapSessionProviderwrapClientProvider
关停packages/server/src/server/agent/provider-registry.tsshutdownAgentClients
静态清单与模式packages/protocol/src/provider-manifest.tsAgentProviderDefinitionAgentProviderModeDefinitiongetUnattendedModeIdgetModeVisuals
目录缓存packages/server/src/server/agent/provider-snapshot-manager.tsProviderSnapshotManagerrefreshProvider
轮次运行器packages/server/src/server/agent/providers/provider-runner.tsrunProviderTurn
共享工具细节 schemapackages/server/src/server/agent/providers/tool-call-detail-primitives.tstoolDetailBranchByNametoShellToolDetailToolShellOutputSchema
JSONL 传输packages/server/src/server/agent/providers/jsonl-rpc-process.tsJsonlRpcProcessJSONL_RPC_NO_TIMEOUT
工具名归属packages/protocol/src/tool-name-normalization.tsisPaseoToolNameisLikelyExternalToolName
图片输出packages/server/src/server/agent/providers/provider-image-output.tsmaterializeProviderImagerenderProviderImageOutputAsAssistantMarkdown
回滚能力闸门packages/server/src/server/agent/rewind/rewind.tsinvokeRewindCapability
Claude 适配器packages/server/src/server/agent/providers/claude/agent.tsClaudeAgentClienthandlePermissionRequest
Claude 工具映射packages/server/src/server/agent/providers/claude/tool-call-mapper.tsresolveClaudeToolKindresolveDetailName
Claude 子 agent / 回滚packages/server/src/server/agent/providers/claude/{sidechain-tracker,rewind}.tsClaudeSidechainTrackerrevertClaudeConversation
Codex 适配器packages/server/src/server/agent/providers/codex-app-server-agent.tsCodexAppServerAgentClienttryHandleOutOfBand
Codex 工具映射packages/server/src/server/agent/providers/codex/tool-call-{mapper,detail-parser}.tstoToolCallTimelineItemderiveCodexToolDetail
ACP 基类packages/server/src/server/agent/providers/acp-agent.tsACPAgentClientACPAgentSessionmapToolDetailselectPermissionOption
ACP 派生类packages/server/src/server/agent/providers/{generic,copilot,cursor,kiro,trae}-acp-agent.tsGenericACPAgentClientwriteCopilotProviderModeparseKiroExtensionCommands
OpenCode 适配器与服务器packages/server/src/server/agent/providers/opencode{-agent.ts,/server-manager.ts}OpenCodeAgentClientOpenCodeServerManager
Pi / OMP 适配器packages/server/src/server/agent/providers/{pi,omp}/agent.tsPiRpcAgentClientOmpAgentClient

15. 接着读什么