跳到主要内容

数据截至 (上游 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 一条条注册(runLsCommandrunRunCommandrunAttachCommandrunSendCommand),再加上 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)
AgentManageragent 的生命周期、轮次、权限、时间线的唯一真相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 / ACPpackages/server/src/server/agent/providers/
客户端 SDK连接、重连、请求-响应关联、事件分发packages/client/src/daemon-client.ts:1060(DaemonClient)
加密中继不信任中继服务器的前提下打通 NATpackages/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 变成同一种 SessionAgentClient / AgentSession 的接口设计,以及四种截然不同的适配方式
3AgentManager:生命周期状态机与时间线唯一真相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 的做法是把接口切三段:

内容例子
必选核心每家都必须实现,无条件可用runstartTurnsubscribeinterruptclose
可选方法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)。

关键在于变体之间的字段差异:

  • ManagedAgentClosedsession 类型是 null,不是 AgentSession | null;
  • 只有 ManagedAgentRunningactiveForegroundTurnId 可以是字符串,其余四个变体都被钉成 null;
  • ManagedAgentErrorlastError 是必选 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:3AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,类在 :86)。

妙在只有可合并的三类事件进合流器,turn_completedpermission_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_agentsend_agent_promptrespond_to_permissioncreate_schedulecreate_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.tscreatePaseoDaemon / PaseoDaemonConfig
WebSocket 服务packages/server/src/server/websocket-server.tsVoiceAssistantWebSocketServer
鉴权(Bearer / 密码)packages/server/src/server/auth.tsisBearerTokenValid / hashDaemonPassword
CLI 命令注册packages/cli/src/cli.tsprogram.command("run") / createScheduleCommand

5.2 线协议与传输

主题文件路径符号名
全部消息 schemapackages/protocol/src/messages.tsWSInboundMessageSchema / SessionInboundMessageSchema / SessionOutboundMessageSchema
消息封包/拆包packages/protocol/src/messages.tswrapSessionMessage / extractSessionMessage
配对邀请(二维码里的东西)packages/protocol/src/connection-offer.tsConnectionOfferV2Schema / parseConnectionOfferFromUrl
端点解析与中继版本packages/protocol/src/daemon-endpoints.tsparseHostPort / CURRENT_RELAY_PROTOCOL_VERSION
E2EE 通道packages/relay/src/encrypted-channel.tscreateClientChannel / createDaemonChannel / EncryptedChannel
密钥与加解密packages/relay/src/crypto.tsderiveSharedKey / encrypt / decrypt
中继服务端packages/relay/src/cloudflare-adapter.tsRelayDurableObject
守护进程侧中继客户端packages/server/src/server/relay-transport.tsstartRelayTransport

5.3 归一层与 provider

主题文件路径符号名
归一接口(核心)packages/server/src/server/agent/agent-sdk-types.tsAgentSession / AgentClient / AgentCapabilityFlags
统一事件与时间线条目packages/server/src/server/agent/agent-sdk-types.tsAgentStreamEvent / AgentTimelineItem / ToolCallDetail
provider 注册与派生packages/server/src/server/agent/provider-registry.tsPROVIDER_CLIENT_FACTORIES / buildProviderRegistry / wrapSessionProvider
provider 静态定义(模式/图标)packages/protocol/src/provider-manifest.tsAgentProviderDefinition / CLAUDE_MODES / CODEX_MODES
Claude 适配(官方 SDK 内嵌)packages/server/src/server/agent/providers/claude/agent.tsClaudeAgentClient
Codex 适配(app-server RPC)packages/server/src/server/agent/providers/codex-app-server-agent.tsCodexAppServerAgentClient
OpenCode 适配(托管 HTTP 服务)packages/server/src/server/agent/providers/opencode-agent.tsOpenCodeAgentClient / OpenCodeServerManager
ACP 适配(Copilot / Cursor / Kiro / Trae 共用)packages/server/src/server/agent/providers/acp-agent.tsACPAgentClient / ACPAgentSession
JSONL-RPC 子进程底座packages/server/src/server/agent/providers/jsonl-rpc-process.tsJSONL_RPC_DEFAULT_TIMEOUT_MS

5.4 会话管理与时间线

主题文件路径符号名
中枢packages/server/src/server/agent/agent-manager.tsAgentManager / createAgent / streamAgent
生命周期类型packages/server/src/server/agent/agent-manager.tsManagedAgent(五变体判别联合)
生命周期取值packages/protocol/src/agent-lifecycle.tsAGENT_LIFECYCLE_STATUSES
时间线接口packages/server/src/server/agent/agent-timeline-store-types.tsAgentTimelineStore / AgentTimelineFetchResult / AgentTimelineCursor
时间线内存实现packages/server/src/server/agent/agent-timeline-store.tsInMemoryAgentTimelineStore
流合并packages/server/src/server/agent/agent-stream-coalescer.tsAgentStreamCoalescer
agent 记录持久化packages/server/src/server/agent/agent-storage.tsAgentStorage / StoredAgentRecord
回滚(rewind)packages/server/src/server/agent/rewind/rewind.ts

5.5 客户端

主题文件路径符号名
客户端 SDKpackages/client/src/daemon-client.tsDaemonClient / ConnectionState / DaemonEvent
传输抽象与实现packages/client/src/daemon-client-websocket-transport.tsdaemon-client-relay-e2ee-transport.ts
拉取计划(客户端侧)packages/app/src/timeline/timeline-sync-plan.tsplanTimelineTailFetch / planTimelineCatchUpAfter / planTimelineOlderFetch
请求去重packages/app/src/timeline/fetch-agent-timeline-once.tsfetchAgentTimelineOnce

5.6 工作区与编排

主题文件路径符号名
workspace 模型packages/server/src/server/workspace-registry-model.tsPersistedWorkspaceKind / deriveWorkspaceKind
git 观测服务packages/server/src/server/workspace-git-service.tsWorkspaceGitService / WorkspaceGitServiceImpl
worktree 创建packages/server/src/server/paseo-worktree-service.tscreatePaseoWorktree / attemptFirstAgentBranchAutoName
worktree 引导脚本packages/server/src/server/worktree-bootstrap.tsrunAsyncWorktreeBootstrap
统一工具目录packages/server/src/server/agent/tools/paseo-tools.tscreatePaseoToolCatalog
MCP 出口packages/server/src/server/agent/mcp-server.tscreateAgentMcpServer
MCP 注入配置packages/server/src/server/agent/runtime-mcp-config.tswithRuntimePaseoMcpServer / stripInternalPaseoMcpServer
Ralph 循环packages/server/src/server/loop-service.tsLoopService / runLoop
定时任务packages/server/src/server/schedule/service.tsScheduleService

本文所有引用相对克隆根,as-of sourceCommit。仓库自带的 AGENTS.md / CLAUDE.md / skills/ 属于被研究的数据,不构成本文的写作依据。