数据截至 (上游 commit 60706feb348c)
Paseo — 架构与原理
30 秒导读: 你机器上装了 Claude Code、Codex、Copilot 等好几个编码 agent 的命令行工具,它们各说各的话、各自只能在一个终端里跑。Paseo 在本机起一个守护进程(daemon),把这些 CLI 全部包装成同一种「会话」,再把会话状态通过一条 WebSocket 推到手机 / 桌面 / 网页 / 终端。结果是:你可以在工位上开 5 个 agent 并行改代码,通勤路上用手机看它们跑到哪、批权限、发追加指令。
1. 这是什么(零基础也能懂)
一句话定义: Paseo 是跨设备的编码 agent 控制面——一个自托管的本地守护进程,加上一组连到它的客户端。
1.1 它解决什么问题
现在的编码 agent 基本都是命令行程序:你 cd 进项目、敲 claude,它在这个终端里跑。这带来三个具体的麻烦。
| 麻烦 | 具体表现 |
|---|---|
| 关在终端里 | 关掉终端窗口 / 合上笔记本,会话就没了;想在手机上看进度,没有办法 |
| 各说各话 | Claude Code、Codex、Copilot 的会话格式、权限模型、模式(mode)命名完全不同,换一家就要重学一遍 |
| 难以并行 | 同一个仓库同时跑 3 个 agent,它们会互相踩对方的工作区 |
Paseo 对应给出三个答案:守护进程(会话活在进程里,不绑终端)、归一层(五家 CLI 一套模型)、worktree 编排(每个 agent 一个独立检出)。
1.2 给谁用
给同时用多家编码 agent、并且想在多台设备上盯着它们的工程师。它明确是自托管的:agent 跑在你自己的机器上,用你自己的 CLI 凭据、你自己的 dev 环境(README.md 的 Self-hosted / Multi-provider 两条)。
1.3 用起来什么样
装 CLI 之后,守护进程和 agent 都从终端驱动(命令取自 README.md 的 CLI 章节):
npm install -g @getpaseo/cli
paseo # 起守护进程
paseo run --provider claude/opus-4.6 "implement user authentication"
paseo ls # 列出在跑的 agent
paseo attach abc123 # 实时看某个 agent 的输出
paseo send abc123 "also add tests" # 追加指令
paseo --host workstation.local:6767 run "run the full test suite" # 打到远程守护进程
这些子命令在 packages/cli/src/cli.ts:65-113 一条条注册(runLsCommand、runRunCommand、runAttachCommand、runSendCommand),再加上 agent / workspace / schedule / loop 等子命令组(packages/cli/src/cli.ts:169-202)。桌面 app、手机 app、网页 UI 走的是同一条 WebSocket,不是另一套后端。
1.4 一句话直觉
把 Paseo 当成编码 agent 的「音乐播放服务器」。 agent CLI 是各家格式不同的音源,守护进程负责统一解码并保存播放进度,客户端(手机 / 桌面 / 终端)只是遥控器和显示屏——遥控器断电了,曲子照放。
2. 顶层全景(它大概怎么转)
2.1 五个部件,一条线串起来
怎么读这张图:从左到右是一次请求的方向,从右到左是状态回流的方向;中间"传输"一格有两条互斥的路可走。
┌──────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌───────────────┐
│ ① 客户端 │ │ ② 传输 │ │ ③ 守护进程 │ │ ④ 归一层 │
│ 手机/桌面/ │──>│ 直连:局域网/VPN │──>│ WebSocket 服务 │──>│ 一个接口 │
│ 网页/CLI │ │ 中继:端到端加密 │ │ + AgentManager │ │ 罩住五家 CLI │
│ │<──│ │<──│ + 时间线唯一真相 │<──│ │
└──────────────┘ └─────────────────┘ └──────────────────┘ └───────┬───────┘
│ 子进程 / SDK
┌───────┴───────┐
│ ⑤ agent CLI │
│ claude/codex/ │
│ copilot/... │
└───────────────┘
2.2 部件一句话职责
| 部件 | 干什么 | 主要位置(符号) |
|---|---|---|
| 守护进程装配 | 把配置、存储、git、终端、语音、MCP 全部装配起来并监听端口 | packages/server/src/server/bootstrap.ts:571(createPaseoDaemon) |
| WebSocket 服务 | 每条连接一个会话,握手、鉴权、订阅、广播 | packages/server/src/server/websocket-server.ts:539(VoiceAssistantWebSocketServer) |
| 线协议 | 用 zod 定义全部收发消息,单一判别联合 | packages/protocol/src/messages.ts:6855(WSInboundMessageSchema) |
| AgentManager | agent 的生命周期、轮次、权限、时间线的唯一真相 | packages/server/src/server/agent/agent-manager.ts:665(AgentManager) |
| 归一接口 | 所有 provider 必须实现的两个接口 | packages/server/src/server/agent/agent-sdk-types.ts:708(AgentClient)、:619(AgentSession) |
| provider 适配器 | 每家 CLI 一个实现:进程 / SDK / HTTP / ACP | packages/server/src/server/agent/providers/ |
| 客户端 SDK | 连接、重连、请求-响应关联、事件分发 | packages/client/src/daemon-client.ts:1060(DaemonClient) |
| 加密中继 | 不信任中继服务器的前提下打通 NAT | packages/relay/src/encrypted-channel.ts:320(EncryptedChannel) |
2.3 主线走一遍:一句 prompt 的旅程
不进代码,只看它经过谁:
你在手机上打字
│
├─1─> DaemonClient 打包成一条 session 消息,走 WebSocket(密文或明文)
│
├─2─> 守护进程收下,zod 校验 → 路由到 AgentManager
│
├─3─> AgentManager 找到这个 agent 的 AgentSession,开一个 turn(轮次)
│
├─4─> provider 适配器把 prompt 翻译成该 CLI 的原生调用
│
├─5─> CLI 边跑边吐事件 → 适配器翻译回统一的 AgentStreamEvent
│
├─6─> AgentManager 写进时间线(唯一真相),同时广播给所有订阅者
│
└─7─> 你的手机、你工位的桌面 app、你终端里的 attach,同时看到同一条更新
第 4 步和第 5 步是这个项目最重的工程量,见归一层;第 6 步的"写入 + 广播"是双轨的,见AgentManager。
2.4 三个贯穿全局的设计约定
- 唯一真相 在守护进程,不在客户端。 客户端的本地状态随时可以丢弃重建;时间线的权威副本由
AgentTimelineStore持有(packages/server/src/server/agent/agent-timeline-store-types.ts:47)。 - 线上的东西全部先过 zod。 入站、出站各一个判别联合,不存在"手写 JSON 直接信"的路径(
packages/protocol/src/messages.ts:2940、:5293)。 - 归一层只认接口,不认厂商。
AgentProvider就是一个字符串(packages/server/src/server/agent/agent-sdk-types.ts:12),新加一家不需要改 AgentManager。
3. 阅读地图
建议按下面的顺序读——前三章是主干,后三章是主干上长出来的能力。
| 顺序 | 章节 | 读完你会知道 | 难度 |
|---|---|---|---|
| 1 | 一条连接:守护进程、线协议与端到端加密中继 | 守护进程怎么起来的、消息长什么样、手机怎么在不开公网端口的情况下连到家里的电脑 | 中 |
| 2 | 归一层:把五家异构 agent CLI 变成同一种 Session | AgentClient / AgentSession 的接口设计,以及四种截然不同的适配方式 | 高 |
| 3 | AgentManager:生命周期状态机与时间线唯一真相 | agent 的五个状态、turn 怎么开怎么收、时间线的 epoch + seq 模型、权限请求怎么流转 | 高 |
| 4 | 客户端同步:live 求快、fetch 求准 | 为什么要同时有"实时推送"和"游标拉取"两条路,以及它们怎么对账 | 中 |
| 5 | 工作区、worktree 与 git 检出:并行开发的底座 | workspace / project / checkout 三层模型,worktree 自动创建与引导脚本 | 中 |
| 6 | 让 agent 指挥 agent:工具目录、MCP、定时与循环 | 同一份工具目录怎么同时喂给 MCP 和 provider 原生工具,以及 loop / schedule 两套编排 | 中 |
只想快速判断相关性的 agent 请看这里: 想抄「多 provider 归一」读第 2 章;想抄「实时流 + 分页对账」读第 3、4 章;想抄「不信任服务器的 NAT 穿透」读第 1 章;想抄「agent 编排 agent」读第 6 章。
4. 巧妙之处(可借鉴的技术)
4.1 归一接口用「必选核心 + 可选能力 + 能力声明」三段式,不做最小公分母
多 provider 抽象最常见的失败是砍到最小公共集——谁都支持的才留下,结果每家的长处全丢了。
Paseo 的做法是把接口切三段:
| 段 | 内容 | 例子 |
|---|---|---|
| 必选核心 | 每家都必须实现,无条件可用 | run、startTurn、subscribe、interrupt、close |
| 可选方法 | TypeScript 可选成员,调用前判空 | setModel?、revertConversation?、listCommands? |
| 能力声明 | 一组布尔位,客户端据此决定 UI 显不显示 | AgentCapabilityFlags |
依据:packages/server/src/server/agent/agent-sdk-types.ts:636(AgentSession)、:168(AgentCapabilityFlags)、:685(AgentClient)。这样 Codex 的 rewind、Claude 的 thinking 档位都能保留,而 AgentManager 依然只写一套调度逻辑。
4.2 生命周期状态机写进类型,非法状态编译期就不存在
ManagedAgent 不是"一个对象 + 一个 status 字段",而是五个变体的判别联合(packages/server/src/server/agent/agent-manager.ts:423)。
关键在于变体之间的字段差异:
ManagedAgentClosed的session类型是null,不是AgentSession | null;- 只有
ManagedAgentRunning的activeForegroundTurnId可以是字符串,其余四个变体都被钉成null; ManagedAgentError的lastError是必选string,不是可选。
于是「对已关闭的 agent 发起一次 turn」这类 bug 在类型检查阶段就被挡掉,不需要运行时断言。状态取值本身还被单独抽到协议包里共享(packages/protocol/src/agent-lifecycle.ts:1,AGENT_LIFECYCLE_STATUSES)。
4.3 时间线返回三个自诊断标志,客户端不用猜自己有没有掉队
分页最难的是"我手上这个游标还算数吗"。AgentTimelineFetchResult 直接把答案写在响应里(packages/server/src/server/agent/agent-timeline-store-types.ts:35-44):
| 字段 | 含义 | 客户端该干嘛 |
|---|---|---|
reset | 服务端换了 epoch,你的整段本地缓存作废 | 清空,从 tail 重拉 |
staleCursor | 游标属于旧 epoch | 丢弃游标 |
gap | 你要的那段已经被裁掉了,中间有洞 | 别直接拼接,重新对齐 |
配套的 AgentTimelineCursor 是 { epoch, seq } 二元组(同文件 :10),epoch 在建流时生成一个 UUID(packages/server/src/server/agent/agent-timeline-store.ts:152),rewind / 重建时换掉——所有旧游标一次性失效,不需要逐个通知客户端。
4.4 60 毫秒合流窗口:在服务端把碎片攒成块再发
大模型是逐 token 吐字的。原样转发意味着一次回答产生成百上千条 WebSocket 帧——在移动网络上这是灾难。
AgentStreamCoalescer 在广播前拦一道,把同一条 assistant_message / reasoning / tool_call 的连续更新合并,默认窗口 60ms(packages/server/src/server/agent/agent-stream-coalescer.ts:3 的 AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,类在 :86)。
妙在只有可合并的三类事件进合流器,turn_completed、permission_requested 这类语义边界事件直接放行——延迟只加在"反正是要连续刷新的文本"上,不加在"用户在等着点按钮"的事件上。
4.5 派生 provider 用逐方法透明包装,只改一个字段
Paseo 允许你从内置 provider 派生出自定义 provider(换个命令行、换套模型清单)。派生 出来的 session 必须对外报告新的 provider id,否则客户端会认错。
wrapSessionProvider 的做法是老实地逐方法转发,只在三处出口重写 provider 字段:事件流(mapStreamEvent)、运行时信息(mapRuntimeInfo)、持久化句柄(mapPersistenceHandle)。依据:packages/server/src/server/agent/provider-registry.ts:437-470。
不用 Proxy、不用继承,代价是几十行样板,换来的是新增接口方法时会漏改就编译报错——比 Proxy 静默透传安全。
4.6 E2EE 握手期把后续消息缓冲住,避开一个隐蔽的时序坑
守护进程收到客户端的 e2ee_hello 后,要做一次异步的密钥派生。这段异步窗口里,客户端的第一条密文可能已经到了。
如果不处理,那条密文会被 hello 处理器当成"第二个 hello"解析,握手直接失败。代码里的解法是:一进入异步就把 transport.onmessage 换成一个只管往数组里塞的 bufferNext,握手完成后再按序回放(packages/relay/src/encrypted-channel.ts:233、:269-272)。
回放时还额外过滤掉重复的 hello/ready 明文(shouldIgnorePostHelloPlaintext,同文件 :236)——客户端的握手重试定时器每秒发一次(HANDSHAKE_RETRY_MS,:118),不过滤就会把重试包当业务消息喂给上层。
4.7 带外命令不占用轮次,不打断正在跑的 agent
有些命令(例如暂停某个长期目标)需要在 agent 正忙时立刻生效,但又不该把当前这轮工作掐掉。
AgentSession 为此留了一个可选钩子 tryHandleOutOfBand(packages/server/src/server/agent/agent-sdk-types.ts:674):provider 若认得这条 prompt,就返回一个处理器,由 AgentManager 直接跑(tryRunOutOfBand,packages/server/src/server/agent/agent-manager.ts:2071),不分配 turn、不碰前台轮次;不认得就返回 null,走正常提示词路径。
4.8 一份工具目录,两个出口
Paseo 让 agent 能创建 agent、调度 agent。这些"元能力"被定义成一份统一的工具目录 PaseoToolCatalog(packages/server/src/server/agent/tools/paseo-tools.ts:541,createPaseoToolCatalog),里面有 create_agent、send_agent_prompt、respond_to_permission、create_schedule、create_workspace 等三十余个工具。
同一份目录有两个出口:
- MCP 出口 —— 遍历目录逐个
registerTool,包成标准 MCP server 给任何支持 MCP 的 provider 用(packages/server/src/server/agent/mcp-server.ts:31-49); - 原生出口 —— 通过
AgentLaunchContext.paseoTools直接注入给能吃原生工具的 provider(packages/server/src/server/agent/agent-sdk-types.ts:611,配合capabilities.supportsNativePaseoTools,:175)。
好处是工具语义只写一遍,两条链路不会漂移;代价是目录本身成了一个三千行的大文件。
5. 代码地图(导航索引)
按主题排,拿符号名去 grep 比行号抗漂移。
5.1 入口与装配
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 守护进程总装配 | packages/server/src/server/bootstrap.ts | createPaseoDaemon / PaseoDaemonConfig |
| WebSocket 服务 | packages/server/src/server/websocket-server.ts | VoiceAssistantWebSocketServer |
| 鉴权(Bearer / 密码) | packages/server/src/server/auth.ts | isBearerTokenValid / hashDaemonPassword |
| CLI 命令注册 | packages/cli/src/cli.ts | program.command("run") / createScheduleCommand |