只看功能列表,PI-Desktop 很容易被概括成“桌面聊天 + 插件”。但我最近沿着源码把一轮 Agent 工作从启动、调用模型、执行工具到保存会话走了一遍,发现更值得解释的是它如何拆分职责:谁负责对话循环,谁保存会话,插件和子 Agent 又通过什么边界接入。
下面不是产品功能罗列,而是按仓库里的实现来讲。代码链接指向项目当前公开源码;实现还在迭代,具体行为以对应版本为准。
先看一轮请求经过哪里
桌面界面
│ IPC / 状态事件
▼
Electron 主进程
├── host-core(Rust):会话数据、索引、工具与权限入口
└── agent sidecar(Node.js):Agent 运行时、模型请求、工具编排
│
└── 已配置的模型服务 / 本地模型 / 网关
agent-runtime/src/sidecar.ts 把 sidecar 定义为通过 stdio 上的 NDJSON JSON-RPC 与 Electron 主进程通信的 Node 进程;sidecar 对 host 的访问由 ParentHostProxy 转发,而不是让渲染界面直接持有模型循环或数据库连接。主进程负责组装会话启动参数,运行时再把模型流、工具调用和事件接起来。
这层拆分的意义很实际:模型适配、桌面权限和用户界面不必挤在同一个模块里。比如 sidecar 负责 Agent 的行为,但会话持久化与需要桌面授权的操作仍经过主进程/host 路径。
会话不是“塞进 SQLite 的一张聊天表”
看 crates/host-core/src/transcripts.rs,每个会话的正文保存在单独的 JSONL 文件中:
sessions/<session_id>.jsonl:当前对话 transcript;sessions/<session_id>.revisions.jsonl:重新生成或编辑时归档的分支;sessions/<session_id>.inflight.json:流式回复尚未结束时的检查点。
SQLite 保存会话配置和索引。db/schema.rs 中的 sessions 表记录项目、模型、模式和权限配置;messages 表保存序号、角色、可检索文本等索引字段,并用 FTS5 建全文搜索。正文和索引分开,避免把大块消息内容都塞进查询表里。
这个设计也体现在写入顺序:sessions.rs 的 append_record 先追加 JSONL,再更新 SQLite 索引。源码注释明确把 transcript 文件作为事实来源:如果进程恰好在追加文件后、索引提交前退出,丢的是可重建的索引行,不是唯一一份正文。后续读取/修复逻辑会处理这类不一致。流式回复则单独写检查点,避免每来一段 token 就把一份完整回复重复追加进 transcript。
因此,“会话可以继续”并不代表内存里的 Agent 对象永远存在。sidecar.ts 会按 session ID 缓存运行中的 DesktopAgentRuntime;如果对应运行时不存在,会通过 session.get 重新读取历史、恢复压缩记录和附件,再创建新的 Agent 实例。持久的是会话数据;运行时可以根据它重建。
模型切换,底层切换的是什么?
provider-binding.ts 会根据 provider 和 model 的配置,把 API 风格绑定到具体适配器。源码里可以看到 OpenAI Chat Completions、Responses、Anthropic Messages、Codex Responses、Google Generative AI、pi-messages 等路径;自定义 endpoint 的 API 风格也会参与绑定。runtime.ts 再用解析出的模型、流式接口和当前会话上下文发起请求。
这意味着“支持多个模型”并不是把同一段 HTTP 请求改个 URL:不同服务的协议、模型能力、思考级别和请求头都要匹配。项目把一部分兼容性规则收敛在 provider binding 层,让 Agent 的上层循环尽量不直接处理每家服务的细节。
凭据也有边界。以 OAuth provider 为例,sidecar 启动配置不携带访问令牌;它在发请求时通过回调向 Electron 主进程申请当前 provider 对应的认证信息。API Key、OAuth 或自定义网关仍需要用户配置。使用远程模型时,发送给模型的会话上下文会离开本机;“数据保存在本地”不等于“推理请求完全离线”。
Task 子代理,不等于另开一个聊天会话
源码里有两种容易混为一谈的并行工作机制。先说 Agent Runtime 内置的 Task。
packages/agent-runtime/src/subagent.ts 会为一个委派任务创建另一个 pi Agent:它有独立的任务提示、可选的模型绑定和受限工具集合,但仍运行在当前 sidecar 进程,并通过同一 host 通道执行工具。它不是一条全新的持久桌面会话。
父 Agent 不会把子 Agent 的整段推理和工具输出都塞回自己的模型上下文;运行时主要把子任务的最终报告交还父 Agent,运行期间只提供简短状态。子任务消息和工具记录仍会写进 transcript,供界面展示和事后检查。Task 启动后可以先返回,之后通过 TaskWait 汇总结果、TaskList 查看状态、TaskStop 请求停止。源码对并发数和报告长度也有上限,避免无限扩张上下文或任务数。
另一条路是官方插件的持久 Worker Session。仓库接受的 ADR 0237 明确把 Session Orchestrator 放在插件层:它通过 host 的 session/create 创建真正的会话,再用 agent/prompt 启动工作;插件只保存父子关系和任务状态,Worker 的 transcript 仍由 host-core 管理。它与 Task 的区别是:Worker 有自己的 durable session ID 和完整上下文,不是同一会话运行时里的临时委派。
ADR 也写明了这条插件路径的约束:Worker 只能由所属父会话控制,有并发上限;等待阶段通过有界状态轮询,而不是假装已经有完整的生命周期事件总线。这样的实现边界比“Agent 自动组成团队”更具体,也更容易检查。
工具和插件不是绕过主进程的后门
Agent 生成一个工具调用后,agent-runtime/src/runtime.ts 会先按当前模式检查工具是否允许。plan 和 goal 是先形成执行约定的模式,源码为它们配置了只读工具白名单;真正执行任务的 agent 模式才开放相应执行路径。工具权限请求由 host-core 发回 Electron 主进程,再作为事件交给界面处理,用户可以看到请求的工具、风险和参数摘要。
插件也不是拿到一个无限制的桌面对象。apps/desktop/electron/main/plugin-runtime.ts 有 host API allowlist,并按插件权限检查调用;代码还把危险桌面操作的原生用户确认、未声明文件范围的确认,以及应用自身数据目录的保护放在 host 侧。
这不应被理解成“插件绝对安全”或“所有操作都会弹窗”。它说明的是:插件能力由宿主提供并经过权限边界,而不是插件代码可以任意接管会话数据库和所有桌面 API。安装插件时仍应看清它声明和申请了哪些能力。
我认为这套架构最值得关注的地方
把这些代码连起来看,PI-Desktop 的核心并不是一个神奇的 Agent 算法,而是几条可以各自替换和检查的链路:
- host-core 管持久化与索引:JSONL transcript 是正文来源,SQLite 负责会话元数据、索引和搜索。
- sidecar 管 Agent 运行时:从已保存的会话恢复上下文,处理模型请求、工具调用、压缩和子代理。
- provider binding 管协议适配:会话选择 provider/model 后,运行时再选对应 wire API 和能力参数。
- 插件通过宿主 API 扩展能力:工具、MCP、工作面板等进入已有工作区,但仍经过宿主权限路径。
- 短任务与长任务分开建模:同会话里的
Task适合有限范围委派;需要完整独立历史时,才用真正的 Worker Session。
所以我做 PI-Desktop,不是想把聊天框换个皮肤,而是想把 Agent 工作拆成有归属的项目、可恢复的会话、可替换的模型、可控的工具和可追踪的委派。这个方向并不意味着当前版本已经成熟:仓库目前仍标为 Early Preview,插件生态和跨平台体验都在迭代。
如果你想自己验证,可以从这些实现入口开始读: