Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Pi Deep Dive

TL;DR:这是一套解读 Pi 的中文教程,对齐 Pi v1.5.0(2026-11-02)。Pi 是一个"极简、可扩展的 Agent Harness":pi-ai 把不同模型统一成一次流式 LLM 调用,pi-agent-core 把多次调用和工具执行组织成 Agent Loop,pi-coding-agent 再加上会话、资源、配置、压缩、扩展和多种运行模式,做成可以长期使用的 Coding Agent。教程只讲设计思想、职责边界和核心链路,不逐行读源码,适合用来建立整体理解和准备面试。

怎么读

只有半小时,就读 Pi 总览。它是全书的精简版,每一层都有可以直接在面试中说的版本,三层之间的边界和一次完整请求的链路也在里面。

想真正弄懂,就按依赖顺序从下往上读。先读 pi-ai 的两篇:统一模型调用层讲多 Provider 的统一数据模型和装配方式,流式事件架构讲一次响应如何变成标准化事件流。然后读 pi-agent-core,它讲双层循环、steering/follow-up 和运行时 Hook,是整个系统的控制流中心。最后读 pi-coding-agent 主笔记,再按需要深入它的各个模块。

pi-coding-agent 的模块里,面试中最常被追问的是会话树与上下文压缩、扩展系统和 Skills。它们分别回答三个问题:长对话怎么持久化和瘦身,产品怎么在不改核心的前提下被定制,模型怎么按需获得领域知识。MCP 与 Codemode 是 0.99(1.0 前夕)新加的能力,可以拿来对照 Pi 早期"不做 MCP"的立场。

附录不属于三层主线,但面试中也可能被问到:pi-tui 讲终端 UI 怎么做差分渲染、怎么接住流式事件,沙箱与容器化回答"Pi 没有权限系统,那怎么隔离",另外两篇讲 monorepo 的构建、检查和发布脚本。

每篇笔记的结构

每篇开头有一段 TL;DR,可以面试前背、面试时直接说。正文先讲它解决什么问题、怎么运行,再沿着一次真实任务的顺序讲核心链路,最后讲设计取舍。部分章节有"一句话"总结和"常见追问"。文末的"源码入口"列出 v1.5 的关键文件,方便想深入的读者对照源码。

参考资料

Pi 架构理解:从 LLM 调用到 Coding Agent Harness

TL;DR:Pi 是一个"极简、可扩展的 Agent Harness",核心是三层库叠起来跑在同一个 Node 进程里。pi-ai 解决"怎么调用不同的大模型":把各家 Provider 的差异收进适配器,对上只暴露一次流式 LLM 调用和一套统一事件。pi-agent-core 解决"怎么让模型通过工具循环完成任务":用双层循环串起多次调用和工具执行,内层处理工具调用和 steering,外层处理 follow-up,产品策略全部通过 Hook 从上层注入。pi-coding-agent 解决"怎么把通用 Runtime 变成能长期使用的 Coding Agent":以 AgentSession 为中心,加上 JSONL 会话树、配置与资源发现、内置编码工具、自动压缩与重试、扩展系统,以及 TUI / Print / JSON / RPC / SDK 几种入口。1.x 最值得说的一点是:会话树是模型上下文的唯一来源,每次请求都从会话文件重新投影出上下文。

这篇是全书的精简版,目标是先建立整体理解,再能在面试里讲清楚。每一层的细节都有单独的章节,文中给了链接。本书内容对齐 Pi v1.5.0。

Pi 是什么、怎么运行

Pi 的定位写在官方 README 第一句:a minimal, extensible agent harness that you can make your own。它内置一套够用的默认能力(读写文件、执行命令、会话管理、上下文压缩),但刻意不内置 sub-agent、plan mode、to-do、权限弹窗这类"工作流",希望用户通过扩展、Skills、Prompt 模板或第三方 Pi 包按自己的方式加。这个取舍决定了它的架构:核心要薄,扩展点要多而清楚。

运行形态很简单。执行 pi 时只有一个 Node 进程,没有常驻 daemon。三层是这个进程里的三个库,按依赖从下往上:pi-ai → pi-agent-core → pi-coding-agent,终端 UI 用的是独立的 pi-tui 库。会话、配置、凭据都是磁盘上的文件(会话在 ~/.pi/agent/sessions/ 下按工作目录分组的 JSONL,凭据在 auth.json),进程退出就结束,下次用 --continue / --resume 打开文件恢复。扩展是加载进同一进程的 TypeScript 模块,和内置工具一样拥有进程的全部 OS 权限,所以 Pi 自己不提供沙箱,需要隔离就放进容器或用沙箱类扩展。RPC 模式也不是服务,而是被宿主程序拉起的子进程;SDK 模式则直接在宿主自己的进程里创建会话。

到 1.5,仓库里还多了一些外围包:pi-durable(持久化的会话/任务运行时,1.0 从 agent-core 里拆出来)、pi-mcp 和 pi-codemode(MCP 客户端和"让模型写 JS 调工具")、pi-schema(工具参数的 JSON Schema 校验)、chord(插件宿主运行时)、pi-telemetry 等。面试讲 Pi 的核心设计,抓住三层就够了,这些外围包知道它们存在、各管什么即可。

一句话:越往下越通用、越接近模型调用;越往上越产品化、越接近具体的 Coding Agent 场景。

一、pi-ai:统一的 LLM 调用层

OpenAI、Anthropic、Google 等厂商的 API 在请求字段、system prompt 的位置、工具 schema 格式、流式事件、thinking 参数、usage 和错误结构上都不一样。pi-ai 把这些差异收进各个 API 适配器,对上层提供统一的模型、上下文、消息和事件。调用方传入一个 Model 和一份上下文(消息、工具声明、system prompt),立刻拿到一条事件流:start,然后是文本、thinking、工具调用参数各自的 start / delta / end,最后是 done 或 error。流结束后可以拿到完整的 AssistantMessage,里面有文本块、tool call 块、usage 和 stopReason。

内部分三层。Models 集合负责找 Provider、解析认证、合并请求选项。Provider 把模型目录、认证方式和 API 实现装成一个单元。api/* 适配器才真正构造 HTTP 请求、解析 SSE 或 WebSocket,再翻译成统一事件。适配器按需动态加载,用到哪家才加载哪家的 SDK。模型目录在构建时从 models.dev 生成并打包进来,所以离线也能用;运行时再叠加 pi.dev 的远程目录。

有两个边界要讲清楚。第一,pi-ai 能声明工具、能解析模型产出的 tool call,但不执行工具,也不自动发起下一次请求。它的执行边界永远是一次独立的 LLM 请求,执行工具、把结果送回模型属于上一层。第二,错误是事件而不是异常。网络错误、认证失败、用户中止都会变成一条带着已生成部分内容和 usage 的 error 事件,所以调用方处理成功、失败和取消只需要一条路径。

1.x 还有一个容易被旧资料误导的变化:适配器拿到的是归一化后的 TranscriptContext,system prompt 和工具声明作为 system 消息放在消息序列里,而不是单独的字段。这是为了配合上层"会话里记录 prompt 和工具变化"的设计。切换模型时,上一个模型的 thinking 会被丢掉,不会当成普通文本喂给新模型。

面试表述:pi-ai 是 Pi 最底层的统一 LLM 调用层。它通过 API 适配器屏蔽各家模型在消息格式、工具协议、流式事件和推理参数上的差异,对上提供统一的流式接口:调用方传入模型、上下文和工具声明,得到标准化的事件流和最终的 AssistantMessage。它能让模型产生 tool call,但只负责模型 I/O,不执行工具,也不负责多轮循环;失败、取消也都编码成事件,而不是抛异常。

详见 pi-ai(一):统一模型调用层 和 pi-ai(二):流式事件架构。

二、pi-agent-core:Agent Runtime 与双层循环

如果说 pi-ai 只完成一次模型请求,pi-agent-core 就负责把多次请求和工具执行连起来,形成真正的 Agent Loop:调用 LLM,模型返回 tool call,执行工具,把结果写回上下文,再调用 LLM,直到模型不再调用工具。它的本质是一个带状态管理、工具执行和消息队列的 Agent Runtime,而且边界划得很窄:不懂 coding、不懂会话持久化、不碰文件系统。1.0 甚至把早期放在这个包里的实验性 harness(会话存储、压缩、skills 等)整体删掉了,现在包里只剩 Agent 类、agent loop、proxy 流函数和类型。

它分两层。无状态的 agentLoop 只做"调模型、执行工具、决定要不要继续",给它上下文和配置,它产出一串事件。有状态的 Agent 类在外面包一层,持有 transcript、模型、工具、steering 和 follow-up 两个队列,提供 prompt()、steer()、followUp()、abort()、subscribe()。把循环做薄是有意的:持久化、重试、压缩、UI 都交给上层,同一个循环才能被终端、Slack bot、Web UI、测试随意复用。

先分清三个粒度。一次 LLM 请求加上这次响应里所有工具调用的执行,叫一个 Turn。一次 agent.prompt() 从 agent_start 到 agent_end 的完整运行,叫一个 Run,里面可能有多个 Turn。跨越多次用户输入的长期对话叫 Session,而 agent-core 里根本没有 Session 这个概念,那是 coding-agent 的事。所以 Agent.prompt() 不等于一次 pi-ai 调用,它是"加入输入、零到多次 LLM 请求、零到多次工具执行、处理插话、更新状态"。

双层循环是为了区分用户的两种插话。Steering 是 agent 正在干活时插一条指令,比如"别改公共 API",它在当前这批工具执行完之后、下一次 LLM 请求之前注入,不会跳过已经发出的工具调用。Follow-up 是追加一个后续任务,比如"做完顺便更新 CHANGELOG",只在 agent 本来要停下时才被消费。内层循环负责"持续工作",外层循环负责"被唤醒":

while (true):                                   # 外层:只管 follow-up
    while (还有工具调用 or 有待注入消息):          # 内层:工具调用链 + steering
        注入 pending 消息(steering / follow-up)
        prepareRequest → 调 LLM(流式)
        error / aborted → 直接结束整个 run
        执行工具(beforeToolCall / afterToolCall)
        finishTurn → turn_end
        pending = 拉取 steering
    pending = 拉取 follow-up
    没有 → break
agent_end

follow-up 进来后循环继续,而不是递归调用 prompt(),所以它和之前的工作共享同一个 run、同一个 abort signal,agent_end 只发一次。真要立刻停下,用的是 abort()。

这一层对上有两种接口,方向相反。生命周期事件(agent_start、turn_start、message_start/update/end、tool_execution_start/end、turn_end、agent_end)自下而上报告"发生了什么",UI 渲染和会话持久化都靠订阅它们。运行时 Hook 在明确的控制点自上而下注入"接下来怎么做":prepareRequest(每次请求前替换上下文)、transformContext 和 convertToLlm(发给模型前的转换)、beforeToolCall / afterToolCall(拦截或改写工具调用)、finishTurn(一轮结束时决定结束还是继续,0.87 起取代了 shouldStopAfterTurn)、prepareNextTurn(轮与轮之间换上下文或模型)。持久化、压缩、权限、重试这些产品策略,全都是上层通过这些 Hook 塞进来的。

面试表述:pi-agent-core 是建在 pi-ai 上的通用 Agent Runtime。Agent 维护 transcript、模型、工具和两个消息队列,agent.prompt() 启动 Agent Loop。循环分两层:内层处理 LLM 调用、工具执行和 steering 消息,让任务连续跑多个 Turn;内层结束后外层检查 follow-up,决定是否继续处理后续任务。它向上广播生命周期事件,同时在请求前、工具前后、每轮结束等位置提供 Hook,让上层注入持久化、压缩、权限和重试等策略,自己保持与产品无关。

详见 pi-agent-core:Agent Runtime。

三、pi-coding-agent:产品级 Coding Agent Harness

pi-coding-agent(CLI 名 pi)不重写 agent loop,而是通过 agent-core 预留的 Hook 把产品逻辑接进去。它要补上的是一个每天在真实仓库里用的 coding agent 缺的东西:对话要能落盘、恢复、分叉,长对话要能压缩;用户级和项目级配置要合并,项目里的 AGENTS.md、skills、模板、扩展要被发现;要有好用的文件和命令工具;网络抖动和上下文溢出要能自动恢复;同一套能力既要给人用,也要给脚本和其他程序用。

启动链与 AgentSession。 启动时 main.ts 先确定最终的工作目录和会话文件,再解决项目信任,然后创建与工作目录绑定的服务(ModelRuntime、SettingsManager、ResourceLoader),由 createAgentSession() 工厂装出 Agent 和 AgentSession。外面还有一层 AgentSessionRuntime 负责换会话:/new、/resume、/fork 时工作目录可能变,配置和资源都要按新目录重建。核心是 AgentSession,它是总协调者:订阅底层事件做持久化和转发,在 prepareRequest、beforeToolCall、finishTurn 等 Hook 上挂产品逻辑,把会话、配置、资源、压缩、扩展串起来。准确的说法不是"AgentSession 持有一个 Agent",而是它通过订阅和 Hook,把一个通用 Agent 升级成了产品会话。

会话树是唯一事实来源。 会话存成 append-only 的 JSONL,每条记录用 id / parentId 指向前一条,整个文件是一棵树,从当前叶子回溯到根就是活动分支。回到旧节点重试只需要移动叶子再追加,旧路径原样保留;/fork 才会复制出新文件。0.87 起,每次模型请求前 prepareRequest 都从活动分支重新投影出上下文,内存里的 agent.state.messages 只是投影的缓存。system prompt 和工具声明也作为 system 消息记在会话里,后续变化只追加补丁,不重写开头。这样做一是保住 Provider 的前缀缓存,二是让 prompt 和工具状态跟着分支走,fork 或回到旧节点时自然恢复。

上下文压缩不删历史。 上下文快满时,Compaction 用一次额外的 LLM 调用把旧消息总结成一条 compaction 记录,保留最近约 20K token 的原文,切点不会把 tool call 和它的结果拆开。摘要只在之后的请求里替代旧消息,原始记录仍在树里。自动触发有两处:一轮工具执行完后超过阈值,就在下一次请求前先压缩;Provider 报上下文溢出时,先压缩再重试一次。

资源与提示词。 ResourceLoader 从全局目录、项目 .pi/、Pi 包和命令行参数四类来源发现扩展、skills、prompt 模板、主题和 context files(AGENTS.md / CLAUDE.md)。项目目录可能来自陌生仓库,所以项目信任要在加载任何项目设置和资源之前决定,未信任就跳过。system prompt 不是写死的字符串,而是按段重建的:基础说明、工具、上下文文件、skills 列表、工作目录等。Skills 用的是渐进披露:system prompt 里只放每个 skill 的名字、描述和路径,模型判断需要时再用 read 工具读全文,所以 skill 再多也只占几行。

内置工具。 有 read、bash、edit、write、grep、find、ls,默认只启用前四个。值得讲的设计有三点。edit 是精确文本替换,不是 diff:每段 oldText 必须在文件里唯一匹配,匹配不上就失败并把错误返回给模型修正,而不是悄悄改错位置。输出有上限,read 截断时告诉模型用 offset 继续读,bash 保留输出尾部、完整输出写进临时文件。并行工具调用时,同一文件的写操作会串行化。

扩展系统。 扩展通过 pi.on() 和一组 register* API 给 Pi 加工具、命令、Provider、UI 和策略。分层是:agent-core 只暴露通用的运行时 Hook,完全不知道扩展存在;AgentSession 把这些 Hook 桥接到 ExtensionRunner;ExtensionRunner 对外统一成 pi.on(name, handler),内部再按事件选择分发策略,比如通知、链式改写、任一阻断即停、出错即拒绝。统一的是注册方式,不是运行语义。

其他模块。 SettingsManager 合并用户级和项目级 settings.json(项目覆盖用户,对象递归合并,数组整体替换,写回时在文件锁里只合并改过的字段)。模型与认证拆成三件互不越界的事:凭据存储、模型目录、模型解析。1.x 还以内置扩展的形式加入了 MCP 客户端和 Codemode:MCP 工具默认不直接塞给模型,而是让模型写一段 JavaScript,在沙箱里搜索并并行调用工具,只有脚本输出进入上下文。这也是 Pi 对早期"不做 MCP"立场的修正:问题本来是工具 schema 太占上下文,Codemode 正是为了绕开这一点。

多种入口共用一个核心。 Interactive(全屏 TUI)、Print(只输出最终文本)、JSON(把事件流写成 JSONL)、RPC(stdin 收 JSONL 命令,stdout 回响应和事件)、SDK(宿主进程内直接 createAgentSession())用的是同一个 AgentSession,区别只在输入从哪来、事件怎么输出、进程活多久。

面试表述:pi-coding-agent 把通用 Runtime 产品化,核心是 AgentSession。它通过 Hook 和事件订阅挂在 agent-core 上,协调会话、配置、资源、压缩和扩展。会话用 append-only 的 JSONL 树保存,并且是模型上下文的唯一来源,每次请求从活动分支重新投影;压缩通过摘要记录改变投影,不删除历史;资源加载在项目信任之后进行,Skills 渐进披露;扩展通过统一的 pi.on() 注册,但不同事件有不同的分发语义。几种运行模式共用同一个会话核心。

详见 pi-coding-agent 主笔记,以及其中的模块:会话与压缩、资源与提示词、Skills、扩展系统、包管理、配置、模型与认证、MCP 与 Codemode、运行模式、错误处理。

四、一次请求的完整链路

用一个场景把三层串起来:会话已经很长,用户在终端里输入"把 utils.ts 里的 parseDate 改成支持时区"。

请求方向先发生在 coding-agent 里,这时还没碰模型。AgentSession.prompt() 先看输入是不是扩展命令,再发 input 事件给扩展改写,展开 /skill:name 和 prompt 模板。如果 agent 正在运行,调用方必须指明这条消息是 steer 还是 follow-up。接着准备这次 run:会话太长就先压缩,发 before_agent_start 让扩展追加消息或调整工具,prompt 或工具有变化就追加一条补丁 system 消息。然后调用 agent.prompt() 进入 agent-core 的双层循环。每次请求前,prepareRequest 从会话树投影出上下文,经 pi-ai 的适配器转成 Provider 请求,以 SSE 流式发出去。

返回方向是反过来的。Provider 的流被 pi-ai 归一成统一事件,agent-core 把它们变成 message_update,AgentSession 先转给扩展,再转给订阅者(TUI 渲染、JSON 输出、RPC 转发都在这里)。模型返回 tool call 时,agent-core 先过 beforeToolCall(扩展的 tool_call 可以拦截),执行 read、edit、bash,再过 afterToolCall(扩展的 tool_result 可以改写),然后进入下一个 Turn;轮与轮之间超过阈值就先压缩。message_end 时完整消息追加进 JSONL:流式 delta 只用于显示,落盘的是 message_end 那条完整消息。

底层 agent_end 之后 run 还没结束。假如中途网络抖了一下,AgentSession 会把失败的回复用 context_edit 从投影里省略(原始记录保留),按 2s、4s、8s 退避后 continue();如果是上下文溢出,就先压缩再重试一次。全部处理完才发 agent_settled,prompt() 的 Promise 这时才 resolve。

用户输入
  → AgentSession:命令/input/模板 → 压缩检查、before_agent_start、prompt 补丁
  → Agent(双层循环):prepareRequest 从会话树投影上下文
  → pi-ai:适配器 → HTTP/SSE → Provider
  ← pi-ai:统一事件流
  ← Agent:message_* / tool_execution_* / turn_end,执行工具后回到内层循环
  ← AgentSession:扩展通知 → UI 渲染 → message_end 写入 JSONL
  ← agent_end 后:重试 / 溢出压缩 / 队列 → agent_settled

五、三层之间最重要的边界

能力pi-aipi-agent-corepi-coding-agent
适配不同 Provider、发起一次 LLM 请求负责通过注入的 streamFn 调用用 ModelRuntime 包装认证、超时、重试
解析 tool call负责使用使用
真正执行工具、多 Turn 循环负责提供具体工具和拦截策略
Steering / Follow-up负责队列和注入时机提供交互入口和模式设置
会话持久化、分支、恢复负责(JSONL 树,上下文唯一来源)
上下文压缩提供摘要调用提供轮间 Hook负责策略和时机
自动重试请求级重试(默认关闭)不负责,出错即结束 run负责回合级重试
资源、配置、扩展、运行模式提供通用 Hook负责

这张表背后的思想是:下层只提供机制,上层决定策略。pi-ai 不知道有循环,agent-core 不知道有会话和扩展,所以下层都能脱离 Pi 单独使用。

六、最终面试版总结

30 秒版本

Pi 是一个三层架构。最底层 pi-ai 统一封装 OpenAI、Anthropic、Google 等 Provider 的 API,对上提供标准化的流式 LLM 调用;中间的 pi-agent-core 在单次调用之上实现带工具执行、状态管理、steering 和 follow-up 的双层 Agent Loop,并通过 Hook 让上层注入策略;最上层 pi-coding-agent 以 AgentSession 为中心,加上 JSONL 会话树、配置和资源加载、编码工具、自动压缩与重试、扩展系统,以及 TUI / RPC / SDK 等入口,把通用 Agent Runtime 封装成完整的 Coding Agent 产品。

1–2 分钟版本

我把 Pi 理解成从 LLM API 到 Agent Runtime、再到产品级 Coding Agent Harness 的三层架构,三层都是同一个进程里的库。

第一层 pi-ai 负责统一不同的模型 Provider。调用方传入模型、上下文和工具声明,得到标准化的流式事件和最终的 AssistantMessage。它能解析模型产生的 tool call,但不执行工具,执行边界始终是一次独立的 LLM 请求;错误和取消也编码成事件,而不是抛异常。

第二层 pi-agent-core 在此之上实现 Agent Runtime。Agent 维护 transcript、模型、工具等状态,agent.prompt() 启动 Agent Loop。循环分内外两层:内层负责 LLM、工具调用和 steering 消息,让任务连续跑多个 Turn;内层结束后外层检查 follow-up,决定是否继续。它向上广播生命周期事件,同时在请求前、工具前后、每轮结束等控制点提供 Hook,自己不包含任何产品策略。

第三层 pi-coding-agent 把通用 Runtime 产品化,核心是 AgentSession。会话用 append-only 的 JSONL 树保存,并且是上下文的唯一来源,每次请求从活动分支重新投影;Compaction 用摘要记录改变投影而不删除历史;ResourceLoader 在项目信任之后加载 AGENTS.md、Skills、模板和扩展;扩展系统通过统一的 pi.on() 把处理函数接到生命周期事件和控制 Hook 上。最终这些模块提供一个可恢复、可配置、可扩展的 Coding Agent,并通过 TUI、Print、JSON、RPC、SDK 几种入口暴露出去。

一句话版本

pi-ai 把不同模型统一成一次 LLM 调用,pi-agent-core 把多次调用和工具执行组织成 Agent Loop,pi-coding-agent 再把 Agent Loop 封装成具备会话、资源、配置、压缩和扩展能力的完整 Coding Agent。

常见追问

为什么 Pi 不内置权限确认、sub-agent、plan mode? 作者认为这些是工作流选择,不同团队差异很大,内置一种就等于替用户做决定。Pi 只保证这些都能用扩展实现:权限确认就是在 tool_call 上拦截,plan mode 可以写文件或做成扩展。代价是开箱即用的护栏少,安全边界要靠容器或扩展自己搭。

为什么上下文要从会话树重新投影,而不是维护一个内存数组? 内存数组和磁盘会分叉:分支导航、重试省略失败回复、压缩、扩展编辑上下文,都要同时改两处。只认会话文件,所有这些操作都变成"追加一条记录,再重新投影",恢复和 fork 天然正确。代价是每次请求多一次投影计算,以及投影逻辑本身更复杂。

事件和 Hook 为什么要分开,不能都用一种? 事件只报告事实,可以有很多订阅者,互不影响,适合 UI 和日志;Hook 会改变主流程,必须明确触发时机、返回值协议和多个处理函数怎么合并。全用事件就没法拦截工具调用,全用 Hook 就会让每个观察者都可能阻塞或改写主流程。

源码入口(v1.5):packages/ai/src/models.ts、packages/agent/src/agent-loop.ts、packages/agent/src/agent.ts、packages/coding-agent/src/main.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/session-manager.ts

pi-ai(一):统一模型调用层

TL;DR:pi-ai 是 Pi 最底层的统一 LLM 调用库,以普通 npm 包的形式运行在调用方进程内,不是独立服务。它用一套统一的 Model + Context + 事件流 屏蔽 OpenAI、Anthropic、Google 等厂商在消息格式、工具协议、流式事件、推理参数、usage 和错误上的差异。内部按三层组织:Models 集合负责找 Provider、解析认证、合并请求选项;Provider 把模型目录、认证方式和 API 实现装成一个运行时单元;api/* 适配器才真正构造请求、解析 SSE 并翻译成统一事件。它支持声明工具、解析模型产出的 Tool Call,但不执行工具,也不驱动多轮循环,这些属于 pi-agent-core。

OpenAI、Anthropic、Google 这些服务的 API 并不相同:请求字段不同,system prompt 的放法不同,工具 schema 的格式不同,流式响应的事件不同,thinking/reasoning 参数不同,usage 和错误结构也不同,图片等多模态内容的编码方式还各不一样。如果上层 Agent 直接面对这些差异,就得为每家厂商各写一遍调用逻辑。pi-ai 把差异关进各个适配器里,对上只暴露一套统一的数据结构和一条统一的事件流。

本质上就一句话:**用一套统一的模型、消息、工具、流式事件协议,屏蔽掉不同 LLM 的协议差异。**这也是读它源码时不失真的关键心法。

底层被屏蔽的线协议包括 OpenAI Chat Completions、OpenAI Responses、Anthropic Messages、Gemini、Bedrock Converse、Mistral 等。

在 Pi 中的位置与运行形态

pi-coding-agent   产品层:ModelRuntime、auth.json/models.json、会话、system prompt、工具、扩展、TUI/RPC
      │  提供 streamFn(内部调 modelRuntime.streamSimple)
      ▼
pi-agent-core     循环层:组装上下文,调用 streamFn,执行工具,决定是否继续下一轮
      │
      ▼
pi-ai             模型层:Provider/Model/Auth、协议适配、消息转换、统一事件流、usage/cost
      │
      ▼
Anthropic / OpenAI / Google / Bedrock / Mistral / 各类 OpenAI 兼容网关

三层的边界可以这样记:pi-coding-agent 决定用哪个模型、当前有哪些工具、凭据存在哪里;pi-agent-core 决定什么时候请求模型、什么时候执行工具、要不要继续;pi-ai 只接收已经构建好的上下文,把它翻译成目标厂商的请求,再把响应翻译回统一事件。所以 pi-ai 不拼装完整的 system prompt、不执行工具、不管理会话树和 compaction、不做工具审批。

运行形态上,pi-ai 是一个纯库:没有常驻进程,没有本地 server,所有对象(Models、Provider、事件流)都是调用方进程里的内存对象,一次 stream() 调用就是一次直连厂商的 HTTP(个别是 WebSocket)请求。它有三种使用层级:

入口谁管认证和 Provider 集合适用场景
@earendil-works/pi-ai/api/<api-id> 直接调适配器调用方自己传 key,没有集合适配器调试、已有独立认证体系的宿主
createModels() / builtinModels() 得到 ModelsModels 通过所属 Provider 解析认证并分发独立使用 pi-ai 的应用
pi-coding-agent 的 ModelRuntime在 Models 之上叠加 auth.json、models.json、远端目录、扩展 ProviderPi CLI、SDK、RPC

ModelRuntime 在 coding-agent 包里,实现了 Models 接口,但不属于 pi-ai,细节见 模型与认证。

一句话:pi-ai 完成"一次独立的模型请求":可以声明工具、可以产出 tool call,但不执行工具,也不自动发起下一次请求。

核心数据模型

pi-ai 的数据结构刻意保持为纯 JSON:模型、上下文、消息都没有挂函数,可以直接 JSON.stringify 存盘、跨进程传递、换一个模型继续。理解它只需要抓住下面几个概念。

**Model 描述"一个模型怎么被调用"。**它带 id、上下文窗口、最大输出、价格、是否支持图片和推理等元信息,其中最容易混的是三个字段:provider 说明谁提供服务、用哪套认证(如 deepseek);api 说明请求遵循哪种线协议(如 openai-completions);compat 修正"同一协议、不同实现"之间的偏差,比如某个 OpenAI 兼容服务不支持 developer role、不支持 strict schema 或 streaming usage。多个 Provider 可以共用一个 API 实现(xAI、Groq、OpenRouter 等都走 openai-completions),一个 Provider 也可以混用多种 API(GitHub Copilot、OpenCode 按模型分发)。从 0.99 起 Model 还有 type:chat(默认,可省略)、image、classifier,决定它能被哪个操作接受。

Context 是一次请求的输入,形如 { systemPrompt?, messages, tools? }。1.x 的一个重要变化是:systemPrompt 和 tools 只是"开头一条 system 消息"的简写。公共入口(Models.stream() 等)会调用一次 normalizeContext(),把它变成只有 messages 的 TranscriptContext,往下的 Provider 和适配器只看得到这份 transcript。transcript 里除了开头那条,还可以在对话中途插入新的 system 消息,用来修改 prompt 片段(sections,按名字打补丁)或增删工具(toolsAdded / toolsRemoved)。当前生效的 prompt 和工具由 getCurrentSystemPrompt() / getCurrentTools() 回放全部 system 消息得出。这样设计的好处是:改 prompt、加工具不用重写历史,也就不会毁掉 prompt cache 的前缀(见后文"工具调用"一节)。

Message 有四种角色:system、user、assistant、toolResult。Assistant 的内容由三种块组成:文本 text、思考 thinking、工具调用 toolCall。最终的 AssistantMessage 还记录实际的 provider/api/model、usage(输入/输出/缓存读写 token 与费用)、stopReason、responseId、durationMs(1.1 起由事件流用单调时钟统一计时,所有适配器都有)等元数据。

StopReason 取值为 stop、length、toolUse、error、aborted、deferred,加上流式过程中的 pending。其中 error/aborted 由 error 事件给出,其余由 done 事件给出,这是下一篇流式架构的核心约定。

**Tool 就是 name + description + JSON Schema。**1.5 起 pi-ai 不再依赖 TypeBox,parameters 可以是 pi-schema 构建器生成的 schema、TypeBox schema 或手写对象,只要是 JSON Schema 就行。相应地,ToolCall.arguments 和 ToolResultMessage.details 被限制为 JSON 兼容值,保证整段对话可序列化。

一次请求的主链路

把 pi-ai 的工作按时间分成三个阶段看最清楚。

**构建期:生成静态模型目录。**内置模型目录由脚本从 models.dev 的 v2 目录生成,每个 Provider 在 catalog/ 下有一份 TypeScript 规则(过滤哪些模型、补哪些 compat),产物是 src/providers/*.models.ts。所以 pi-ai 离线也能同步查到模型,启动时不必访问网络。

**启动期:装配 Models。**应用调用 builtinModels()(注册全部内置 Provider)或 createModels() + setProvider(anthropicProvider()) 逐个注册。每个 Provider 工厂做的事都一样:把"已生成的模型目录 + 认证策略 + API 实现"装成内存里的 Provider 对象。此时 SDK 还没有被加载。

**请求期:一次 models.stream() 真正发出请求。**以调用 OpenAI 模型为例:

models.stream(model, context, options)          ← 同步返回一条 AssistantMessageEventStream
  ├─ normalizeContext(context)                   ← Context → TranscriptContext
  └─ lazyStream #1(后台异步)
       ├─ requireChatProvider(model)             ← 按 model.provider 找 Provider
       ├─ getAuth(model)                         ← 解析 key / OAuth / env,合并 headers、baseUrl、identity
       └─ provider.stream(requestModel, transcript, requestOptions)
            └─ 按 model.api 选 ProviderStreams = openAIResponsesApi()
                 └─ lazyStream #2:await import("./openai-responses.ts")
                      └─ 真实适配器:转换消息 → 构造 payload → 发请求 → 把 SSE 翻译成统一事件

这条链路可以看成一条"把异步准备工作藏进统一事件流"的流水线。它有两层 lazyStream():第一层在 Models 里,容纳异步的认证解析(OAuth 可能要刷新 token);第二层在 lazyApi() 里,容纳协议模块的动态 import。两层都遵守同一个契约:先同步返回一条流,再在后台准备;准备阶段任何失败(未知 Provider、未配置认证、模块加载失败)都被转成这条流上的终止性 error 事件,而不是让调用点抛异常。调用方始终只看到一条事件流,感知不到中间发生了认证等待、模块加载和 Provider 分发。

非流式入口只是等同一条流的结果:complete() = stream().result(),completeSimple() = streamSimple().result()。stream() 走 provider.stream(),接受目标 API 的全部专属选项;streamSimple() 走 provider.streamSimple(),接受统一的 reasoning 等选项再由适配器映射。两条链结构完全相同。

一句话:Models 管归属和认证,Provider 管装配和按协议分发,*.lazy.ts 管按需加载,api/*.ts 管真实协议,事件流把成功、失败、取消统一收口。

Provider、API 与模型目录如何装配

**createProvider 是唯一的装配点。**内置 Provider 和自定义 Provider 用的是同一个函数:给它 id、auth、models 和一个 API 实现。单协议 Provider 直接传一个 ProviderStreams;混合协议 Provider 传一个以 model.api 为键的映射,请求时按模型的 api 字段查表,查不到就返回一条错误流。Provider 级的请求改写(例如把 baseUrl 里的租户占位符替换掉)通过包装 ProviderStreams 实现,Cloudflare 的 Provider 就是这么做的。接入一个兼容 OpenAI 的本地服务,只需要描述模型、声明认证(无 key 的本地服务也要提供一个"总是已配置"的 resolve,让每个 Provider 都有明确的认证语义),然后复用 openAICompletionsApi()。只有现有适配器加 compat 都表达不了的新线协议,才需要写新的 api/<id>.ts。早期全局 registerApiProvider() 注册表的做法已经废弃,旧 API 只留在 @earendil-works/pi-ai/compat 里供迁移。

**懒加载是为了包体和启动速度。**每个 API 有一个 .lazy.ts 入口,形如 lazyApi(() => import("./openai-responses.ts")):调用 openAIResponsesApi() 只创建一个 loader 闭包,真实模块和它静态引入的 openai SDK 要等第一次请求该协议的模型时才加载,宿主的模块缓存保证只加载一次。配合子路径导出:@earendil-works/pi-ai/models 只含运行时骨架;providers/<id> 只带该 Provider 的目录和懒加载包装;providers/all 才带全部目录。浏览器打包时没用到的厂商 SDK 不会进包;Bedrock 和 OAuth 这类 Node-only 代码还通过打包器看不透的 import 隔离开。

模型目录有静态和动态两种来源,读永远是同步的。getModels() / getModel() 同步返回"最后已知的目录";访问网络是显式的异步动作 models.refresh()。动态 Provider(llama.cpp 服务、OpenRouter 实时列表)在 createProvider 里给一个 fetchModels,refresh() 时带着该 Provider 的有效凭据去拉。拉到的结果可以通过 ModelsStore 持久化(pi-ai 只定义接口,默认内存实现),记录模型列表、lastModified、checkedAt 和 ETag,供下次离线启动恢复和条件请求。refresh({ allowNetwork: false }) 只从存储恢复,force: true 跳过新鲜度检查。

**远端目录覆盖在 coding-agent 层实现。**Pi CLI 启动时以随包发布的内置目录为基线,再从 pi.dev 拉取更新的目录覆盖上去:只有远端 Last-Modified 比本地目录的生成时间新才采用,同一模型按"类型 + id"合并、远端优先;默认每 4 小时检查一次,用 ETag 做条件请求,结果写进 ModelsStore 缓存,离线时继续可用,pi update --models 可以强制刷新。这样新模型上线不必等 Pi 发版。Radius 网关比较特殊:内置一份 radius.pi.dev 的公开目录供同步查找,配置认证后用网关返回的目录整体替换。

认证架构

1.x 的原则是认证归 Provider 所有。每个 Provider 带一个 ProviderAuth,里面可以有 apiKey(怎么解析 key)和 oauth(怎么登录、刷新)。调用方不用手动取 key 再传进来,Models 在请求路径上通过所属 Provider 解析。

这里区分两个概念:持久化的是 Credential(api_key 类型带 key 和可选的 Provider 级 env,oauth 类型带 access/refresh/expires);真正发请求用的是派生出来的 ModelAuth(apiKey、headers、baseUrl)。分开的好处是 OAuth 或复杂 Provider 可以从凭据派生请求头和 endpoint,比如 GitHub Copilot 每个账号的 baseUrl 就来自 OAuth 凭据。

解析优先级是:本次请求显式传的 apiKey 最高;其次是 CredentialStore 里该 Provider 的凭据(OAuth 过期先刷新);只有什么都没存时,才去查环境变量、AWS profile、gcloud ADC 这类环境来源。存储的凭据"拥有"这个 Provider:OAuth 刷新失败时会报错让用户重新登录,绝不静默退回到环境变量里的 key,避免悄悄换账号或换计费来源。同一进程里需要不同配置时,可以用请求级 options.env 覆盖进程环境变量。

CredentialStore 的契约很小:read、list(只列非敏感元数据)、modify(唯一的写路径,串行化的读-改-写)和 delete。OAuth 刷新放在 modify 里做双重检查,所以多个并发请求甚至多个进程不会重复刷新同一个已轮换的 refresh token。pi-ai 默认是内存实现,Pi 产品层注入基于 auth.json 的实现;在 Pi CLI 里优先级是 --api-key > auth.json > models.json 的 apiKey > 环境变量。

OAuth 流程(Anthropic Claude 订阅、Sign in with ChatGPT、GitHub Copilot、OpenRouter 等)由 Provider 提供 login / refresh / toAuth 三个操作,Models.login() 负责编排和持久化。登录 UI 通过与 Provider 无关的 AuthInteraction.prompt() / notify() 注入,所以终端、桌面、浏览器宿主能复用同一套流程。

请求头合并顺序是固定的:Provider 认证头 → model.headers → 显式 options.headers → transformHeaders(只属于 Models,执行后从 options 中移除,Provider 永远收不到)。1.4 新增的 identity(ClientIdentity)控制 User-Agent 和 OpenRouter、NVIDIA NIM、OpenCode 的应用归属头,作为最低优先级的默认值;可以在 createModels({ identity }) 上设默认值,也可以按请求覆盖。

最后要分清:pi-ai 的 auth 只回答"这个 Provider 配置好没有、请求该带什么 key/header/baseUrl、token 要不要刷新"。工具审批属于上层 agent,API key 的组织/项目/额度权限由厂商服务端执行,都不是 pi-ai 的事。

工具调用:pi-ai 做什么,不做什么

工具的完整生命周期分属三层:pi-coding-agent 注册工具、决定当前激活哪些、执行扩展工具;pi-agent-core 驱动"请求模型 → 执行工具 → 写回结果"的循环;pi-ai 只负责两件事——把工具 schema 翻译成各家协议的格式,把模型返回的调用翻译成统一的 ToolCall。

在这个范围内 pi-ai 做得比较细。第一,schema 适配:比如 Gemini 不接受 const,会被转成单值 enum;StringEnum() 生成 {type: "string", enum} 这种所有厂商都接受的形式。第二,约束采样:工具可以声明 strict: "prefer"(支持就用厂商的 strict schema,否则退化为普通工具调用)或 strict: "require"(不支持直接失败),OpenAI 的 GPT-5+ 模型还支持 Lark/regex 语法约束的自定义工具。第三,流式参数:参数随 toolcall_delta 增量解析,UI 可以在参数还没传完时就显示要写的文件路径(解析机制见 流式事件架构)。第四,参数校验工具:toolcall_end 给出的参数是完整的但未校验;执行前由上层调用 validateToolCall()。1.5 起校验改用 pi-schema(compile(schema, { coerce: true }).parse()),对所有 JSON Schema 一视同仁地做宽松强制转换:"42" 转数字、单值包成数组、null 对应省略的可选属性。这对 MCP 工具这类手写 schema 很重要。如果最终参数文本根本不是合法 JSON,ToolCall 会带上 argumentsError,校验直接失败并把原始文本告诉模型,而不是拿解析了一半的字段去执行。

pi-ai 不做的事同样明确:不查找工具实现、不执行、不构造 ToolResult、不自动发起下一次请求。校验失败时正确的做法是把错误作为 isError: true 的工具结果写回上下文,让模型自己改参数重试。

**对话中途增删工具与 prompt cache。**prompt cache 按请求前缀复用,而工具列表通常位于历史消息前面,改了顶层 tools 后面的缓存就全失效。1.x 用 transcript 里的 system 消息记录工具变化,再由各适配器按目标模型的能力选择最省缓存的表达:Anthropic 支持原生中途工具变更的模型(inline-tools beta)让顶层工具列表保持不变,新工具以 tool_addition 块就地定义,移除用 tool_removal;OpenAI Responses 支持 additional_tools 的模型把所有工具都声明在输入项里,后续新增就地追加,移除只能用文字告知;只支持 tool search 或 Kimi 风格的模型把新工具锚定在出现的位置;其余模型一律收到 collapseSystemMessages() 的结果,也就是回放后的完整 prompt 和工具列表放在开头。模型是否支持中途 system 消息由 compat 字段(如 supportsMidConvoSystemMessages)声明,由生成的目录给已验证的模型打开。这套机制降低的是缓存失效的范围,不保证缓存永不失效。

跨 Provider 上下文交接

Context 可序列化,所以同一段对话可以从 Claude 切到 GPT 再切到 Gemini。问题是每家都有自己的专属状态:thinking 签名、加密推理、工具调用 ID 格式。每次请求前,适配器都会用 transformMessages() 把历史改造成目标模型能接受的样子。

判断标准不是"同一个 Provider",而是同一个模型:assistant 消息的 provider、api、model 三者都与目标模型一致才算。规则如下:user 和 system 消息原样通过;同模型的 assistant 消息原样保留,包括 thinking 和签名,以便原生回放;不同模型的 assistant 消息丢弃 thinking 块(1.3 之前是降级成普通文本,后来发现新模型会把旧模型的推理当成自己回复的一部分,甚至开始把推理写进可见答案里),文本保留但去掉专属字段,tool call 去掉 thoughtSignature,ID 按目标协议规范化,对应的 tool result 的 ID 同步改写。

除了逐条变换,还有两个修补:如果某个 tool call 没有对应结果(例如用户中途打断),就补一条 isError 的 "No result provided" 结果,避免目标 API 因为"调用没有结果"拒绝整段历史;stopReason 为 error 或 aborted 的 assistant 消息在重放时整条跳过,因为它们可能含有不完整的推理或调用,会触发诸如"reasoning 后面没有 item"的 API 错误。另外,模型不支持图片时,历史里的图片会被替换成占位文字。

所以跨模型交接是保留对话的因果链,丢弃无法安全重放的专属状态,不是无损迁移。

Thinking、缓存与请求选项

两套入口对应两种需求。streamSimple() 用统一的推理等级 minimal | low | medium | high | xhigh | max(模型侧还有 off),适配器根据模型的 thinkingLevelMap 映射成 Anthropic 的 thinking/adaptive effort、OpenAI 的 reasoning.effort、Google 的 thinking 参数或兼容服务的字段,并用 clampThinkingLevel() 夹到模型支持的范围;xhigh/max 是部分模型才有的等级,用 getSupportedThinkingLevels() 查询。需要协议专属控制(如 Anthropic 的 thinkingBudgetTokens、OpenAI 的 reasoningSummary)时用 stream(),并用 hasApi() 把模型收窄到具体 API 以获得完整类型。pi-agent-core 和 coding-agent 都走 streamSimple(),保持与厂商无关。

缓存相关选项是统一字段名,不是统一语义。cacheRetention(none | short | long,默认 short)、sessionId、transport(sse | websocket | websocket-cached | auto,WebSocket 目前用在 OpenAI Codex 适配器上)由适配器映射成 cache_control、prompt_cache_key、prompt_cache_retention 或会话亲和头,不支持的厂商直接忽略。有的模型还支持中途改推理强度而不破坏缓存(compat 里的 supportsMidConvoEffort)。

观察与调试钩子:onPayload 在适配器构造好请求体后调用,可以查看甚至替换 payload(结构随 model.api 而变,不是统一 DTO);onResponse 在拿到 HTTP 响应、开始读 body 之前调用;onProviderStreamEvent 收到适配器解析后、归一化之前的原始事件,用来读取 pi 没保留的厂商字段,它是按顺序 await 的,慢回调会拖慢流。此外还有 telemetry(传入父 span,Models 会创建 pi.ai.request span 并记录首包时延、token、成本等指标,不记录任何 prompt 内容)、timeoutMs、maxRetries 和 maxRetryDelayMs。

图片模型、分类模型与 deferred

0.99 起图片生成并入同一个 Provider / Models 体系,不再有单独的 ImagesModels 集合:type: "image" 的模型用 models.generateImages() 调用,Provider 通过 createProvider({ images }) 按 api 注册实现。分类模型(type: "classifier")用 models.classify(),返回统一的 choice/score/bool 结果。它们不进文本事件流,因为都是一次性任务,没有工具循环,也没有同样的增量协议;没写 type 的查询(getModels() 等)只返回 chat 模型,保持旧代码兼容。

文本侧还支持少数厂商的异步 deferred response:done 的原因是 deferred,之后用 fetchDeferred() / cancelDeferred() 取回或取消。不要把它和 deferred tools 混为一谈:前者是模型响应延后获取,后者是工具 schema 延后加载。

常见追问

为什么不直接用 Vercel AI SDK 或 LangChain 这类现成抽象? pi-ai 要的是一个面向 Agent 的、可序列化的上下文和一条能表达 thinking、增量工具参数、中途工具变更、精确 usage/cost 的事件流,并且对 prompt cache 和跨模型交接有细粒度控制;这些都需要贴着各家协议自己写适配器,换取的是维护几十个 Provider 的成本。

provider、api、compat 为什么要拆成三个字段? 因为"谁收钱/怎么认证"、"说哪种协议"、"协议实现有什么偏差"是三个独立维度。拆开之后,新接一个 OpenAI 兼容服务通常只要写目录和 compat,不用写新适配器。

认证为什么在请求路径上解析,而不是启动时一次性拿好 key? OAuth token 会过期,必须在发请求前检查并刷新;同时还要处理多进程并发刷新、刷新失败不降级等语义。放在 Models 的请求路径上,配合 CredentialStore.modify 的锁,调用方就完全不用关心这些。

pi-ai 会自动重试吗? 只有可选的请求级重试(maxRetries,默认 0 即关闭;厂商 SDK 自带的重试被置为 0,由适配器包一层可中止的版本),并用 maxRetryDelayMs 限制服务端要求的等待时长,超过就直接失败。是否重试一整轮由上层决定:pi-ai 提供 utils/retry.ts 这样的错误分类和 utils/overflow.ts 上下文溢出识别,coding-agent 据此决定重试还是先压缩上下文(见 错误处理与重试)。

为什么模型列表的读取是同步的? 上层(模型选择器、启动逻辑)大量地方需要立即拿到模型;把网络刷新做成显式的 refresh(),读取就永远是对内存快照的同步访问,离线也能工作,不会因为一次查询意外触发网络。

源码入口(v1.5):packages/ai/src/models.ts、packages/ai/src/types.ts、packages/ai/src/api/lazy.ts、packages/ai/src/api/transform-messages.ts、packages/ai/src/auth/resolve.ts、packages/coding-agent/src/core/remote-catalog-provider.ts

pi-ai(二):流式事件架构

TL;DR:pi-ai 的每次模型调用都同步返回一条 AssistantMessageEventStream:适配器在后台异步任务里读厂商的 SSE/WebSocket,原地修改一个 output 消息,并把变化翻译成统一事件 start → text/thinking/toolcall 的 start/delta/end → done | error 推进去;调用方用 for await 拉取增量,用 result() 拿最终权威消息。底座 EventStream 是一个"push 生产者 + 异步迭代消费者"的队列,终止事件同时兑现 result promise。核心约定是错误是事件而不是异常:网络错误、认证失败、用户中止都变成带部分内容和 usage 的 error 事件,所以成功、失败、取消只有一条处理路径。上层 pi-agent-core 把这些事件映射成 message_start/update/end 广播给 TUI 和会话持久化。

本篇讲 pi-ai 的流式部分,整体定位和请求主链路见 pi-ai(一):统一模型调用层。

为什么要流式,为什么要自己的事件协议

不用流式的话,调用方必须等服务端把整段响应生成完才能拿到数据。对 coding agent 来说这不可接受:一次回复可能有几十秒的思考、几百行的文件写入,用户需要实时看到推理和输出,需要在参数还没传完时就看到"正在写 src/index.ts",还需要随时按 Esc 中止并保留已经生成的内容。

流式本身不难,难在统一。从底向上看有三层:

第三层:自己的事件流(pi-ai 的 AssistantMessageEventStream)  ← pi、Claude Code 这类 Agent
第二层:厂商 SDK 的流式接口(openai、@anthropic-ai/sdk)        ← 大多数项目
第一层:裸 HTTP 流式(fetch + ReadableStream 逐行解析 SSE)      ← 追求极致控制的少数项目

SDK 已经处理了请求格式化、SSE 逐行解析、重试和认证头,但每家 SDK 吐出的事件形状都不一样:Anthropic 是 content_block_start/delta/stop,OpenAI Responses 是一串 response.* 事件,Chat Completions 是不断追加 choices[0].delta 的 chunk,Gemini 每个 chunk 带完整的 parts。pi-ai 在 SDK 之上再加一层,把这些全部统一成一种事件,同时统一 abort 信号的传递、部分结果的保存、成本计算,以及 thinking 和工具参数的增量解析。这样上层的 Agent 循环和 UI 只需要针对一种事件协议写一次。

统一事件协议

一条流的事件顺序固定为:

start → (text_* | thinking_* | toolcall_*)* → done
start → (更新事件)* → error            生成开始后失败
error                                    请求准备阶段就失败(认证、模块加载),没有 start

更新事件按内容块分三组,每组都是 *_start、*_delta、*_end 三段:文本块的 delta 是新增文字,text_end 给出完整文本;thinking 块同理(被厂商加密的 redacted thinking 可能在 start 时就完整,没有 delta);工具调用块的 delta 是参数 JSON 的一个片段,toolcall_end 给出完整的 ToolCall。终止事件只有两种:done 的 reason 是 stop | length | toolUse | deferred,带最终消息;error 的 reason 是 error | aborted,带一条同样完整的 AssistantMessage,里面有已生成的部分内容、errorMessage 和已经产生的 usage。

有两个字段决定了消费者怎么写:

contentIndex 指明事件属于最终消息 content 数组的哪个块。不同块的事件不保证连续:厂商可能在同一个 chunk 里同时给出文本和工具参数,pi 也会交错地发出 text_start, text_delta, toolcall_start, text_delta, toolcall_delta。消费者必须按 contentIndex 归属事件,不能假设一个块的三段事件不会被打断。

partial 是"到目前为止的响应",每个非终止事件都带。注意它是共享的活对象,不是事件发生时的快照:适配器一直在原地修改同一个 output,队列里还没被消费的旧事件看到的 partial 也已经是更新后的样子。所以处理事件时可以读它,但不能把它当历史状态保存。这个设计让 TUI 每次只需要"拿 partial 重新渲染",不用自己累积状态,代价是消费者要清楚它的可变语义。

EventStream:push 生产者与 async iterator 消费者

生产者(网络回调)和消费者(UI 渲染、Agent 循环)的节奏不同:网络可能一次来三个 token,也可能半天不来;消费者可能很慢,也可能早就在等。需要一个中间缓冲协调两边。EventStream<T, R> 只有一百多行,核心是两个队列:

push(event)         生产者调用:有消费者在等就直接交给它,否则放进事件队列;
                    若是终止事件,标记 done 并兑现 result promise
for await (...)     消费者:队列里有事件就取,已结束就退出,否则挂一个 resolver 到等待队列
end(result?)        结束流,唤醒所有还在等的消费者
result()            返回 Promise<R>,在终止事件到来时兑现

"何为终止事件、如何从中取结果"由构造参数决定。AssistantMessageEventStream 把 done 和 error 定义为终止事件,结果分别取 event.message 和 event.error。它在此之上只多做一件事(1.1 起):在终止时用单调时钟给最终消息写入 durationMs,所以所有适配器、包括直接调用 API 实现的路径都有统一的耗时。1.1 起 stream 函数也必须返回这个类型本身,手写的 EventStream 子类不再能冒充。

这个结构带来两个好处。一是流式和非流式统一:不关心过程的调用方直接 await s.result(),complete() 就是这么实现的,底层只维护一套机制。二是失败也有结果:因为 error 也是终止事件,result() 永远不会 reject,总能拿到一条消息,成功的或失败的。

**为什么是事件流而不是 for 循环加回调。**一般的简单 Agent 写法是同步的:

for chunk in stream:                 # 边收边处理
    if delta.content: on_token(...)  # 回调里打印,处理和消费绑死在一起
    if delta.tool_calls: args += ... # 累积参数,等流结束再执行工具

这种写法事件只有两种、消费者只有一个终端打印,够用。但 pi 的事件类型多得多,消费者也多:UI 关心 text/thinking 增量,会话持久化关心最终消息,扩展关心工具调用,遥测关心首包和块大小。把这些都塞进一个 on_token 回调会让生产者认识所有消费者,任何一个出错都会连累其他的。事件流让生产者(适配器)只管 push,完全不知道谁在消费;消费者也不知道事件来自哪家厂商。测试时不需要真实 LLM,手动 push 一串事件(或用内置的 faux provider)就能测 UI 和 Agent 循环。另外 TypeScript 的 I/O 本来就是强制异步的(不像 Python 里异步是可选的),用 async iterator 表达"会陆续到达的数据"是最自然的写法。

要澄清一点:EventStream 本身是单消费者的。它是一个队列,每个事件只会交给一个等待者,两个地方同时 for await 同一条流会把事件分走。真正的"多个消费者各自独立订阅"发生在上一层:pi-agent-core 是这条流唯一的消费者,它把事件翻译成 Agent 事件后,再通过 Agent.subscribe() 广播给所有监听者。

适配器如何把 SSE/WebSocket 归一

所有适配器的骨架几乎一样,以 anthropic-messages.ts 为例:

stream(model, transcript, options):
  stream = new AssistantMessageEventStream()
  启动后台 async 任务,然后立即 return stream
    output = { role: "assistant", content: [], usage: 0, stopReason: "pending", ... }   ← 唯一的"工作区"
    try:
      转换消息(transformMessages)→ 构造 payload(onPayload 可改)→ 发请求(带 signal)
      onResponse → push(start)
      for await 每个厂商事件:
        await onProviderStreamEvent?.(event)
        按厂商的块 index 找到对应的 content 块 → 原地修改 output → push 对应的 *_start/_delta/_end
        遇到 usage 就更新 output.usage 并 calculateCost()
      检查:已中止?没有 stop reason? → throw
      push(done) ; end()
    catch(error):
      output.stopReason = signal.aborted ? "aborted" : "error"
      output.errorMessage = error.message
      push(error) ; end()

一句话概括:在后台任务里只维护一个 output 对象并不断原地修改,通过 push 把增量实时推给消费者;成功时 push done,失败或中止时 push 带着部分内容的 error。

各适配器的差异只集中在几处:消息和工具 schema 怎么转换、厂商的块编号怎么映射到 contentIndex、thinking 和签名怎么表示、usage/缓存/价格怎么计算。几个值得一提的点:

  • 厂商的块编号和流式中间状态(工具参数原始文本、块 index 等)放在 content 块之外的 Map 里,不挂在 content 上。1.5 之前这些字段挂在块上,会漏进 partial 和失败流的 transcript,现在不会了。
  • start 在拿到 HTTP 响应之后才 push,所以连接阶段的失败表现为"只有 error 没有 start"。Anthropic 在 message_start 时就记下输入 token,这样即使很早被中止也有准确的输入 usage。
  • 传输方式不限于 SSE。OpenAI Codex 适配器可以走 WebSocket(transport 选项),归一化逻辑一样;Gemini 不流式传输工具参数,会一次性给出一个带完整参数的 toolcall_delta。
  • pi-messages 是 pi 自己的线协议:把 { model, context, options } 一次 POST 给后端,后端用 SSE 回传已经序列化的 pi 事件。Radius 网关就说这种协议,所以统一事件协议本身也可以跨网络传输。
  • onProviderStreamEvent 在归一化之前看到解析后的原始事件,是读取 pi 没保留的厂商字段(如 OpenRouter 的元数据)的官方出口;它按顺序 await,抛错会让请求失败。

工具参数的增量解析

工具参数是一段逐步到达的 JSON 文本,比如 {"path": "src/in 然后 dex.ts", "content": "...。为了让 UI 能在 toolcall_delta 时就显示文件路径,每个 delta 后 partial 里的 arguments 都应该是"到目前为止能解析出的对象"。

早期做法是每来一个 delta 就把累积的全部文本重新解析一遍,复杂度随参数大小平方增长:一次 90KB 的 write 在 Anthropic 上峰值约 140MB 堆,500KB 的要跑好几分钟。1.5 引入 StreamingJsonParser 和其上的 ToolCallArgumentsStream,所有 Provider 统一使用:每个片段只读一次,总代价与文本长度线性相关;它是容错的(未闭合的字符串显示已读部分,未开始的成员省略,非法转义保留为字面量,从不抛错);每次返回的快照之后不再改变,已完成的子结构在快照间共享。append() 还会返回这一片带来的变化(某路径被设值、某字符串被追加),供自己维护副本的写入方使用。

增量解析只服务于预览。toolcall_end 时会对完整文本做一次严格解析:如果最终文本不是合法 JSON,ToolCall 带上 argumentsError,校验阶段直接失败并把原始文本回给模型,而不是拿解析了一半的字段去执行。toolcall_end 的参数完整但未经 schema 校验,校验由上层用 validateToolCall() 完成。

中止、错误与 usage 语义

错误是事件,不是异常。stream 函数的契约是:必须返回事件流;一旦被调用,请求、模型和运行时的失败都编码进流里,而不是抛出;错误终止必须产出一条 stopReason 为 error 或 aborted、带 errorMessage 的 AssistantMessage。lazyStream() 把同一契约延伸到了请求准备阶段:认证失败、Provider 不存在、模块加载失败也都变成 error 事件。唯一的例外是直接调用 API 实现的 streamSimple() 而又缺少认证时,会同步抛错,因为那条路径没有 Models 帮你解析认证。

好处是调用方只有一套处理逻辑,不需要 try-catch 加事件处理两套;失败消息和成功消息结构一样,可以照常写进会话历史。

中止的完整链路是这样的:用户在 TUI 按 Esc → Agent.abort() 调用本次运行的 AbortController.abort() → 同一个 signal 已经沿着 agent-loop 传进了 streamFn 和适配器 → 适配器把 signal 交给 SDK/fetch,底层连接被取消;同时流式循环每处理一个厂商事件都会检查 signal.aborted → 抛出异常进入 catch → 因为 signal 已中止,stopReason 记为 aborted → push error 事件 → Agent 循环收到后结束这一轮。

场景终止事件stopReasoncontent
正常说完donestop完整内容
达到输出上限donelength被截断的内容
要调用工具donetoolUse含 toolCall 块
用户中止erroraborted已收到的部分内容
网络或服务错误errorerror已收到的部分内容

部分结果是怎么保留的:从流创建到结束只有一个 output 对象,所有修改都是原地进行的,所以 catch 块里直接把这个 output 标上错误状态推出去,已经收到的文本、thinking、工具调用和 usage 都还在。消费者在循环里可以通过 event.partial 实时看,流结束后通过 result() 拿到含部分内容的最终消息。中止的消息可以放回上下文,用户说"继续"后接着聊;不过在重放历史时,error/aborted 的 assistant 消息会被 transformMessages() 跳过,避免把不完整的推理或调用发给厂商。

usage 语义:适配器在流中随时更新 usage 并用模型的价格表计算成本,所以失败和中止的消息也带有已产生的 token 和费用。某个字段为 0 可能只是厂商没提供这一类计量,不代表确定没有。1.5 新增 cost.totalFromProvider 区分"厂商报告的实际账单"(如 OpenRouter)和"按目录价格的估算"。

上层如何消费

pi-agent-core 是事件流的直接消费者。它的 streamAssistantResponse() 先把 Agent 消息转换成 LLM 消息并 normalizeContext(),再调用 streamFn(coding-agent 注入的实现内部就是 modelRuntime.streamSimple()),然后逐个翻译事件:start 时把 partial 推进上下文并发出 message_start;每个 text/thinking/toolcall 事件都替换上下文里的那条 partial 并发出 message_update,原始的 pi-ai 事件附在 assistantMessageEvent 字段里;done/error 时取 result() 作为权威最终消息替换进上下文,发出 message_end。注意当前实现不会边流式边执行工具:工具执行在整条 assistant 流收敛成最终消息之后才开始,tool_execution_* 事件也在那之后发出。详见 pi-agent-core。

广播给多个消费者发生在 Agent 这一层:Agent.subscribe() 注册的每个监听者都会收到上述事件,并且会被依次 await。在 Pi 里,交互模式的 TUI 在 message_update 时用最新的 assistant 消息重新渲染流式组件,并在看到新的 toolCall 块时提前创建工具执行组件,所以参数还在流入时界面上已经有了工具卡片;AgentSession 只在 message_end 时调用 sessionManager.appendMessage() 写入会话文件。也就是说,流式中间态只活在内存和界面上,落盘的永远是最终消息。

streamProxy 展示了这套协议的另一个好处。有些应用不想把厂商 key 放在客户端,而是让服务端代理 LLM 调用。pi-agent-core 提供的 streamProxy() 就是一个可以作为 streamFn 传给 Agent 的流函数:它把模型、上下文和可序列化的选项 POST 给代理服务器,服务器以 SSE 回传事件,但为了省带宽去掉了每个事件里的 partial;客户端拿着这些精简事件自己重建 partial(工具参数同样用 ToolCallArgumentsStream 增量解析),再推进一条本地的 AssistantMessageEventStream。对 Agent 循环来说,它和直连厂商没有任何区别。

取舍

得到的东西:成功、失败、取消只有一条处理路径;流式和非流式共用一套机制;生产者和消费者完全解耦,换厂商、加消费者、做代理、写测试都不需要改另一侧;partial 让渲染逻辑不用自己累积状态;事件和消息都可序列化,便于跨网络传输(pi-messages、streamProxy)和会话持久化。

付出的代价:消费事件流比消费一个 Promise<string> 复杂,消费者要理解 start/delta/end 的状态、事件交错和 partial 的可变语义;"错误不抛出"意味着忘记检查 stopReason 的调用方会把失败当成正常的空回复;EventStream 是单消费者的,需要多路分发时必须在上层自己做广播。

常见追问

为什么 partial 不做成不可变快照? 每个 delta 都深拷贝一次整条消息,代价随消息长度增长,长回复下非常浪费;而大多数消费者只需要"当下最新的状态"。用共享活对象换性能,再在文档里写清"不要保存 partial",是有意的取舍。

为什么错误不直接 reject result()? 失败的回复同样是对话历史的一部分:它带着部分内容和已经计费的 usage,Agent 需要把它记进上下文和会话、显示给用户、决定是否重试。如果是 reject,这些信息就只能从异常对象里绕回来,调用方也要写两套逻辑。

事件流里有背压吗? 没有。push() 从不阻塞,消费者慢时事件堆在内存队列里。对 LLM 响应这种规模(最多几 MB 文本)这不是问题,换来的是生产者实现的简单。

为什么不边流式边执行工具? 一轮回复里可能有多个工具调用,参数要完整并通过校验才能安全执行,而且模型的 stopReason 要到最后才知道。先收敛成最终消息再执行,语义更清楚,也让审批、并行执行和中止处理都基于确定的数据。

源码入口(v1.5):packages/ai/src/utils/event-stream.ts、packages/ai/src/api/lazy.ts、packages/ai/src/api/anthropic-messages.ts、packages/ai/src/utils/streaming-json.ts、packages/agent/src/agent-loop.ts、packages/agent/src/proxy.ts

pi-agent-core:Agent Runtime

TL;DR:pi-agent-core 是建在 pi-ai 之上的通用 Agent Runtime,一个进程内的库,不懂 coding、不懂会话、不碰文件。它分两层:无状态的 agentLoop 负责"调模型 → 执行工具 → 决定要不要再调一次"的双层循环,有状态的 Agent 类负责 transcript、steering/follow-up 两个队列、事件订阅和 abort。内层循环跑工具调用和 steering(当前这批工具跑完再插话),外层循环只在 agent 本来要停时检查 follow-up。所有产品策略(持久化、压缩、权限、重试)都通过 prepareRequest、transformContext、beforeToolCall、finishTurn 等 hook 从上层注入,状态变化则通过事件流向外广播。

定位:一个不懂 coding 的 Agent Runtime

pi-ai 只能完成一次模型请求:给它模型、消息和工具声明,它返回一条流式的 AssistantMessage。真正的 agent 需要把多次请求和工具执行串起来:模型返回 tool call,执行工具,把结果写回上下文,再请求一次,直到模型不再调用工具。pi-agent-core 就是做这件事的那一层,它的本质是一个带状态管理、工具执行和消息队列的 Agent Runtime。

它的边界划得很窄,它不知道:

  • 会话怎么存(JSONL、分支、恢复),那是 pi-coding-agent 的 SessionManager;
  • 系统提示词怎么拼(AGENTS.md、skills、工具说明),那是 coding-agent 的资源加载;
  • 上下文快满了怎么办(compaction),那是 coding-agent 通过 hook 注入的;
  • 哪些工具危险、要不要弹窗确认,那是扩展通过 beforeToolCall 注入的;
  • 失败要不要重试,那是 coding-agent 在 run 结束后调 agent.continue() 实现的。

用一句话概括它的态度:Agent 只管运行时状态,不管配置和策略。 它知道自己正在用哪个模型,但不知道为什么选这个模型;它知道有哪些工具可用,但不知道这些工具是怎么被发现和注册的。

这条边界在 1.0 被进一步收紧。0.8x 时代这个包里还有一个实验性的 AgentHarness(会话存储、durable runtime、harness 版 read/edit/bash 工具、compaction、skills、prompt templates 等),coding-agent 其实从没用过它,而是用 Agent + 自己的 AgentSession。1.0 把整个 harness 从包里删掉了,现在包里只剩四样东西:Agent 类、agent loop、proxy 流函数,以及它们的类型。需要持久会话的场景改用独立的 @earendil-works/pi-durable。

运行形态:它是一个普通的 TypeScript 库,跑在调用方进程内,没有服务、没有常驻进程、没有自己的 I/O。调用关系是单向的:

pi-coding-agent 的 AgentSession(或任何应用)
   │  new Agent({ streamFn, convertToLlm, hooks... }),agent.prompt() / steer() / followUp()
   ▼
Agent(agent.ts,有状态外壳)
   │  每次 run:快照 + 配置 → runAgentLoop(..., emit)
   ▼
agentLoop(agent-loop.ts,无状态引擎)
   │  每次请求:streamFn(model, context, options)
   ▼
调用方注入的 StreamFn(通常是 pi-ai 的 models.streamSimple,或 streamProxy)

注意 streamFn 是必须由调用方注入的。agent-core 不依赖任何 provider 目录,这样只用一个 provider 的应用打包时不会把所有 provider 都带进来;如果嫌每次传麻烦,可以用 setDefaultStreamFn() 注册一个进程级默认值。

一句话:pi-ai 是"一次请求",pi-agent-core 是"多轮循环 + 工具执行 + 生命周期事件",产品能力全在它之上。

两层结构:无状态引擎 + 有状态外壳

无状态引擎 agentLoop:循环应该知道尽可能少的东西

一个直觉上的答案是循环引擎应该知道得越多越好:怎么管理会话、怎么重试、怎么压缩、怎么持久化,毕竟这些都是"循环过程中"会遇到的问题。Pi 给出了一个反直觉的答案:循环引擎应该知道尽可能少的东西。 它只管把消息送进 LLM、把响应拿回来、有工具调用就执行、然后决定要不要继续。它不知道消息从哪来、要存到哪去、哪些工具该被允许、上下文是不是快溢出了。给它 context + config,它产出一串事件,最终返回本次 run 新增的消息 newMessages。

  • 代价:会话持久化、错误重试、上下文压缩、UI 渲染全都得由上层实现。
  • 收益:同一个循环可以被终端 CLI、Slack bot、Web UI、测试用例随意组合,只要给不同配置。把循环做薄,是为了让上层做厚时有足够的自由度。

低层 API 有两个入口:agentLoop(prompts, context, config, signal, streamFn) 带新消息启动,agentLoopContinue(context, config, signal, streamFn) 从已有上下文继续(最后一条必须是 user 或 toolResult,不能是 assistant,否则 provider 会拒绝)。它们立即返回一个 EventStream,可以 for await 消费。要注意这个流是观察性的:事件顺序有保证,但循环不会等你处理完事件再往下走。

有状态外壳 Agent

一个真正可用的 agent 还需要:记住对话历史、通知多个订阅者、接收运行中用户发来的消息、能被中断、防止同时跑两次。Agent 类就是在循环之上加的这层外壳:

  • 状态:agent.state 里有 model、thinkingLevel、tools、messages,以及只读的 isStreaming、streamingMessage(正在流式生成的那条 assistant 消息)、pendingToolCalls、errorMessage。
  • 队列:steer() 和 followUp() 各自往一个队列里塞消息,立即返回。
  • 订阅:subscribe(listener) 注册监听器,返回取消函数。
  • 控制:prompt() / continue() 启动一次 run;abort() 中止;waitForIdle() 等 run 完全结束;reset() 清空对话和队列。
  • 互斥:同一时刻只能有一个 run。run 进行中再调 prompt() 会直接抛错,提示你用 steer() 或 followUp()。

每次 prompt() 内部都是三步:

Agent.prompt(text)
  │
  ├─ ① 打包:createContextSnapshot()  → { messages, tools } 的浅拷贝,用完即弃
  │          createLoopConfig()       → 把 Agent 上的字段和 hook 映射成 AgentLoopConfig
  │                                     getSteeringMessages = () => steeringQueue.drain()
  │                                     getFollowUpMessages = () => followUpQueue.drain()
  ├─ ② 执行:runAgentLoop(messages, snapshot, config, emit = processEvents, signal)
  │
  └─ ③ 归约:processEvents(event)
             先更新 _state(message_end 时把消息 push 进 state.messages,
             tool_execution_start/end 时增删 pendingToolCalls……)
             再按注册顺序逐个 await 每个 listener

这里有两个值得一提的设计。第一,Agent 的 transcript 是由事件归约出来的:所有状态修改都走 processEvents() 这一个入口,循环本身从不碰 Agent 的状态。第二,listener 是被 await 的:Agent 把 assistant 的 message_end 当作屏障,listener 处理完之后才开始工具预检,所以 beforeToolCall 看到的状态里一定已经有了发起这次调用的 assistant 消息;agent_end 的 listener 也算 run 的一部分,prompt() 的 promise 和 waitForIdle() 要等它们都结束才 resolve。这就是想要"事件处理当屏障"时应该用 Agent 而不是裸 agentLoop 的原因。

数据最终在哪:纯用 agent-core 时,transcript 只在内存里的 agent.state.messages,进程退出就没了。在 coding-agent 里,AgentSession 订阅 message_end 把每条消息追加到 JSONL 会话文件,又在 prepareRequest 里每次请求前从会话文件重建上下文。所以对 coding-agent 来说,会话文件才是权威数据,agent.state.messages 只是运行时的工作副本。

一句话:agentLoop 是纯计算,Agent 是状态管理;状态只通过事件归约来变,策略只通过 hook 注入。

Turn、Run 和 Session

理解双层循环之前,先分清三个粒度:

  • Turn:一次 LLM 请求,加上这次响应里所有工具调用的执行。事件上就是一对 turn_start / turn_end,turn_end 里带着这条 assistant 消息和它的全部 toolResults。
  • Run:一次 agent.prompt()(或 continue())启动的完整运行,从 agent_start 到 agent_end。一个 run 包含若干 turn,中途注入的 steering 和 follow-up 都还在同一个 run 里。
  • Session:跨越多次用户输入的长期会话,包含多个 run。agent-core 没有 Session 这个概念,持久化、恢复、分支都是 pi-coding-agent 的事。
Session(coding-agent 管)
  ├── Run 1:agent.prompt("修好失败的测试")
  │     ├── Turn 1:LLM → read, read
  │     ├── Turn 2:LLM → edit
  │     ├── Turn 3:LLM → bash
  │     └── Turn 4:LLM → 最终回答
  └── Run 2:agent.prompt(...)

因此 Agent.prompt() 不等于一次 pi-ai.stream():前者是"加入输入消息 + 零到多次 LLM 请求 + 零到多次工具执行 + 处理 steering/follow-up + 更新状态"。

双层循环:内层跑工具和 steering,外层接 follow-up

为什么要分两层

用户和 agent 交互时有两种插话意图,语义和消费时机都不同:

  • Steering(转向):agent 正在干活时插一条指令,比如"别改公共 API,只改内部实现"。它要在当前这批工具执行完之后注入,影响下一次 LLM 调用。
  • Follow-up(追加):给 agent 追加一个后续任务,比如"做完以后顺便更新 CHANGELOG"。它只在 agent 本来要停下时才被消费,不打断手头的活。

如果只有一层循环,就区分不了"agent 还在干活时插入的指令"和"agent 干完活后追加的任务"。所以 Pi 把循环拆成两层:内层负责"持续工作",外层负责"被唤醒"。

伪代码

下面是 1.5 的 runLoop 去掉细节后的骨架:

async function runLoop(context, config) {
  let pending = await config.getSteeringMessages()   // 启动时先看一眼:用户可能在等待时就打了字

  while (true) {                                      // 外层:只负责 follow-up
    let hasMoreToolCalls = true

    while (hasMoreToolCalls || pending.length > 0) {  // 内层:工具调用链 + steering
      if (!isFirstTurn) {
        await config.prepareNextTurn?.(lastTurn)      // 可换 context/model,coding-agent 在这里做压缩
        emit("turn_start")
      }
      appendAndEmit(pending); pending = []            // steering / follow-up 都从这里进上下文

      await config.prepareRequest?.(context)          // 每次请求前:换上权威上下文
      const msg = await streamAssistantResponse(context) // transformContext → convertToLlm → streamFn

      if (msg.stopReason === "error" || msg.stopReason === "aborted") {
        await config.finishTurn?.(...); emit("turn_end"); emit("agent_end")
        return                                        // 硬退出,不看任何队列
      }

      const results = await executeToolCalls(msg)     // beforeToolCall / afterToolCall 在这里
      hasMoreToolCalls = msg.hasToolCalls && !allTerminate(results)

      const decision = await config.finishTurn?.({ msg, results })
      emit("turn_end")
      if (decision?.action === "end") { emit("agent_end"); return }

      pending = await config.getSteeringMessages()    // 当前批工具全部跑完,才看 steering
    }

    pending = await config.getFollowUpMessages()      // 本来要停了,才看 follow-up
    if (pending.length === 0) break                   // (finishTurn 返回 continue 时还会再补一轮)
  }
  emit("agent_end")
}

几个容易讲错的点:

  1. steering 和 follow-up 共用同一个注入通道 pending。外层循环唯一的动作就是把 follow-up 赋给 pending 然后重新进入内层。两种消息走同一条路进上下文,区别只在什么时候被拉取。
  2. steering 不会打断正在进行的工作。源码的契约是"当前 assistant 消息里的工具调用不会被跳过"。模型一次发了三个工具调用,用户中途 steer,三个工具照样跑完,steering 在下一次请求前进入上下文。所以别把 steering 说成"立刻打断",它是在定义好的安全边界插入的。真要立刻停,用 abort()。
  3. follow-up 不是递归调用 prompt()。语义上它像"下一次 prompt",实现上它仍属于当前这个 run:循环继续,而不是 prompt() 再调用自己。好处是 follow-up 和之前的工作共享同一个 run 的生命周期、同一个 abort signal,agent_end 只发一次。
  4. 错误靠 stopReason 传递,不靠异常。pi-ai 的 StreamFn 契约要求失败编码进流里,最终消息的 stopReason 是 "error" 或 "aborted"。循环只看 stopReason,不需要 try-catch。

队列模式

Agent 的两个队列各有一个模式,steeringMode 和 followUpMode,默认都是 "one-at-a-time":

  • "one-at-a-time":每个拉取点只取最老的一条,其余留到下一个拉取点。用户连发三条 steering,模型会分三次请求逐条看到。
  • "all":一次把队列里所有消息都取出来注入。

默认逐条的理由是让模型每次只面对一条新指令,避免几条互相矛盾的插话被一起塞进去。coding-agent 把这两个模式做成了用户设置。另外 peekQueuedMessages() 可以按当前模式预览下一批会被注入的消息而不消费,clearSteeringQueue() / clearFollowUpQueue() / clearAllQueues() 用来丢弃排队消息(coding-agent 在用户 abort 时把排队消息清出来还给编辑器)。

continue() 与队列

continue() 用于"不加新消息、从现有上下文接着跑",典型用途是重试:coding-agent 在 run 因可重试错误结束后,把失败的那次回复留在原始历史里、但从发给模型的上下文投影中排除,等退避时间过后再调 continue()。它的规则是:

  • 末尾是 user 或 toolResult:直接从当前上下文再请求一次。
  • 末尾是 assistant(provider 不接受从 assistant 继续):退而取一批排队的 steering,没有就取一批 follow-up,当作新输入开一个 run;两个队列都空则抛错。

一句话:内层循环条件是"还有工具调用或有待注入消息",steering 在每个 turn 结束时拉取;外层只在内层自然结束后拉取 follow-up,有就重进内层。

一次真实任务:「把失败的测试修好」

下面沿一次真实任务走一遍,看每一步谁在做什么。场景:在 coding-agent 里,用户输入"把失败的测试修好";模型先读文件、再改代码、再跑测试;改代码时用户按 Enter 插了一条 steering,跑测试时又按 Alt+Enter 追加了一条 follow-up。

启动 run。 AgentSession 调 agent.prompt("把失败的测试修好")。Agent 检查没有活跃 run,创建 AbortController,把 isStreaming 置为 true,打包快照和配置,进入 runAgentLoop。循环发出 agent_start、turn_start,再为用户消息发出 message_start / message_end(Agent 在 message_end 时把它 push 进 state.messages,AgentSession 在同一个事件上把它追加到会话文件)。然后先拉一次 steering 队列,此时是空的。

Turn 1:读文件(并行)。 请求前依次经过三道处理:prepareRequest(coding-agent 在这里从会话文件重建上下文,如果用的是虚拟模型还会做路由)、transformContext(转给扩展的 context 事件,扩展可以临时改写这次请求能看到的消息)、convertToLlm(把 AgentMessage[] 转成 LLM 认识的 Message[],过滤掉 UI 专用的自定义消息)。然后调 streamFn,pi-ai 的流式事件被翻译成 message_start → 一串 message_update → message_end,UI 靠 message_update 里的 text_delta 实时渲染。模型返回两个工具调用:read("tests/foo.test.ts") 和 read("src/foo.ts")。

默认是并行模式:两个调用依次预检(找工具、prepareArguments 兼容旧参数、按 JSON Schema 校验、beforeToolCall),然后并发执行。tool_execution_end 按完成先后发出,但 toolResult 消息按模型给出的原始顺序写入上下文。工具结果都写回后,finishTurn 运行(coding-agent 在这里分发扩展的 turn_end 边界),然后发出 turn_end。因为有工具调用,内层继续。

Turn 2:改代码,用户中途 steer。 下一轮开始前先调 prepareNextTurn:coding-agent 在这里检查上下文是否超过压缩阈值,超了就先压缩,同时刷新系统提示词和工具清单。然后 turn_start、请求、模型返回 edit("src/foo.ts", ...)。edit 执行期间,用户在 TUI 里按 Enter 输入"别改 foo 的公共签名,只改内部实现"。因为 agent 正在跑,这条消息走 agent.steer() 进 steering 队列,立刻返回,正在执行的 edit 不受影响。edit 跑完,finishTurn、turn_end,然后循环拉取 steering 队列,拿到了这条消息。

Turn 3:带着 steering 跑测试。 prepareNextTurn 后发出 turn_start,steering 消息作为 user 消息发出 message_start / message_end 进入上下文。模型看到新指令,确认刚才的改动只动了内部实现,于是调 bash("npm test")。beforeToolCall 这时可能拦下它:如果装了权限扩展,扩展会弹窗问用户,用户拒绝就返回 { block: true, reason },工具不执行,模型收到一条错误 toolResult 和原因。这里用户同意了,bash 执行时通过 onUpdate 持续产生 tool_execution_update,UI 实时滚动测试输出。测试跑着的时候,用户又按 Alt+Enter 输入"顺便更新一下 CHANGELOG",这条消息进 follow-up 队列。bash 结束,afterToolCall 可以改写结果(比如截断超长输出或脱敏),然后 tool_execution_end、toolResult 消息、finishTurn、turn_end。steering 队列为空,但有工具调用,内层继续。

Turn 4:模型宣布完成。 模型回复"测试已全部通过",没有工具调用。turn_end 之后 steering 队列为空,hasMoreToolCalls 为 false,内层循环退出。外层拉取 follow-up 队列,拿到"顺便更新一下 CHANGELOG",赋给 pending 后重新进入内层。

Turn 5–6:处理 follow-up。 follow-up 消息进入上下文,模型调 edit("CHANGELOG.md"),再下一轮给出总结、没有工具调用。内层退出,外层再拉 follow-up,空了,break,发出 agent_end。

收尾。 Agent 逐个 await agent_end 的 listener,然后 finishRun() 清掉 isStreaming、pendingToolCalls、activeRun,prompt() 的 promise 才 resolve。之后才轮到 coding-agent 自己的 run 后处理:检查要不要重试、要不要因上下文溢出而压缩,需要的话再调 agent.continue() 开一个新 run。本场景一切顺利,整个过程是一个 run、六个 turn,steering 和 follow-up 都没有开新的 run。

工具执行

工具执行是循环里规矩最多的部分。每个工具调用都走同一条三段式管道:

tool_execution_start
  → 预检:找工具(找不到 → 错误结果)
          → prepareArguments(可选,兼容旧参数格式)
          → 按 parameters 的 JSON Schema 校验参数(失败 → 错误结果)
          → beforeToolCall(可 block)
  → 执行:tool.execute(id, args, signal, onUpdate),onUpdate → tool_execution_update
  → 收尾:afterToolCall(可改写结果)
tool_execution_end → toolResult 消息的 message_start / message_end

并行和串行。 默认 toolExecution: "parallel":一批调用先串行预检,再并发执行允许的那些。串行预检保证权限检查、用户确认这种有副作用的判断按确定顺序发生(不会同时弹两个确认框),并发执行减少多工具调用的等待。tool_execution_end 按完成顺序发出,方便 UI 尽早更新;toolResult 消息按 assistant 消息里的原始顺序写入,保证上下文稳定。也可以设成 "sequential" 一个一个跑。单个工具还能声明 executionMode: "sequential",只要一批里有一个这样的工具,整批都串行。并行模式的代价是同一批调用之间不能有依赖(比如"先读再改"),Pi 的做法是让模型自己把有依赖的操作放在不同 turn 里。

错误都变成可恢复的对话。 工具找不到、参数校验失败、beforeToolCall 拦截、execute() 抛异常、afterToolCall 抛异常,全都被转成 isError: true 的 toolResult 返回给模型,模型下一轮可以修正参数重试。循环不会因为工具出错而崩溃。工具作者的约定是失败时抛异常(或返回 isError: true),而不是把错误写进正常 content 里假装成功。

输出被截断时不执行。 如果 assistant 消息因为输出 token 上限被截断(stopReason === "length"),其中的工具调用参数可能被尽力修补成"能解析但不完整"的 JSON。循环不会执行它们,而是全部返回错误,提示模型用完整参数重新发起。

beforeToolCall 和 afterToolCall。 beforeToolCall 在参数校验之后、执行之前运行,拿到的是校验过的参数和当前上下文,返回 { block: true, reason } 就阻止执行,适合权限检查、安全策略、用户确认、工作区边界检查。afterToolCall 在执行之后、tool_execution_end 之前运行,可以按字段替换 content、details、isError 等(逐字段覆盖,不做深合并),适合脱敏、审计、结果增强。两个 hook 都会拿到 abort signal,需要自己响应中止。coding-agent 把它们接到扩展系统的 tool_call / tool_result 事件上,权限弹窗就是这样实现的:agent-core 本身没有任何权限概念。

content 给模型,details 给 UI。 工具结果分两部分:content 是发给模型的文本/图片,details 是任意结构化数据,只给日志和 UI 渲染用(比如 edit 工具的 diff)。这样 LLM 只看到它需要理解的内容,UI 也不用从模型输出里二次解析数据。

terminate:让工具决定"不用再问模型了"。 工具结果(或被 block 的结果、afterToolCall 的覆盖)可以带 terminate: true,提示这批工具跑完就别再自动请求模型了。只有当一批里所有结果都是 terminate 时才生效,混合批次照常继续。这个字段只在 agent 层闭环,不会出现在发给 provider 的 toolResult 消息里。

runToolCall():嵌套调用也要过 hook。 有些工具会调用别的工具,比如 codemode 让模型写一段 JS 去调 agent 的工具。如果这些内部调用绕过管道,权限检查就形同虚设。所以 agent-loop 导出了 runToolCall():它把一次调用走完同样的参数准备、校验、beforeToolCall、执行、afterToolCall,只是不发事件、不写消息。

一句话:串行预检保证安全决策有确定顺序,并发执行保证速度;所有失败都变成模型能看到的错误结果,而不是让循环崩溃。

控制 Hook:上层注入策略的入口(1.5)

agent-core 自己不做决策,它在循环的固定位置留了一组 hook(都在 AgentLoopConfig 上,只有 convertToLlm 必填),由上层填入策略;另有从 pi-ai 透传的 onPayload 等回调用来观察或改写原始 provider 请求/响应。用 Agent 时,两个队列 hook 由 Agent 自己用内部队列实现,不对外暴露。

先看它们在一个 provider turn 里的触发顺序:

[非首轮] prepareNextTurn → (若还没取到消息则补拉一次 steering)→ turn_start
注入待处理消息(steering / follow-up / prepareNextTurn 追加的消息)
prepareRequest
  → transformContext → convertToLlm → getApiKey → streamFn      (streamAssistantResponse 内部)
工具:预检(含 beforeToolCall)→ 执行 → afterToolCall
finishTurn
turn_end
  → finishTurn 返回 end:agent_end
  → 否则拉 steering;内层结束后拉 follow-up;都没有则 agent_end

逐个说明它们解决什么问题:

convertToLlm:消息边界的翻译。 agent 内部的消息类型是 AgentMessage,它是 LLM 消息(system / user / assistant / toolResult)加上应用通过声明合并扩展的自定义消息。LLM 只认识前者,所以每次请求前必须转换:自定义消息要么转成 user 消息,要么过滤掉。Agent 的默认实现只保留四种标准角色。coding-agent 有自己的版本,把 bash 执行记录、压缩摘要、分支摘要等自定义消息转成模型能读的 user 消息。

transformContext:改写这一次请求的视图。 它在 convertToLlm 之前运行,输入输出都是 AgentMessage[],适合裁剪旧消息、注入检索结果、临时隐藏某些内容。它处理的是"本次 LLM 请求看到的视图",不改 agent.state.messages,所以可以出现"会话原始历史 ≠ 本次发给模型的消息"。coding-agent 把它接到扩展的 context 事件上。

prepareRequest:每次请求前装上权威上下文(新增)。 它在每一次 provider 请求前运行,包括第一次,此时待注入的消息已经发出事件。它可以替换 context、模型、thinking level,替换后对本次及后续请求生效。它解决的问题是:循环内存里那份 context 可能和持久层不一致(比如中途发生了压缩,或扩展追加了条目),所以 coding-agent 在这里每次都从会话文件重建 messages,虚拟模型的路由也在这里做。它不拉队列,运行期间新来的 steering 等下一个正常拉取点。

prepareNextTurn:下一轮开始前刷新运行快照。 只在循环确定要继续时,在下一个 turn_start 之前运行,可以替换 context / model / thinking level,或追加消息。coding-agent 在这里做阈值压缩、重建系统提示词和工具清单(工具变化会被写成 system 消息追加进去)。因为压缩可能耗时较长,它跑完后循环会补拉一次 steering,接住期间用户打的字。和 prepareRequest 的区别是:prepareNextTurn 是"turn 之间"的钩子,首轮不运行,可以追加消息;prepareRequest 是"每次请求前"的钩子,首轮也运行,不能追加消息。

finishTurn:一轮结束时决定是否继续(取代 shouldStopAfterTurn)。 它在 assistant 消息和所有工具结果都定稿之后、turn_end 之前运行,返回三种结果之一:undefined 保持正常调度;{ action: "end" } 在 turn_end 之后立即结束 run,不拉队列、不再准备请求;{ action: "continue" } 保证至少再有一次请求,如果工具结果、steering 或 follow-up 本来就会触发下一次请求,就不额外加;否则循环会用当前上下文多请求一次。这是对旧 shouldStopAfterTurn 的泛化:旧 hook 只能"优雅停止",新 hook 还能要求"再来一轮"(coding-agent 的扩展可以在 turn_end 边界上请求继续,就是靠它)。还有两个差异要注意:finishTurn 对 error / aborted 的响应也会运行,但这时它的返回值被忽略,因为那两种情况是硬退出;另外,无条件返回 continue 会造成死循环。

getSteeringMessages / getFollowUpMessages:两个队列的拉取点。 契约是不能抛异常,没有消息就返回空数组。steering 在 run 开始时、每个 turn 结束时(除非 finishTurn 选了 end)、以及 prepareNextTurn 之后(如果之前没拉到)被拉取;follow-up 只在内层循环自然结束时被拉取。

getApiKey:每次请求时动态取 key。 对 GitHub Copilot 这类短期 OAuth token,一次长工具执行期间 token 可能就过期了,所以 key 在每次请求时解析,而不是在构造时固定。

beforeToolCall / afterToolCall:见上一节。

这些 hook 的共同契约是"不要抛异常,返回安全的兜底值"。convertToLlm、transformContext 一旦抛出,低层循环会直接中断,不会产生正常的事件序列;这时只能靠 Agent 的兜底(见后文"中止与错误")。

一句话:prepareRequest 管"这次请求用什么上下文",transformContext / convertToLlm 管"这次请求怎么呈现给模型",prepareNextTurn 管"下一轮开始前刷新什么",finishTurn 管"这一轮之后停还是继续",工具 hook 管"这次工具能不能跑、结果怎么改"。

事件流:为什么事件和 Hook 分开

循环向外发出一串 AgentEvent,分三层生命周期:

agent_start                                  run 开始
  turn_start                                 一次 LLM 请求 + 它的工具执行
    message_start / message_end              每条消息(system / user / assistant / toolResult)
    message_update                           仅 assistant 流式生成时,带 pi-ai 的原始增量事件
    tool_execution_start / _update / _end    每个工具调用
  turn_end { message, toolResults }
agent_end { messages }                       run 结束(携带本次 run 新增的所有消息)

message_start / message_end 对所有角色都会发,所以 UI 和持久化只需要监听 message_end 就能拿到每一条定稿的消息,不用关心它是用户输入、模型回复还是工具结果。message_update 只给 assistant,tool_execution_end 带 durationMs(execute() 本身耗时,不含 hook)。

事件和 hook 是两个方向的东西。 事件是"循环 → 外界",告诉外界发生了什么,用来刷新 UI、写日志、落盘;hook 是"外界 → 循环",在固定位置向循环要一个决定,返回值会改变循环的行为。订阅 message_end 不会让你阻止一个工具调用,beforeToolCall 返回 block 才可以;listener 的返回值被忽略,hook 的返回值就是决策本身。

为什么不把两者合成一个机制?因为它们的要求不同:

  • 决策必须有唯一答案、在确定的时刻给出。 一个工具要不要执行,循环得在执行前拿到明确结果。如果靠"广播事件、看谁回应",多个监听者回答冲突时听谁的?没人回答时等多久?hook 是循环配置上的单个函数,答案唯一。
  • 观察可以有任意多个,彼此互不干扰。 UI、日志、会话持久化、扩展都可以同时订阅事件。
  • 分开以后边界清楚。 要改变行为就去找 hook,要知道发生了什么就订阅事件,不存在"某个 listener 偷偷改了循环行为"的情况。

要注意一个细节:在 Agent 层,listener 虽然不能改变决策,但它们是被 await 的,一个慢 listener 会拖慢整个循环(这也是 message_end 能当屏障的原因)。裸 agentLoop 的事件流则完全是观察性的,不会等消费者。

中止与错误

abort 是协作式的。 agent.abort() 只做一件事:触发当前 run 的 AbortController。这个 signal 被传给 streamFn、每个工具的 execute()、beforeToolCall / afterToolCall、prepareRequest 和 finishTurn,各方自己负责响应:

  • 正在流式请求时:pi-ai 的 provider 停止请求,返回一条 stopReason: "aborted" 的 assistant 消息,循环走硬退出路径(finishTurn → turn_end → agent_end)。
  • 正在执行工具时:工具应该响应 signal 尽快结束;串行模式下跑完当前工具就不再跑后续的;并行模式下还没开始执行的调用直接得到 "Operation aborted" 错误结果。这批结果写回后,下一次请求会立即以 aborted 结束。
  • 队列不会被清空:Agent.abort() 不碰 steering / follow-up 队列。coding-agent 在 abort 时自己把排队消息清出来还给编辑器,让用户决定要不要重发。

错误分三层兜底:

  1. 模型请求失败:StreamFn 的契约是不抛异常,把失败编码进流里,最终消息的 stopReason 是 "error" 并带 errorMessage。循环把它当作一个正常结束的 turn 处理,然后硬退出。Agent 在 turn_end 时把 errorMessage 记到 state.errorMessage。agent-core 本身不重试,重试是 coding-agent 在 run 结束后判断错误是否可重试(过载、限流、服务端错误)、指数退避、再调 continue() 实现的。
  2. 工具失败:全部变成错误 toolResult 交给模型,循环继续(见"工具执行")。
  3. 意外异常:如果某个违反契约的 hook 抛出异常,Agent 会 catch 住,补发一条最小的失败事件序列:一条空的 assistant 消息(stopReason 为 "error",如果已 abort 则为 "aborted"),加上 message_start / message_end / turn_end / agent_end,保证订阅者总能看到 run 正常收尾,isStreaming 也一定会复位。已经通过 message_end 写进 state.messages 的内容不会回滚。

与 pi-ai 的边界

agent-core 和 pi-ai 之间只有一个连接点:StreamFn。它的形状和 pi-ai 的 models.streamSimple 一致,(model, context, options) → AssistantMessageEventStream。agent-core 在调用前把上下文经 transformContext、convertToLlm 转成 pi-ai 认识的形状,把 API key、请求身份、abort signal 合进 options,然后消费返回的流,把 pi-ai 的流式事件翻译成 message_* 事件。除此之外,它还用 pi-ai 的 validateToolArguments() 校验工具参数,复用 pi-ai 的消息类型,AgentTool 也只是在 pi-ai 的 Tool(name、description、parameters)上加了 execute 等运行时字段。

系统提示词和工具声明住在 transcript 里(1.x 的变化)。 旧版本的 context 是 { systemPrompt, messages, tools } 三件套;1.5 里系统提示词和"模型可以调用哪些工具"都由 transcript 中的 system 消息承载:开头的 system 消息就是提示词,后面的 system 消息可以追加内容或按名字替换段落。agent.state.systemPrompt 变成只读,是从 transcript 回放出来的。agent.state.tools 是"能执行的工具",每次请求前循环会把它和 transcript 里声明的工具做 diff,有差异就写一条带 toolsAdded / toolsRemoved 的 system 消息告诉模型。这样做的好处是提示词和工具清单的变化本身也是历史的一部分,回放 transcript 就能还原模型在任意时刻看到的东西。

proxy:浏览器里不放 key。 streamProxy() 是一个现成的 StreamFn 实现:它把请求 POST 到你自己的服务端 /api/stream,用 Bearer token 认证,服务端持有真正的 provider key 并代发请求,回传去掉了 partial 字段的精简事件以节省带宽,客户端在本地重新拼出完整的 AssistantMessage。对 agent 来说它和直连没有区别,换一个 streamFn 就行,这也再次说明了把 StreamFn 做成注入点的价值。

常见追问

为什么默认是并行执行工具,而不是更安全的串行? 模型一次发出多个调用时,绝大多数是互不依赖的读操作,并行能明显缩短等待;真正有顺序要求的工具可以声明 executionMode: "sequential" 把整批降级为串行,而预检本身始终是串行的,安全决策不受影响。

steering 和 abort 都能"打断",怎么选? steering 不丢弃任何已经开始的工作,只改变下一次请求的输入,适合"方向不对,调整一下";abort 会让进行中的请求和工具尽快停止,适合"完全不要做了"。前者 run 继续,后者 run 结束。

既然有 prepareRequest,为什么还要 transformContext? 职责不同:prepareRequest 改的是循环持有的 context 本身(后续请求也用它),并且能换模型;transformContext 只产出本次请求的一个临时视图,不影响后续。coding-agent 用前者对齐持久层,用后者给扩展一个改写视图的机会。

为什么 agent-core 不自带重试和压缩? 重试要判断哪些错误可重试、等多久、要不要换模型,压缩要知道消息怎么持久化、摘要存在哪,这些都是产品策略。放进循环会让循环依赖会话存储和设置系统;留在上层,循环才能在 CLI、Web、测试里原样复用。

为什么 Agent 只允许单个 run? transcript 只有一份,两个 run 同时往里追加消息会让顺序和工具结果的对应关系失去意义。并发输入的需求由两个队列来满足:运行中的输入一律排队,由循环在安全点取用。

源码入口(v1.5):packages/agent/src/agent-loop.ts、packages/agent/src/agent.ts、packages/agent/src/types.ts、packages/agent/src/proxy.ts、packages/agent/README.md、packages/coding-agent/src/core/agent-session.ts

pi-coding-agent:产品级 Coding Agent Harness

TL;DR:pi-agent-core 只会跑“模型 → 工具 → 模型”的循环,pi-coding-agent 把它装配成能在真实仓库里长期工作的产品:会话树持久化、配置、资源发现(AGENTS.md / skills / 模板 / 扩展)、内置编码工具、自动压缩与重试、模型与认证,以及 TUI / print / JSON / RPC / SDK 几种外壳。它是一个普通的 Node 进程,没有常驻 daemon,扩展也跑在同一个进程里。启动链是 main.ts → cwd 绑定的 AgentSessionServices → createAgentSession() 工厂 → 负责换会话的 AgentSessionRuntime → 总协调者 AgentSession。1.x 的关键设计是 SessionManager 是模型上下文的唯一权威:每次请求都从会话树重新投影出消息,system prompt 和工具声明也作为 system 消息记在会话里,变化只追加补丁,不重写开头,从而保住 prompt cache。

它解决什么问题

pi-ai 解决“怎么和不同 provider 说话”,pi-agent-core 解决“怎么跑一轮 agent loop、怎么调工具”。但一个能每天在代码仓库里用的 coding agent 还缺很多东西:

  • 对话要能落盘、恢复、分叉,长对话要能压缩;
  • 用户级、项目级配置要合并,项目里的 AGENTS.md、skills、prompt 模板、扩展要被发现并装进 system prompt;
  • 要有一套好用的文件/命令工具,并且输出不能撑爆上下文;
  • 网络抖动、限流、上下文溢出要能自动恢复;
  • 同一套能力既要给人用(终端 UI),也要给脚本和其他程序用(print / JSON / RPC / SDK);
  • 模型选择、API key / OAuth 登录、自定义 provider 要统一管理。

pi-coding-agent(npm 包 @earendil-works/pi-coding-agent,CLI 名 pi)就是这层 harness。它本身不重写 agent loop,而是通过 agent-core 预留的钩子把产品逻辑插进去:请求前改上下文、每轮之间刷新状态、工具调用前后拦截、回合结束时决定要不要继续。

一句话:agent-core 提供“循环 + 钩子”,coding-agent 提供“会话 + 资源 + 工具 + 恢复 + 外壳”,二者靠钩子而不是继承连接。

运行形态:一个进程,几种外壳

执行 pi 时只有一个 Node(或 Bun 打包产物)进程:

cli.ts(setupCli)→ main(args)
   ├─ 解析参数、决定模式:--mode rpc / --mode json / -p 或 stdin/stdout 非 TTY → print,否则 interactive
   ├─ 选定会话文件和最终 cwd,解决项目信任
   ├─ 创建 AgentSessionRuntime(内部创建 services 和 AgentSession)
   └─ 分发:InteractiveMode.run() / runPrintMode() / runRpcMode()

几个要说清的运行事实:

  • 没有后台服务。会话、配置、认证都是磁盘上的文件(会话默认是 ~/.pi/agent/sessions/<按 cwd 编码的目录>/ 下的 JSONL,凭据在 agentDir 的 auth.json,模型配置在 models.json),进程退出就结束。下次启动通过 --continue / --resume / --session 重新打开文件恢复。
  • 扩展是进程内的 TypeScript 模块。它们由 resource loader 加载,工厂函数在同一进程里注册工具、命令、provider、事件处理器和 UI,所以扩展和内置工具一样拥有 Pi 进程的全部 OS 权限。
  • RPC 模式也不是 daemon:它是被宿主程序拉起的长生命周期子进程,宿主关闭 stdin 它就退出。SDK 则完全没有进程边界,宿主直接在自己的进程里 createAgentSession()。
  • 交互模式 1.0 起默认全屏 TUI(tuiMode: "regular" 或 --tui-mode regular 可回到普通滚动缓冲)。

所有模式共用同一个 AgentSession,区别只在“输入从哪来、事件怎么输出、进程活多久”,详见 运行模式。

分层:从 main.ts 到 AgentSession

main.ts:先定 cwd,再造依赖 cwd 的东西

main.ts 是启动编排器。它最重要的一个顺序约束写在源码注释里:先确定最终 cwd,再创建与 cwd 绑定的服务。因为 --session / --resume 可能选中另一个项目的会话,而项目级 settings、资源、extension 注册的 provider 都依赖 cwd,所以 main 先用启动目录的 settings 只做“会话目录查找”,选定 SessionManager 后以 sessionManager.getCwd() 为准。

然后 main 不直接造 session,而是定义一个 createRuntime({ cwd, agentDir, sessionManager, sessionStartEvent }) 闭包。闭包里依次做:判断项目信任 → 创建 AgentSessionServices → 解析 --models 作用域 → buildSessionOptions() 决定模型、thinking、工具 → createAgentSessionFromServices()。最后把这个工厂交给 createAgentSessionRuntime()。

为什么要工厂而不是一次性对象?因为会话切换时 cwd 可能变,cwd 一变 settings、资源、provider 注册都必须重建。runtime 需要一个“如何为某个 cwd 重新创建自己”的配方,在 /new、/resume、/fork、/import 时反复调用。

项目信任也在这里处理:如果项目目录里有需要信任才能加载的资源(项目级 settings、扩展等)且没有记录过信任决定,Pi 在加载项目资源之前做决定:defaultProjectTrust 为 always / never 时直接按配置,默认 ask 时在交互模式弹出询问,没有 UI 的模式(print / JSON / RPC)则按不信任处理,命令行覆盖优先。信任结果按 cwd 缓存,切换会话时复用。顺序是:信任决定 → 加载项目设置与资源 → 加载 context files。

AgentSessionServices:cwd 绑定的基础设施

createAgentSessionServices() 产出一组只和“当前工作目录”有关的服务:

  • ModelRuntime:模型目录与认证的统一入口(1.x 用它取代了旧笔记里分开描述的 AuthStorage + ModelRegistry 组合,详见 模型与认证);
  • SettingsManager:全局 + 项目 settings 合并(配置体系);
  • ResourceLoader:发现扩展、skills、prompt 模板、主题、AGENTS.md / SYSTEM.md(资源发现);
  • diagnostics:创建过程中收集的问题。

它会先 resourceLoader.reload()(扩展在这一步被加载),再把扩展登记的 provider、原生 provider、虚拟模型注册进 ModelRuntime,最后把 CLI 上未识别的 --xxx 交给扩展声明的 flag。它只返回诊断,不打印也不退出,是否中止启动由 app 层决定——这让 SDK 宿主可以自己处理错误。

createAgentSession:装出 Agent 和 AgentSession

sdk.ts 的 createAgentSession()(createAgentSessionFromServices() 只是把 services 填进去再委托给它)负责组装,不负责运行:

  1. 从会话分支恢复模型和 thinking level,恢复不了就按 settings 默认值 / provider 默认值挑,并生成 modelFallbackMessage;
  2. 算出初始工具集:defaultTools 设置(默认 read, bash, edit, write)经 tools / +name -name 修饰 / excludeTools 过滤;
  3. new Agent(...):注入 streamFn(走 modelRuntime.streamSimple,按请求解析认证、超时、provider 级重试参数,并驱动 cache warmer)、transformContext(转发给扩展的 context 事件)、convertToLlm(AgentMessage → LLM Message,按设置过滤图片),以及 steering/follow-up 模式等;
  4. 新会话写入初始 model_change 和 thinking_level_change 条目,便于将来恢复;
  5. new AgentSession({ agent, sessionManager, settingsManager, resourceLoader, modelRuntime, ... })。

agent-core 自己是 provider 无关的,它不知道 pi-ai 的认证和 provider 配置;streamFn 这层就是 coding-agent 对 pi-ai 的产品化适配。

AgentSessionRuntime:会话宿主

AgentSessionRuntime 持有“当前的 AgentSession + 它的 services + 创建工厂”。newSession()、switchSession()、fork()、importFromJsonl() 的套路一致:发 session_before_switch 等扩展事件(可取消)→ 关闭旧会话(session_shutdown)→ 用工厂为目标 cwd 重建 services 和 session → 替换。替换之后旧 session 上的订阅失效,所以各个 mode 都通过 setRebindSession() 注册一个回调,在替换后重新 bindExtensions() 并重新订阅事件。

AgentSession:总协调者

AgentSession 拥有一段对话及其模型、工具、消息队列、压缩状态和扩展运行时。它在构造函数里把自己挂到底层 Agent 上:

  • agent.subscribe(...):所有底层事件先经过 AgentSession,做持久化、扩展转发、重试计数;
  • prepareRequest:每次请求前用会话树投影替换上下文(下面细讲);
  • prepareNextTurnWithContext:每轮之间检查是否该压缩、刷新 system prompt 与工具;
  • beforeToolCall / afterToolCall:桥接扩展的 tool_call / tool_result;
  • finishTurn:桥接扩展的 turn_end 边界,扩展可以要求继续;
  • transformContext:叠加隐藏工具声明、强制 system prompt 等投影。

所以准确的说法不是“AgentSession 持有一个 Agent”,而是 AgentSession 通过订阅和钩子把一个通用 Agent 升级成了产品会话。扩展钩子的完整语义见 扩展系统,这里只需要记住桥接点就在上面这几个钩子和事件订阅里。

一句话:main 决定“在哪个 cwd、用哪个会话、什么模式”,services 提供 cwd 绑定的环境,工厂负责装配,runtime 负责换会话,AgentSession 负责跑对话。

1.x 的上下文模型:会话树是唯一权威

这是读旧资料最容易踩的坑。旧版本里 agent.state.messages 基本就是发给模型的历史,prompt() 往里追加即可。从 0.87 起(1.x 延续),SessionManager 才是 provider 上下文的权威:

  • 每次模型请求前,prepareRequest 调 sessionManager.buildSessionProjection(),从 JSONL 会话树的当前分支重新算出消息:应用压缩摘要、context_edit 条目(替换或省略某条消息)、分支摘要。
  • agent.state.messages 只是这个投影的缓存,直接给它赋值不会改变后续请求,也不会改变持久化内容。要改历史,得往 SessionManager 追加条目再 refreshContext(),或者用 navigateTree()。
  • 数据最终在哪:会话 JSONL 文件是唯一事实来源;内存里的 messages、UI 渲染的 transcript 都是派生物。

system prompt 也进了会话。会话首次请求时持久化一条 system 消息,里面是 system prompt 的各个 section 和全部工具声明;之后无论是切换工具、/reload 重新加载资源,还是扩展在 before_agent_start 里加了一段说明,AgentSession 都会把“当前应有的 prompt sections”与“会话里回放出来的 sections”做 diff,只在有变化时追加一条补丁 system 消息(追加/替换/删除 section,toolsAdded / toolsRemoved)。回放所有 system 消息就得到当前状态。

为什么这样设计?两个原因:

  1. 保住 prefix cache。如果改了工具就重写 system prompt 开头,后面整段历史的缓存全部失效;改成在末尾追加补丁,前缀保持不变。
  2. 可回放、可分叉。prompt 和工具状态跟着会话分支走,fork 或回到旧节点时自然恢复当时的工具集,不需要额外记录。

例外是扩展在 before_agent_start 返回了完整的 systemPrompt:它需要那段文字出现在请求最前面,所以 Pi 只在请求投影时把 system 消息折叠成一个开头,不写进会话记录,run 结束即失效。

旧笔记里那条“工具在会话启动时基本固定”的结论在 1.x 仍成立,只是表述要更新:启动时扫描并注册工具、确定初始 active tools;之后每轮只是复用内存里的状态,不会重新扫描扩展目录。只有 setActiveTools()、refreshTools()、reload() 或扩展主动改动工具时才会变,而且变化以补丁消息的形式声明给模型,不会重写已有前缀。

一句话:会话树是事实来源,内存消息是投影缓存,system prompt 和工具是会话里可回放的 system 消息,变化只追加补丁。

内置编码工具

内置工具有 read、bash、edit、write、grep、find、ls(Windows 上另有 powershell)。默认只启用 read、bash、edit、write,其余要手动打开。选择方式:

  • --tools read,grep,find,ls:纯名字列表是白名单,整个替换默认集合(支持 * 通配);
  • --tools +grep,-write:只含 +name / -name 时是修饰默认集合,与 defaultTools 设置的语义一致;两种写法不能混用;
  • --exclude-tools:最后按名字或模式剔除;--no-builtin-tools 只关内置;--no-tools 全关。
  • 内置扩展还提供 codemode 和 tool_search 两个工具,不在默认工具集里,配置了 MCP 服务器时 codemode 会自动启用,见 MCP 与 Codemode。

几个值得在面试里讲的工具设计:

  • edit 是精确文本替换,不是 diff/patch。参数是 edits[],每个 oldText 必须在原文件中唯一匹配且互不重叠。模型输出“原文片段 → 新片段”比输出行号或统一 diff 稳定得多;唯一性约束让错误的编辑直接失败、返回给模型修正,而不是悄悄改错位置。
  • 输出截断有上限,并告诉模型怎么继续。read 默认最多 2000 行或 50KB,截断时附上 Use offset=N to continue;bash 保留输出尾部(错误通常在最后),并把完整输出写到临时文件、在结果里给出路径。目的都是防止一个工具结果撑爆上下文,同时不丢信息。
  • 同一文件的写操作串行化。并行工具调用时,edit / write 按文件真实路径排队,不同文件仍然并行。
  • 没有内置沙箱。工具以 Pi 进程的 OS 权限运行,需要隔离时由外部容器或扩展(tool_call 拦截)实现。

一次 session.prompt() 的完整链路

场景:用户在交互模式里输入“把 utils.ts 里的 parseDate 改成支持时区”,期间会话已经很长、网络还抖了一下。

1. 入口预处理(还没碰模型)。 prompt(text) 先看是不是 /xxx 扩展命令,是就直接执行并返回 handled。若正在压缩则拒绝。然后发 input 事件,扩展可以改写输入或直接吞掉。接着展开 /skill:name 和 prompt 模板。如果 agent 正在运行,调用方必须指明 steer(当前 assistant 回合及其工具调用结束后插入)或 followUp(agent 本来要停下时再处理,仍在同一个 run 里),否则直接报错而不是猜。最后检查模型和认证是否可用。

2. 准备 run。

  • 先拿最后一条 assistant 消息检查是否需要压缩(包括上次被中止的回复),会话太长就在发请求前压缩;
  • 发 before_agent_start:扩展可以追加自定义消息、修改本次 run 的 system prompt 选项、调 setActiveTools();
  • 构造用户消息(图片在这一步按目标模型的限制归一化),附上待注入的 nextTurn 消息和扩展消息;
  • 如果本次 run 的 prompt/工具与会话里回放出的状态不同,在最前面加一条补丁 system 消息。

3. 进入 agent loop。 agent.prompt(messages) 交给 agent-core 的双层循环:

while 还有工作:
    prepareRequest → 从 SessionManager 投影出 canonical 上下文
    streamFn → pi-ai → provider,流式产出 assistant 消息
    有 tool call → beforeToolCall(扩展 tool_call 可拦截) → 执行 → afterToolCall(扩展 tool_result 可改写)
    finishTurn → 扩展 turn_end 边界
    prepareNextTurn → 超阈值就先压缩,刷新 prompt/工具补丁
    处理 steering / follow-up 队列

模型读 utils.ts(read),用 edit 精确替换 parseDate,再用 bash 跑测试。注意压缩可以发生在 run 中间:每轮之间的 prepareNextTurn 会估算投影后的 token,超过阈值就在下一次 assistant 回复前压缩,避免长任务跑到一半溢出。

4. 事件与持久化。 底层每个事件都先经过 AgentSession:先发给扩展,再发给 session 订阅者(TUI 渲染、JSON 输出、RPC 转发都在这一层)。message_end 时把完整消息 appendMessage() 进 JSONL——流式 delta 只用于显示,落盘的是 message_end 的权威消息。成功的 assistant 回复会把重试计数清零。

5. run 结束后的自动恢复。 底层发出 agent_end 不代表结束。AgentSession 在一个循环里做 run 后处理:

  • 最后一条是可重试错误(比如 fetch failed、429、overloaded)→ 按 2s、4s、8s 退避,把失败回复用 context_edit 从投影中省略(原始记录仍保留)→ agent.continue();
  • 是上下文溢出 → 不走普通重试,先压缩再继续,只尝试一次;
  • 正常结束但超过阈值 → 压缩但不继续;
  • agent_end 处理器又排了消息 → 再跑一轮;
  • 最后给扩展一次 agent_before_settle 机会,可以要求继续。

都处理完后发 agent_settled,表示 Pi 不会再自动做任何事。prompt() 的 Promise 在这之后才 resolve,所以 SDK 调用方 await session.prompt() 拿到的是包含重试和压缩恢复之后的最终状态。错误与重试的细节见 错误处理与重试。

把上面压缩成面试版:

Agent.prompt()        = 跑一次 agent loop
AgentSession.prompt() = 输入预处理(命令/input/模板/队列)
                      + run 准备(压缩检查、before_agent_start、prompt 补丁)
                      + Agent.prompt()(每次请求从会话树投影上下文)
                      + 事件转发与 JSONL 持久化
                      + 自动恢复循环(重试、溢出压缩、队列)直到 agent_settled

常见追问

为什么不让 agent-core 直接管持久化和重试? agent-core 定位是通用 runtime,只负责一次 loop 的语义并在错误时干净地结束;重试策略、压缩时机、落盘格式都是产品决策,放在 harness 里,其他宿主可以用 agent-core 实现自己的策略。

agent_end 和 agent_settled 有什么区别? agent_end 是一次底层 loop 结束,之后可能还有重试、溢出恢复、排队消息;agent_settled 才表示整个会话级 run 不会再自动继续。写集成时等前者会过早收尾。

为什么 system prompt 变化要追加补丁而不是重建? 重建开头会让整段历史的 provider prompt cache 失效,补丁追加在末尾只影响新增部分;同时 prompt 状态随分支回放,fork 后自动正确。代价是会话里多了 system 消息,需要投影逻辑正确地折叠和压缩它们。

扩展跑在进程内有什么取舍? 好处是零 IPC、能直接注册工具/UI/provider、能同步拦截工具调用;代价是没有隔离,扩展崩溃或恶意代码与 Pi 同权限,所以才有项目信任:项目自带的扩展和设置必须先被信任才加载。

SDK 宿主想恢复外部历史怎么办? 不能给 agent.state.messages 赋值,要构造一个包含那些条目的 SessionManager(例如 SessionManager.inMemory(...))传进 createAgentSession(),因为会话树才是权威。

模块地图

源码入口(v1.5):packages/coding-agent/src/main.ts、packages/coding-agent/src/core/agent-session-services.ts、packages/coding-agent/src/core/sdk.ts、packages/coding-agent/src/core/agent-session-runtime.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/tools/

会话树与上下文压缩

TL;DR:Pi 把一次会话存成一个 append-only 的 JSONL 文件,每条 entry 用 id/parentId 指向前驱,整份文件是一棵树,当前 leaf 到根的那条路径就是“活动分支”。下一次模型请求的上下文不是“内存里的消息数组”,而是 SessionManager 每次从活动分支重新投影出来的(0.87 起它就是 provider 上下文的唯一来源)。回到旧节点重试只需移动 leaf 再追加,分支零拷贝,旧路径原样保留。上下文快满时,compaction 用一次额外的 LLM 调用把旧消息总结成一条 compaction entry、保留最近约 20K token 原文;摘要只在之后的请求里替代旧消息,原始 entry 仍在树里。触发点有两个:一轮工具执行完后超过阈值就先压缩再继续,provider 报上下文溢出就压缩后重试一次。

为什么会话要是一棵树

聊天机器人的会话是线性的,一问一答到底。coding agent 的工作流天然是非线性的:

  • agent 重构时改了 5 个文件,你发现方向错了,想回到改之前换种做法;
  • 一条 bash 命令结果不对,你想从那条命令之前重来;
  • 第 20 轮才发现第 5 轮的决策有问题,想跳回第 5 轮开一个新分支。

用线性列表存,要么做不到回溯,要么每次分叉都得复制整段历史。Pi 的做法是让每条 entry 带一个 parentId:分支就是同一父节点下的多个子节点,“回到过去”就是把 leaf 指针移到旧节点,之后追加的 entry 自然挂在它下面。文件只追加、从不改写旧行,所以分支是零拷贝的。

A: user "分析 auth 模块"
└── B: assistant
    ├── C: user "用策略模式重构 login"
    │   └── D: assistant ...         (旧分支,仍在文件里)
    └── [branch_summary: 试过策略模式,太重]
        └── E: user "用简单 if-else 重构"
            └── F: assistant         ← 当前 leaf,A→B→summary→E→F 是活动分支

运行形态上,这一切都在 Pi 进程内:SessionManager 是一个普通的类,持有内存里的 entry 列表、byId 索引和 leafId,负责追加、建树、导航和投影上下文;AgentSession 订阅 agent 事件,决定“什么时候存什么”。没有数据库,也没有常驻服务。

一句话:AgentSession 决定什么时候保存什么,SessionManager 决定以什么结构保存、以及之后如何恢复成模型上下文。

文件里存了什么

文件第一行是 header(type: "session",含版本号、会话 id、创建时的 cwd,fork/clone 出来的会话还有 parentSession 指回来源文件)。之后每行一条 entry,都有 id(通常是 8 位十六进制短 id,方便在 /tree 里显示)、parentId、timestamp。格式经历过 v1(纯线性)→ v2(加 id/parentId 成树)→ v3(统一 custom 角色名),旧文件加载时自动迁移。

entry 记录的是事件而不是状态:不维护一个“当前配置”对象,而是把每次变化(换模型、改 thinking level、压缩、命名)作为不可变事件追加,当前状态靠沿路径重放得出。这是 event sourcing 的思路,在 append-only 存储里也是最自然的做法。按“是否影响模型上下文”可以把 entry 分成几类:

类别entry 类型进入模型上下文?
对话message(system / user / assistant / toolResult 等)是
摘要compaction、branch_summary是,以摘要消息的形式
上下文编辑context_edit自身不产生消息,但会删除或替换某条旧消息在上下文里的内容
扩展custom_message(进上下文)、custom(扩展私有状态,不进)前者是,后者否
状态与元数据model_change、thinking_level_change、label(书签)、session_info(会话名)、usage(不属于 assistant 消息的用量,如缓存预热)否,只用于恢复设置、UI 和计费

有两点是 1.x 相对旧版本的重要变化,面试时值得点出:

system prompt 也进了会话文件。 第一次请求前,Pi 会追加一条 role: "system" 的消息,里面是按名字分段的 prompt(sections)和完整的工具声明;之后 prompt 或工具集有变化(比如 /reload 后 skill 列表变了),只追加一条“补丁”system 消息,只写变化的 section 和 toolsAdded/toolsRemoved。按顺序重放这些 system 消息就得到当前 prompt 和工具集,没有单独的“prompt 状态”。好处是会话可以精确复现“当时模型看到了什么”,导出和分享也能带上 prompt。

context_edit 让“改上下文”也变成追加。 它指向一条更早的 entry,replacement: null 表示从今后的模型上下文里删掉它,非空则只替换其内容。原始 entry 在 UI、导出、计费里都不变;编辑是分支相关的,导航到编辑之前的位置,原内容就又生效了。溢出恢复时 Pi 正是用它把失败的那次尝试从上下文里“抹掉”(见后文)。

一次会话的生命周期

下面沿着一次真实使用的顺序走一遍:在 /project 里启动 Pi,聊几轮,回到旧节点换种做法,聊到上下文快满,退出后再 pi --continue。

1. 启动:文件不会立刻出现

pi 启动时 SessionManager.create(cwd) 在内存里建好 header,并算出文件路径:

~/.pi/agent/sessions/--project--/<timestamp>_<session-id>.jsonl

目录按 cwd 分组(去掉开头的 /,把 / \ : 换成 -),所以 --continue 能直接找到“这个目录最近的会话”。位置可以用 --session-dir、PI_CODING_AGENT_SESSION_DIR 或 sessionDir 设置覆盖,CLI 参数优先。

此时文件还不存在。模型选择、thinking level、首条 system 消息这些“准备性” entry 只留在内存里,直到出现第一条 user 或 assistant 消息才一次性把内存中的全部 entry 写进新文件,之后每条 entry 只 appendFileSync 一行。这样“打开 Pi 又直接退出”不会留下空会话;而从 user 消息(不是第一条 assistant 回复)开始落盘,保证第一轮没跑完时你的 prompt 也不会丢。

2. 对话:在 message_end 时持久化

AgentSession 订阅底层 agent 的事件。流式过程中的 message_update 只用来刷新 UI,不逐 token 落盘;一条消息完整结束(message_end)时,才把它追加为 message 或 custom_message entry,leaf 前移。追加是“先改内存再写盘”,写盘失败会把内存回滚,避免出现“父节点不在文件里”的悬空 entry。

用户中途 /model 换模型,追加 model_change;/name 命名,追加 session_info。这些都只是新的一行。

3. 上下文从哪来:每次从活动分支重新投影

这是 1.x 最值得讲的设计点。0.87 之前,运行时上下文实际是 agent.state.messages 这份内存数组,SessionManager 更像旁路日志;0.87 起 SessionManager 是 provider 上下文的唯一来源:直接给 session.agent.state.messages 赋值不再改变后续请求,要改上下文就得往会话里追加 entry(或导航 leaf),再让 session 刷新。

投影过程分三步:

  1. 从 leaf 沿 parentId 回溯到根,得到活动分支的 entry 列表,顺带从路径上取出当前模型和 thinking level;
  2. 如果路径上有 compaction,只用最新那条:先放 compaction 本身,再放从 firstKeptEntryId 到 compaction 之间被保留的 entry,再放 compaction 之后的所有 entry;更早的 entry 不进上下文;
  3. 对每个目标应用最新的 context_edit(删掉或替换内容),再把 entry 转成消息:message 原样、compaction 变成“system 检查点 + 摘要消息”、branch_summary 变成摘要消息、custom_message 变成自定义消息,其余类型不产生消息。

所以模型看到的始终是:

[当前 system prompt + 工具] [压缩摘要] [保留的近期原文] [压缩后的新消息]

只有当前活动分支参与,其他分支一个 token 都不占。把“存储”(完整树)和“投影”(模型上下文)分开,是后面分支和压缩都不需要改写历史的前提。

4. 走回头路:/tree、/fork、/clone

聊到第 5 轮,你发现第 3 轮让它“用策略模式重构 login”是个错误决定。三种做法:

操作结果适用场景
/tree在同一个文件里移动 leaf相关的几种尝试应该放在一起
/fork从某条更早的 user 消息起,新建一个会话文件另一条路要变成独立的工作
/clone把当前活动分支复制成新会话想要一份当前状态的独立副本

用 /tree 选中第 3 轮那条 user 消息时,它的文本会回到编辑器里,leaf 移到它的父节点;你改成“用简单的 if-else 重构”再提交,新消息就成了同一父节点下的第二个孩子。选中 assistant 回复或其他 entry,则从那条之后继续,编辑器为空。整个过程没有复制任何旧 entry。

离开旧分支时 Pi 可以问你要不要总结被放弃的路径。如果要,它找到新旧位置的共同祖先,把从旧 leaf 回溯到祖先的那段 entry(在 token 预算内、优先最新的)交给 LLM 生成摘要,作为 branch_summary 挂在新位置。这样模型在新分支里知道“之前试过策略模式、为什么放弃”,又不用背着旧分支的全部消息。

/fork 和 /clone 会写一个新文件,header 的 parentSession 指向原文件,只复制选中的那条路径(不含其他分支)。它们也是“修剪”一个越长越大的会话文件的方式:原文件不动,新文件只保留一条路径。

5. 退出后恢复

pi --continue 打开当前目录最近的会话,--resume / /resume 打开选择器。加载时逐行解析 JSONL、必要时迁移版本、重建 byId 索引,文件里最后一条 entry 就是 leaf,然后按第 3 步重新投影上下文。会话会记住创建时的 cwd,--resume 会在会话自己的目录里运行(工具路径、项目配置、context 文件都按那里解析);--fork 则在当前目录新建会话。

--no-session 是纯内存会话,退出即消失。SDK 场景下可以用 SessionManager.inMemory(cwd, { id }, entries) 把外部存储(比如数据库里的 entry)恢复成一个内存会话,再交给 AgentSession 使用,这也是 0.87 之后恢复上下文的正规方式。

Compaction:把长对话装进有限窗口

要解决的问题

一个任务跑上几十轮,用户消息、assistant 回复、tool call、tool result(尤其是 read 和 bash 的输出)不断堆积,迟早超过模型的 context window。最简单的做法是截断最早的消息,但 coding agent 的上下文不是闲聊,第 5 轮读过的文件结构可能是第 50 轮决策的基础,暴力截断会丢掉关键记忆。

Pi 的方案是 compaction:用 LLM 把旧消息总结成一份结构化摘要,保留最近一段原文,摘要作为一条 compaction entry 追加到会话里。它放在 coding-agent(产品层)而不是 agent-core(runtime),因为它依赖会话树、设置、模型选择和扩展钩子,这些都是 harness 的概念。

一句话:Compaction 不修改历史事实,只改变“如何把完整会话投影成有限长度的模型上下文”。

什么时候触发

判定公式只有一行:

contextTokens > contextWindow - reserveTokens      (reserveTokens 默认 16384)

contextTokens 优先用最近一条 assistant 消息里 provider 报告的真实 usage,再加上它之后新增消息的估算(字符数 / 4);没有可用 usage 时(报错、全零用量)退回到纯估算。reserveTokens 只决定何时开始压缩,生成摘要时用的是模型自身的输出上限(必要时为了塞进窗口再收缩)。以 200K 窗口为例,上下文超过约 184K 就会触发。

自动触发有两个检查点,正好对应“预防”和“补救”:

  1. 阈值检查(一轮结束后)。一次 prompt 里 agent 可能连续跑多轮 LLM → tool → LLM,某个巨大的 tool result 可能让下一次请求直接溢出。所以在工具执行完、结果已追加、下一次 assistant 请求开始之前(prepareNextTurn 阶段),Pi 用规范化后的投影检查一次,超了就先压缩再继续;如果这批工具已经让这次运行结束且没有排队消息,就跳过这次检查。新用户 prompt 进来前也会检查。
  2. 溢出恢复(报错之后)。provider 返回上下文溢出错误,或者回答被过早截断(stopReason: "length"),Pi 会做一次“压缩后重试”:先追加 context_edit 把失败的那次尝试从上下文里删掉,再以 reason: "overflow" 压缩,成功后作为一次全新的运行重试。如果压缩失败或被取消,保留删除编辑、不重试;重试后仍溢出就报错,提示换更大窗口的模型。这样失败尝试仍在原始历史、导出和计费里,只是不再污染模型上下文。

此外可以随时手动 /compact [instructions],附加说明让摘要侧重某个主题。关掉自动压缩(compaction.enabled: false)不影响手动命令。

总结什么、保留什么

压缩前(活动分支):
  hdr | usr ass tool | usr ass tool tool ass tool
        └─ 要总结的 ─┘ └────── 保留的近期原文 ──────┘
                        ↑ firstKeptEntryId

压缩后追加一条 cmp,模型看到:
  system | summary | usr ass tool tool ass tool | 之后的新消息
  1. 找切点:从最新的消息往前累加 token 估算,直到达到 keepRecentTokens(默认 20000)。切点只能落在 user、assistant、bash 执行、自定义消息或分支摘要上,绝不落在 tool result 上,否则 tool call 和它的结果会被拆开,很多 provider 会直接拒绝这种请求。
  2. 处理“超大单轮”:正常情况下切在 user 消息边界上;如果最近一轮(从一条 user 消息到下一条之间)本身就超过预算,切点会落在这轮中间的某条 assistant 消息上。这时 Pi 生成两份摘要再合并:一份总结更早的历史,一份总结这一轮的前半段。
  3. 生成摘要:把待总结的消息序列化成纯文本([User]: ...、[Assistant tool calls]: read(path=...)、[Tool result]: ...),防止模型把它当作要续写的对话;thinking 块不带,tool result 截到 2000 字符。摘要是固定结构:Goal、Constraints & Preferences、Progress(Done / In Progress / Blocked)、Key Decisions、Next Steps、Critical Context,末尾附上读过和改过的文件列表。
  4. 增量更新:如果之前已经压缩过,上一份摘要作为 previous summary 一起交给模型,要求在其基础上更新而不是从头重写,避免“摘要的摘要”越压越失真。再次压缩的范围从上次的 firstKeptEntryId 开始,上次保留下来的原文这次也会被纳入总结。
  5. 文件操作累积:从 tool call 里提取读/改过的文件,并继承上一次(Pi 生成的)压缩里的文件列表,存进 details.readFiles / modifiedFiles。对话细节被压掉了,agent 仍然知道这次会话碰过哪些文件。
  6. 追加 entry 并重新投影:compaction entry 记录摘要、firstKeptEntryId、tokensBefore、生成摘要的 usage,以及压缩边界处完整的 system prompt 和工具检查点。之后的请求按“投影”那一节的规则重建上下文。

关键在于:摘要只在之后的请求里替代旧消息,原始 entry 一条不删。/tree 里仍能看到全部历史,导航到压缩点之前的位置,那段原文就又成了活动上下文。

按模型调预算

不同模型窗口差别很大,1M 窗口的模型用 16K 的 reserve 意味着要攒到将近 1M 才压缩,成本和延迟都不划算。compaction.modelOverrides 允许按精确的 provider/modelId 单独设置 reserveTokens 和 keepRecentTokens,例如给某个 1M 模型设 reserveTokens: 400000,它就会在 600K 时压缩。两个值各自按“模型覆盖 → 普通设置 → 内置默认”回退,enabled 仍是全局的;手动压缩、阈值检查、溢出恢复和扩展看到的设置都用这套解析结果,切换模型后的下一次检查立即生效。

扩展可以接管

session_before_compact 在自动压缩和 /compact 之前触发,拿到的是已经算好的 preparation(待总结消息、超大单轮的前半段、上一份摘要、文件操作、firstKeptEntryId、生效的设置)以及触发原因(manual / threshold / overflow)和是否会重试。handler 可以返回 { cancel: true } 取消,也可以自己调用别的(比如更便宜的)模型生成摘要后返回 compaction 对象,Pi 照常追加 entry,fromHook 标记为 true。“准备”和“执行”分离,使扩展不必重新实现切点算法就能换掉摘要策略。对应地,session_compact_failed 报告失败或中止,session_before_tree 可以在 /tree 导航时取消或提供自定义的分支摘要。

取舍

得到的:append-only 的 JSONL 崩溃时最多丢最后半行,前面的 entry 完好(解析时跳过坏行);纯文本、可读可修,不依赖 SQLite 之类的存储引擎;分支和导航只是“追加一条 parentId 指向目标的 entry”,零拷贝;压缩、上下文编辑也都是追加,历史永远可审计、可回退。

放弃的:没有随机访问和索引,打开会话要从头解析整个文件、重建 byId,查“所有改过 auth.ts 的 tool call”只能遍历;文件只增不减,压缩只压上下文、不压文件,想瘦身只能 /fork 或 /clone 出一条路径;同步追加写不考虑多进程同时写同一个文件。对单用户、本地、会话大小在 KB 到几 MB 的场景,这些代价都可以接受。Compaction 本身是有损的,早期细节可能丢失,生成摘要还要多花一次 LLM 调用。

常见追问

为什么不把上下文直接存成内存数组,每次 push? 内存数组和会话文件是两份“真相”,谁改了一份另一份就可能对不上(扩展直接改数组、压缩后重建、分支导航都会碰到)。让 SessionManager 成为唯一来源后,所有改变上下文的操作都必须表现为追加 entry,可复现、可回放,也能导出“模型当时到底看到了什么”。

压缩后旧消息还能找回来吗? 能。原始 entry 一直在文件里,/tree 导航到压缩点之前即可;导出和分享也包含它们。压缩只影响从活动分支投影出的模型上下文。

分支摘要和压缩摘要有什么区别? 压缩是纵向的:同一条分支太长,把前半段总结掉。分支摘要是横向的:离开一条分支时,把被放弃路径的经验带到新分支。两者用几乎相同的结构化格式和文件追踪,触发方式和挂载位置不同。

溢出恢复为什么只重试一次? 压缩后仍溢出,说明问题不在旧历史(比如单条输入本身就太大),继续重试只会浪费调用;此时报错让用户换大窗口模型或减少输入。

源码入口(v1.5):packages/coding-agent/src/core/session-manager.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/compaction/compaction.ts、packages/coding-agent/src/core/compaction/branch-summarization.ts、packages/coding-agent/docs/session-format.md、packages/coding-agent/docs/compaction.md

资源发现、项目信任与系统提示词

TL;DR:Pi 的“外部资源”有好几种:会执行代码的 extensions、给模型读的 skills、展开成用户输入的 prompt templates、只管界面的 themes,以及进入 system prompt 的 context files(AGENTS.md / CLAUDE.md)和 SYSTEM.md / APPEND_SYSTEM.md。它们来自全局目录、项目 .pi/、Pi 包和 CLI 参数四类来源,由 ResourceLoader 在 reload() 时统一发现、去重、缓存,同名冲突按“CLI > 项目 > 用户 > 包”先到先得。项目目录可能来自陌生仓库,所以 Pi 先只加载用户级和 CLI 扩展,在加载任何项目设置和项目资源之前解决项目信任,未信任就跳过它们。system prompt 不是写死的字符串,而是按名字分段(基础说明、工具、规则、文档、追加内容、项目上下文、skills 列表、cwd)每次运行前重建,与会话里已有的版本做 diff,只把变化的段追加成 system 消息。

它解决什么问题

Pi 的定位是“内核小、靠扩展长大”,大部分个性化都通过外部资源完成:团队在仓库里放 AGENTS.md 写约定,用户在 ~/.pi/agent/skills 放自己的 skill,装一个 Pi 包带进来一组扩展和模板,临时用 pi -e ./my-ext.ts 试一个扩展。如果每种资源各写一套“去哪找、谁覆盖谁、出错怎么办”,就会有四五套发现逻辑和冲突规则。

ResourceLoader 把这件事收口:给定 cwd,把所有来源、所有类型的资源装配成一份可查询的缓存,再交给 AgentSession(注册扩展、建 system prompt、展开模板)和 TUI(主题、命令补全、启动时的资源列表)使用。它在 Pi 进程内运行,不常驻扫描磁盘:启动时 reload() 一次,之后各方读缓存;用户 /reload 时整体重建一次。构造函数本身不做 I/O,调用方可以先注入 override 钩子(SDK 和测试常用),再决定何时加载。

各资源的去向并不相同,这张表是理解整个模块的钥匙:

资源本质最终影响谁受项目信任约束
ExtensionsTS/JS 模块,加载即执行代码工具、命令、事件处理、provider、UI项目内的是
SkillsSKILL.md 指令文档加配套文件system prompt 里的 skills 列表,按需被模型读取项目内的是
Prompt templates带参数的 Markdown 文本用户输入在进入 agent 前被展开项目内的是
Themes主题 JSON交互界面配色,不进模型上下文项目内的是
Context filesAGENTS.md / CLAUDE.md 等目录级规则system prompt 的 project_context 段否,始终加载
SYSTEM.md / APPEND_SYSTEM.md替换 / 追加 system prompt 的文本system prompt 的基础段 / 追加段项目内的是

一句话:extension 改系统的行为,skill 和 context file 改模型的行为,prompt template 改用户的输入,theme 只改界面。

资源从哪里来、谁覆盖谁

每类资源(extensions、skills、prompts、themes)都有四类来源:

  • 用户级:~/.pi/agent/ 下的约定目录(extensions/、skills/、prompts/、themes/),以及用户 settings.json 里显式列出的路径;skills 还额外支持 Agent Skills 标准的 ~/.agents/skills/。
  • 项目级:<cwd>/.pi/ 下的同名目录和项目 settings.json 里的路径;skills 还会从 cwd 向上逐级找 .agents/skills/,到仓库根为止。
  • Pi 包:settings 里 packages 声明的 npm / git / 本地包,包清单决定它贡献哪些资源(详见 Pi Packages 与包管理)。
  • CLI:-e/--extension、--skill 等临时路径,只对这一次运行有效。

路径的发现和启用由 package manager 统一解析(settings 里的数组支持 !glob 排除、+path / -path 精确开关),然后按优先级排序:CLI 临时路径最先,其次是项目的显式配置、项目的自动发现、用户的显式配置、用户的自动发现,再是包资源,内置扩展最后。加载时同名资源“先到先得”,后来者被丢弃并生成一条 collision 诊断(技能名、模板名、主题名都如此;同一文件经由符号链接出现两次会被静默去重)。所以项目里的同名 skill 会覆盖你全局的那个,而一个包里的同名 skill 覆盖不了你本地的。--no-extensions、--no-skills、--no-prompt-templates、--no-themes 可以关掉自动发现,只保留 CLI 显式给的。

单个资源坏掉不会阻塞启动:skill 的 frontmatter 不合法、主题 schema 不对、扩展加载抛错,都只记一条诊断,启动时在资源列表里展示,其余资源照常可用。

context files 和 system prompt 文件走的是另一套简单得多的规则,后面单独讲。

项目信任:在加载项目资源之前做决定

问题

你 git clone 了一个陌生仓库,cd 进去运行 pi。如果仓库里有 .pi/extensions/evil.ts,而 Pi 启动就加载它,那么在你说第一句话之前,任意代码已经以你的权限执行了。项目 settings 也可能配置包让 Pi 去安装,同样危险。

做法

Pi 在加载项目设置和项目资源之前先确定“这个目录是否可信”。只要从 cwd 发现了下面任意一种,就需要一个信任决定:.pi/settings.json、.pi/mcp.json、.pi/extensions|skills|prompts|themes、.pi/SYSTEM.md 或 .pi/APPEND_SYSTEM.md、当前目录或祖先目录里的项目 .agents/skills。一个空的 .pi 目录不算。

决定的来源按顺序:

  1. CLI 的 --approve / --no-approve,一次性覆盖;
  2. 用户级和 CLI 扩展处理 project_trust 事件,第一个回答 yes/no 的说了算,可以选择记住;
  3. ~/.pi/agent/trust.json 里为当前目录或最近的祖先目录保存的决定(/trust 写入这里);
  4. 全局设置 defaultProjectTrust:always / never / ask(默认)。ask 在交互模式下弹窗;print、JSON、RPC 模式没法弹窗,ask 和 never 都按不信任处理。

如果项目里根本没有需要信任的资源,就直接取已保存的决定,没有则视为信任,不打扰用户。

实现上这是 ResourceLoader.reload() 的一个“两遍加载”:第一遍强制把项目视为不可信,只加载用户级和 CLI 扩展(它们需要先存在,才能处理 project_trust 事件);拿到信任结果后写回 SettingsManager,按这个状态重新读取设置,再进行正式的全量加载。信任为假时,项目 settings、项目 MCP 配置、.pi 下的扩展/skills/模板/主题/系统提示词文件、项目配置的包都被跳过。cwd 在运行中切换时也会重新走这套流程。

边界

面试时要能说清楚项目信任不是什么:

  • 它不是沙箱。信任之后,工具调用照样以 Pi 进程的系统权限读写、执行;扩展在同一个进程里跑。真正的隔离要靠容器、虚拟机或单独的系统用户。
  • 它不覆盖 context files。AGENTS.md、CLAUDE.md 无论是否信任都会加载(除非用 --no-context-files 关掉),因为它们只是文本,不会执行;但文本仍可能是 prompt injection,应当当作不可信输入看待。
  • 它不是完全的启动边界。项目 settings 里的 sessionDir 在选择/创建会话时就被读取了,早于信任决定。

一句话:项目信任防的是“打开一个目录就静默执行它的代码”,不防模型被目录里的内容带偏,也不限制工具能做什么。

一次启动的完整顺序

把上面串起来,pi 在一个仓库里启动时:

读取全局设置 → 选择/创建会话(此时会读项目 sessionDir)
→ ResourceLoader.reload()
    第一遍:项目视为不可信,只加载用户级 + CLI 扩展
    解决项目信任(CLI 覆盖 → project_trust 扩展 → trust.json → defaultProjectTrust/弹窗)
    按信任状态重读设置,package manager 解析全部来源并排序
    加载扩展 → skills → prompt templates → themes(各自去重、记诊断)
    加载 context files,发现 SYSTEM.md / APPEND_SYSTEM.md
→ 扩展的 resources_discover 事件可再追加 skill / 模板 / 主题路径
→ AgentSession 用这些结果构建 system prompt、注册工具和命令

最后一步的“追加”走的是 extendResources():扩展在启动或 reload 时声明额外的资源路径,loader 在现有缓存上增量加载,不触发完整 reload。这样一个扩展可以顺带“带进来”一组 skill。

/reload 会重做整个 reload():清掉扩展缓存、重读所有资源,并重新发出 resources_discover。它是全量重建,而不是增量更新,对几十个资源的规模来说开销只有几十毫秒,换来的是不需要维护文件监听和失效逻辑。重建后 skills 列表或 context 变了,下一次请求的 system prompt 自然跟着变(见下文的 diff 机制)。

Context files:沿目录树向上叠加

AGENTS.md 解决的是“项目约定怎么告诉 agent”。Pi 的规则是:

  1. 先看全局 ~/.pi/agent/;
  2. 再从 cwd 一路向上走到文件系统根,每一级目录取一个文件,候选顺序是 AGENTS.override.md → AGENTS.md → CLAUDE.md(大小写两种写法都认),找到第一个就停;
  3. 最终顺序是“全局在前,祖先目录从远到近”,全部按顺序拼接,不互相覆盖。
~/.pi/agent/AGENTS.md            全局规则
/home/me/work/AGENTS.md          上层目录
/home/me/work/repo/AGENTS.md     仓库根
/home/me/work/repo/pkg/CLAUDE.md 当前目录(离得最近,排在最后)

拼接而不是覆盖,是因为目录级规则不应该消灭上层规则,而是逐层细化;离 cwd 最近的放在最后,在 prompt 里也最“新鲜”。AGENTS.override.md 提供了一个“只在这一级目录里压过同级 AGENTS.md”的口子。对嵌套在主仓库里的 git worktree,Pi 会识别出主仓库的同名文件与 worktree 自己的副本是同一份规则,只加载一次。

SYSTEM.md 与 APPEND_SYSTEM.md:替换与追加

  • SYSTEM.md(项目 .pi/ 优先,需要信任;否则 ~/.pi/agent/)替换 Pi 默认的基础说明。一旦替换,默认的工具清单、规则、文档指引这几段都不再生成,只保留你的文本,后面再接项目上下文、skills 和 cwd。用户自定义 system prompt 时通常想完全掌控内容,所以是替换语义。CLI 的 --system-prompt 同理。
  • APPEND_SYSTEM.md(同样项目优先)追加一段内容,不动默认部分。适合“默认的挺好,我只想多加几条要求”。CLI 对应 --append-system-prompt。

System prompt 是怎么拼出来的

分段结构

system prompt 由一组有名字的 section 依次组成:

section内容来源
preamble默认是一句角色说明:“你是运行在 pi 里的编码助手……”;有 SYSTEM.md 时就是它的全文内置 / SYSTEM.md
tools当前启用工具的一行说明,加“可能还有其他工具”各工具的 prompt snippet
rules去重后的行为规则:比如只有 bash 没有 grep/find 时提示用 bash 搜文件、各工具贡献的 guideline、扩展追加的 guideline,最后是“简洁”“清楚给出文件路径”工具与扩展
docsPi 自身文档和示例的本地路径,只在用户问到 harness 本身时去读内置
addendumAPPEND_SYSTEM.md 的内容用户 / 项目
project_context所有 context files,每个包在 <project_instructions path=...> 里AGENTS.md 等
skills<available_skills> 列表:每个 skill 的名字、描述、SKILL.md 路径,并告诉模型“任务匹配时用 read 读取”Skills
cwd当前工作目录运行时
自定义扩展添加的命名段扩展

除 preamble 外,每段都用同名 XML 标签包起来(<rules>...</rules>),方便模型和后续的“补丁”对上号。tools/rules/docs 三段只在没有自定义 prompt 时生成。工具的 JSON schema 不在 prompt 文本里,而是作为工具声明随请求发送;被扩展隐藏声明的工具也不会出现在工具清单和规则里,保证 prompt 与实际声明一致。skills 段只有在模型能读文件(有 read 或 bash 工具)时才加入,否则列出来也用不了。

注意 1.x 的默认 prompt 不含当前日期:日期每天变,会让 prompt 前缀的 provider 缓存隔天就失效,所以被去掉了。

何时重建、怎么生效

prompt 不是会话开始时拼一次就固定的。每次运行开始(before_agent_start 扩展处理之后)以及运行中每一轮之间,AgentSession 都会按当前状态重建一遍 section,然后和会话里已经记录的版本做 diff:

  • 第一次请求:追加一条完整的 system 消息(全部 section + 全部工具声明);
  • 之后有变化(/reload 后 skills 变了、扩展换了工具集):只追加一条补丁 system 消息,写变化的 section(删掉的写 null)和 toolsAdded/toolsRemoved;
  • 没变化:什么都不追加。

这些 system 消息存在会话文件里(见 会话树与上下文压缩),重放即可得到任意时刻的完整 prompt。这样设计有两个直接收益:prompt 前缀尽量稳定,provider 缓存不被无谓打破;会话能如实导出“模型当时看到的 prompt 和工具”。扩展若在 before_agent_start 里返回一整段 systemPrompt 强制替换,Pi 不会把它写进会话,而是在请求时把它投影为 provider 的首条 system prompt。

一句话:资源决定“有哪些段”,AgentSession 在每次运行和每轮之间重建这些段,只把差异以 system 消息的形式追加到会话里。

Prompt templates:在进入 agent 之前展开

prompt template 是 prompts/ 目录下的 Markdown 文件,文件名就是命令名:~/.pi/agent/prompts/review.md 对应 /review。frontmatter 里的 description 用于命令补全,argument-hint 提示参数。正文支持 $1、$@/$ARGUMENTS、${1:-默认值}、${@:N} 等 shell 风格的参数替换,参数按 shell 规则切分引号。

用户输入一行以 / 开头的文本时,AgentSession.prompt() 的处理顺序是:

  1. 先看是不是扩展注册的命令,是则直接执行,不进入对话;
  2. 触发扩展的 input 事件,扩展可以改写或拦截原始输入;
  3. /skill:name args 展开成 skill 全文(见 Skills);
  4. /name args 匹配 prompt template 就展开成正文;
  5. 展开后的文本才作为 user 消息进入 agent(运行中则排入 steering / follow-up 队列)。

所以模板是纯文本层面的复用,模型看到的只是一条普通的 user 消息,不知道它来自模板。

取舍

得到的:所有资源一套优先级、一套诊断,用户学会一次规则就能预测结果;单个资源失败不拖垮启动;资源带来源信息(哪个包、用户级还是项目级),TUI 能展示、冲突报告有意义;override 钩子和 --no-* 开关让 SDK 与测试能精确控制加载结果;项目信任把“打开目录”和“执行目录里的代码”拆开。

放弃的:资源类型差异很大(扩展要执行、skill 只读文件、主题要校验),统一入口不得不按最复杂的扩展来设计(例如异步的 reload);来源多、规则多,同名资源的最终归属需要理解完整排序才能预测;reload() 是全量重建,缓存的生命周期就是两次 reload 之间,外部修改在 reload 前不会被感知。

常见追问

为什么 context files 不受项目信任约束? 它们只是注入 prompt 的文本,不会执行代码;跳过它们会让大量只放了 AGENTS.md 的正常仓库体验变差。代价是目录里的文本可能做 prompt injection,所以文档明确要求把它当不可信输入。

为什么要两遍加载扩展? 信任决定本身可以交给扩展(比如公司统一的信任策略),而做决定的扩展只能来自用户级或 CLI,不能来自尚未被信任的项目。第一遍只加载这些“可信来源”的扩展,第二遍才加载项目的。

system prompt 每轮都变会不会打破缓存? 正因为怕这个,Pi 按 section 做 diff,没变化就不追加,变化也只追加补丁段;日期也从默认 prompt 里移除了。只有真正的变化(比如 reload 后 skills 变了)才会改变后续请求。

项目的 skill 和全局的同名,用哪个? 项目的。排序是 CLI > 项目 > 用户 > 包,同名先到先得,后者产生 collision 诊断。

源码入口(v1.5):packages/coding-agent/src/core/resource-loader.ts、packages/coding-agent/src/core/project-trust.ts、packages/coding-agent/src/core/trust-manager.ts、packages/coding-agent/src/core/system-prompt.ts、packages/coding-agent/src/core/package-manager.ts、packages/coding-agent/docs/security.md

Skills:不是代码,是按需加载的文档

TL;DR:Skill 是一个带 frontmatter 的 SKILL.md(可附带脚本、参考资料、模板),Pi 实现的是 Agent Skills 标准。它的核心是渐进披露:启动时只把每个 skill 的名字、描述和文件路径放进 system prompt,模型判断任务匹配时再用 read 工具读全文,用户也可以用 /skill:name 强制加载。所以 skill 再多也只占几行 prompt。它没有执行入口、没有 handler:extension 改系统的行为,skill 改模型的行为,prompt template 只是复用一段用户输入。

它解决什么问题

有些工作需要一大段专门的指导:怎么做 PDF 抽取、团队的发布流程、某个内部框架的坑。把这些都写进 AGENTS.md 或 system prompt,每次请求都要背着它们,大部分时候用不上,还会稀释模型对真正相关指令的注意力;写成扩展又太重,这些内容本质上只是“给模型看的说明书”。

Skill 的做法是把说明书放在磁盘上,只在 prompt 里留一个目录:

<available_skills>
  <skill>
    <name>pdf-tools</name>
    <description>Extract text and tables from PDF files. Use when reading, converting, or inspecting PDFs.</description>
    <location>/home/me/.pi/agent/skills/pdf-tools/SKILL.md</location>
  </skill>
</available_skills>

列表前面有几行固定说明:这些 skill 提供特定任务的指令,任务匹配描述时用 read 工具加载它的文件,文件里的相对路径按 skill 所在目录解析、调用工具时用绝对路径。最后这条容易被忽略却很重要,skill 常常引用同目录下的脚本和模板。这段内容是 system prompt 的 skills 段(整体结构见 资源发现、项目信任与系统提示词),只有模型手里有 read 或 bash 这类能读文件的工具时才会加入。

一句话:description 是路由信息,正文是按需加载的内容;prompt 里只放前者。

一次使用的过程

假设 ~/.pi/agent/skills/pdf-tools/ 里有 SKILL.md、scripts/extract.sh 和 references/formats.md,你对 Pi 说“把 report.pdf 里的表格转成 CSV”:

  1. 启动时,ResourceLoader 扫描 skill 目录,解析每个 SKILL.md 的 frontmatter,校验后得到 { name, description, filePath, baseDir },全文并不读进 prompt。
  2. 构建 prompt 时,可见的 skill 被格式化成上面的 <available_skills> 列表。
  3. 模型推理时,看到任务和 pdf-tools 的描述匹配,于是发起一次普通的 read 工具调用读取 SKILL.md。这和读任何源码文件没有区别,读到的内容作为 tool result 进入上下文。
  4. 模型按说明执行:先读 references/formats.md,再用 bash 跑 scripts/extract.sh,路径都按 skill 目录解析。

Pi 在这里几乎什么都没做:没有专门的“加载 skill”工具,没有运行时状态,“加载”就是模型自己调用 read。这既是它简单的地方,也是它的局限:模型可能没意识到该加载某个 skill。这时用户可以显式调用:

/skill:pdf-tools extract report.pdf

AgentSession 在用户输入进入 agent 之前把它展开:读出 SKILL.md,去掉 frontmatter,包成 <skill name="..." location="...">References are relative to <目录>. ...正文...</skill>,参数作为用户请求附在后面,整体成为一条 user 消息。展开发生在扩展的 input 事件之后、prompt template 展开之前。

放在哪里会被发现

位置范围说明
~/.pi/agent/skills/用户Pi 自己的约定目录
~/.agents/skills/用户Agent Skills 标准的通用位置,可与其他 agent 工具共享
<cwd>/.pi/skills/项目需要项目信任
.agents/skills/(cwd 及其祖先,到仓库根为止)项目需要项目信任
settings 里的 skills 数组、Pi 包、--skill <path>显式包用于分发,CLI 用于临时试用

Pi 不会去扫描 .claude/skills/;想复用这类目录,可以把路径加进 settings 的 skills 数组,或建符号链接(同一文件经符号链接出现两次会被去重)。

目录扫描的规则:一个目录里有 SKILL.md 就把它当作 skill 根,不再往下找;否则递归子目录(跳过隐藏目录和 node_modules,遵守 ignore 文件)。~/.pi/agent/skills、.pi/skills 和包的 skills/ 根目录下的独立 .md 文件也能作为 skill,但 .agents/skills 根下的会被忽略,可移植的形式始终是“目录 + SKILL.md”。

多个来源出现同名 skill 时“先到先得”,顺序是 CLI > 项目 > 用户 > 包,后来者被丢弃并产生 collision 警告。所以项目里的同名 skill 会覆盖你的全局 skill。项目 skill 可以指示模型运行脚本、修改文件,这也是它被纳入项目信任的原因。

Frontmatter 与校验

---
name: pdf-tools
description: Extract text and tables from PDF files. Use when reading, converting, or inspecting PDFs.
disable-model-invocation: false
---

# PDF tools

Read `references/formats.md` before converting a document. Run scripts relative to this skill directory.
  • name:命令名和显示名。小写字母、数字和连字符,不能以连字符开头或结尾、不能连续连字符,最多 64 字符。省略时用父目录名。标准建议与目录名一致,Pi 不强制。
  • description:唯一的路由依据,最多 1024 字符。要同时写清“做什么”和“什么时候用”,“Helps with PDFs” 这种描述几乎无法帮助模型决策。
  • disable-model-invocation: true:不进入 <available_skills>,模型看不到,只能通过 /skill:name 手动触发。适合只在特定场合才有意义或带敏感操作的 skill。
  • 标准里还有 license、compatibility、metadata、allowed-tools(实验性)等字段。

校验是宽松的:名字不合规、描述过长等大多只产生警告,skill 照常加载;只有 SKILL.md 解析失败或缺少 description 时才不加载。这和 ResourceLoader 整体“单个资源出错不阻塞启动”的原则一致。enableSkillCommands 设置控制 /skill:name 是否出现在命令补全里,手动输入始终有效;编辑 skill 后用 /reload 生效。

Skill、Prompt template、Extension 怎么选

Prompt templateSkillExtension
本质一段可带参数的用户输入给模型读的指令文档和配套文件在 Pi 进程里执行的 TS 模块
谁触发用户输入 /name模型按描述自行加载,或用户 /skill:name事件、命令、工具调用
进入上下文的方式展开成一条 user 消息prompt 里只有目录,全文按需作为 tool result 进入不直接进入,通过注册的工具、消息、prompt 段间接影响
能做什么复用措辞教模型一套流程,可附脚本让它通过 bash 执行新工具、拦截与改写、UI、provider、权限控制
典型例子/review:审查暂存的改动pdf-tools、发布流程、TDD 工作流权限确认、子 agent、MCP 接入

经验法则:只是想少打字,用 prompt template;需要“比一段 prompt 多、但不需要新的执行能力”的专门知识,用 skill;需要改变 Pi 本身的行为或接入新能力,用 扩展。

取舍

得到的:prompt 体积与 skill 数量基本无关;格式就是 Markdown 加标准 frontmatter,能跨 agent 工具复用;没有运行时、没有 API,写一个 skill 的门槛几乎为零。

放弃的:是否加载完全取决于模型对 description 的判断,描述写得不好就会漏用或误用;加载后的全文占用上下文,且和普通 tool result 一样可能被后续 compaction 总结掉;skill 里的脚本靠模型用 bash 执行,没有额外的隔离,安全性等同于让模型直接跑命令。

常见追问

为什么不在启动时把所有 skill 全文放进 prompt? 大多数任务只用到其中一两个,全文注入会让每次请求都背着大量无关指令,既费 token 又分散模型注意力;渐进披露把成本推迟到真正需要的时候。

为什么不提供一个专门的 load_skill 工具? 读 skill 和读普通文件是同一件事,已有的 read 工具足够,skill 里的相对资源也要靠 read/bash 访问。少一个工具,prompt 和协议都更简单,也更符合 Pi 的极简内核。

/skill:name 和模型自己 read 有什么区别? 结果相似,都是把 SKILL.md 正文放进上下文;区别在于前者是用户确定性地注入(作为 user 消息,附带目录说明和参数),后者依赖模型判断、以 tool result 形式进入。

源码入口(v1.5):packages/coding-agent/src/core/skills.ts、packages/coding-agent/src/core/system-prompt.ts、packages/coding-agent/src/core/agent-session.ts(_expandSkillCommand)、packages/coding-agent/src/core/package-manager.ts、packages/coding-agent/docs/skills.md

扩展系统:生命周期事件与 Control Hook

TL;DR:Pi 的扩展(Extension)是加载进同一进程的 TypeScript 模块,通过 pi.on() 和 pi.registerXxx() 给产品加工具、命令、Provider、UI 和策略。扩展点分两类:生命周期 Event 自下而上报告"发生了什么",只能观察;Control Hook 在明确的控制点自上而下注入"接下来怎么做",返回值会改变主流程。agent-core 只暴露通用的 runtime hook,完全不知道扩展的存在;AgentSession 负责把这些 hook 桥接到 ExtensionRunner;ExtensionRunner 对外统一成 pi.on(name, handler),内部再按事件选择不同的分发策略:通知、链式转换、gate(任一阻断即停)、fail-closed。统一的是注册方式,不是运行语义。

扩展是什么,跑在哪里

Pi 的核心刻意做得很小:调模型、跑循环、管会话。权限确认、Git checkpoint、自定义压缩、子 agent、远程执行、甚至 MCP 支持,都不写进核心,而是通过扩展提供。所以扩展系统要回答的问题是:怎样在不改核心代码的前提下,让第三方既能加能力,又能干预流程?

一个扩展就是一个默认导出工厂函数的 TypeScript 模块:

export default function (pi: ExtensionAPI) {
  pi.on("tool_call", async (event, ctx) => {
    if (isToolCallEventType("bash", event) && event.input.command.includes("rm -rf")) {
      if (!(await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"))) return { block: true, reason: "Blocked by user" };
    }
  });
  pi.registerTool({ name: "greet", /* ... */ });
  pi.registerCommand("hello", { /* ... */ });
}

运行形态要讲清楚:扩展不是独立进程,也没有 RPC 协议。Pi 用 jiti 在运行时直接加载 .ts(无需预编译),并把 @earendil-works/pi-coding-agent、pi-ai、pi-agent-core、pi-tui、typebox 这些 import 映射到 Pi 自己的那份模块,让扩展和宿主共享同一套类与注册表。扩展的 handler 就是普通的进程内 async 函数,返回值是类型化的对象(如 { block: true, reason }),而不是像 Claude Code 的外部 hook 那样靠 stdout JSON 加退出码通信。

扩展的来源有几种:~/.pi/agent/extensions/(全局)、.pi/extensions/(项目,信任后才加载)、settings 里的 extensions 路径、Pi Package(见 Pi Packages 与包管理),以及 pi -e ./ext.ts 临时加载。自动发现位置里的扩展可以用 /reload 热重载。

工厂函数里能做的事分两类:

  • 注册:pi.on() 订阅事件;registerTool / registerCommand / registerShortcut / registerFlag 增加交互入口;registerProvider / registerVirtualModel / registerMcpServer 接入模型和外部工具;registerMessageRenderer / registerToolRenderer / registerEntryRenderer 等定制渲染。
  • 动作:sendMessage、sendUserMessage、appendEntry、setModel、setActiveTools 等改变会话状态的调用。

加载分两阶段。工厂执行时核心还没就绪,所以 loader 先给扩展一个 runtime,其中动作方法全是"调用即抛错"的占位;注册方法可以正常用,因为它们只是往扩展对象的表里加条目。等 AgentSession 建好,ExtensionRunner.bindCore() 把真实实现换进去,并冲刷工厂里排队的 registerProvider。用抛错占位而不是静默 no-op,是为了让"在 setup 阶段发消息"这种误用立刻暴露。工厂可以是 async 的,Pi 会等它完成再继续启动,因此适合做"启动时拉取远端模型列表再注册 Provider"这类一次性工作;但不应在工厂里起后台进程、socket、定时器,这些应推迟到 session_start,并在 session_shutdown 里清理。

一个值得在面试中提的设计演进:1.5 里 MCP、codemode、tool-search、llama.cpp 都是内置扩展,和第三方扩展走同一套 ExtensionAPI。其中 codemode、tool-search、mcp 标记为可替换:如果第三方扩展注册了同名工具或 /mcp 命令,就由它接管,内置版本让位。这说明扩展 API 的表达力已经足够承载核心功能(详见 MCP 与 Codemode)。

一句话:扩展是进程内、类型化、两阶段初始化的 TS 模块;Pi 把几乎所有产品能力都外置成扩展,连 MCP 都是。

为什么同时需要 Event 和 Hook

先看最容易产生的误解:pi.on("agent_start")、pi.on("tool_call")、pi.on("tool_result") 写法一模一样,那它们是同一种东西吗?注册形式相同,运行时语义不同。

Agent Loop 在运行中同时有两种需求。第一种是向外报告状态:Agent 开始了、模型输出了一个增量、工具开始执行了、Turn 结束了。TUI 渲染、日志、Tracing、Token 统计、会话持久化都靠它。这就是生命周期事件(Lifecycle Event):方向是 Runtime → Harness → Extension/UI,Agent Loop 只管发布,不关心谁在听,也不读返回值。

第二种是让上层影响流程:这次工具调用允许吗?参数要不要改?结果要不要脱敏?下一次请求该看到哪些消息?这个 Turn 之后要不要再跑一轮?这些问题通知解决不了,因为 Agent Loop 必须停下来等答案,并根据答案走不同分支。这就是 Control Hook:先由上层安装到 Runtime,运行时 Runtime 反向调用它,读取返回值再决定后续行为。

Lifecycle Event:Runtime ──────────────────────────→ Harness / Extension(只读)
Control Hook:   Runtime ─→ Harness / Extension ─→ 决策 ─→ Runtime

要分清两个维度:是否 await 是时间顺序语义,是否读取返回值 才是控制语义。agent_start 的 handler 也会被等待(保证 Tracing 初始化完成后才开始第一个 Turn,这叫 barrier),但它返回 { block: true } 毫无作用,因为分发代码根本不看返回值。反过来,工具执行前如果只是"发一个通知",扩展还在判断命令是否危险时,工具可能已经开始执行了,这就是 tool_call 必须是 Control Hook 的原因。

一句话:Event 传递已经发生的事实,Hook 位于控制点并改变后续执行;await 只保证顺序,读返回值才构成控制。

三层结构:runtime hook → AgentSession 桥接 → ExtensionRunner

第一层:agent-core 只提供通用 runtime hook

pi-agent-core 是可独立使用的 Agent Runtime(见 pi-agent-core),它不知道 pi.on()、ExtensionRunner、AGENTS.md 或会话 JSONL。它只暴露两样东西:一条 AgentEvent 事件流(agent.subscribe()),和一组可选回调。1.5 中这组回调是:

runtime hook触发时机作用
transformContext每次请求前,convertToLlm 之前改写本次请求看到的消息(不改历史)
convertToLlm每次请求前把 AgentMessage 转成 provider 消息
prepareRequest每次请求前(含第一次)替换本次及后续请求的 context / model / thinking
beforeToolCall参数校验后、执行前返回 { block, reason, terminate } 阻止执行
afterToolCall执行后、tool_execution_end 前部分覆盖 content / details / isError / usage
finishTurn一个 Turn 的消息都已发出、turn_end 前返回 end 结束 run,或 continue 保证再请求一次
prepareNextTurn确定继续后、下一 Turn 开始前刷新 context / model / thinking(压缩就挂在这里)
getSteeringMessages / getFollowUpMessagesTurn 后 / 即将停止时拉取排队的 steering 与 follow-up 消息

另外,provider 级的 onPayload、onResponse、transformHeaders 作为流式调用选项传给 pi-ai。版本差异值得一提:早期的 shouldStopAfterTurn 在 0.87 被移除,改由 finishTurn 统一表达"结束 / 继续 / 按默认调度",并且它也能看到出错和中止的响应。

依赖方向因此保持单向:pi-coding-agent → pi-agent-core → pi-ai。底层只定义抽象回调,上层提供实现,绝不会出现 agent-core 反向 import ExtensionRunner。

第二层:AgentSession 是桥

AgentSession(以及创建它的 sdk.ts 装配代码)做两件方向相反的事。一是订阅 AgentEvent,在更新会话状态、持久化、通知 TUI/RPC 之后,把生命周期事件转发给 ExtensionRunner;二是把 ExtensionRunner 的控制入口安装成 Agent 的 runtime hook:

agent.beforeToolCall  = (c) => runner.emitToolCall({ toolName, toolCallId, input: c.args });
agent.afterToolCall   = (c) => runner.emitToolResult({ ...c.result });
agent.transformContext = (msgs) => runner.emitContext(msgs);         // context + context_with_system
agent.finishTurn      = (turn) => runner.emitBoundary(turn_end ...);  // actionable turn_end
streamOptions.onPayload = (p) => runner.emitBeforeProviderRequest(p);

这些回调在执行时才读取当前的 runner,所以 /reload 换了一个新 runner 也不必重装 hook。还有一部分扩展点根本不进 Agent Loop,由 AgentSession 自己在对应位置调用:input、before_agent_start、session_before_*、agent_before_settle、resources_discover;user_bash 则由交互模式和 RPC 模式在执行 ! 命令前调用。

第三层:ExtensionRunner 与统一的 pi.on()

注册阶段所有事件都一样:pi.on(name, handler) 只是把 handler 按事件名存进当前扩展的表里,并返回一个取消订阅函数。区别全部发生在触发阶段:ExtensionRunner 为不同事件实现了不同的分发方法(emit、emitToolCall、emitToolResult、emitContext、emitInput、emitBoundary、emitUserBash……),它们在"读不读返回值、多个返回值怎么合并、出错怎么办"上各不相同:

分发策略代表事件返回值处理handler 抛错
通知agent_start、turn_start、message_update、tool_execution_*、agent_settled、model_select忽略记录错误,继续
原地修改tool_call.input、before_provider_headers.headers、before_agent_start.systemPromptOptions后面的 handler 看到前面的修改视事件而定
链式转换(middleware)input、context、context_with_system、message_end、tool_result、before_provider_request前一个的输出是后一个的输入记录错误,跳过该 handler
Gate(任一阻断即停)tool_call、session_before_*、input 的 handled第一个 block / cancel 立即返回tool_call 出错即阻断工具(fail-safe)
第一个明确决定者胜project_trust、user_bash第一个 yes/no 或有效结果生效user_bash 出错直接中止命令(fail-closed)
收集合并resources_discover合并所有扩展贡献的路径记录错误
可行动边界turn_end、agent_before_settle每个 handler 看到之前的提案并可替换报错后其他 handler 照常运行
最后覆盖者胜cache_warming_decision最后一个给出 action 的生效记录错误

顺序规则是确定的:先按扩展加载顺序,再按扩展内注册顺序。分发开始时会对 handler 列表做快照,分发中途增删 handler 不影响本次。

错误策略的不对称是有意为之。普通 handler 出错只记录,Agent 照常运行,避免一个写坏的日志扩展拖垮整个会话;但安全相关的 gate 必须 fail-closed:tool_call 的 handler 抛错时工具不执行,user_bash 的 handler 抛错或返回无效结果时命令直接中止,不会回退到本地执行。

一句话:agent-core 提供通用回调,AgentSession 双向桥接,ExtensionRunner 把统一的 pi.on() 分发成通知、转换、gate、fail-closed 等不同语义。

一次 session.prompt():每个扩展点在哪里触发

用户在交互模式输入一句话并回车,下面是按发生顺序的完整链路(括号里是对应的 runtime hook):

用户输入
  │
  ├─ /cmd 是扩展命令?→ 直接执行命令 handler,input 事件不触发
  ├─ input                       拦截:continue / transform / handled
  ├─ 展开 /skill:xxx 与 /template
  ├─ 发请求前的压缩检查
  ├─ before_agent_start          注入消息、修改 system prompt 选项
  ▼
Agent Loop(一次 run)
  ├─ agent_start
  ├─ turn_start
  │   ├─ context                 (transformContext) 只看对话消息
  │   ├─ context_with_system     (transformContext) 看完整 transcript
  │   ├─ before_provider_headers (transformHeaders)
  │   ├─ before_provider_request (onPayload) 可替换 provider payload
  │   ├─ after_provider_response (onResponse) 状态码与响应头
  │   ├─ provider_stream_event / message_start / message_update... / message_end
  │   └─ 对每个工具调用:
  │       tool_execution_start → tool_call (beforeToolCall) → 执行
  │       → tool_execution_update... → tool_result (afterToolCall) → tool_execution_end
  ├─ turn_end                    (finishTurn) 可追加条目、请求继续
  ├─ 继续?→ 阈值压缩 (prepareNextTurn) → 下一个 turn_start
  └─ agent_end
  ▼
重试 / 溢出压缩重试 / 队列中的 follow-up?→ 回到 agent_start
  ├─ agent_before_settle         最后一个可行动边界,可再请求一次继续
  └─ agent_settled               真正空闲,只通知

几个细节决定了这条链路的正确理解:

  • 工具链的顺序在 1.5 中是先 tool_execution_start 再 tool_call。也就是说"开始执行"事件代表这次调用进入了执行流程,而 tool_call 才是真正的放行或阻断点。被阻断、参数校验失败、未知工具的调用会跳过执行和 tool_result,但仍然会收到带错误结果的 tool_execution_end。所以想观察"每个最终确定的调用",应该监听 tool_execution_end。
  • 并行工具模式(默认)下,同一条 assistant 消息里的多个工具调用先按顺序做预检(tool_call 逐个执行),然后并发执行;tool_execution_end 按完成顺序发出,最终的 toolResult 消息仍按原顺序写入。tool_call 运行前,Pi 会等之前的事件在 AgentSession 里处理完,所以 handler 读到的 ctx.sessionManager 已经包含当前这条 assistant 消息。
  • agent_end 不代表空闲。之后可能还有自动重试、压缩后重试或 follow-up,会再次进入 agent_start。要知道"Pi 不会再自己跑了",用 agent_settled。
  • 扩展通过 pi.sendMessage(..., { triggerTurn: true }) 在空闲时触发的 run,从 1.2 起也会经过 before_agent_start,和用户 prompt 走同一套准备流程。事件里的 messageType 区分 "user"(有 prompt)与 "custom"(有起始 message),只想处理用户输入的 handler 要自己判断。
  • tool_call 的 terminate: true 不是全局 break:只有同一批次所有最终结果都要求终止时,Agent 才会提前停。

一句话:Event 和 Hook 不是两套系统,而是同一条执行链上分别负责"状态输出"和"策略输入"的两类节点。

重要扩展点逐个说明

project_trust:项目里有 .pi/settings.json、.pi/extensions 等需要信任的资源时,Pi 在加载它们之前触发。只有用户级和 -e 扩展参与,项目扩展此时还没加载,否则就成了"让被检查者决定是否信任自己"。第一个返回 yes 或 no 的扩展拥有决定权,可以 remember: true 写入 trust.json;都返回 undecided 时走内置流程。详见 资源发现、项目信任与系统提示词。

resources_discover:在 session_start 之后触发,扩展返回额外的 skillPaths / promptPaths / themePaths,所有扩展的贡献合并。公司内部可以用它统一挂载共享的 skills 目录。

input:在扩展命令检查之后、skill/template 展开之前,拿到原始输入。返回 continue(不变)、transform(改写文本)或 handled(扩展自己处理完,不进 Agent Loop)。例如把 ?quick xxx 改写成"请简短回答:xxx"。事件里的 source 区分键盘输入、RPC 和扩展发来的消息。

before_agent_start:run 开始前,可以注入一条持久化的自定义消息,或修改 system prompt。1.5 推荐修改结构化的 systemPromptOptions(sections、selectedTools、promptGuidelines),Pi 会对比新旧 prompt 分段,只追加一条修补变化部分的系统消息,支持中途系统消息的模型可以保住缓存前缀;直接返回整段 systemPrompt 则会替换整个 prompt,变化时就是一次缓存未命中。

context 与 context_with_system:每次请求前触发,都是改写本次请求的视图,不改会话历史。context 拿到不含系统消息的对话深拷贝,返回修改后的列表;如果列表变了,Pi 会把当前 prompt 分段和工具声明重新放到最前面,所以过滤或截断不会误删 prompt 和工具。context_with_system(0.87 新增)在所有 context 之后运行,拿到含系统消息的完整 transcript,返回值原样发送,适合需要精确控制系统消息的场景,规则是首条必须保留系统消息。

before_provider_request 及其他 provider hook:context 改的是 Pi 的统一消息结构,跨 provider 通用;before_provider_request 改的是已经序列化好的 provider 专属请求体(OpenAI / Anthropic 各自的格式),和具体 API 协议耦合,主要用于调试序列化和缓存行为。before_provider_headers 原地改请求头,after_provider_response 在消费流之前看状态码和响应头(比如观察 429),provider_stream_event 看每个原始流事件。

tool_call 与 tool_result:前者是 Guard,后者是 Middleware。tool_call 可以原地修改 event.input(修改后不再重新校验,后续 handler 能看到),返回 { block: true, reason } 阻断;后者每个 handler 看到前一个处理后的结果,可以只返回部分字段(content、details、isError、usage),未返回的字段保持不变。一个细节:替换 content 而不返回 structuredContent 时,结构化内容会被丢弃,因为它可能已经和新内容对不上。

message_end:底层来源是生命周期事件,但 ExtensionRunner 给了它转换语义:handler 可以返回 { message } 替换最终消息(必须保持同一 role),比如修正 usage 中的费用。这说明分类不能只看事件从哪来,还要看 Runner 是否读取返回值。

turn_end 与 agent_before_settle:0.87 起它们从纯通知变成"可行动边界"。turn_end 在 assistant 和工具结果消息都已持久化后运行,handler 可以提议追加结构化条目(custom、custom_message、context_edit、compaction),并请求 continue: true,保证再发一次请求。每个 handler 都能看到前面 handler 的提案,所有提案在全部 handler 结束后统一校验、按顺序追加。agent_before_settle 是整个 run 收尾前最后一次这样的机会,比如"回复前提醒模型再审一遍 diff"。无条件 continue: true 会造成死循环,必须自己加条件。

session_before_*:session_before_switch(/new、/resume)、session_before_fork(/fork、/clone)、session_before_compact、session_before_tree。它们由 AgentSession 自己处理,不进 LLM/工具循环,返回 { cancel: true } 取消操作。session_before_compact 还可以直接返回自定义的压缩摘要,接管压缩(压缩本身见 会话树与上下文压缩)。会话替换成功后,旧扩展实例收到 session_shutdown,新实例重新加载后收到 session_start,所以扩展的内存状态必须能从会话条目中重建。

user_bash:用户在输入框敲 !cmd 时触发。返回 { operations } 把命令转到另一个后端执行(比如 SSH),返回 { result } 直接给出结果,返回 undefined 交给下一个 handler,最后回落到本地执行。它是 fail-closed 的:出错或返回无效对象时命令中止,后续 handler 不再执行,也不会回落到本地执行。原因很直接:一个"把命令转到远程沙箱"的扩展出错时,悄悄在本机执行才是最危险的结果。

容易混淆的几对

tool_call 与 tool_execution_start:后者是生命周期事件,报告"这次调用进入执行流程";前者是 Control Hook,决定"能不能真的执行"。1.5 的顺序是 tool_execution_start 先于 tool_call,所以不能把 tool_execution_start 当作"已经放行"的信号。

tool_result 与 tool_execution_end:前者改结果,后者报告最终结果已确定。顺序是执行 → tool_result → tool_execution_end,后者看到的是被 middleware 改过的结果,并带有 durationMs。

before_agent_start 与 agent_start:前者在 AgentSession 层、agent.prompt() 之前,能改 system prompt 和注入消息;后者在 agent-core 层,Loop 已经开始,只用于通知。

context 与压缩:context 只改本次请求的消息视图,临时、不持久化;压缩会生成摘要、追加 compaction 条目,改变之后每次重建上下文的方式,是会话级的持久效果。

agent_end 与 agent_settled:前者是一次底层 run 结束,后面可能还有重试或 follow-up;后者才是"不会再自动运行"的最终信号。

为什么不只用 Event,也不只用 Hook

只用 Event 不行。普通事件分发器可能不等待 handler、忽略返回值、不知道多个返回值如何合并,也没有明确的 block 协议。对安全控制来说这就是竞态:扩展还在判断命令是否危险,工具已经开始执行了。阻断工具、改写结果、转换上下文、取消会话切换、决定是否继续下一轮,这些都需要同步的控制屏障。

只用 Hook 也不行。如果 message_update、tool_execution_update 这种高频节点也做成 Control Hook,每个节点都要定义:能返回什么、多个返回值怎么合并、冲突时谁优先、失败是否中断、改了状态如何校验。扩展 A 返回 block、扩展 B 返回替换后的 context、扩展 C 要求重试,Runtime 就得裁决优先级和回滚。结果是返回协议膨胀、扩展之间更容易冲突、流式性能下降、Agent 状态更难保持一致。

理论上当然可以把所有东西都叫 Hook,再给每个 Hook 配一个 reducer(忽略、first-block-wins、pipeline、merge……)。但统一命名并没有消除语义差异,只是把差异挪进了每个 Hook 的分发策略里。Pi 的选择是:只有确实需要改变后续行为的位置才定义 Control Hook,其余保持为通知;对外用统一的 pi.on() 降低学习成本,再由 TypeScript 按事件名推导各自的 handler 返回类型,所以统一 API 并不意味着统一类型。

设计 Hook 系统的检查清单

自己设计一个 Agent 扩展系统时,只定义 on(name, handler) 远远不够,每个扩展点都要明确下面这些契约。括号里是 Pi 的做法:

  1. 触发时机:操作前还是后,持久化前还是后?(turn_end 在消息持久化之后;tool_result 在写入上下文之前)
  2. 是否阻塞:主流程是否 await,多个 handler 串行还是并行?(全部串行 await;ui_prompt_* 例外,是 best-effort 不等待的)
  3. 返回值协议:忽略、block、替换、部分 patch,还是请求继续?(每个事件有自己的结果类型)
  4. 多 handler 聚合:ignore、first-block-wins、pipeline、merge、last-wins?(见上面的分发策略表)
  5. 错误策略:出错是跳过、继续,还是阻断?(普通事件记录后继续;安全 gate fail-closed)
  6. 顺序规则:先脱敏再截断和先截断再脱敏结果不同。(加载顺序 + 注册顺序,分发前快照)
  7. 状态一致性:改写后 tool call 与 tool result 的 ID 仍要对应,消息顺序对 provider 合法,持久化内容与 Agent 状态一致。(message_end 替换必须同 role;context 改动后 Pi 自动恢复 prompt 和工具声明;边界提案先整体校验再追加)

第 7 条也解释了为什么不能在每个生命周期事件上都开放任意修改能力。

扩展能注册什么:工具、Provider、虚拟模型

工具。pi.registerTool() 注册的工具和内置工具在 Agent Loop 的工具分派中一视同仁。除了 schema 和 execute,工具还可以带 promptSnippet 和 promptGuidelines,直接把"说明书"注入 system prompt;带 renderCall / renderResult 定制 TUI 显示。1.5 的工具还有 exposure,控制模型怎样接触到它:direct(默认,直接声明给模型)、codemode(只能在 codemode 脚本里调用)、deferred(需要通过 tool_search 激活)等。工具也可以用 ctx.executeTool() 调用别的工具,嵌套调用同样经过参数校验、tool_call 和 tool_result,事件带 parentToolCallId。这一点很关键:权限扩展不会被"工具调用工具"绕过。注册同名工具还可以覆盖内置的 read、bash 等工具。

状态。扩展的状态建议放在工具结果的 details 里,并在 session_start 和 session_tree 时从当前分支(getBranch())重建,这样 fork 和 /tree 切换后状态自然正确。不想发给模型的持久数据用 pi.appendEntry()。往会话里注入内容有三档:sendMessage 发自定义消息(进入 LLM 上下文,可选择是否触发回复),sendUserMessage 模拟用户输入,appendEntry 只持久化。

Provider 与模型。pi.registerProvider() 可以接入新的模型服务(自定义 baseUrl、API、OAuth),也可以覆盖已有 Provider 的配置;pi.registerVirtualModel() 注册一个"虚拟模型",用户选的是它,路由器在每次请求时选择真实的物理模型和 thinking level,比如简单问题走小模型、难题走大模型。会话记录里,选择(model_change 条目)和实际分派(每条 assistant 消息上的 provider/model)是分开记录的。详见 模型与认证。

MCP 服务器。pi.registerMcpServer() 为当前会话注册 MCP 服务器,不持久化,每次加载都要重新注册。

安全边界:扩展就是你的代码

扩展运行在 Pi 进程内,拥有和 Pi 相同的操作系统权限,能读取 prompt、工具调用、文件、凭证和会话历史。Pi 没有内置沙箱,它选择的是"信任来源 + 项目信任门控":

  • 项目级扩展(.pi/extensions、项目 settings 里的 package)只有在项目被信任后才加载;
  • 用户应只安装自己信任来源的扩展和包。

要注意 tool_call 上的权限确认扩展只是策略,不是隔离:它能拦住模型发起的 rm -rf,但拦不住另一个扩展、或一个被模型诱导写出来的脚本。真正的隔离要靠操作系统或虚拟化边界。官方给的思路有两种:把整个 Pi 跑在容器或虚拟机里(最强),或者用扩展把内置工具的执行转发进隔离环境。后者的官方示例是 Gondolin:它用 createBashTool、createReadTool 等工厂函数配合自定义的 operations,重新注册同名工具,让 bash、read、write 等都在本地 Gondolin 微虚拟机里执行,宿主工作目录挂载到 /workspace。这种方式只隔离了工具,Pi 本身和其他扩展仍在边界之外。1.5 已经移除了早期基于 OS 原生机制的 sandbox/ 示例,推荐 Gondolin 或容器化方案。

常见追问

Pi 的 hook 和 Claude Code 的 hooks 有什么不同? Claude Code 的 hook 是外部进程,通过 stdin/stdout JSON 和退出码通信,语言无关、隔离性好,但每次调用都有进程开销,能力也受协议限制。Pi 的扩展是进程内 TS 函数,返回类型化对象,能力强、延迟低,还能注册工具、UI 和 Provider,代价是没有隔离、和宿主同生共死。

为什么 tool_call 出错要阻断,而普通事件出错只记录? 普通事件只做观察,出错时继续运行损失最小;安全 gate 出错时如果默认放行,就等于攻击者只要让检查逻辑抛异常就能绕过。所以安全相关的 gate 一律 fail-closed。

扩展能改 Agent Loop 本身吗? 不能。扩展只能通过 runtime hook 暴露的控制点施加影响,循环逻辑、状态归约都不对扩展开放。需要更深度的定制,就要用 SDK 自己组装 Agent,或者 fork。

多个扩展都改 tool_result,顺序谁定? 由扩展加载顺序和注册顺序决定,没有优先级字段。这让行为可预测,但也意味着组合扩展时需要用户自己注意顺序。

/reload 之后扩展状态怎么办? /reload 会触发 session_shutdown,重新执行工厂函数,再触发 session_start,闭包里的内存状态会丢失。所以扩展应在 session_start 时从会话条目中恢复状态,而不是依赖内存。

源码入口(v1.5):packages/agent/src/types.ts、packages/coding-agent/src/core/extensions/runner.ts、packages/coding-agent/src/core/extensions/types.ts、packages/coding-agent/src/core/extensions/loader.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/docs/extensions.md

Pi Packages 与包管理

TL;DR:Pi Package 是把扩展、skills、prompt 模板、主题打成一个单元分发的普通目录或 npm 包,来源可以是 npm、git 或本地路径。安装只做两件事:把包下载到 Pi 自己管理的目录,并在 settings 的 packages 里记一条声明;之后每次启动或 /reload,PackageManager.resolve() 把所有声明(加上顶层路径和自动发现目录)翻译成一份带来源、作用域和启用状态的资源路径清单,交给 ResourceLoader 去加载。包内资源靠 package.json 的 pi 字段或约定目录声明,用户还能在 settings 里按资源类型过滤。包等于可执行代码,所以项目级包要等项目信任之后才安装和加载。

它解决什么问题

一个好用的定制往往不止一个文件:一个权限扩展、配套的 review skill、几个 prompt 模板、一个主题。如果只能手动把文件拷进 ~/.pi/agent/extensions/,分享、升级、在团队里统一版本都很麻烦;扩展依赖的 npm 包也没人装。Pi Package 解决的就是分发与版本管理:复用 npm 和 git 现成的生态,而不是自建 registry。

运行形态要先讲清楚:包管理不是常驻服务。它分两部分,都在 Pi 进程内执行:

  • pi install / remove / update / list / config 命令(package-manager-cli.ts):解析参数、做信任检查,然后调用 DefaultPackageManager 完成安装并写回 settings,命令结束进程就退出。
  • 启动和 /reload 时的解析(DefaultPackageManager.resolve()):由 ResourceLoader 调用,把配置翻译成资源文件清单。缺失的包会在这时自动补装。

数据最终存在哪里:settings 里的声明才是唯一权威(全局 ~/.pi/agent/settings.json,项目 .pi/settings.json);下载下来的包目录只是可重建的缓存,删掉后下次启动会按声明重新安装。

一个包长什么样

最简单的包就是按约定放目录:

my-pi-package/
├── package.json
├── extensions/   # .ts / .js 扩展
├── skills/       # skill 目录
├── prompts/      # .md 模板
└── themes/       # .json 主题

资源不在约定位置,或者需要筛选时,用 package.json 里的 pi 字段(manifest)显式声明,路径相对包根目录,支持 glob 和 ! 排除:

{
  "name": "my-pi-package",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./src/extension.ts"],
    "skills": ["./resources/skills"],
    "prompts": ["./resources/prompts/*.md"],
    "changelogPath": "./docs/CHANGELOG.md"
  }
}

manifest 的解析非常克制(pi-manifest.ts 只有几十行):只认 extensions / skills / prompts / themes 四个字符串数组和 changelogPath。pi-package 关键字让 npm 包出现在 pi.dev 的包展示页上。

依赖声明有一个容易踩的坑。扩展运行时需要的包放进 dependencies,安装时 Pi 会一并安装(devDependencies 不装)。但 @earendil-works/pi-ai、pi-agent-core、pi-coding-agent、pi-tui、typebox 这些由宿主提供,应该写进 peerDependencies 并用 "*",不能写进 dependencies。原因和扩展系统的设计直接相关:Pi 把这些 import 映射到宿主自己的模块,扩展才能和宿主共享同一套类和注册表;包里如果带了一份物理拷贝,就可能绕过映射,出现重复的类、重复的注册表和重复初始化。Pi 检测到这种配置会报扩展警告。另外,每个安装的包有独立的模块根,不能指望两个包共享同一个依赖实例。

一次 pi install 到资源生效

以团队成员执行 pi install npm:@acme/pi-guard@1.2.0 为例。

第一步:解析来源。parseSource() 把字符串分成三类:npm: 前缀是 npm 源,git: 前缀或 https:// URL 是 git 源,其余按本地路径处理。带精确版本的 npm 规格、git 的 tag 或 commit 视为固定版本(pinned)。

第二步:安装到受管目录。npm 包装到 ~/.pi/agent/npm/,git 仓库 clone 到 ~/.pi/agent/git/<host>/<path>;加 --local(-l)则改为项目下的 .pi/npm/、.pi/git/,并且要求项目已被信任。本地路径不复制、不安装,只校验存在,所以它的依赖树由作者自己负责。

第三步:写回 settings。installAndPersist() 在安装成功后把来源追加到对应 settings 的 packages 数组。这一步才让安装"持久":下次启动是 settings 驱动解析,而不是扫描安装目录。

第四步:启动时解析。之后每次启动或 /reload,ResourceLoader 先解决项目信任,再调用 packageManager.resolve():

resolve()
  ├─ 读取全局和项目 settings
  ├─ 合并两边的 packages,按包身份去重(项目优先)
  ├─ 逐个包:缺失就补装 → 进入包目录收集资源
  │     settings 里的过滤器 > 包内 pi manifest > 约定目录扫描
  ├─ 解析顶层的 extensions / skills / prompts / themes 路径
  ├─ 自动发现 ~/.pi/agent/* 与 .pi/* 下的资源
  ├─ 加入内置扩展(mcp、codemode 等)及其启用状态
  └─ 输出 ResolvedPaths:每类资源一个列表,每项带 path、enabled、metadata

metadata 记录资源的来源(哪个包、本地配置、自动发现、CLI、内置)、作用域(user / project / temporary)和来源方式(包内还是顶层配置)。所以包管理器输出的不只是路径,而是可追溯的路径:/reload、pi config、冲突报告和项目信任判断都依赖这些元数据。同一资源被多个来源命中时,项目级优先于用户级,显式配置优先于自动发现,再往后是包内资源,内置扩展排在最后。

第五步:交给 ResourceLoader 加载。ResourceLoader 只取 enabled 的条目:扩展交给扩展 loader 用 jiti 加载(见 扩展系统),skills、prompts、themes 各自交给对应的加载器(见 资源发现、项目信任与系统提示词)。pi -e npm:xxx 走的是同一套解析,只是作用域为 temporary,包缓存在 ~/.pi/agent/tmp/extensions/ 下,不写 settings。

这里体现了职责划分:PackageManager 负责"从哪里拿到哪些文件",ResourceLoader 负责"把文件变成运行时对象"。包管理器不执行任何扩展代码。

一句话:settings 声明是权威,安装目录是缓存,resolve() 把声明翻译成带元数据的资源路径清单,加载交给 ResourceLoader。

作用域、身份与过滤

同一个包可以同时出现在全局和项目 settings 里。为了避免重复加载,Pi 按"包身份"去重:npm 按包名(忽略版本),git 按仓库地址(忽略 ref),本地按解析后的绝对路径。默认项目条目替换全局条目;如果项目条目写了 autoload: false,它就不再替换,而是作为对全局包的过滤增量。

settings 里可以用对象形式,按资源类型缩小加载范围:

{
  "packages": [
    {
      "source": "npm:@example/pi-tools",
      "extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
      "skills": [],
      "prompts": ["prompts/review.md"]
    }
  ]
}

省略某类表示加载包允许的全部,[] 表示一个都不加载,!pattern 排除 glob 匹配,+path 强制包含某个确切路径,-path 强制排除。关键约束是过滤只能收窄 manifest,不能暴露包本身没声明的资源。pi config 提供交互界面来启用或禁用已发现的资源和内置扩展,Tab 在全局和项目范围间切换。

命令行

任务命令
安装pi install <source> [-l]
列出已配置的包pi list
删除包及其 settings 条目pi remove <source> [-l](别名 uninstall)
配置加载哪些资源pi config [--local]
更新所有包pi update --extensions
更新单个包pi update <source>
更新 Pi 本身pi update;--all 同时更新 Pi 和所有包

注意 pi update 不带参数时更新的是 Pi 自己,不是包。包更新的语义是"按声明对齐":npm 包检查是否有更新版本,固定版本不动;git 包 fetch 后 reset 到声明的分支或 ref,tag 和 commit 不会被移动。1.5 起 pi update 也可以通过 PI_SELF_UPDATE_COMMAND 或 selfUpdateCommand 设置,经由包装器或其他包管理器更新 Pi 本身。

容错策略是分场景的:启动时某个已配置的包装不上(比如没网),Pi 只发警告、跳过这个包的资源、继续运行,下次启动或 /reload 再试;而显式的 pi install 和 pi update --extensions 失败会以非零退出码报错。理由是启动路径不应该因为一个可选组件坏了就整体不可用,而用户主动发起的命令需要明确的失败信号。

更新后显示 changelog(1.5)

1.5 新增:包更新后,下次全新启动时 Pi 会读取包的 changelog,在"Extension Updates"区域显示比上次看到的版本更新的条目,和 Pi 自己的更新说明一样。默认读包根目录的 CHANGELOG.md,pi.changelogPath 可以指向其他文件,设为 "" 则关闭。版本匹配看的是 package.json 的 version 字段,所以 git 包只有在这个字段增加时才会显示。第一次见到某个包时只记录版本,不显示。只有至少加载了一个扩展的包才显示 changelog;collapseChangelog 设置可以把每个包压缩成一行"Updated xxx to v1.2.0"。

这个功能解决的是一个真实痛点:扩展会随更新悄悄改变行为(新的 hook、新的权限检查),用户需要知道变了什么。

信任与安全

包本质上是可执行代码:扩展在 Pi 进程内以用户权限运行,skill 还可以指示模型去执行程序。所以安全模型和扩展一致,靠的是"信任来源",而不是隔离:

  • 全局包由用户自己安装,等同于用户主动信任;
  • 项目包的声明写在 .pi/settings.json 里,只有在项目被信任之后,Pi 才会读取这些声明、补装缺失的包、加载项目包里的扩展。在 --local 下执行 install / remove 时,包管理器同样会先检查项目是否已被信任。

这一点很重要:克隆一个陌生仓库后直接启动 Pi,仓库里声明的包不会被自动下载执行,必须先经过项目信任决策(交互确认、project_trust 扩展、trust.json 里保存的决定,或 defaultProjectTrust 设置)。1.5 还修复了一个相关问题:保存过"不信任"的项目,即使目录里只有 AGENTS.md 这类无需信任的资源,也不会再被自动信任,包命令据此判断。

但项目信任只决定"加载什么",不限制"加载后能做什么"。要真正限制影响范围,仍然要靠容器、虚拟机等隔离手段(见 扩展系统 的安全边界部分)。安装第三方包前审阅源码,在信任项目前审阅它声明的包,是官方给出的建议。

常见追问

为什么不自建 registry,而是复用 npm 和 git? 包的作者和用户本来就在这两个生态里,版本、依赖、私有源、鉴权都现成可用;Pi 只需要定义一个很小的 manifest 约定。代价是要处理 npm 和 git 各自的边角问题(锁文件、代理、Windows 文件占用等),CHANGELOG 里相当一部分修复都与此相关。

为什么 settings 是权威,而不是扫描安装目录? 声明可以提交进项目仓库、在团队中共享,并且可以表达版本固定和资源过滤;安装目录只是缓存,丢了可以按声明重建。反过来扫描目录就无法区分"用户想要的"和"残留的"。

本地路径的包和直接写 extensions 路径有什么区别? 本地包会按包规则发现四类资源,并参与按包身份去重和过滤;顶层 extensions 路径只加载一个文件或目录里的扩展。两者都不复制文件,也不安装依赖。

两个包依赖同一个库的不同版本会冲突吗? 不会互相影响,每个安装的包有独立的模块根。但也意味着不能依赖它们共享单例;需要共享状态时,应通过宿主提供的机制(如 pi.events)。

源码入口(v1.5):packages/coding-agent/src/core/package-manager.ts、packages/coding-agent/src/package-manager-cli.ts、packages/coding-agent/src/core/pi-manifest.ts、packages/coding-agent/src/core/resource-loader.ts、packages/coding-agent/docs/packages.md

配置体系:配置文件放在哪、SettingsManager 怎么合并与写回

TL;DR:Pi 的配置分两层:用户级放在 agent 目录(默认 ~/.pi/agent,可用 PI_CODING_AGENT_DIR 改),项目级放在工作目录的 .pi/。config.ts 只负责算路径,SettingsManager 负责把两份 settings.json 读进内存、按"项目覆盖用户、对象递归合并、数组整体替换"合成一份有效设置。项目设置要等项目被信任后才加载,未信任时项目层就是空对象。写回时只写本次改过的字段:在文件锁里重读磁盘、合并后再写,这样多个 Pi 进程同时改同一个文件也不会互相覆盖。坏掉的配置文件不会让 Pi 启动失败,只会变成一条带路径的警告。

它解决什么问题

一个 coding agent 的配置有三类:跟人走的偏好(默认模型、主题、重试策略),跟仓库走的约定(这个项目要加载哪些扩展、skills、默认开哪些工具),以及只影响这一次运行的临时值(--model、--use-theme)。Pi 要回答四个问题:这些东西各存在哪;同一个字段两层都写了听谁的;仓库里的配置能不能直接生效(克隆来的仓库可能带恶意扩展);运行中通过 /settings 改的值怎么安全写回磁盘。

对应的分工很简单:

  • config.ts:纯路径与身份计算。agent 目录在哪、.pi 目录叫什么、包内资源(内置主题、文档、codemode worker)在哪。它不读任何配置内容。
  • SettingsManager:读写两份 settings.json,提供合并后的有效设置和一组 getter/setter。是一个进程内的普通对象,不常驻、不监听文件变化,由 main.ts、SDK 的 createAgentSession()、包管理 CLI 各自创建。
  • 信任判定(project-trust.ts / trust-manager.ts):决定项目层能不能加载,结果存在 <agent-dir>/trust.json。细节见 资源发现、项目信任与系统提示词。

settings.json 只是 agent 目录里的配置之一,其他文件各有自己的加载者:models.json 归 ModelRegistry,auth.json 归 AuthStorage(见 模型与认证),mcp.json 归内置 MCP 扩展(见 MCP 与 Codemode),keybindings.json 归 TUI。所以 SettingsManager 不是一个"配置中心",它只管偏好和资源声明这一份文件。

配置在哪:两个根目录

config.ts 里最重要的是两个"根":

  • 用户世界的根 getAgentDir():优先读环境变量 PI_CODING_AGENT_DIR(支持 ~),否则是 ~/.pi/agent。settings.json、models.json、auth.json、mcp.json、trust.json、sessions/、extensions/、skills/、prompts/、themes/、托管的 bin/(fd、rg)都从它派生。
  • 包内世界的根 getPackageDir():Pi 自己安装在哪,用于找内置主题、导出模板、文档。可用 PI_PACKAGE_DIR 覆盖(Nix/Guix 这种只读 store 路径会用到);Bun 编译出的单文件二进制没有真实的包目录,config.ts 会检测 $bunfs 这类虚拟路径并改用可执行文件旁边的目录。

项目层是 <cwd>/.pi/,里面可以有 settings.json、mcp.json、SYSTEM.md、APPEND_SYSTEM.md 以及 extensions/、skills/、prompts/、themes/。

有一个容易忽略的设计:.pi 这个目录名、pi 这个应用名、环境变量前缀都不是写死的,而是从 package.json 的 piConfig 读出来的(CONFIG_DIR_NAME、APP_NAME,环境变量名由 ${APP_NAME.toUpperCase()}_CODING_AGENT_DIR 拼出)。这样别人基于 Pi 发行一个改名的 agent,配置目录和环境变量会自动跟着变,不会和原版 Pi 抢同一个 ~/.pi。

环境变量在这里的角色是"改路径和改进程行为",不是第三层设置。PI_CODING_AGENT_DIR 改 agent 目录,PI_CODING_AGENT_SESSION_DIR 改会话目录,PI_OFFLINE 关掉自动联网(包括模型目录刷新);Provider 的 API key 变量属于认证体系。只有极少数设置有对应环境变量做兜底,例如 terminal.clearOnShrink 未设置时才看 PI_CLEAR_ON_SHRINK。

还要和 .pi 配置区分开的是上下文文件(AGENTS.md、CLAUDE.md):它们从 agent 目录、工作目录及其各级父目录加载,不属于 .pi 配置,也不需要项目信任。

一次启动里 SettingsManager 被创建了几次

以在一个刚克隆、带 .pi/settings.json 的仓库里运行 pi 为例,按 main.ts 的顺序看:

  1. 引导阶段:先创建一个 projectTrusted: false 的 SettingsManager,只用它读用户层的 httpProxy 并配置全局 HTTP 代理。此时还没决定信不信任项目,所以项目层不读。httpProxy、defaultProjectTrust、selfUpdateCommand、cacheWarming 这几项只认用户层,原因一样:它们要么在信任判定之前就要用,要么本身就是"信任策略",让仓库自己决定是否被信任显然不行。
  2. 选会话阶段:再创建一个 startupSettingsManager,用来收集配置文件的诊断警告、跑首次设置向导、读 sessionDir。sessionDir 是文档里唯一明确"在信任之前读取"的项目设置,因为 --resume 必须先找到会话文件。会话可能属于另一个项目目录,所以真正跟 cwd 绑定的设置要等会话的 cwd 确定之后再读。
  3. 运行时阶段:每个 runtime(新会话、切换到别的 cwd 的会话都会重建)创建自己的 runtimeSettingsManager。如果项目里有需要信任的资源且还没有信任记录,先以未信任状态创建;资源加载器先只加载用户级扩展,给扩展一个 project_trust 事件的机会代为决定,然后查 trust.json,再看 defaultProjectTrust(ask/always/never),最后在交互模式下弹窗询问。拿到结论后调用 setProjectTrusted(true),此时才从磁盘读入项目层并重新合并。

这个顺序体现的原则是:项目配置等同于仓库作者写的代码。.pi/settings.json 能声明扩展和 packages,扩展就是任意 TypeScript,所以它必须和 .pi/extensions/ 一起放在信任闸门后面。未信任时 SettingsManager 不是"读了但忽略",而是项目层直接为 {},并且任何写项目层的操作会抛错("Project is not trusted; refusing to write project settings")。

一句话:SettingsManager 很便宜,Pi 按阶段多次创建;信任状态是它的构造参数,项目层在信任前根本不进内存。

合并语义:对象递归、数组替换、工具列表例外

有效设置 = deepMerge(用户层, 项目层),规则是:

  • 普通值:项目层覆盖用户层;项目层值为 undefined 视为没写。
  • 嵌套对象(compaction、retry、terminal……):递归合并。项目只写 {"compaction": {"keepRecentTokens": 40000}} 时,用户层的 compaction.enabled 仍然生效。早期版本这里出过 bug:项目里写了 retry.provider 的某个子字段会把用户的整个 retry.provider 冲掉,后来改成了逐层递归。
  • 数组:整体替换。

有两处例外,都和"项目想在用户基础上增减,而不是重写"有关:

  • defaultTools:支持 +name/-name 修饰语法。一个只含修饰项的列表(如 ["+codemode"])会追加到上一层的列表后面,相当于"在继承来的选择上改";只要出现一个普通名字,就整体替换。最后再相对内置默认 read, bash, edit, write 求值。CLI 的 --tools +x 用的是同一套规则,叠加在合并结果之上。
  • 资源列表(packages、extensions、skills、prompts、themes):文档说的是"两层都加载",但这个合并不在 SettingsManager 里发生。包管理器分别调用 getGlobalSettings() 和 getProjectSettings(),两层各自按自己的基准目录解析路径(用户层相对 agent 目录,项目层相对 .pi/),再合到一起,并且项目层可以用 +builtin:mcp/-builtin:mcp 覆盖用户层对内置扩展的开关。原因是两层路径的基准目录不同,深合并成一个数组之后就分不清每条路径该相对哪个目录解析了。

再往上还有一层运行期覆盖 applyOverrides():直接合并进内存里的有效设置,不标记修改、不落盘。在 1.5 里 main.ts 只用它处理 --use-theme。--model、--thinking 这类 CLI 参数不经过 SettingsManager,而是在模型解析阶段直接作为更高优先级的输入(见 模型与认证)。旧笔记里说"pi --model 通过 applyOverrides 覆盖 defaultModel",这个说法不准确。

默认值不进合并。用户层和项目层对象里只保存用户显式写过的字段,默认值集中在 SETTINGS_DEFAULTS(settings-defaults.ts),由 getter 用 ?? 兜底,比如 getCompactionEnabled() 返回 settings.compaction?.enabled ?? 默认值。这样做有两个好处:写回时不会把默认值写进用户的文件;以后默认值变了,用户什么都不用改就能拿到新默认。和旧版把默认值散写在各个 getter 里不同,现在默认值只在一处维护,schema 定义和 /settings 面板也引用同一份。

写回:只写改过的字段,在锁里重读再写

/settings 面板、/model 记住默认模型、pi install 记录 package,最终都调用 SettingsManager 的 setter。写回链路如下:

setter(value)
  -> 改内存里的 globalSettings(或 projectSettings)
  -> markModified(field[, nestedKey])     记录"本进程改过哪些字段"
  -> 重算有效设置(内存立即生效)
  -> 拍一份快照,排进串行 writeQueue
       -> storage.withLock(scope):
            读磁盘当前内容 -> 迁移旧格式
            只把 modified 字段(嵌套对象只到被改的子键)覆盖上去
            写回文件
       -> 清掉该 scope 的修改标记

几个关键选择:

  • 按字段写,不整文件覆盖。磁盘是唯一权威,内存里的两层只是本进程读入时的快照。如果另一个终端里的 Pi 改了 theme,本进程改 defaultModel 时会先在锁内重读文件,只覆盖 defaultModel,不会把别人刚写的 theme 冲掉。嵌套对象精确到子键,改 compaction.enabled 不会覆盖磁盘上的 compaction.keepRecentTokens。
  • 跨进程用文件锁,进程内用 Promise 队列。FileSettingsStorage 用 proper-lockfile 加同步锁,被占用时最多重试 10 次、每次间隔 20ms。进程内的写操作排成一条 writeQueue,setter 本身是同步的、立刻返回,磁盘写在微任务里串行完成。需要确认已落盘时调用 flush();reload() 会先等队列排空,避免读到自己还没写下去的旧状态。
  • 快照在入队时拍下。写任务真正执行时用的是调用 setter 那一刻的 structuredClone,后续内存修改不会混进这次写入。
  • 大多数 setter 写用户层。/settings 里能改的偏好都写 <agent-dir>/settings.json;只有资源类的 setProject*(packages、extensions、skills、prompts、themes)写项目层,并且要先通过信任检查。偏好跟人走,资源声明才可能跟仓库走。
  • 文件读失败就不写。如果启动时某一层 JSON 解析失败,这一层的 save 会直接跳过。否则用户手里那份写坏、等着修的文件会被一份"空对象加一个字段"整个覆盖。
  • 迁移只在读取时做,不主动回写。migrateSettings() 每次加载都把旧格式转成新格式(queueMode→steeringMode、websockets 布尔值→transport、retry.maxDelayMs→retry.provider.maxRetryDelayMs 等)。新格式只在用户下次改到对应字段时才落盘,Pi 不会因为升级就改写用户的文件。

存储后端被抽象成只有一个方法的 SettingsStorage.withLock(scope, fn):fn 拿到当前文本,返回新文本表示要写,返回 undefined 表示只读。基于这个接口有两个实现:FileSettingsStorage 读写真实文件,并且只在确实要写时才创建目录;InMemorySettingsStorage 是两个字符串字段。SettingsManager.inMemory({...}) 主要给 SDK 嵌入和测试用:同样的合并、迁移、修改追踪逻辑全部照常运行,只是不碰磁盘。宿主应用嵌入 Pi 时,可以完全不让它读写用户的 ~/.pi。

校验与诊断:编辑器侧严格,运行时宽容

1.x 给 settings.json、models.json、keybindings、主题发布了 JSON Schema(https://pi.dev/schemas/settings.schema.json)。在文件里加 "$schema" 字段,编辑器就能补全并标红。这份 schema 由 settings-schema.ts 里的 TypeBox 定义生成,同一份定义也是 Settings 的 TypeScript 类型来源,字段说明和默认值都写在定义里。

运行时的态度则相反,SettingsManager 加载时不做 schema 校验(源码注释明确写着 "Settings files are not validated")。它只处理两类致命问题:JSON 解析失败,以及少数必须是合法值的字段(如超时设置)解析出错。出错的那一层按空对象处理,错误连同文件路径记进 errors。之后 collectSettingsDiagnostics() 调 drainErrors() 把它们变成 Invalid settings file <path>: ... 警告,交互模式下渲染在 TUI 里,启动期和运行期两个 manager 报的同一个错会去重。字段类型不对、写了未知字段,基本由各个 getter 的兜底吸收掉。

这个取舍是:配置写错不应该让 agent 起不来。用户最需要 Pi 帮忙的时候,往往正是配置刚被改坏的时候。严格校验放在编辑器里,用户写的时候就能看到;运行时只保证"能启动,并且告诉你哪个文件坏了"。

一句话:schema 给编辑器和类型用,运行时只兜底解析错误并报警告,坏文件既不阻断启动,也不会被自动覆盖。

/reload 与热更新

SettingsManager 不监听文件。用户手动改了 settings.json 后要执行 /reload:等写队列排空,重新读两层(项目层按当前信任状态决定读不读),清空所有修改标记,再重新合并。哪一层读失败,就保留该层旧的内存值并记一条错误,不会因为一次编辑没保存完整就丢掉全部配置。

调用方在用到设置时才去调 getter 查询的,reload 后马上生效;只在构造时读一次的(默认模型、启动工具列表),reload 后只影响之后的行为。例如 defaultTools 新增的工具会在 reload 时启用,删掉的工具却不会因此被关掉,以免打断用户手动开关的状态。

常见追问

  • 为什么不做三层以上(比如目录级、组织级)的设置? Pi 的定位是最小 harness。两层已经覆盖"跟人走"和"跟仓库走";更细的定制交给扩展,扩展可以在事件里改行为,不需要设置系统支持任意层级。
  • 为什么项目层不能设置 httpProxy、defaultProjectTrust? 前者在信任判定之前就要用;后者本身就是信任策略。允许仓库配置它们,就等于让仓库替自己开门,或者把流量导到别处。
  • 多个 Pi 进程同时写会怎样? 按字段写回加文件锁,不同字段的修改都能保留;同一字段并发写时后写的赢。它不是事务系统,但对"偏好文件"来说已经够用。
  • SDK 嵌入时怎么避免污染用户配置? 传 SettingsManager.inMemory({...}),或者用 agentDir 选项指向独立目录。前者完全不碰磁盘,后者等价于设置 PI_CODING_AGENT_DIR。
  • 为什么 setter 是同步的而写盘是异步的? UI 交互(切主题、切模型)要求内存立即生效,不能等 IO。串行队列保证落盘顺序,flush() 给需要确定性的调用方(测试、退出前)一个等待点。

源码入口(v1.5):packages/coding-agent/src/config.ts、packages/coding-agent/src/core/settings-manager.ts、packages/coding-agent/src/core/settings-schema.ts、packages/coding-agent/src/core/settings-diagnostics.ts、packages/coding-agent/src/core/project-trust.ts、packages/coding-agent/src/main.ts

模型与认证:凭据、模型目录与模型解析

TL;DR:Pi 把"用哪个模型"拆成三件互不越界的事。凭据存储(AuthStorage,落在 auth.json)只负责安全地存取 API key 和 OAuth token;模型目录(ModelRuntime,对扩展暴露为 ModelRegistry)把 pi-ai 内置目录、pi.dev 远程目录、models.json 和扩展注册的 provider 合成一张表,并判断哪些模型"有凭据可用";模型解析(model-resolver)把 --model sonnet:high、默认设置、enabledModels 这类输入变成一个具体模型和 thinking level。每次请求时再按"运行时 --api-key → auth.json → models.json → 环境变量"现取凭据,OAuth 快过期就在文件锁里刷新一次。0.99 起新增的虚拟模型(实验性)让扩展在每次请求前把用户选中的模型路由到一个物理模型,选择和实际调用分开记录。

整体:三件事,一个运行时对象

一个 coding agent 发请求前要回答三个问题:

  1. 凭据在哪:Anthropic 的 key 可能在环境变量里,也可能是 /login 存下的 OAuth token,或是这次 --api-key 临时给的。
  2. 有哪些模型、各有什么能力:上下文窗口、价格、是否支持 reasoning、走哪种 API 协议、baseUrl 是什么。
  3. 用户这次想用哪个:用户输入的是 sonnet 这样的模糊名字,或者什么都没写。

它们的变化频率和信任级别都不同。凭据是机密,必须加锁写、不能进日志;模型目录是公开数据,可以联网刷新、可以缓存;解析是纯函数,输入字符串和模型列表,输出选择。Pi 让三者各自独立:

AuthStorage (auth.json)          凭据:只存取,不判断优先级
      │  作为 CredentialStore 注入
      ▼
ModelRuntime                     目录 + 可用性 + 每次请求的认证解析
  ├─ pi-ai 内置 provider(叠加 pi.dev 远程目录,缓存在 models-store.json)
  ├─ models.json(自定义 provider / 覆盖)
  ├─ 扩展 registerProvider / registerVirtualModel
  └─ 运行时 API key 覆盖(内存,不落盘)
      ▲
      │  getModels / hasConfiguredAuth
model-resolver                   --model / 默认 / enabledModels / 恢复会话 → (model, thinkingLevel)

它们都运行在 Pi 进程内,不是独立服务。ModelRuntime 在 createAgentSessionServices() 里每个 runtime 创建一个,AgentSession 发请求时通过它拿到 provider 和凭据。

1.x 的结构变化值得在面试里提一句:旧版 AuthStorage 自己有 getApiKey(),把运行时覆盖、auth.json、环境变量、models.json 兜底的优先级全部写在里面;ModelRegistry 则自己加载 models.json。1.x 把"认证怎么解析"下沉到了 pi-ai 的 Models 集合(resolveProviderAuth),每个 provider 自己声明支持哪些认证方式(API key 解析器、OAuth 的 login/refresh)。AuthStorage 退化成一个纯粹的 CredentialStore(read / modify / list / delete),运行时覆盖由外面一层 RuntimeCredentials 叠加。coding-agent 内部直接用 ModelRuntime,ModelRegistry 只是保留给扩展的同步兼容外观(ctx.modelRegistry.find()、getAvailable())。这样 SDK 用户直接用 pi-ai,也能得到和 Pi 完全一样的认证行为。

凭据:AuthStorage 只管存,优先级在解析时定

auth.json 是一个以 provider id 为键的 JSON,值有两种:{type: "api_key", key} 和 {type: "oauth", access, refresh, expires, ...}。AuthStorage 的职责只有三点:

  • 并发安全的读写。与 settings 一样用 proper-lockfile,但凭据修改是异步的 modify(provider, fn):在锁内读最新内容、交给 fn 算出新凭据、写回后再释放锁。读操作按文件 revision 判断是否需要重读,同一路径的多个实例共享一份读缓存。
  • 值引用延迟解析。存进去的 key 可以是 $ENV_NAME 或 !command 这类引用,读取时才用 resolveConfigValue() 解析。/login 存原值,真正发请求时才执行命令或展开环境变量,所以密码管理器里的 key 轮换后不需要重新 login。
  • 可替换后端:文件、内存(AuthStorage.inMemory(),给 SDK 和测试用)、只读包装。

凭据优先级在每次请求时由 pi-ai 的 resolveProviderAuth() 决定:

  1. 运行时覆盖:--api-key 通过 setRuntimeApiKey() 放进内存,不落盘。
  2. auth.json 中存储的凭据:OAuth 走刷新逻辑,API key 走解析器。
  3. models.json 里该 provider 配置的 apiKey / headers(由 provider-composer 叠加进 provider 的认证定义)。
  4. 环境变量或环境里的云凭据(ANTHROPIC_API_KEY、AWS profile、Google ADC 文件)。

有一条容易被忽略的规则:存储的凭据独占该 provider。只要 auth.json 里有这个 provider 的记录,就不会再看环境变量;OAuth 刷新失败也不会悄悄退回环境变量里的 key。原因是静默回退会让"我登录的是订阅账号"变成"其实在用按量付费的 key 扣钱",用户却毫不知情,所以宁可直接报错。

OAuth 刷新是这部分最精细的地方。token 剩余有效期不足 5 分钟时,刷新在 credentials.modify() 的锁内进行,并且进锁后会重新检查一次是否仍需刷新。这样多个请求、多个 Pi 进程同时发现 token 过期时,只有第一个真正去刷新,后面的直接读到新 token。另外,刷新一旦开始就不响应调用方的取消,只受 15 秒超时约束。很多 provider 的 refresh token 是一次性的,换到新 token 却没写盘,就等于丢掉了唯一有效的 refresh token,用户只能重新登录。

一句话:AuthStorage 是带锁的保险箱;"用哪份凭据"在每次请求时由 provider 声明的认证方式决定,并且已存储的凭据不会静默回退到环境变量。

模型目录:四个来源合成一张表

ModelRuntime 按 provider 逐个合成模型表,合成后交给 pi-ai 的 Models 集合:

  • pi-ai 内置目录:构建时生成的 provider 和模型元数据(上下文窗口、价格、reasoning 能力、API 类型)。不依赖网络,离线也能用。
  • pi.dev 远程目录:内置 provider 外面包一层远程目录,从 pi.dev 拉更新的模型数据叠加上去,结果缓存在 <agent-dir>/models-store.json,约每 4 小时刷新一次。新模型发布后不必等 Pi 发版就能选到。PI_OFFLINE 会关掉这类自动联网,pi update --models 可以强制刷新。OpenRouter 更进一步,按用户 key 的权限裁剪可见模型和价格。
  • models.json:用户的自定义层。可以新增 provider(Ollama、vLLM 这类 OpenAI 兼容端点,只需 baseUrl、api 和模型列表),也可以只给内置 provider 改 baseUrl 走代理(模型和认证保持不变),或者用 modelOverrides 改某个已有模型的元数据而不替换整个列表。打开 /model 时会重新读这个文件。1.x 为它发布了 models.schema.json。
  • 扩展:pi.registerProvider() 用配置描述一个 provider,或者注册一个完整的原生 provider(自定义流式协议、模型发现、认证流程),以及 pi.registerVirtualModel()。

合成规则是"能不叠就不叠":一个内置 provider 如果既没有 models.json 配置也没有扩展覆盖,就原样使用,保证它的 login、refresh、stream 行为和 pi-ai 完全一致;有覆盖时才交给 composeModelProvider() 叠加。叠加失败会记成该 provider 的错误,并退回原始定义,不会因此让整张表不可用。

可用和存在是两个概念。目录里有几百个模型,但 /model 和 Ctrl+P 只显示 provider 有可用认证的那部分。ModelRuntime 维护一份可用性快照:对每个 provider 并行做 checkAuth,结果分为运行时、已存储、配置、环境四种来源,供 /login 列表显示状态。登录或登出后只刷新这一个 provider 的快照,并用序号丢弃过期的并发刷新结果。

除了聊天模型,1.x 的目录里还有分类器模型(回答结构化的是非题、选择题和打分)和图像模型。它们不出现在 /model 里,模型通过 codemode 脚本调用,扩展则通过 ctx.modelRegistry.classify() 和 generateImages() 使用(见 MCP 与 Codemode)。

模型解析:从输入到 (model, thinkingLevel)

启动时选模型的优先级(main.ts 加 findInitialModel):

  1. --model(可带 --provider,或写成 provider/pattern),可加 :<thinking> 后缀;显式 --thinking 优先于后缀。
  2. 新会话且配置了 scoped models(--models 或 enabledModels)时:如果保存的默认模型在范围内就用它,否则用范围内的第一个。
  3. settings.json 里保存的 defaultProvider/defaultModel,前提是该 provider 有认证。
  4. 有认证的 provider 中,按内置的"每个 provider 的推荐默认模型"表找第一个可用的;都没有就用第一个可用模型。

thinking level 也有自己的优先级链:CLI 指定 > scoped 模式里的后缀 > modelThinkingLevels["provider/id"] > defaultThinkingLevel > 默认 medium。最终会按模型能力夹紧(clampThinkingLevel):不支持 reasoning 的模型只能是 off,不支持 max 的模型会降到它支持的最高级。Pi 的 thinking level 是一个 provider 无关的七级刻度(off / minimal / low / medium / high / xhigh / max),pi-ai 负责把它翻译成各家的参数,可能是 token budget,也可能是 effort 档位。thinkingBudgets 设置可以改各级的 budget。

scoped models 是 Ctrl+P 循环的范围。它的模式支持 glob(anthropic/*、*sonnet*:high),同一个 id 出现在多个 provider 时会全部加入并给出警告。模式里没写 thinking level 时,循环到这个模型会沿用当前会话的级别,而不是重置。

恢复会话时,模型来自会话里最新的 model_change 记录(见 会话树与上下文压缩)。如果该模型已不在目录里,或者它的 provider 没有认证了,Pi 会警告并回退:先用本次启动已经确定的模型(CLI 或默认),没有的话再走推荐默认表,最后取第一个可用模型。恢复时不会改动新会话的默认设置。例如 Azure provider 改名后,旧会话就会走这条回退路径。

场景:pi --model sonnet:high 从参数到请求

  1. 构建目录。main.ts 创建 ModelRuntime:读内置目录,用 models-store.json 缓存叠加远程目录,加载 models.json;扩展加载后再补上扩展注册的 provider。同时异步做一次可用性检查。
  2. 解析 sonnet:high。resolveCliModel() 在全部模型里找,不只是有认证的那些,这样第一次使用时可以靠 --api-key 完成设置。先把整个串当 id 精确匹配,失败后再做模糊匹配,也失败。于是按最后一个冒号拆出合法的 thinking 后缀 high,对 sonnet 递归:没有精确 id,模糊匹配到所有 id 或名字里含 sonnet 的模型,可能来自 anthropic、openrouter、bedrock 等多个 provider。provider 不止一个时,用"有没有认证"做 tie-break,只保留有认证的候选;再优先取别名(claude-sonnet-4-5 优于 claude-sonnet-4-5-20250929),然后按 id 取最新。如果同一个 id 在两个都有认证的 provider 上并列,Pi 直接报 ambiguous,要求写成 provider/id,而不是按目录顺序悄悄挑一个。如果用户写了 provider/xxx 但目录里没有这个 id,Pi 会以该 provider 的默认模型为模板构造一个"自定义 id"模型并警告,这样新模型上线当天就能用。
  3. 建会话。createAgentSession() 拿到 (anthropic/claude-sonnet-x, high),按模型能力夹紧 thinking level,写入会话的 model_change 和 thinking_level_change 记录。shell 工具的子进程从 PI_PROVIDER、PI_MODEL、PI_REASONING_LEVEL 环境变量读到这一选择。
  4. 发请求。agent loop 每轮调用 ModelRuntime.streamSimple()。prepareRequest() 现取认证:没有 --api-key,就读 auth.json;里面是 OAuth 且剩余不足 5 分钟时,先在锁内刷新,否则看环境变量里的 ANTHROPIC_API_KEY。然后合并 models.json 里配置的 headers 和 baseUrl,交给 provider 的 streamSimple,由它把 high 翻译成 Anthropic 的 thinking 参数。凭据不在启动时缓存,每次请求都现取,所以长会话中途 token 过期或用户在另一个终端 /login,下一轮请求就会生效。

/login:provider 自带认证流程

/login 列出 provider 及其支持的认证方式,用户选 OAuth 或 API key。流程由 provider 自己实现(provider.auth.oauth.login),Pi 只提供交互能力:打开浏览器、显示 device code、让用户粘贴回调 URL。最后一项是为远程或 headless 机器准备的,因为那里本地回调端口收不到浏览器的跳转。拿到凭据后,pi-ai 在 credentials.modify() 的锁内写入 auth.json;ModelRuntime 随后只为这个 provider 重新合成、离线刷新模型列表、刷新可用性快照,于是 /model 立刻就能看到新模型。同一个 provider 的 login、logout、设置运行时 key 会排进同一个串行队列,避免"登录写到一半时又登出"。如果凭据已经写盘、但本地快照同步失败,Pi 会报 CredentialSynchronizationError,明确告诉用户凭据其实已经保存。/logout 只删除 auth.json 里的记录,不会取消环境变量,也不会去 provider 那边吊销 token。

虚拟模型:选择与调用分开

虚拟模型(0.99 起,标记为实验性)解决的问题是:用户只想选一个"自动"模型,背后由策略按任务、成本、对话阶段决定每次请求实际用哪个模型。扩展用 pi.registerVirtualModel({provider, id, thinkingLevels, route}) 注册,它和普通模型一样出现在 /model、--model、scoped models 和设置里。

每次请求前,Pi 调用 route(request, ctx),返回一个物理的 (model, thinkingLevel)。request.reason 说明这次请求的起因:user(用户刚发消息)、continuation(工具结果之后的后续轮)、retry(失败重试,附带失败信息)、direct(压缩摘要等 agent loop 之外的调用)。文档建议 continuation 返回 previous、retry 返回 failed,也就是一轮之内不换模型,这样 prompt cache 和 thinking 签名才不会失效。路由器可以返回可 JSON 序列化的 state,Pi 把它存在会话树的当前分支上,fork 和 /tree 导航时各分支看到各自的状态。

关键设计是选择和调用分开记录:model_change 记录用户选的虚拟模型,每条 assistant 消息记录实际调用的物理模型。provider 永远只收到物理模型;上下文用量和压缩判断都按实际路由到的那个物理模型的窗口计算;恢复会话时如果虚拟模型已不存在(扩展被卸载),就回退到最后一次实际回答的物理模型。约束也很明确:虚拟模型不能路由到另一个虚拟模型;返回的模型没有凭据或 route() 抛错时,这次请求以错误结束。

常见追问

  • 为什么不把凭据放进 settings.json? 两者的信任级别和生命周期不同。设置可以分享、可以放进项目 .pi/,凭据绝不能;凭据需要锁内原子刷新,设置不需要。把它们分成两个文件,项目配置就不可能意外携带或覆盖凭据。
  • 为什么解析 --model 时用全部模型,而不是只用有认证的? 首次使用时用户往往就是靠 --api-key 提供凭据的。认证只在多个 provider 撞名时做 tie-break;带 --api-key 时认证甚至不参与选择,因为这个 key 会给选中的那个 provider 用。
  • 模型目录联网刷新会不会拖慢启动? 启动时用缓存和内置目录就能工作,联网刷新有超时,可以完全关掉(PI_OFFLINE)。目录数据不可用时最坏情况也只是看不到最新模型,不会阻塞请求。
  • 虚拟模型和在扩展里拦截请求改模型有什么区别? 虚拟模型是一等公民:它出现在选择器里,会话里有清楚的"选择/实际调用"两条记录,压缩和上下文统计都知道实际用的是哪个模型。拦截请求偷偷换模型,会让会话历史、成本统计、恢复行为都对不上。
  • SDK 嵌入时怎么隔离凭据? 向 ModelRuntime.create() 传入 credentials: AuthStorage.inMemory({...}) 或自定义的 CredentialStore,同时把 modelsPath 设为 null,这样完全不读写用户的 ~/.pi/agent。

源码入口(v1.5):packages/coding-agent/src/core/model-runtime.ts、packages/coding-agent/src/core/auth-storage.ts、packages/coding-agent/src/core/model-resolver.ts、packages/coding-agent/src/core/virtual-models.ts、packages/ai/src/auth/resolve.ts、packages/coding-agent/src/core/model-registry.ts

MCP 与 Codemode(1.5)

TL;DR:早期 Pi 的 README 明确写着 "No MCP",主要顾虑是 MCP 会把一大堆工具 schema 塞进上下文。从 0.99(1.0 前夕)起,Pi 以内置扩展的形式加入了 MCP 客户端,同时加入了 Codemode 来化解这个顾虑。MCP 服务器默认不直接声明给模型,而是以 codemode 方式暴露:模型写一段 JavaScript,在 QuickJS 沙箱里用 searchTools() 找工具、并行调用 tools.mcp__server__tool()、过滤结果,最后只有脚本输出进入上下文。小而常用的工具可以设成 direct,大服务器可以设成 deferred 交给 tool_search 按需加载。这三块都走公开的 ExtensionAPI,可以整体关掉(--no-mcp、-builtin:mcp)或被第三方扩展替换。代价是产品面变大了:OAuth、暴露模式、worker 并发这些都成了 Pi 要维护的东西。

它解决什么问题

MCP 的价值在于一个已有生态:GitHub、Sentry、Figma、各家内部工具都提供 MCP 服务器,用户在 Claude Code、Cursor 里已经配好了。不支持 MCP,用户就得为每个服务自己写扩展或 CLI 包装。

传统接法的问题也很明显。把每个 MCP 工具都声明成模型可见的工具,一个 GitHub 服务器就是几十个工具定义,每轮请求都要带上它们;每次调用都是一次完整的模型往返,返回的大 JSON 原样进入上下文。工具越多,模型越难选对,上下文越贵,prompt cache 也越容易因为工具列表变化而失效。

Pi 的回答分两层:

  • MCP 客户端解决"连上":读配置、连接 stdio/HTTP 服务器、OAuth 登录、把服务器工具注册成 Pi 工具。
  • Codemode 解决"怎么用才不贵":工具不必声明给模型,模型写脚本调用;中间结果留在沙箱里,只有脚本输出进入上下文。

再加一个配套的 tool_search:在完全声明和完全通过脚本调用之间,提供"先搜索、再把命中的工具声明给下一次调用"的折中方案。

组成与运行形态

全部运行在 Pi 进程内,没有新的常驻服务:

  • packages/mcp(@earendil-works/pi-mcp):独立的小型 MCP 客户端,不依赖官方 MCP SDK,也不依赖其他 pi 包。提供 stdio 和 Streamable HTTP 两种传输、OAuth 子集,以及 toLlmContent() 把 CallToolResult 转成模型可读内容。不支持旧版 SSE 传输。
  • packages/codemode(@earendil-works/pi-codemode):在 QuickJS(编译为 WebAssembly)里运行模型写的 JS,唯一的能力是调用宿主注入的函数。Node/Bun 上每次执行占用一个 worker 线程,另有 Cloudflare Durable Object 版本和在调用线程内运行的版本。同样不依赖 pi。
  • coding-agent 的四个内置扩展:builtin:mcp、builtin:codemode、builtin:tool-search、builtin:llama.cpp。它们和第三方扩展用同一套 ExtensionAPI(注册工具、命令、事件处理),默认加载;pi config 里能看到,-builtin:mcp 可以关掉,第三方扩展注册了 /mcp 或同名的 codemode、tool_search 工具时,内置版本让位。
  • CLI:pi mcp add|remove|list|login|logout 不启动会话、不加载扩展,直接操作配置文件;会话内用 /mcp 查看、登录、重连、调整暴露方式。

这个分层是 Pi 一贯的做法:协议客户端和沙箱是可以单独复用的库,产品集成只是一个扩展,核心循环(pi-agent-core)并不知道 MCP 的存在。

配置:两层 mcp.json,项目层要信任

服务器配置在 <agent-dir>/mcp.json(用户级)和 .pi/mcp.json(项目级),格式与 Claude Desktop、Cursor 的 mcpServers 一致,可以直接复制过来。规则与 settings 类似(见 配置体系),但合并语义是按服务器名:

  • 项目级配置只在项目被信任后读取。stdio 服务器就是一条会被执行的命令,克隆来的仓库不能自动运行它。
  • 同名时项目条目整体替换用户条目;如果项目条目里没有 command、url、type,就只覆盖 enabled、exposure、toolExposure,保留用户层的 env、headers、auth。典型用法是"在这个项目里关掉某个个人服务器",而不会把凭据也冲掉。
  • env、headers 支持 ${VAR} 和 !command,所以 token 不必明文写进文件。文档建议带凭据的服务器只放用户级。
  • 单个条目无效只会报告并跳过,不影响其他服务器连接。
  • 扩展可以用 pi.registerMcpServer() 为当前会话加服务器;同名时文件配置优先。

OAuth:远程服务器拒绝未认证连接时,/mcp 会提示登录。Pi 通过受保护资源元数据(RFC 9728)发现授权服务器,校验 issuer,然后用 pi.dev 上的 Client ID Metadata Document 或动态注册来标识自己。token 存在 <agent-dir>/mcp-auth.json,过期或被拒绝时自动刷新;服务器要求更多 scope 时重新登录。这和模型 provider 的 auth.json 是两套独立的凭据。

命名:每个工具注册为 mcp__<server>__<tool>,非法字符替换为 _,冲突时加 hash 后缀。服务器名也是命名空间,describeNamespace("mcp__github") 和 --tools 'mcp__github__*' 都按它工作。

连接时机:会话开始时后台连接所有启用的服务器,不阻塞启动。第一次 prompt 最多只等 10 秒,而且只等含 direct 工具的服务器,因为那些工具必须出现在请求的工具声明里。其他服务器在真正需要时才等:codemode 脚本提到了 mcp__x,或调用了 searchTools()。

暴露模式:同一个工具,四种到达方式

exposure(服务器级)和 toolExposure(按工具名或通配符覆盖)决定模型怎么够到一个 MCP 工具:

模式模型看到什么适合
codemode(默认)看不到声明;只能在 codemode 脚本里通过 searchTools() / ALL_TOOLS 找到并调用一般服务器,尤其需要组合、过滤调用时
deferred先看不到;tool_search 命中后,下一次模型调用时声明大服务器,但希望找到后直接调用
direct像内置工具一样直接声明,脚本里也能调少量高频工具
hidden注册了但不可达屏蔽危险工具,如 delete_*

默认值选 codemode,是整个设计的关键:接入再多服务器,模型的工具声明列表也不变长。为了让模型知道有哪些服务器,系统提示词里有一个 mcp_servers 段,每个服务器一行描述(来自配置的 description 或服务器 instructions)。服务器连上后描述变了,Pi 会把新的段追加到对话里,而不是修改工具声明,这样前面的消息仍然能命中 prompt cache。基于同样的考虑,codemode 工具的描述里也不列 MCP 工具,因此它不会随服务器陆续连上而变化。

有 codemode 暴露的服务器连上时,Pi 自动启用 codemode 工具;有 deferred 暴露时,自动启用 tool_search。两者都没启用、导致非 direct 工具不可达时,Pi 会警告一次。

所有 MCP 调用都走 Pi 的统一工具管线:扩展的 tool_call / tool_result 处理器(包括权限确认)同样作用于 MCP 工具,脚本里发起的嵌套调用会带上 parentToolCallId。服务器声明的 readOnlyHint / destructiveHint 等注解会透传给扩展,供权限扩展判断哪些调用需要确认。也就是说,codemode 并没有绕过权限:脚本里的每一次 tools.x(),在 Pi 看来都是一次普通的工具调用。

场景:一个脚本完成十次工具调用

用户接了一个 GitHub MCP 服务器(几十个工具,默认 codemode 暴露),问:"最近两周带 bug 标签、涉及 auth 的 issue 有哪些?按严重程度排一下。"

  1. 模型看到的:系统提示词的 mcp_servers 段里有 github 一行;工具列表里只有 read、bash、edit、write 和 codemode。GitHub 的几十个工具一个都没有声明。

  2. 模型写脚本,作为 codemode 工具的输入(就是 JS 源码本身):

    // 示意:假设该服务器在 structuredContent 里返回结构化结果
    const [found] = await searchTools("list issues", { namespace: "mcp__github", limit: 1 });
    const res = await tools[found.name]({ owner: "acme", repo: "api", labels: ["bug"], since: "2026-10-22" });
    const issues = res.structuredContent.items.filter((i) => /auth|login|token/i.test(i.title + i.body));
    const details = await Promise.all(issues.map((i) => tools.mcp__github__get_issue({ owner: "acme", repo: "api", number: i.number })));
    return details.map((d) => ({ n: d.structuredContent.number, title: d.structuredContent.title, comments: d.structuredContent.comments }));
    
  3. Pi 执行:脚本进入一个 codemode worker(整个进程默认最多 2 个,超出就排队)。每个 tools.* 调用通过 worker 边界回到宿主,走正常的工具管线,命中 MCP 扩展,再由 pi-mcp 客户端发给服务器;Promise.all 里的调用是真正并行的。几百 KB 的 issue JSON 只存在于沙箱内存里。

  4. 回到模型的:只有 return 的那份精简列表,外加 Script completed 和耗时。模型据此排序、回答。

对比传统方式:同样的任务要几十个工具声明常驻上下文,外加 1 次列表调用和 N 次详情调用的模型往返,每次的完整 JSON 都留在历史里。Codemode 把 N 次往返压成 1 次,并把"过滤和组合"这一步从模型的上下文挪进了确定性的代码。

Codemode 的设计要点

  • 沙箱只有一种能力:调用工具。QuickJS 里没有 Node API、文件系统、网络和定时器;对外界的一切访问都必须经过 tools.*(以及调用分类器、图像等非聊天模型的 models.*),所以权限模型不变。脚本 VM 内存上限 256 MB,不能再嵌套启动 codemode。等待一个永远不会完成的 promise(没有挂起的工具调用、又没有定时器)会立即失败,不会卡住。
  • 找工具靠检索,不靠全量列出。ALL_TOOLS 列出全部可调用工具;searchTools() 用 BM25 排序;describeTool() 返回 TypeScript 声明;describeNamespace() 返回服务器说明和工具名。codemode 工具自身的描述里,内置和扩展工具的声明共享一个预算(codemode.inlineBudget,默认约 3000 token),放不下的留给检索。
  • 结果是结构化的。有输出 schema 的工具在脚本里返回结构化值:bash 返回 {output, exit_code, ...},非零退出码也正常返回;MCP 工具返回完整的 CallToolResult,isError 在脚本里不抛异常,交给脚本判断。脚本拿到的 bash 输出上限是 1 MiB,而模型直接看到的只有 50 KB / 2000 行,所以更适合在脚本里过滤大输出。
  • 跨调用状态存进会话树。store(key, value) 在脚本成功后以 codemode-store 自定义条目写入会话,恢复会话后仍在,每个分支只看到自己路径上写入的值(见 会话树与上下文压缩)。
  • 两种模式。codemode.mode: "on"(默认)下,原有工具保持声明,只在描述里补一句"也可以在脚本里调用";"only" 下把它们都从模型侧隐藏,模型只能通过脚本调用。
  • 非 LLM 模型的入口。分类器、图像生成模型不出现在 /model,模型通过脚本里的 models.classify() / models.generateImages() 使用,费用计入会话(见 模型与认证)。

它的局限也要说清楚:模型必须会写正确的 JS,脚本出错要再来一轮;工具调用是真实发生的,脚本中途失败时已完成的调用不会回滚;用户在 UI 上看到的是"一个 codemode 调用"加若干嵌套调用,不如逐个工具调用直观;worker 有并发上限,进程内跑多个子 agent 时需要调大 PI_CODEMODE_MAX_WORKERS。对单次、小结果的调用,direct 反而更省事。

和"最小 harness"哲学的张力

这一节值得在面试里坦白讲。Pi 的自我定位是 "a minimal, extensible agent harness"。0.8x 时代的 README 在 Philosophy 一节里明确写着 "No MCP.":要么写带 README 的 CLI 工具配合 skills,要么自己写扩展接 MCP,理由见作者的文章 What if you don't need MCP。核心论点是模型本来就会用 bash,按需读文档即可,不必为常驻的工具 schema 付上下文成本。0.99 内置 MCP 是一次立场调整,但调整方式仍然体现了原则:

  • 没有进核心。MCP、codemode、tool_search 都是内置扩展,用的是任何第三方扩展都能用的 API;pi-agent-core 和 pi-ai 不知道 MCP 的存在。可以整体关闭(--no-mcp、-builtin:mcp),也可以被 pi-mcp-adapter 这类第三方扩展完全接管。
  • 默认配置针对的就是当初的反对理由。默认 codemode 暴露意味着接入 MCP 不会增加工具声明;系统提示词变化以追加方式处理,保护 cache。
  • 协议和沙箱做成零依赖的独立包,不引入官方 SDK 的依赖树。

但张力是真实存在的。内置功能的面积明显变大:OAuth 的一串 RFC 细节(CIMD、DCR、RFC 9207 的 iss 校验、step-up scope)、四种暴露模式加按工具覆盖、/mcp 管理界面、worker 并发与排队、资源工具……这些都是 Pi 团队要长期维护、用户要理解的概念。"最小"从"不提供"变成了"提供,但默认克制、可以关掉"。对面试来说,比较好的表述是:Pi 的最小主义针对的是核心和默认的上下文成本,而不是功能清单。当一个生态(MCP)的需求大到用户必然会自己接时,与其让每个人各写一个质量参差的适配器,不如官方用扩展机制做一个默认克制、可替换的版本。

常见追问

  • 为什么默认不是 direct,也不是 deferred? direct 会让工具声明随服务器数量线性增长;deferred 每次用到都要先 tool_search 一轮,再在下一次调用时声明,并且声明会留在该分支上。codemode 让发现、调用、过滤在一个脚本里完成,上下文最稳定,所以作为通用默认值。
  • 为什么用 QuickJS 而不是 Node 的 vm 或子进程? 要的是"除了注入的函数什么都没有"的隔离:没有 require、没有网络、能限制内存、能中断。Node vm 不是安全边界,子进程又太重。QuickJS 编译成 WASM 后在 worker 里运行,这几点都能满足,同一套 API 还能跑在 Cloudflare 上。
  • Codemode 会绕过权限扩展吗? 不会。脚本里的每个 tools.x() 都回到宿主的工具管线,tool_call 钩子照常触发,并且能通过 parentToolCallId 看出是哪个脚本发起的。
  • 项目里的 .pi/mcp.json 有什么风险? stdio 服务器就是任意命令,所以它和项目扩展一样放在信任闸门之后;未信任的项目不会读取,用户级服务器则不受影响。
  • SDK 嵌入时能用吗? SDK 会话默认不加载内置扩展,需要自己把 MCP、codemode、tool-search 扩展加进资源加载器;或者完全不用 coding-agent,直接用 pi-mcp 加 pi-codemode 两个独立包,自己包装成 AgentTool。

源码入口(v1.5):packages/coding-agent/src/extensions/mcp/runtime.ts、packages/coding-agent/src/core/mcp-servers.ts、packages/coding-agent/src/extensions/codemode/tool.ts、packages/codemode/README.md、packages/mcp/README.md、packages/coding-agent/docs/mcp.md

运行模式:Interactive / Print / JSON / RPC / SDK

TL;DR:Pi 的所有模式共用同一个 AgentSession,模式只决定三件事:输入从哪来、事件怎么输出、进程活多久。Interactive 把事件渲染成全屏 TUI;Print 只输出最终文本;JSON 把事件流写成 JSONL 后退出;RPC 是被宿主拉起的长生命周期子进程,stdin 收 JSONL 命令、stdout 回响应和事件,扩展的对话框也走这条通道;SDK 则不是 CLI 模式,宿主直接在自己的 Node/Bun 进程里 createAgentSession()。选型口诀:人用 Interactive,脚本要结果用 Print、要过程用 JSON,其他语言或要进程隔离用 RPC,TypeScript 宿主要完整控制用 SDK。

为什么能共用一个核心

主笔记里讲过,会话的全部产品逻辑都在 AgentSession 里:输入预处理、扩展钩子、上下文投影、持久化、压缩、重试。它对外只暴露两类接口:

  • 方法:prompt() / steer() / followUp() / abort() / compact() / setModel() / navigateTree() ……
  • 事件:session.subscribe(listener) 收到的 AgentSessionEvent(消息、工具执行、队列、压缩、重试、agent_settled 等)。

会话的新建、切换、分叉由外面一层 AgentSessionRuntime 负责。于是每个模式都只是一个“I/O 外壳”:把自己的输入翻译成方法调用,把事件翻译成自己的输出。main.ts 创建好 runtime 后按模式分发:

main.ts
  └─ createAgentSessionRuntime(createRuntime, ...)
       ├─ --mode rpc              → runRpcMode(runtime)
       ├─ 交互终端(默认)         → new InteractiveMode(runtime).run()
       └─ -p / --mode json / 管道 → runPrintMode(runtime, { mode: "text" | "json" })
SDK:宿主自己调 createAgentSession() / createAgentSessionRuntime(),不经过 main.ts

模式判断规则:--mode rpc → RPC;--mode json → JSON;显式 -p/--print 或者 stdin / stdout 任一不是 TTY → Print;否则 Interactive。所以 echo "总结一下" | pi 会自动走 Print。非交互模式还会接管 stdout,把普通日志改写到 stderr,保证 stdout 只有结果或协议记录。

每个模式都要处理一件共同的事:runtime 换了会话之后,旧 session 上的订阅就失效了。它们都通过 runtime.setRebindSession() 注册回调,在替换后重新 session.bindExtensions({...}) 并重新 subscribe()。bindExtensions 时各模式传入自己的 UI 能力和“命令上下文动作”(新建会话、fork、切换、reload 等),扩展通过 ctx.mode 知道自己运行在 tui / print / json / rpc 哪种外壳里。

一句话:会话核心和 I/O 外壳分离,模式之间只差输入来源、事件出口和进程生命周期。

各模式拿事件做什么

Interactive。 最重的外壳:全屏 TUI(1.0 起默认,tuiMode: "regular" 可回到普通滚动缓冲)、编辑器、快捷键、各种选择器(模型、会话树、设置)、diff 和工具执行渲染。它订阅 session 事件,把 message_update 增量渲染成流式文本,把 tool_execution_* 渲染成工具卡片,把 auto_retry_*、compaction_* 渲染成倒计时和提示。用户在 agent 运行时继续输入:Enter 发出的是 steer(当前回复及其工具调用结束后引导下一次回复),Alt+Enter 发出的是 follow-up(等整个任务做完);中止时队列里的消息退回编辑器。扩展可以用真实的终端 UI(自定义组件、footer、编辑器替换等)。它没有重写任何会话逻辑。

Print(text)。 一次性执行:创建 runtime → bindExtensions → 依次 await session.prompt(...) → 打印最后一条 assistant 文本 → 退出。中间事件不输出。退出码有讲究:最后的回复 stopReason 为 error 或 aborted 时错误写 stderr、退出码非 0;因输出 token 上限被截断(length)时仍打印部分文本,但在 stderr 警告并返回非 0。适合命令替换、管道、CI 里的一次性任务。

JSON。 同样是一次性执行,所有 prompt 在启动时给定,跑完就退出,不接受后续命令。区别在输出:先写一条会话 header,然后把每个 session 事件序列化成一行 JSON。为了让流的体积线性增长,线上的 message_update 只带增量(去掉了 SDK 事件里的累计 message 和 partial 快照),消费方应该用 delta 拼实时显示,再用 message_end 的权威消息替换。JSON 模式下失败的回复只体现在事件流里,不会单独让退出码变非 0,需要调用方自己看事件。它还订阅了底层 agent 事件来等待 stdout 背压,避免消费方读慢时内存堆积。

RPC 和 SDK 下面单独讲。

RPC:一条进程边界上的 JSONL 协议

RPC 解决的问题是:宿主不是 TypeScript,或者想把 agent 放进独立进程(崩溃隔离、权限隔离、IDE 插件、自定义前端),但又需要双向控制,而不是 Print/JSON 那种“一次输入、一次输出”。

启动方式是 pi --mode rpc(常配 --no-session),其他 CLI 选项照常决定 cwd、模型、工具和资源。进程是长生命周期的,宿主关闭 stdin 即请求有序退出,Pi 会先 dispose runtime。

协议由四类记录组成:

方向记录作用
stdin命令prompt、steer、follow_up、abort、get_state、set_model、compact、set_auto_retry、bash、switch_session、fork、get_tree 等
stdoutresponse某条命令是否成功及返回数据,带回命令的 id
stdout会话事件与 JSON 模式同一套事件(消息、工具、队列、压缩、重试、agent_settled)
双向扩展 UI 记录extension_ui_request / extension_ui_response

几个设计点值得在面试里讲:

响应和完成是两回事。 prompt 命令的成功响应只说明“已受理”,data.disposition 是 started(开始了一次 run)、queued(作为 steer/follow-up 排队)或 handled(被扩展命令或 input 处理器吃掉,不会有 run)。实现上 RPC 不 await session.prompt(),而是用 preflightResult 回调在预检通过时立即回响应,之后的进展全靠事件。客户端要一直读事件直到 agent_settled——agent_end 之后还可能有重试、溢出恢复、排队消息。provider 失败也不会变成失败响应,而是出现在消息和事件流里;失败响应只表示命令本身没处理成功(比如模型不存在、JSON 解析失败)。

按 id 关联,不按顺序。 命令处理是异步的,多个命令可以同时在途,所以每条命令带一个可选 id,响应原样带回。事件一般不带 id,因为它描述的是会话活动而不是某个请求;唯一例外是 RPC 专属的 bash_execution_update,它带回发起 bash 命令的 id,方便把输出块对应回去。

严格的 LF 分帧。 每条记录一行,只按 \n 切分。不能用 Node 的 readline,因为它还会在 U+2028 / U+2029 处断行,而这两个字符在 JSON 字符串里是合法的,模型输出里也真的会出现。stdout 只放协议记录,诊断一律走 stderr;Pi 尊重 stdout 背压,客户端不读 stdout 会让 Pi 卡住。

扩展 UI 也能跨进程。 扩展调用 ctx.ui.select / confirm / input / editor 时,RPC 模式把它变成一条 extension_ui_request(带唯一 id)写到 stdout,阻塞等待客户端在 stdin 回 extension_ui_response;带 timeout 的对话框超时由 Pi 一侧自动用默认值结束。notify、setStatus、setWidget、setTitle 等是只发不收的通知,客户端可以显示也可以忽略。依赖真实终端的能力(custom() 组件、自定义 footer/header、编辑器替换、主题切换)在 RPC 下是空操作或返回失败。此时 ctx.hasUI 为 true(对话框可用),想用 TUI 专属能力的扩展应判断 ctx.mode === "tui"。

有官方客户端。 TypeScript 宿主可以直接用导出的 RpcClient:它负责拉起子进程、解析 JSONL、关联请求和响应、提供类型化的命令方法,promptAndWait() 会在发送 prompt 之前先装好事件监听,避免快速完成时漏掉事件。

一句话:RPC 把 AgentSession 的方法映射成 stdin 命令、把事件原样搬到 stdout,响应只代表受理,完成以 agent_settled 为准。

SDK:在宿主进程里嵌入 Pi

SDK 不是一种 CLI 模式,而是同一个 npm 包的库用法:

import { createAgentSession } from "@earendil-works/pi-coding-agent";

const { session } = await createAgentSession();
const unsubscribe = session.subscribe((event) => { /* 接自己的 UI */ });
try {
  await session.prompt("解释这个仓库的启动流程");
} finally {
  unsubscribe();
  session.dispose();
}

不传参数时,它和 CLI 一样使用当前目录、发现的资源、已存储的设置和凭据。每一条边界都可以替换:modelRuntime / model / thinkingLevel 控制模型,settingsManager 可以是内存配置,sessionManager 可以是 SessionManager.inMemory()(不落盘),resourceLoader 控制扩展、skills、模板、context files,tools / excludeTools / customTools 控制工具。需要 /new、切换、fork 时用 createAgentSessionRuntime(),并在替换后重新订阅新 session。

几个嵌入时容易踩的点:

  • await session.prompt() 在 run settle 之后才返回,包括自动重试和压缩恢复;agent 正在运行时再调 prompt() 必须指明 streamingBehavior,否则直接拒绝。
  • 会话树才是上下文权威:恢复外部历史要构造带这些条目的 SessionManager,给 session.agent.state.messages 赋值不会改变后续请求。
  • CLI 默认加载的内置扩展(codemode、tool_search、MCP)在 SDK 里不会自动加载,要自己加进 DefaultResourceLoader 的 extensionFactories;MCP 在 session_start 时连接服务器,所以还要调用 session.bindExtensions()。
  • 用完调用 session.dispose():中止进行中的工作、使扩展上下文失效、断开与 agent 的连接。

像 OpenClaw 这样以 Pi 为内核做自己产品的宿主,走的就是这条路:直接在自己的进程里创建会话、注入自己的工具和资源加载、订阅事件接到自己的界面或消息通道上,不需要再跨进程转协议。

什么时候选哪种

模式进程边界生命周期适合
Interactive本进程,终端 UI直到用户退出人直接使用
Print本进程,stdout 最终文本一次调用脚本只要最终答案:命令替换、管道、CI
JSON本进程,stdout JSONL 事件一次调用脚本要结构化过程:进度、工具调用、成本
RPC子进程,JSONL 双向长期非 TS 宿主、IDE、自定义前端、需要进程隔离
SDK无边界,同进程由宿主决定Node/Bun 宿主要完整 API 和最低开销

判断顺序可以这样说:先问“是不是人在用”,是就 Interactive;再问“要不要中途交互/控制”,不要就是 Print 或 JSON(看要结果还是要过程);要的话再问“宿主是不是 TypeScript 且接受同进程”,是就 SDK,否则 RPC。

常见追问

为什么 RPC 不用 HTTP 或 WebSocket? stdio 不需要端口、鉴权和服务发现,进程生命周期天然绑定宿主,任何语言都能 spawn 子进程并读写管道;Pi 本身也没有常驻服务的概念。代价是一条连接只服务一个宿主,并且宿主要自己处理子进程崩溃和超时。

RPC 和 JSON 模式的事件有什么区别? 是同一套事件和同一种增量编码。JSON 模式开头多一条会话 header;RPC 多了 response、bash_execution_update、extension_error 和扩展 UI 子协议,会话 id 要用 get_state 查。

SDK 和 RPC 都能用 TypeScript 驱动,怎么取舍? SDK 拿到的是完整对象和带累计快照的事件,能直接注入自定义工具和资源加载;RPC 拿到的是协议子集,但换来进程隔离——扩展或工具崩了不会拖垮宿主,也更容易限制权限。

交互模式里能用的扩展,在 Print/RPC 里一定能用吗? 不一定。扩展逻辑都能跑,但 UI 能力按模式降级:RPC 只支持对话框和通知,Print/JSON 没有 UI。扩展应该检查 ctx.hasUI 和 ctx.mode 再决定是否交互。

源码入口(v1.5):packages/coding-agent/src/main.ts、packages/coding-agent/src/modes/print-mode.ts、packages/coding-agent/src/modes/rpc/rpc-mode.ts、packages/coding-agent/src/modes/rpc/rpc-client.ts、packages/coding-agent/src/modes/interactive/interactive-mode.ts、packages/coding-agent/docs/rpc.md

错误处理与重试

TL;DR:Pi 的错误处理是分层接力的:pi-ai 的 provider 适配器把异常吞掉,变成一条 stopReason: "error" 的标准 assistant 消息;agent-core 看到错误就干净地结束本次 loop,不做任何重试决策;真正的策略在 coding-agent 的 AgentSession:可重试的瞬时错误按 2s → 4s → 8s 退避,最多重试 3 次,失败的那次回复从模型上下文中省略后 continue();上下文溢出不走普通重试,而是先压缩再重试一次;额度、计费类错误直接失败。另外 pi-ai 里还有一层默认关闭的“请求级重试”,不要和 AgentSession 的“回合级重试”混为一谈。

先看一个具体错误

交互模式里偶尔会看到:

Retry failed after 3 attempts: fetch failed

它的意思是:模型请求遇到网络/传输错误 fetch failed,Pi 认为这可能是瞬时问题,自动重试了 3 次(加上第一次,共 4 次模型调用),仍然失败,最后由 UI 拼出这句提示。它不是 Pi 进程崩溃,也不是工具执行出错——工具抛异常走的是另一条路:agent-core 把它包装成 isError: true 的工具结果交还给模型,让模型自己调整,不会触发这里的重试。本轮请求结束后会话仍然可用,可以继续输入。

下面沿着这条错误从底层往上走一遍。

第一层 pi-ai:把异常变成消息

provider 适配器(例如 OpenAI Completions)把整个“建立请求 + 读取流”包在 try/catch 里。无论是请求失败、流中途断开,还是 provider 返回的错误体,都会被转换成:

AssistantMessage { stopReason: "error", errorMessage: "fetch failed", ...已收到的部分内容 }

然后通过流的 error 事件交出去,而不是继续向上抛异常。用户主动中止时同样走这条路,只是 stopReason 是 aborted。错误文本由统一的归一化逻辑生成,主要保留 HTTP 状态、响应体和 error.message。

这样设计的好处是上层永远只需要处理“一条消息”,不用区分各家 SDK 抛出的异常类型;坏处是信息可能被压扁——Node 的 DNS、TLS、代理、连接重置等具体原因常藏在 error.cause 里,只看 fetch failed 无法进一步判断是哪一种。

pi-ai 同时提供了两个纯分类函数,供上层做决策:

  • isRetryableAssistantError(message):先匹配“不可重试”名单(额度耗尽、billing、订阅用量上限等),命中直接返回 false;否则匹配“可重试”名单:过载、限流、429/5xx、fetch failed 等连接错误、超时、流提前结束等。负面规则优先,所以同时出现 429 和 quota exceeded 时会判为额度问题、立即失败。
  • isContextOverflow(message, contextWindow):识别上下文溢出,包括 provider 明确报错、成功返回但输入 token 已超过窗口的“静默溢出”,以及服务端截断输入导致输出为 0 的 length 停止。

注意它们只做分类,不实现重试策略——策略留给调用方。

第二层 agent-core:结束本次 run,不管重试

agent loop 拿到这条 assistant 消息后,只要 stopReason 是 error 或 aborted,就调用 finishTurn、发 turn_end 和 agent_end,然后返回:

  • 不执行错误回复里可能残留的工具调用;
  • 结束当前 agent run;
  • 把错误留给上层。

它不决定是否重试。这和 agent-core 的定位一致:它是通用 runtime,重试次数、退避、是否压缩都是产品策略,由宿主决定。

第三层 AgentSession:真正的回合级自动恢复

AgentSession 在 message_end 时把每条 assistant 消息先落盘到会话 JSONL,并记下“最后一条 assistant 消息”。发给订阅者的 agent_end 会附带 willRetry,让 UI 提前知道后面还有动作。

真正的恢复发生在 AgentSession 的 run 循环里:底层 loop 结束后做一次 run 后检查,需要继续就 agent.continue() 再跑一次,直到没有自动工作为止,最后发 agent_settled。对一条错误消息,它按顺序判断:

1. 是否可重试。 先排除上下文溢出(溢出交给压缩),再调用 pi-ai 的 isRetryableAssistantError()。可重试时:

  • 读取 settings.retry,默认 enabled: true、maxRetries: 3、baseDelayMs: 2000,单次退避上限 maxAgentDelayMs 60 秒;
  • 计数加一,超过上限就不再重试;
  • 延迟为 baseDelayMs × 2^(attempt-1),即 2s、4s、8s,并发出 auto_retry_start { attempt, maxAttempts, delayMs, errorMessage };
  • 把失败的回复(以及它残留的工具结果)从模型上下文中省略:做法是向会话追加 context_edit 条目(replacement: null),原始失败记录仍保留在 JSONL 里便于追溯,但后续请求的投影里看不到它。这样模型不会看到上一次残缺的输出,也不会出现“工具调用没有配对结果”导致下一次请求被 provider 拒绝;
  • 可中止地 sleep(abort、RPC 的 abort_retry 都能打断),然后返回 true,由循环调用 agent.continue() 从原来的用户消息继续。

2. 重试成功。 下一条非错误的 assistant 消息在 message_end 时就会触发 auto_retry_end { success: true } 并把计数清零——计数按“连续失败”算,不会在一个长任务的多次模型调用之间累积。

3. 重试耗尽或不可重试。 如果是错误且之前重试过,发 auto_retry_end { success: false, attempt, finalError },交互模式据此显示 Retry failed after 3 attempts: ...。不可重试的错误(额度耗尽、401、无效参数等)不进入重试,直接作为最终结果。如果在退避期间被中止,则以 finalError: "Retry cancelled" 结束。

所以 “after 3 attempts” 指的是 3 次重试,实际最多发生 1 + 3 = 4 次模型调用。

一句话:pi-ai 负责分类,agent-core 负责停下,AgentSession 负责决定退避多久、要不要省略失败回复、何时 continue。

上下文溢出:先压缩,再重试一次

上下文溢出如果按普通重试处理,只会原样失败 4 次。所以 run 后检查在重试判断之后再做一次压缩检查,区分几种情况:

  • 溢出类错误(provider 明确报超长、静默溢出、服务端截断)或可恢复的 length 截断(输出在模型原定上限之前就被截断,通常是上下文挤压所致):先把这次失败回复省略,再以 reason: "overflow" 运行自动压缩,成功后 compaction_end 带 willRetry: true,循环 continue() 重新请求。
  • 只尝试一次:同一条用户消息内,如果压缩后重试仍然溢出,就发 compaction_end(带错误)和失败事件,提示“Context overflow recovery failed after one compact-and-retry attempt”,建议缩减上下文或换更大窗口的模型。每次新的用户消息开始时这个标记才重置。
  • 回复其实成功了但已超窗口:压缩,但不重试——一条完整的回复无法用 continue() 接着生成。
  • 未出错但超过阈值:普通的阈值压缩(reason: "threshold"),也不重试。

还有两个防误判的细节:消息来自另一个模型(用户刚从小窗口模型切到大窗口模型)时不按溢出处理;早于最近一次压缩的旧消息不会再次触发压缩。

此外,阈值压缩不只在 run 结束后检查:每轮之间和每个 run 开始前也会估算投影后的 token,超过阈值就提前压缩,尽量让长任务根本不走到溢出这一步。压缩本身也要调模型生成摘要,这次调用使用同一套 settings.retry 预算和退避(通过 pi-ai 的 retryAssistantCall),并发出单独的 summarization_retry_* 事件。压缩算法本身见 会话树与上下文压缩。

两套重试不要混淆

Pi 实际上有两层重试,排查问题时必须分清看到的是哪一层:

请求级重试(pi-ai)回合级重试(AgentSession)
位置provider 适配器里的 retryProviderRequest()AgentSession 的 run 后处理
配置retry.provider.maxRetries,默认 0retry.maxRetries,默认 3
判断依据HTTP 状态 408/409/429/5xx、无状态的连接异常、x-should-retry 头错误文本分类(isRetryableAssistantError)
覆盖范围主要是建立请求阶段,不一定覆盖响应已开始后的流中断整个回复,包括流中断和 provider 在流里报的错
对外可见无事件,上层只看到最终结果auto_retry_start / auto_retry_end 事件,UI 有倒计时

请求级重试复刻了 OpenAI/Anthropic SDK 的策略(尊重 retry-after,否则指数退避),存在的原因是 SDK 自带的重试计时器不响应 AbortSignal,所以 Pi 让 SDK 以 maxRetries: 0 调用、自己包一层可中止的版本。它默认关闭,文档也建议保持 0:请求级重试会在底层悄悄等待,延迟 Pi 识别额度和用量上限错误。

两层之间还有一个衔接:如果服务端要求的 retry-after 超过 retry.provider.maxRetryDelayMs(默认 60 秒),请求级直接失败,错误文本是 “Server requested Ns retry delay (max: Ms)”,其中的 retry delay 正好命中回合级的可重试规则,于是交给外层带事件、可中止的重试来处理,用户能看到也能取消。

所以看到 Retry failed after 3 attempts 时,可以确定提示来自外层回合级重试;内层默认根本没有重试。

各模式看到的是什么

  • Interactive:auto_retry_start 显示倒计时,auto_retry_end 失败时显示最终错误;溢出恢复显示压缩提示。
  • Print:最终回复 stopReason 为 error / aborted 时把错误写 stderr、退出码非 0。
  • JSON / RPC:重试、压缩事件原样出现在事件流里。RPC 命令的成功响应只代表受理,provider 失败体现在消息和事件中,不会变成失败响应;set_auto_retry 可开关回合级重试,abort_retry 可取消等待中的重试。
  • SDK:await session.prompt() 在所有自动重试和恢复结束、发出 agent_settled 之后才返回;需要判断成败时检查最后一条 assistant 消息的 stopReason。

细节见 运行模式。

常见追问

为什么重试前要把失败回复从上下文里去掉,而不是直接删掉? 会话 JSONL 是追加式的事实记录,删掉会丢失排障信息;用 context_edit 省略只影响模型看到的投影,既保留了历史,又避免模型基于残缺输出继续,或因工具调用缺少配对结果被 provider 拒绝。

为什么额度错误要放在“负面优先”? 很多 provider 用 429 同时表示“稍后再试”和“额度用完”。后者要几小时甚至到下个计费周期才恢复,按瞬时错误退避重试只会浪费时间、推迟提示用户。

为什么用错误文本匹配而不是结构化错误码? 错误要穿过几十家 provider、代理网关和传输层,到 AgentSession 时只剩归一化后的消息;文本匹配是唯一跨 provider 一致的信号。代价是需要持续维护模式表,源码里每条规则基本都对应一个真实 issue。

溢出为什么只恢复一次? 压缩后仍然溢出,说明保留的近期内容本身就放不下(比如一个巨大的工具结果),继续循环只会反复压缩;交给用户换模型或缩减上下文更合理。

源码入口(v1.5):packages/ai/src/utils/retry.ts、packages/ai/src/utils/overflow.ts、packages/ai/src/utils/provider-retry.ts、packages/agent/src/agent-loop.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/src/core/settings-defaults.ts

从零理解 Agent 终端 TUI:以 pi-tui 为例

TL;DR:pi-tui 是一个进程内的终端 UI 库,不依赖其他 pi 包,pi-coding-agent 的交互模式就建在它上面。它的核心约定是组件只做一件事:render(width) 返回字符串行数组;TUI 负责合并重绘请求、把输入交给焦点组件、合成 overlay,再用差分算法只重写变化的行,并用同步输出(CSI 2026)避免闪烁。1.0 起交互模式默认全屏:在 alternate screen 里由程序自己管理 transcript 视口和滚动,tuiMode: "regular" 可以退回主屏幕、交给终端原生 scrollback。接入 Agent 时,InteractiveMode 订阅 AgentSession 事件,message_update 每次用完整消息快照刷新同一个消息组件,而不是往后追加 token,再调用 requestRender()。原则就一句:Agent 决定发生了什么,TUI 决定怎样显示。

这篇是教程,目的不是复刻 pi-tui,而是讲清楚 Agent 终端界面为什么需要这些抽象、哪些细节绕不开,以及 pi 在 v1.5 里是怎么做的。

先说清运行形态:pi-tui(@earendil-works/pi-tui)是一个普通的 npm 库,和 pi-coding-agent 跑在同一个 Node 进程里,没有独立的渲染服务或守护进程。它的运行时依赖只有三个:marked、marked-cjk-friendly(Markdown)和 get-east-asian-width(字符宽度),不依赖 pi-ai、pi-agent-core。交互模式(pi 不带 -p 运行时)创建一个 TUI 实例,挂上组件,订阅会话事件;Print、JSON、RPC 模式完全不加载这一层(见 运行模式)。扩展也通过 ctx.ui 使用同一个 TUI 实例,官方文档明确要求扩展不要再自己建第二个渲染器。

1. 先分清:CLI、TUI 与 Agent

普通 CLI 是一次性的:读参数、执行、打印、退出。TUI 是常驻的交互循环:不断接收键盘输入和后台事件,修改内存状态,重绘终端里的一部分内容。Agent 又多了一条异步事件流:一次提交可能产生文本增量、thinking、工具调用参数、工具的流式输出,最后是完成、中止或错误。

所以 Agent TUI 不是"给聊天接口加点颜色",而是两个事件循环的接合:

键盘/粘贴/resize ──> TUI 输入分发 ──> 编辑器 onSubmit ──> AgentSession.prompt()
                                                              │
终端字节流 <── 差分渲染 <── 组件树 <── InteractiveMode.handleEvent() <── 会话事件

最重要的分层是:Agent 决定发生了什么,TUI 决定怎样显示。渲染组件不调用模型,Agent 运行时不拼 ANSI 字符串。正因为有这条边界,同一个 AgentSession 才能同时服务 TUI、JSON 流和 RPC。

一句话:Agent TUI 是"输入事件循环"和"Agent 事件流"在组件状态上的汇合点。

2. 终端究竟提供了什么

终端不是浏览器,没有 DOM,也没有"修改第三个消息节点"这种接口。程序能做的只有两件事:从 stdin 读字节,往 stdout 写字符和控制序列。所有能力都从这两条路上长出来。

ANSI 控制序列是夹在文本里的终端命令:\x1b[31m 设颜色,\x1b[2A 光标上移两行,\x1b[2K 清行,\x1b[?25l 隐藏光标,\x1b[?2026h … \x1b[?2026l 是同步输出,让终端把中间的一批更新一次性显示出来。这些序列应该集中在终端适配器和渲染器里,业务组件不该到处写 \x1b[。OSC 一类序列还承载了更高级的能力:OSC 8 超链接、OSC 52 剪贴板、OSC 133 提示符标记、OSC 7501 程序状态,后面会提到。

raw mode:终端默认是 canonical mode,用户按回车后程序才收到整行,终端还会自动回显。TUI 必须切到 raw mode,才能立即收到方向键、Backspace、Escape。代价是 Ctrl+C 不再自动变成 SIGINT,应用要自己识别;而且无论正常退出还是崩溃,都必须把终端恢复原状,否则用户的 shell 会"键盘坏掉"。pi-tui 的 ProcessTerminal 在 stop() 里恢复各种终端模式,1.4 起还注册了进程 exit 处理器,即使没走到 stop() 也会同步恢复鼠标跟踪、Kitty 键盘协议、bracketed paste 和 alternate screen。

bracketed paste:终端用 \x1b[200~ 和 \x1b[201~ 包住粘贴内容,这样一大段粘贴不会被当成一串独立按键(比如里面的换行被当成回车提交)。

字符数不等于终端列宽:ANSI 序列占 0 列,大多数汉字占 2 列,emoji 可能由多个 code point 组成却只显示一个字形,组合字符不额外占列。所以 text.length 和 slice(0, width) 都不能用。pi-tui 用 visibleWidth()、truncateToWidth()、sliceByColumn()、wrapTextWithAnsi() 统一处理测宽、截断和换行,组件只调用这些函数。

主屏幕和 alternate screen:终端有两个缓冲区。主屏幕就是平时的 shell 输出,内容会滚进终端自己的 scrollback;alternate screen 是 vim、less 用的那块"全屏画布",没有 scrollback,退出后主屏幕原样恢复。这个选择决定了谁拥有滚动:在主屏幕上终端拥有滚动历史,程序只能改底部还在视口里的部分;在 alternate screen 上程序拥有整个视口,滚动、选择文本、搜索都要自己实现。pi 1.x 的两种模式就是这两种选择,见第 5 节。

3. 核心抽象

Terminal:隔离真实终端

Terminal 接口把 stdin/stdout、raw mode、尺寸、光标移动、清行清屏这些细节藏起来,是渲染器和操作系统之间的接缝。它有两个实现:ProcessTerminal 连接真实的 stdin/stdout,并负责 bracketed paste、Kitty 键盘协议协商、终端标题和程序状态上报;VirtualTerminal 在测试目录里,基于 @xterm/headless 模拟一个真实终端。这些协议细节都不会泄漏给组件。

Component:宽度进去,行数组出来

pi-tui 最关键的接口非常小:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  invalidate(): void;
}

每个字符串是一行。组件知道可用宽度,却不知道自己最终会落在终端第几行。这是一个"深模块":调用者只需要会调 render(width),组件内部可以做 Markdown 解析、换行、着色、光标定位和缓存。

这个约定带几条硬约束:每行的可见宽度不能超过 width,超了 TUI 会直接报错;render() 应该是确定性的,不发网络请求、不启动 Agent、不写 stdout;invalidate() 只清理渲染缓存,不直接重绘。TUI 会在每行末尾追加 SGR 重置和 OSC 8 重置,所以样式不会跨行延续,多行文本要逐行重新上色(wrapTextWithAnsi() 会自动处理)。

Container 与布局

终端天然按行排列,所以最基本的布局就是 Container:把子组件的行依次拼起来。pi-tui 没有把 Flexbox 搬进终端;在主屏幕模式下,聊天历史、状态区、编辑器和页脚依次放进根容器就够了。

全屏模式多了一层约束:画面高度固定为终端高度,transcript 要能独立滚动,编辑器要钉在底部。为此 1.x 增加了 VStack、HStack(按 basis/grow/shrink/minSize 分配区域)和 ScrollView(为一个区域负责滚动),通过 setLayoutRoot() 设置。这些布局语义只在 alternate screen 渲染器上生效,因为在主屏幕上滚动归终端管。

TUI:一个接口,两个渲染器

TUI 本身也是组件(它是根容器),同时是渲染引擎:保存上一帧画面,合并重绘请求,把输入交给焦点组件,管理 overlay 栈,比较新旧画面生成 ANSI 更新,负责启动和恢复终端。1.x 把它拆成了一个共享接口加两个实现:

  • TuiMainScreen(tui-main-screen.ts):渲染到主屏幕,保留终端 scrollback;
  • TuiAltScreen(tui-alt-screen.ts):在 alternate screen 里渲染一个终端高度的视口,自己处理滚动、鼠标、文本选择和搜索;停止时恢复主屏幕并打印完整的最终文档。

公共逻辑(渲染调度、输入分发、焦点、overlay 合成、终端颜色查询)在 tui.ts 的 TuiBase 里。应用代码面向 TUI 接口编程,只在构造时选具体渲染器。

焦点、光标与 IME

根 TUI 收到输入后不会广播给所有组件,只交给当前焦点组件(setFocus())。在此之前还有几道拦截:终端颜色查询的回复会先被消费掉;addInputListener() 注册的全局监听器可以改写或吞掉输入;Kitty 协议的按键释放事件默认被过滤,除非组件声明 wantsKeyRelease。

编辑器里有两种"光标":字符串中的逻辑位置,和屏幕上的硬件光标。中文输入法的候选窗口跟随硬件光标,所以光标位置必须准确。pi-tui 的做法是让可聚焦组件(实现 Focusable)在自己的输出里插入一个零宽的 APC 序列 CURSOR_MARKER,渲染器在最终画面里找到它、算出前面的可见宽度、删掉标记,再把硬件光标移过去。这样组件不必知道自己在第几行,IME 也能拿到正确位置。组件声明"光标在这里",渲染器负责"怎样定位终端光标",这是一个很好的接缝。1.2 起组件画出来的假光标也改成用 renderFakeCursor() 包一层标记,由 TUI 统一渲染成反色;打开 showHardwareCursor(或环境变量 PI_HARDWARE_CURSOR=1)时只显示真实光标。包着 Input/Editor 的容器必须把 focused 状态传给子组件,否则候选窗口会出现在错误位置。

Overlay

自动补全、模型选择器、确认对话框适合做 overlay。showOverlay() 返回一个 handle,可以隐藏、重新显示、聚焦、释放焦点;TUI 会记住打开 overlay 前的焦点,关闭后恢复。overlay 在差分之前按终端列合成进主画面(compositeOverlays()),差分比较的是合成后的最终画面。顺序反过来的话,渲染器就没法清理 overlay 的旧位置。

一句话:组件只回答"画面是什么",TUI 决定"怎样、何时写进终端",Terminal 隔离"写到哪个真实设备"。

4. 渲染管线与差分渲染

调度:生产者只请求,渲染器决定何时画

Agent 的 token 增量可能非常密集,如果每个 token 都立即重绘,就会闪烁、CPU 空转。pi-tui 的规则是:状态变化后只调用 requestRender(),由渲染器合并。

requestRender()
  已有待处理请求 → 直接返回(合并)
  否则 nextTick → scheduleRender()
      距上次渲染不足 16ms → setTimeout 补足剩余时间
      到点 → doRender();渲染期间又有新请求 → 再调度一次

键盘输入走另一条路:焦点组件处理完按键后,TUI 调用立即渲染,跳过 16 ms 节流,并取消已排队的节流帧。源码注释给的原因是按键对延迟敏感,而 Windows 上连 setTimeout(0) 都可能等满一个 16 ms tick。所以流式输出最多约 60 帧每秒,打字却是即时响应。

差分:只重写变化的部分

每次渲染先得到组件树的 nextLines(已经合成了 overlay),再和上一帧逐行比较,找出第一处和最后一处变化。TuiMainScreen 有三种策略:

  1. 首次渲染:直接输出所有行,不清 scrollback;
  2. 宽度变化,或变化发生在已滚出视口的区域:清屏全量重绘(软换行全部失效,或者改不到 scrollback 里的内容);
  3. 普通更新:光标移到第一处变化行,清到末尾,重写变化的行。

TuiAltScreen 只维护一个终端高度的视口,就地更新变化的视口行;用户在底部时自动跟随流式输出,手动滚上去后即使内容在增长也保持位置。两个渲染器都把一帧的全部写入包在同步输出 \x1b[?2026h … \x1b[?2026l 里;支持该协议的终端一次显示整批变更,不支持的会忽略它,功能照常。

自己从零写的话,入门版本 resize 时直接清屏全量重绘就行;内容缩短后的残留行、多行内联图片、硬件光标和逻辑光标不在同一行这些情况,等真遇到再处理。

5. 1.x 值得知道的变化

默认全屏。1.0 起交互模式默认 tuiMode: "fullscreen",也就是 TuiAltScreen;设置 "regular" 或传 --tui-mode regular 回到主屏幕。交互模式通过 createInteractiveTui()(tui-renderer.ts)这一个组合根选择渲染器。全屏布局在 chat-viewport.ts:上面是 follow: "end" 的 transcript ScrollView,下面是固定的输入区 VStack,依次放待发送消息、状态行、扩展 widget、编辑器和页脚。全屏模式下程序拥有视口,所以多出了一组能力:鼠标滚轮和触控板滚动、拖动选择并通过 OSC 52 复制(fullscreenCopyOnSelect)、可拖动的滚动条、Ctrl+Shift+F 搜索 transcript、按 OSC 133 标记在消息之间跳转、点击 OSC 8 链接打开。退出时由 fullscreenExitOutput 决定留下什么:默认 "transcript" 会先切到主屏幕渲染器、把完整对话打印进 scrollback,再停止;"resume-hint" 只留一行恢复提示。

两种模式可以在 /settings 里实时切换。switchTuiMode() 把旧渲染器的子组件和焦点原样搬到新渲染器上,组件实例不重建;组件持有的 TUI 引用是一个 Proxy(createInteractiveTuiReference()),始终指向当前渲染器。这正是"组件只依赖 TUI 接口"带来的好处。

程序状态(OSC 7501)。pi 用 Program Status Protocol 告诉终端或 agent 面板自己的状态:working(运行或压缩中)、blocked(扩展对话框或登录在等用户)、done、error、idle。InteractiveMode 每收到一个会话事件就交给 ProgramStatusReporter 处理。上报只在终端回应了支持查询之后才发送,内容从不包含 prompt 或模型输出;PI_PROGRAM_STATUS=1/0 可以强制开关。

终端能力探测与覆盖。pi 自动探测 OSC 8 超链接、内联图片协议(Kitty / iTerm2)和 truecolor,主要依据环境变量,在 tmux 下还会确认 tmux 是否转发超链接。终端代理或多路复用器可能让探测失准,所以每项都能覆盖:环境变量 PI_HYPERLINKS、PI_IMAGE_PROTOCOL、PI_TRUE_COLOR,或设置项 terminal.hyperlinks、terminal.images、terminal.trueColor,设置优先于环境变量。不支持超链接时退回 text (url) 格式;全屏模式下 iTerm2 图片退回文本,因为该协议无法在视口重绘时裁剪或删除图片。

终端颜色与 system 主题。默认主题 system 不自带调色板:启动时向终端查询前景、背景和 16 色调色板(最多等 100 ms,超时先用 ANSI 回退色,回复晚到了再补上),再按对比度要求(正文至少 WCAG 4.5:1)生成配色;终端切换明暗时重新查询、重建主题。颜色值支持 ANSI 索引、sRGB、OKLCH、OKHSL,统一可转成 sRGB 做混色,最后按终端能力输出 truecolor 或 256 色。对组件作者来说,要点是不要把带主题色的字符串长期存在状态里,除非 invalidate() 会重建它们:主题切换会清渲染缓存,但清不掉已经嵌进应用状态的旧 ANSI 颜色。

流式性能(1.5 "Faster streaming")。三处改动都针对"每次 message_update 都把整条消息重做一遍"的成本。第一,pi-ai 新增增量容错的 StreamingJsonParser / ToolCallArgumentsStream,所有 provider 都用它解析流式工具参数,大型工具调用(比如写一个长文件)不再每来一块就从头解析 JSON。第二,Markdown 组件复用上次渲染的顶层块,流式消息只重渲染变化的最后一块,resize 时也复用已高亮的代码。第三,AssistantMessageComponent 在更新之间保留 Markdown 实例(按块类型复用 slot),Text.setText() 遇到相同文本也不再丢弃换行缓存。

原生模块。pi-tui 带了 macOS、Linux(X11)、Windows 的预编译 N-API 模块,提供修饰键状态检测和异步剪贴板访问(文本、图片、文件路径)。修饰键检测解决的是终端发不出区分信号的情况:比如 Apple Terminal 把 Shift+Enter 发成 ESC+回车,有了本地 Shift 状态,ProcessTerminal 才能把它和 Alt+Enter 区分开。原生模块只是增强,没有它也能运行。

6. 接入 Agent:事件更新组件,不要在 token 回调里打印

最容易写出的版本是 agent.onToken(t => process.stdout.write(t))。它绕过了组件树和差分渲染,工具输出、状态行或编辑器一更新,光标和画面就会互相覆盖。正确路径是:事件更新组件状态,然后请求重绘。

pi 里做这件事的是 InteractiveMode.handleEvent(),它订阅 AgentSession 的事件流(事件模型见 pi-coding-agent),映射关系如下:

message_start(assistant)  -> 新建 AssistantMessageComponent,加入 chatContainer
message_update            -> 用完整消息快照 updateContent();
                             发现新的 toolCall 块 -> 按 id 新建 ToolExecutionComponent
                             已有的 toolCall 块   -> updateArgs()
message_end               -> 最终 updateContent();工具参数 setArgsComplete()
tool_execution_start      -> 按 toolCallId 找到(或补建)工具组件,markExecutionStarted()
tool_execution_update     -> updateResult(partial)
tool_execution_end        -> updateResult(final),从 pendingTools 移除
agent_end                 -> 清除 working 状态和残留的流式组件
每个分支最后              -> ui.requestRender()

有两个细节值得讲。一是工具组件在模型还在流式生成参数时就创建了,而不是等工具开始执行,所以用户能看到参数一点点出现(比如 edit 的 diff 在 setArgsComplete() 后才计算)。二是用 pendingTools: Map<toolCallId, Component> 定位组件:工具可以并发执行,不能假设"最后一个工具组件就是当前更新目标"。codemode 脚本里的嵌套调用带 parentToolCallId,显示在父工具那一行里,不单独建组件。

7. 为什么更新"完整消息"而不只追加 delta

教程里写 append(delta) 很直观,但 pi 的 message_update 携带的是当前完整的 assistant message,组件每次调用 updateContent(message)。

收益是幂等:UI 不必推测前一个 delta 有没有丢、有没有重复、结构有没有变。一条完整消息里可能同时有 text、thinking 和 tool call,而且顺序交错。updateContent() 每次按最新消息重建内部内容容器:text 变成 Markdown,连续的 thinking 块合并显示(或折叠成一行标签),停止原因(aborted、error、length)显示成明确的状态。同一个组件既能处理流式更新,也能处理从会话文件恢复的历史消息,这两条路径用的是同一份代码。

代价是每次更新都可能重新解析、高亮整条消息,消息越长越贵。旧版靠渲染节流和组件缓存兜底;1.5 进一步把成本压到"只重做变化的部分"(第 5 节的 Markdown 块复用和 slot 复用)。接口仍然是快照语义,增量只是内部优化。这个取舍值得记住:协议层保持快照的简单和幂等,性能在组件内部用缓存解决。自己写入门实现可以先追加 delta;一旦事件协议需要支持重放、重连或结构化内容,就该换成快照。

一句话:快照让 UI 正确性不依赖事件完整性,缓存让快照不至于太慢。

8. 一次完整场景:流式回复、工具调用、Enter 插话

假设用户问"把 README 里的版本号改成 1.5",全屏模式下。

提交。编辑器有焦点,用户按 Enter(tui.input.submit)。Editor 触发 onSubmit,此时会话不在流式输出,文本经输入回调交给会话开始一次运行。ProgramStatusReporter 收到运行开始,向终端上报 working。

用户消息与思考。message_start(user) 把用户消息组件加入 transcript;turn_start 显示 working 状态指示器。随后 message_start(assistant) 新建 AssistantMessageComponent。模型先吐出几段文字,每个 message_update 都带着完整消息,组件复用同一个 Markdown 实例只改文本,Markdown 只重渲染最后一个块。每次都调用 requestRender(),但 16 ms 内的多次请求合并成一帧,差分只重写 transcript 底部变化的几行。transcript 是 follow: "end",用户没往上滚,视口自动跟随。

工具调用流式出现。模型开始生成 edit 调用。pi-ai 增量解析参数 JSON,下一个 message_update 里出现一个 toolCall 块,InteractiveMode 发现 pendingTools 里没有这个 id,就新建 ToolExecutionComponent 加到 transcript;之后的更新只调用 updateArgs()。message_end 时参数完整,setArgsComplete() 触发 diff 计算。接着 tool_execution_start 用 toolCallId 找到同一个组件、标记执行开始,tool_execution_end 填入结果。

用户插话。工具还在跑,用户想起来还要改 CHANGELOG,在编辑器里输入后按 Enter。按键先走立即渲染路径,输入即时可见。onSubmit 发现 session.isStreaming,于是调用 session.prompt(text, { streamingBehavior: "steer" })。这不会打断当前运行,只是把消息放进 steering 队列。会话发出 queue_update,InteractiveMode 刷新输入区上方的待发送消息区。等 agent 循环在下一个注入点取出这条消息,它会作为 message_start(user) 出现在 transcript 里,模型在后续回合里看到它(队列语义见 pi-agent-core)。如果按的是 Alt+Enter(Windows/WSL 上是 Ctrl+Q),消息进 follow-up 队列,等这次运行全部结束后才发送;按 Escape(app.interrupt)则中止运行。

结束。agent_end 清掉 working 指示器,程序状态变成 done。整个过程里没有任何代码直接写 stdout:Agent 只发事件,InteractiveMode 只改组件,TUI 只管什么时候、写哪几行。

9. 编辑器与快捷键

主编辑器是 pi-tui 的 Editor,交互模式在它上面包了一层 CustomEditor,用来挂应用级快捷键。编辑器自带多行编辑、历史、撤销、kill ring、按词移动和自动补全(/ 补全命令,@ 和 Tab 补全文件路径);自动补全打开时它声明 capturesViewportInput,这样 PageUp/PageDown 移动的是补全选项,而不是滚动 transcript(1.5)。

快捷键是"命名动作 → 按键"的映射:tui.* 是库层动作(tui.input.submit、tui.altScreen.pageUp 等),app.* 是应用层动作(app.interrupt、app.message.followUp 等)。用户在 <agent-dir>/keybindings.json 里覆盖,/reload 生效,/hotkeys 查看当前绑定。按键识别用 matchesKey(),它兼容 Kitty 键盘协议和传统转义序列。部分终端发不出某些组合键(比如 Shift+Enter),terminal-setup.md 给了各终端的配置方法,这也是平台默认值不同的原因:Windows Terminal 把 Alt+Enter 占用为全屏切换,所以那里的 follow-up 改成 Ctrl+Q。扩展替换编辑器时应继承 CustomEditor,把自己不处理的按键转给基类,避免丢掉应用快捷键。

10. 测试:虚拟终端比截图更有用

TUI 很适合通过终端适配器测试。pi-tui 的测试用 VirtualTerminal,它基于 @xterm/headless,是一个真正解析 ANSI 的无头终端,所以测试断言的是"终端最终显示了什么",而不只是"写了哪些字节"。最值得覆盖的是真实风险,不是每个 getter:相同画面不产生内容更新;中间一行变化时只重写变化区域;内容缩短后旧字符被清掉(tui-shrink);resize 后全量重绘;退出和异常时终端模式被恢复(exit-restore);CJK、emoji、ANSI 样式的测宽与截断;overlay 跨越宽字符边界的合成;并发工具事件按 id 更新正确的组件。仓库里还有渲染抖动和大 transcript 的基准脚本。

排查渲染问题时,PI_TUI_WRITE_LOG 可以把原始 ANSI 流记录下来。官方建议的手动验收清单是:窄宽度、宽字符、resize、主题切换、焦点切换,以及 regular 和 fullscreen 两种模式都要试。

11. 常见错误与设计原则

几种典型错误都违反了同一条分层原则。Agent 事件直接写 stdout:绕过统一渲染,工具输出、编辑器和流式文本互相覆盖,所有可见内容都应先进入组件状态。每个 token 清空整屏:能跑,但长内容闪烁、破坏 scrollback,应该先合并渲染请求,再做差分。用字符串长度算列宽:英文 demo 看不出问题,一输入中文就错位。组件持有 Agent 客户端:渲染、网络和生命周期耦合在一起;组件只接收数据,由应用层订阅 Agent 并更新组件。忘记恢复终端:崩溃后 shell 停在 raw mode;用 finally、exit 处理器和幂等的 stop() 兜底。在 render() 里做昂贵计算:渲染在交互路径上,token 统计、会话扫描、文件访问要在事件处理阶段做完,组件只格式化结果;昂贵的版式和高亮按宽度和内容缓存,在 invalidate() 里清除。

如果自己从零做,推荐顺序是:静态 render(width): string[] → Container 纵向组合 → Terminal 启停与恢复 → 全量重绘 → 焦点与最小编辑器 → 16 ms 渲染合并 → 简单差分 → 事件适配层(按 id 定位组件)。Kitty 键盘协议、图片、overlay 栈、全屏视口、主题热更新都来自真实需求,等需求出现再加。

常见追问

为什么不用 Ink(React for CLI)这类现成框架? render(width): string[] 这个契约足够小,差分直接在行上做,不需要虚拟 DOM 和 reconciler;Agent 场景的瓶颈在长消息的 Markdown/高亮,这些靠组件内部缓存解决,比通用 diff 更可控。依赖也只有三个小包。

全屏模式的代价是什么?为什么还保留 regular? 程序接管视口后,滚动、选择、复制、搜索、链接都得自己实现,而且受终端鼠标上报质量影响(比如 iTerm2 快速滚动、Ghostty 的链接悬停提示)。regular 模式把这些交还给终端,在 tmux、远程会话或偏好原生 scrollback 时更稳。两个渲染器实现同一个接口,切换不影响组件。

主屏幕模式下为什么"变化在视口之上"就要全量重绘? 终端 scrollback 里的内容程序改不了,光标也移不过去,只能清屏重画。这是主屏幕模式的固有限制,也是全屏模式改由程序拥有视口的动机之一。

快照式 message_update 会不会让长消息越来越慢? 会,所以 1.5 把复用下沉到了组件和 Markdown 层:只重渲染变化的最后一块,工具参数增量解析。协议仍然是快照,正确性不依赖增量。

扩展想画自定义 UI,该怎么接? 优先用 ctx.ui.select/confirm/input/notify/setWidget/custom(),它们自动拿到当前主题和快捷键;确实需要自定义渲染时才写组件,但不要自己再起一个 TUI 实例(见 扩展系统)。

源码入口(v1.5):packages/tui/src/tui.ts、packages/tui/src/tui-main-screen.ts、packages/tui/src/tui-alt-screen.ts、packages/tui/src/terminal.ts、packages/coding-agent/src/modes/interactive/interactive-mode.ts、packages/coding-agent/src/modes/interactive/components/assistant-message.ts

Monorepo 工程化:根目录配置与构建链路

TL;DR:Pi 是一个 npm workspaces monorepo,packages/* 下 12 个包同版本(lockstep)发布,根目录不发布,只负责编排。npm workspaces 不按依赖拓扑排序,而子包编译时又依赖兄弟包 dist/ 里的类型声明,所以根 build 脚本手写了一条从 telemetry 到 coding-agent 的固定顺序。pi-ai 的内置模型目录在构建时从上游拉取(build),也可以用已就绪的数据离线构建(build:offline),Nix 打包走的就是后者,用一个带 sha256 的目录快照。npm run check 是唯一的提交门禁:格式、依赖钉死、运行时依赖声明、入口模块图预算、安装锁、类型检查、浏览器冒烟。整个仓库的供应链立场是:依赖精确钉版本、新版本至少发布 2 天才装、安装时一律 --ignore-scripts、锁文件当作需要评审的代码。

仓库长什么样

根 package.json 名为 pi-monorepo,private: true、type: module,要求 Node >=22.19.0。它本身不是一个可运行的程序,只是 workspace 的入口和脚本集合。workspaces 声明了三类目录:

  • packages/*:真正发布的包。1.5 里有 agent、ai、chord、codemode、coding-agent、durable、env、mcp、schema、telemetry、tui,外加私有的 evals(private: true,用于评测,不发布)。
  • apps/*:只在私有仓库 pi-private 里存在(pre-commit 钩子里的注释写明了),公开仓库里这一项是空的。
  • 四个带独立依赖的示例扩展:with-deps、custom-provider-anthropic、custom-provider-gitlab-duo、gondolin。它们有自己的第三方依赖,挂进 workspace 才能让一次 npm ci 装好、让类型检查能解析。旧版里的 sandbox 示例已删除,换成了 gondolin(见 沙箱与容器化)。

所有公开包版本一致(1.5.0),内部互相依赖也写精确版本号,比如 coding-agent 依赖 "@earendil-works/pi-agent-core": "1.5.0"。这是 lockstep 发布:任何一个包改了,全部一起升版本,用户不会遇到 "pi-ai 1.5 配 agent-core 1.4" 这种组合。

一句话:根目录只编排不发布,12 个包同版本前进,示例扩展也是 workspace 成员。

为什么 build 顺序要手写

根 build 是一长串 cd packages/x && npm run build,顺序是:

telemetry → schema → chord → tui → codemode → mcp → ai → durable → env → agent → coding-agent

为什么不直接 npm run build --workspaces?因为 npm workspaces 只按声明(目录 glob)顺序执行,不做依赖拓扑排序;而子包的 tsconfig.build.json 是对着兄弟包的构建产物编译的。比如 coding-agent 的构建配置把 @earendil-works/pi-agent-core 映射到 ../agent/dist/index.d.ts,把 pi-ai 映射到 ../ai/dist/index.d.ts。如果 agent 还没构建,coding-agent 就找不到类型。于是依赖关系决定了顺序:

  • chord、ai 依赖 schema 和 telemetry,所以这两个最先;
  • durable 依赖 chord、ai、codemode;env 依赖 chord、durable;
  • agent 依赖 ai、schema;
  • coding-agent 依赖 agent、ai、codemode、mcp、tui,放最后。

clean 和 test 则可以用 --workspaces 并列跑,因为它们之间没有先后依赖。

coding-agent 自己的 build 也分两步:先 tsc -p tsconfig.build.json 产出普通 dist/,再用 scripts/build-coding-agent-bundle.mjs(esbuild)打一个 dist/bundle/cli.js。bundle 只把少数可选原生模块(如 bufferutil、photon、jiti)留为外部依赖,Nix 包装出来的 pi 命令运行的就是这个 bundle。

模型数据:在线构建与离线构建

pi-ai 的内置模型目录不是手写的,由 packages/ai/catalog/generate.ts 从上游拉取:models.dev、OpenRouter、Vercel AI Gateway、NVIDIA NIM 等来源先全部拉下来,再按 catalog/providers/*.ts 里每个 provider 的定义做映射和修正。生成物分两种:

  • src/providers/<provider>.models.ts 和 src/models.generated.ts 是类型包装,提交进 git;
  • src/providers/data/*.json 是真正的模型数值,被 .gitignore 忽略,每次构建重新生成。

于是 pi-ai 的脚本拆成两层:build = generate-models(联网拉取)+ build:offline;build:offline = check:model-data(校验数据存在且不过期)+ tsc + 把 data 目录拷进 dist。根目录对应有 build 与 build:offline 两条链,唯一区别就是 ai 那一步。新克隆的仓库要离线构建,先跑 npm run hydrate:model-data(只写数据、不写类型文件)。这样设计的原因是模型价格、上下文长度变化频繁,把 JSON 提交进 git 会制造大量噪音 diff;而 CI、Nix 这类需要可复现的场景又不能每次联网,于是用"联网生成"和"给定数据离线构建"两条路径分开。

一句话:手写顺序是因为 npm workspaces 不排拓扑、而子包又对着兄弟包的 dist 编译;模型数据可以联网刷新,也可以用快照离线构建。

check:唯一的提交门禁

npm run check 是 AGENTS.md 规定的首选检查(改完代码必须跑、不跑测试),pre-commit 钩子也调用它。1.5 的完整链路:

biome-check.mjs --write          格式化 + lint,warning 也算失败
check:pinned-deps                所有 package.json 的直接依赖必须是精确版本
check:runtime-deps               公开包源码里的运行时 import 必须在自己的 dependencies 里声明
check:ts-imports                 相对导入写法规范(源码里写 .ts 后缀)
check:entry-graphs               每个导出入口的模块图不超过预算
check:install-lock:coding-agent  coding-agent 安装锁与根锁文件一致
tsc --noEmit                     全仓类型检查
tsc --noEmit -p examples/...     示例扩展按"扩展视角"再查一遍
check:browser-smoke              esbuild 打包浏览器入口,抓误引入的 Node-only 模块

几个值得说的点:

  • biome 包了一层脚本。biome-check.mjs 本质是 biome check --error-on-warnings,但 Biome 没有 Android(Termux)原生二进制,脚本会回退到 @biomejs/wasm-nodejs 逐文件检查,所以 devDependencies 里同时有 biome 和它的 wasm 版本。
  • 类型检查换成了 TypeScript 7。旧版用 @typescript/native-preview 提供的 tsgo,1.5 的 devDependency 是 typescript: 7.0.2,原生实现的 tsc 直接就是快的那个,check-runtime-deps 也是基于 TS 7 的 AST API 写的。
  • 入口是成本契约。check-entry-graphs.mjs 的注释说得很直白:一个多余的 export * 就能让窄入口拖进整个 barrel,引一个单文件纯函数可能要求值约 37 MB 的模块图,而且什么都不会报错,直到有人测进程内存。所以它沿着值导入(import type 不算)遍历每个声明的入口,超预算就在提交时失败。
  • 示例扩展单独查一遍。扩展运行时看到的 @earendil-works/pi-ai 是一个"扩展视角"(pi-ai-extension-view.ts,额外暴露 TypeBox 的 Type 等),和普通包导入不一样,所以 packages/coding-agent/examples/tsconfig.json 把这条路径改指向扩展视角,单独做一次 tsc。根 tsconfig 则把 examples/extensions/** 排除掉。gondolin 示例因为依赖 Node 23.6+ 的 Gondolin 包,两边都排除。
  • shrinkwrap 没了。旧版 check 里有 check:shrinkwrap(npm-shrinkwrap.json 随包发布);1.5 改成 packages/coding-agent/install-lock/,见下文供应链一节。

pre-commit(.husky/pre-commit)在 check 之外还做三件事:先跑 check-lockfile-commit.mjs 拦截锁文件提交;暂存文件涉及 packages/ai、package.json 或锁文件时再单独跑一次浏览器冒烟;最后把被格式化改过的暂存文件重新 git add。

一句话:check 把"代码风格、依赖卫生、包边界、类型、浏览器兼容"全部收进一条命令,提交时强制执行。

TypeScript、Vitest 与 Biome 配置

TypeScript 配置分三层,各管一件事:

  • tsconfig.base.json 是所有包共享的编译选项:ES2024 + Node16 模块解析、strict、declaration + declarationMap + sourceMap。两个选项体现了仓库风格:erasableSyntaxOnly 禁止 enum、namespace、参数属性这类需要生成 JS 的语法,verbatimModuleSyntax 要求类型导入显式写 import type,两者合起来保证 TS 能被"擦掉类型"直接运行。allowImportingTsExtensions + rewriteRelativeImportExtensions 让源码里写 ./foo.ts,编译产物自动改成 ./foo.js。旧版里的 experimentalDecorators、emitDecoratorMetadata 在 1.5 已经移除。
  • 根 tsconfig.json 只用于类型检查(noEmit,NodeNext),核心是 paths:把所有 @earendil-works/* 包名及其子路径映射到 packages/*/src,编辑器和 tsc --noEmit 因此不需要先构建就能跨包跳转、检查。
  • 各包的 tsconfig.build.json 负责真正产出 dist/,paths 反过来指向兄弟包的 dist/*.d.ts。这正是 build 顺序必须手写的原因。

vitest.base.ts 做的是和根 tsconfig 同样的事,只是给测试用:用 resolve.alias 把包名精确映射到源码文件,测试跑的是 src 而不是可能过期的 dist。各包的 vitest.config.ts 用 mergeConfig 继承它;coding-agent 还额外设置 PI_OFFLINE=1,测试默认离线,需要网络的测试显式调用 allowNetwork()。

biome.json 规则从旧版延续:推荐规则集,useConst 设为 error,关掉 noNonNullAssertion、noExplicitAny、useNodejsImportProtocol;格式化用 Tab、宽 3、行宽 120。1.5 新增的是:范围扩到 packages/ai/catalog、packages/agent/examples 和模型目录协议脚本,排除 models.generated.ts、*.models.ts 这些生成文件,以及一个自定义 GritQL 插件 scripts/biome/model-type-comparison.grit 做仓库特有的 lint。

一句话:根 tsconfig 和 vitest.base 都把包名指向源码,构建配置则指向 dist;前者让开发和测试免构建,后者决定了构建顺序。

本地运行与测试:pi-test.sh 与 test.sh

pi-test.sh(以及 Windows 的 .ps1、.bat)用来直接从源码运行 CLI。旧版用 tsx 跑,1.5 改成 node --import <source-resolver.ts> packages/coding-agent/src/cli.ts。原因写在 source-resolver.ts 注释里:Node 已经能原生剥离 TypeScript 类型,但不认 tsconfig 里的 paths,于是用 module.registerHooks 预加载一个解析器,把包名导入解析到源码,避免悄悄落到过期的 dist 上。--no-env 参数会 unset 一长串 provider 的 API key 环境变量(和 pi-ai 的 env key 表对应),用来模拟"什么凭证都没有"的启动。

test.sh 是 AGENTS.md 指定的跑全部非 e2e 测试的方式(禁止直接跑全量 vitest,因为 e2e 测试会在检测到 key 时自动启用、花真钱)。1.5 的做法比旧版"挪走 auth.json、unset 几个 key"彻底得多:

  • 用 mktemp 建一个临时根目录,把 HOME、TMPDIR、XDG_CONFIG_HOME、npm 的 userconfig/globalconfig/cache 全部重定向进去;
  • 用 env -i 从空环境启动 npm test,只放行 PATH、locale、时区、PI_NO_LOCAL_LLM=1、AWS_EC2_METADATA_DISABLED 等白名单变量,以及 CI 标识和 NODE_EXTRA_CA_CERTS;
  • Git 也被隔离:不读系统和全局配置,禁止终端提示,GIT_ASKPASS 指向 false;
  • 退出时清理临时目录前先校验它确实是自己创建的(路径前缀 + 标记文件 .pi-test-owned + 不是符号链接),否则拒绝删除。

思路从"黑名单删掉已知的密钥"变成"白名单只给必要的环境",新加的 provider 不用记得更新脚本。根 npm test 本身则是 test:scripts(node --test 跑 scripts/*.test.*)加各 workspace 的测试。

一句话:pi-test.sh 免构建从源码跑,test.sh 用空环境 + 临时 HOME 保证测试碰不到真实凭证。

Nix flake

1.x 新增了 flake.nix,对外提供 packages.default/pi、apps.default(运行 bin/pi)和 overlays.default,覆盖 aarch64/x86_64 的 darwin 与 linux(Intel Mac 单独钉在 nixpkgs 26.05-darwin 分支,因为 unstable 已不支持)。具体构建在 nix/package.nix,分两个 derivation:

  1. workspacePackages:以根 package-lock.json 为依赖源(importNpmLock,--ignore-scripts),用 nix/model-catalog.json 里钉住的目录版本从 pi.dev 下载模型目录,sha256 就是版本号本身;hydrate-model-catalog.ts 写入数据后跑 npm run build:offline,再 npm pack 出各包 tarball。
  2. pi:以 packages/coding-agent/install-lock/ 为依赖源,把锁文件里指向 registry 的内部包替换成第一步本地构建的 tarball,安装后用 makeWrapper 生成 pi 命令,运行 bundle 版 CLI,并把 fd、ripgrep(Linux 上还有剪贴板工具)放到 PATH 前面。installCheckPhase 校验 pi --version,并实际加载 TUI 原生模块和 photon,缺依赖就让构建失败,而不是运行时悄悄丢掉剪贴板功能。

这里能看出离线构建和安装锁的价值:Nix 沙箱构建不能联网,模型目录靠"钉版本 + 哈希"变成确定输入;运行时依赖树靠 install-lock 固定,与 pi.dev 安装器装出来的是同一棵树。

供应链立场

Pi 的依赖安全不是靠某一个工具,而是一组互相配合的约定:

  • 精确版本:.npmrc 设 save-exact=true,check-pinned-deps 在 check 里强制所有 dependencies、devDependencies、optionalDependencies 都是精确 semver,内部 workspace 依赖也一样。
  • 冷却期:.npmrc 的 min-release-age=2 让 npm 拒绝安装发布不足 2 天的版本,给恶意版本被发现、撤回留出窗口。
  • 不跑生命周期脚本:AGENTS.md 规定本地用 npm install --ignore-scripts、CI 风格用 npm ci --ignore-scripts,CI workflow、Nix、官方 Docker 示例都照此执行;版本脚本里的 npm install --package-lock-only 也带 --ignore-scripts。
  • 安装锁:packages/coding-agent/install-lock/ 是给 pi.dev 安装器(@earendil-works/pi-coding-agent-install)用的锁文件根,由 generate-coding-agent-install-lock.mjs 从根锁文件派生,check 保证两者同步。脚本里有一张允许执行安装脚本的白名单,每项写明理由(例如 protobufjs 的 postinstall 只是打印警告),新增有安装脚本的依赖必须评审后显式加入。
  • 锁文件即代码:pre-commit 默认拒绝提交锁文件变更,除非设置 PI_ALLOW_LOCKFILE_CHANGE=1;npm-audit.mjs 跑 npm audit --omit=dev,被接受的已知漏洞必须写明包名和不适用的理由。根 overrides 只钉了一个 protobufjs@7.6.6。

一句话:钉死版本、延迟采纳、禁止安装脚本、锁文件需评审,四件事把"装依赖"从默认信任变成默认怀疑。

常见追问

为什么不用 Turborepo、Nx 这类工具来排构建顺序? 包只有十来个、依赖关系稳定,一行手写顺序足够且零依赖;引入编排工具意味着又多一个需要钉版本、审计的依赖,与仓库的供应链立场相悖。代价是新增包时要记得改这一行。

为什么开发时指向 src、构建时指向 dist? 开发和测试要的是改完立刻生效,不想每次先构建;构建和发布要的是验证包之间的公开接口(.d.ts)真的够用,指向 dist 能提前暴露"依赖了对方未导出的内部类型"这种问题。

为什么从 shrinkwrap 换成 install-lock? 从 Nix 和安装器的用法看,install-lock 是一个独立的锁文件根,既能让安装器装出确定的依赖树,也能让 Nix 把内部包替换成本地构建,而 shrinkwrap 只能随 npm 包发布、对这些场景不够灵活。(这是对设计的推断,仓库没有写明迁移理由。)

源码入口(v1.5):package.json、tsconfig.json、packages/coding-agent/tsconfig.build.json、vitest.base.ts、test.sh、nix/package.nix

仓库 scripts 脚本

TL;DR:根目录 scripts/ 是整个 monorepo 的工程化工具箱,大部分脚本不需要手动调用,而是挂在根 package.json 的 npm script、husky pre-commit 钩子或 GitHub Actions 上。按用途分五类:提交和 CI 时自动跑的检查、发版与发布、模型目录维护、打包与产物验证、本地开发与会话分析。理解这些脚本的价值不在于记住名字,而在于看出仓库在防什么:依赖漂移、包边界被破坏、发布产物装不上、模型目录让旧客户端崩溃。

怎么分组

判断一个脚本属于哪类,看它被谁触发。npm run check 和 pre-commit 触发的是门禁,任何人提交都会跑;release:*、publish 只有维护者发版时用;模型目录脚本服务于 pi-ai 的内置模型数据和 pi.dev 的目录服务;打包脚本在 CI 和本地发版演练里验证"发布出去的东西真能装、真能跑";剩下的是开发者按需运行的小工具。另有几个没有入口的 .mjs 是被其他脚本 import 的公共库(如 package-files.mjs、package-workspaces.mjs、release-packages.mjs、npm-command.mjs),以及与之配套的 *.test.mjs,由 npm run test:scripts 统一跑,这里不逐个列出。

提交与 CI 时自动运行的检查

pre-commit 钩子先跑 check-lockfile-commit.mjs,再跑 npm run check,涉及 ai 包或锁文件时追加浏览器冒烟。check 链路的设计见 Monorepo 工程化。

  • check-lockfile-commit.mjs:发现暂存的锁文件里有包版本变化时阻止提交,除非设置 PI_ALLOW_LOCKFILE_CHANGE=1。
  • biome-check.mjs(+ biome/model-type-comparison.grit):包装 biome check --error-on-warnings,在没有原生二进制的 Android/Termux 上回退到 WASM;.grit 是仓库自定义的 lint 规则。
  • check-pinned-deps.mjs:所有 package.json 的直接依赖必须是精确版本。
  • check-runtime-deps.mjs:用 TypeScript 7 的 AST API 扫描公开包源码,运行时 import 的包必须在本包 dependencies 里声明,也不允许通过 import 把 tsconfig 排除的文件拉回构建。
  • check-ts-relative-imports.mjs:检查相对导入写法(源码统一写 .ts 后缀)。
  • check-entry-graphs.mjs:每个导出入口有模块图预算,防止一个 export * 让窄入口拖进整个 barrel。
  • generate-coding-agent-install-lock.mjs --check:确认 packages/coding-agent/install-lock/ 与根锁文件同步;不带 --check 时重新生成(npm run install-lock:coding-agent)。
  • check-browser-smoke.mjs:用 esbuild 打包 browser-smoke-entry.ts、durable-browser-smoke-entry.ts、agent-treeshake-smoke-entry.ts,抓误引入的 Node-only 模块,并验证只用一个 provider 时 agent 能被 tree-shake。
  • npm-audit.mjs:CI 定期跑 npm audit --omit=dev,被接受的漏洞必须写明包名和不适用理由。

发版与发布

  • release.mjs(npm run release:patch|minor|major 或指定 x.y.z):完整发版流程。检查工作区干净,确认所有公开包已在 npm 注册,为 Nix 钉住最新模型目录并构建,升版本,把各 CHANGELOG 的 [Unreleased] 改成版本号,重新生成产物,跑检查和测试,提交并打 tag,再加回新的 [Unreleased],最后推送 main 和 tag,由 CI 完成真正的 npm 发布。
  • sync-versions.js:被 version:* 调用,校验所有公开包版本一致(lockstep),并把内部依赖改成精确的新版本号。
  • publish.mjs(npm run publish、publish:dry):逐个包检查是否已发布、再 npm publish,--dry-run 只做验证。
  • publish-release-announcement.mjs:npm 发布后,从 registry 解析刚发布的包,更新 pi.dev 上 releases/v1 与安装器 installer/v1 的指针,带重试。
  • release-notes.mjs:从 CHANGELOG 提取某版本的 GitHub Release 说明;npm run release:fix-links 修复历史 Release 中的仓库链接。
  • create-source-archive.sh:生成随 GitHub Release 上传的确定性源码包(需先 hydrate 模型数据)。
  • preview-packages.mjs:CI 里把内部依赖指向本次提交的构建、npm pack 所有公开包,发布到 pkg.earendil.com 作为预览版本。

模型目录

pi-ai 的内置模型数据由 packages/ai/catalog/generate.ts 从上游生成,同一份目录也以 JSON 形式由 pi.dev 分发给已发布的客户端。根目录这几个脚本负责其中"跨仓库、跨版本"的部分:

  • model-catalog-protocol.ts:pi 与 pi.dev 共享的目录协议(存储布局、索引格式、版本排序),两个仓库各一份,必须逐字节一致。
  • model-catalog-minimum-version.mjs:能收到本仓库所发布目录的最低 pi 版本;只有生成的模型元数据依赖旧客户端没有的行为时才上调。
  • check-model-catalog-compat.mjs(npm run check:model-catalog-compat):发布前确认已发布的 pi 版本能使用即将发布的目录。pi.dev 会把目录直接推给所有不低于最低版本的客户端,客户端不更新也会立刻使用,所以目录一旦需要新代码就会让旧版崩溃,这个检查就是防这个。
  • publish-model-catalog.mjs:把生成的 JSON 目录按协议发布;npm run check:model-catalog 是它的 dry-run。
  • update-model-catalog-pin.mjs:查询最新发布的目录,更新 nix/model-catalog.json 里钉住的 revision(sha256),供 Nix 离线构建使用。
  • diff-model-catalog.mjs(npm run diff:model-catalog):在 HEAD 和工作区各生成一次目录,打印 JSON 差异;--thinking 借助 generate-thinking-capabilities.mjs 比较各模型的有效 thinking 等级。

打包与产物验证

  • pack-packages.mjs(npm run pack:packages):构建所有公开包,输出一组一致的本地产物(.artifacts/),可用 --offline-model-data 离线构建;底层逻辑在 package-artifacts.mjs。
  • check-package-install.mjs(npm run check:package-install):把每个包单独装进一个临时消费者项目,做导入冒烟测试,local-package-install.mjs 提供安装逻辑,coding-agent-smoke.mjs 负责 coding-agent 的启动冒烟。
  • local-release.mjs(npm run release:local):本地发版演练,打包后装进仓库外的隔离目录做测试。
  • use-local-packages.mjs:把外部项目(npm 或 pnpm)的依赖改指向本地产物,用于在真实下游项目里试用未发布的改动。
  • build-coding-agent-bundle.mjs:coding-agent build 的第二步,用 esbuild 打出 dist/bundle/cli.js。
  • build-binaries.sh:本地复现 .github/workflows/build-binaries.yml,为各平台构建 Bun 单文件二进制;smoke-test-codemode-binary.mjs 在 CI 里验证编译后的二进制能启动 codemode worker。

本地开发与分析工具

  • auto-pi.sh:用本仓库最近一次 npm run build 的产物运行 pi,默认开启 PI_EXPERIMENTAL=1,--stable 改用 PATH 上的正式版。
  • profile-coding-agent-node.mjs(npm run profile:tui|rpc):分析 coding-agent 在 TUI 或 RPC 模式下的启动性能。
  • stats.ts、cost.ts:从本地会话文件统计 token 用量和成本。
  • tool-stats.ts、read-tool-stats.mjs、edit-tool-stats.mjs:分析各工具的调用次数与 token 消耗、read 的读取模式、edit 的成功率等,用来评估工具设计。
  • session-context-stats.mjs、session-transcripts.ts:分析上下文窗口使用和压缩频率;把某个 cwd 的会话抽成按上下文大小切分的文本,可选择启动 pi 子 agent 分析模式。
  • update-source-imports-to-ts.sh:把源码相对导入从 .js 批量改成 .ts(编译时 rewriteRelativeImportExtensions 再改回 .js),是迁移时用的一次性工具。
  • repro-5893-wsl-bash.mjs:在 Windows + WSL bash 下复现 issue #5893 的变量展开问题。
  • issues/:每日 issue 分诊的本地工具,用 gh api 同步 issue,再让一个 pi-durable agent 逐个查重、分类、建议优先级,结果只存本地,通过一个 Web UI 审阅。它本身就是 Pi 自家库的一个真实用例。

源码入口(v1.5):package.json、.husky/pre-commit、scripts/release.mjs、scripts/check-entry-graphs.mjs、scripts/check-model-catalog-compat.mjs、scripts/package-artifacts.mjs

沙箱与容器化:Pi 的隔离方案

TL;DR:Pi 没有内置权限系统,也不会在每次工具调用前请求批准。它的立场是:模型生成的命令和代码都是不可信的,安全只能来自操作系统或虚拟化边界,限制 Pi 能碰到的文件、凭证、进程和网络。1.5 官方文档给出两种隔离形态:把整个 Pi 进程放进隔离环境(plain Docker、Docker Sandboxes、OpenShell),或者 Pi 留在宿主机、只把内置工具送进隔离环境(Gondolin 扩展,本地 Linux micro-VM)。后者之所以能做成一个普通扩展,靠的是三个扩展点:用同名工具覆盖内置工具、给内置工具注入自定义 operations 后端、拦截用户 ! 命令的 user_bash 事件。项目信任(project trust)是另一层,只决定启动时加载哪些项目资源,不限制工具能做什么。

先说清楚:Pi 不提供什么

官方 docs/security.md 开宗明义:Pi 以启动它的账户权限读写、执行文件;扩展、包安装器、语言服务器等子进程也都继承这些权限,除非有 OS 或虚拟化边界挡住。文件、注释、命令输出都可能通过 prompt injection 操纵模型。盯着对话记录、开启项目信任、审 diff,这些都不构成安全边界。安全策略里也明确写了:"缺少内置沙箱"本身不算漏洞。

为什么不做内置权限弹窗?因为它挡不住真正的问题。一条 bash 命令可以做任何事,靠模式匹配判断"危险命令"既漏又烦,用户很快就会习惯性点"允许"。Pi 把这件事交给更可靠的层:要么用隔离环境限制爆炸半径,要么由扩展自行实现策略(仓库里的 permission-gate.ts、protected-paths.ts 示例就是用 tool_call 事件做确认和拦截的,但它们是"防手滑",不是安全边界)。

一句话:Pi 的安全模型是"假设模型会做错事,用环境边界限制后果",而不是"在每一步问用户"。

两种隔离形态

docs/containerization.md 列了四种方法,按"Pi 进程在哪"分成两类:

方法Pi 运行在被隔离的部分凭证
Plain Docker容器内Pi、内置工具、! 命令、扩展以环境变量传进容器
Docker Sandboxes托管沙箱内同上真实 key 留在宿主,由代理在出站请求时替换占位符
OpenShell本地或远程沙箱同上策略控制,可通过推理路由让原始 key 不进沙箱
Gondolin 扩展宿主机只有内置工具和 ! 命令文件里的 Pi 凭证留在宿主,但命令会继承宿主环境变量

整进程隔离是官方说的"通常最强的实用方案":Pi 和它加载的所有扩展都在边界里,只要不把宿主的 ~/.pi/agent 挂进去、只挂工作目录,宿主就是安全的。Plain Docker 的示例 Dockerfile 用 npm install -g --ignore-scripts 装 Pi,运行时 -v "$PWD:/workspace" 挂工作目录、用一个命名卷 pi-agent-home 存容器内自己的设置和会话。它的短板是凭证:-e ANTHROPIC_API_KEY 传进去的 key,容器里任何进程都读得到。Docker Sandboxes 和 OpenShell 正是为补这一点:前者的代理只给沙箱一个形如真 token 的占位符,请求发往指定 host 时才替换成真值(所以文档特别提醒不要在沙箱里 /login,那会把真凭证写进去);后者用网关做推理路由,Pi 指向网关暴露的兼容端点即可。

仅工具隔离更窄:Pi 进程本身、TUI、模型调用、会话存储都在宿主,只有内置工具执行的动作进了 VM。好处是保留宿主上完整的交互界面和已登录的 provider 凭证,坏处是边界只覆盖被路由的那部分。官方在表格下专门强调:其他扩展注册的工具仍在宿主执行,除非它们也把操作委托给隔离后端。

不管哪种,文档都列了同一组"仍然会漏"的地方:读写挂载意味着能改宿主文件;挂 ~/.pi/agent 等于交出凭证、扩展和会话;传入的环境变量在里面可见;有网络就可能把数据带出去。

一句话:整进程隔离把 Pi 和扩展都关进去,仅工具隔离只关工具,换来的是宿主上的完整体验和凭证。

Gondolin:一个扩展如何把工具送进 VM

Gondolin 是 earendil-works 自己的本地 Linux micro-VM(基于 QEMU,需要 Node 23.6+)。示例扩展 examples/extensions/gondolin/index.ts 只有五百多行,没有改 Pi 的任何代码。它的启动方式是在工作目录里 pi -e <扩展目录>,下面沿一次会话的顺序看它做了什么。

会话开始:起 VM,挂工作目录。 session_start 时调用 VM.create,用 RealFSProvider 把宿主的 process.cwd() 挂到 guest 的 /workspace。/workspace 下的改动直接写回宿主,guest 其他位置的改动只留在 VM 里。VM 启动后探测一下有没有 bash,没有就用 /bin/sh,并在状态栏显示 VM id。ensureVm() 用一个共享的 Promise 防止并发调用重复起 VM;session_shutdown 时关闭 VM。

同名覆盖内置工具。 扩展对 read、write、edit、bash、ls、find、grep 七个工具各调用一次 pi.registerTool({ ...localRead, execute })。用内置工具的名字注册,就会无提示替换掉内置版本;展开 ...localRead 保留了原来的名字、描述、参数 schema 和提示词片段,模型看到的工具和平时完全一样,渲染也自动沿用内置渲染器。只有 execute 换了:

execute(params)
  → ensureVm()
  → createReadTool("/workspace", { operations: createGondolinReadOps(vm, localCwd) })
  → tool.execute(params)        // 截断、行号、图片识别等逻辑全部复用内置实现

operations:只替换"最后一公里"。 这是整个方案能做小的关键。Pi 的内置工具把"工具逻辑"和"怎么碰文件系统/起进程"拆开了:createReadTool(cwd, { operations }) 里,ReadOperations 只要求 readFile、access、detectImageMimeType;BashOperations 只要求一个 exec(command, cwd, { onData, signal, timeout, env }),返回退出码。Gondolin 版的实现就是把这些调用转成 vm.fs.readFile、vm.fs.writeFile、vm.exec([shell, "-lc", command]),并用 toGuestPath 把宿主路径翻译成 /workspace/...。输出截断、临时文件、超时与中止提示、非零退出码报错这些都还在 bash 工具层,扩展只需遵守约定:中止时抛 "aborted",超时抛 timeout:N。唯一例外是 grep:内置 grep 依赖 ripgrep 进程,扩展干脆在 guest 文件系统上自己遍历匹配,find 也是同理用 vm.fs 遍历加 glob 匹配。

拦截用户的 ! 命令。 工具覆盖只管模型发起的调用,用户在输入框里敲的 !ls 走的是另一条路。扩展监听 user_bash 事件,返回 { operations: createGondolinBashOps(...) },于是用户命令也在 VM 里执行。

改写系统提示词里的工作目录。 before_agent_start 把提示词中的 Current working directory: <宿主路径> 换成 /workspace (Gondolin VM; host workspace mounted from ...),让模型生成的路径与 guest 一致。

什么留在宿主、什么进了 VM:

宿主机                                         Gondolin VM
┌─────────────────────────────────────────┐      ┌────────────────────────────┐
│ Pi 进程:TUI、agent loop、模型请求     │      │ /workspace ← 挂载宿主 cwd     │
│ ~/.pi/agent:auth.json、会话、设置   │      │   (写入直接落到宿主)         │
│ 其他扩展工具、MCP 工具              │      │ 其余文件系统:只在 VM 内      │
│ Gondolin 扩展:覆盖后的 7 个工具 ───┼──────┼→ vm.fs.* / vm.exec(shell -lc) │
│              user_bash 处理器   ───┼──────┼→ vm.exec(shell -lc)          │
└─────────────────────────────────────────┘      └────────────────────────────┘

为什么这样分?VM 里跑的是模型生成的、不可信的命令,它们看不到宿主的 ~/.pi/agent,自然拿不到存在 auth.json 里的 provider 凭证;而模型请求本来就由宿主上的 Pi 进程发出,不需要把凭证交给 VM。但官方文档特别警告:bash 的 env 参数来自宿主进程环境,扩展的 sanitizeEnv 只是把值规整成字符串,并没有过滤,所以通过环境变量提供的 provider key 在 VM 里是可见的。要把它当凭证边界用,就得自己剔除敏感变量。

一句话:Gondolin 用"同名覆盖 + operations 注入 + user_bash 拦截"三件事,把工具执行面整体搬进 VM,而工具语义一行没改。

扩展实现隔离的设计要点

把 Gondolin 和其他示例放在一起看,可以总结出扩展实现隔离时能用的几个钩子,以及它们各自的边界。

operations 覆盖 是最干净的方式,适用于"换执行后端":本地 shell、SSH(examples/extensions/ssh.ts 用同样的套路把 read/write/edit/bash 送到远程机器)、容器、VM。可替换的接口有 ReadOperations、WriteOperations、EditOperations、BashOperations、PowerShellOperations、LsOperations、GrepOperations、FindOperations。如果只想在本地执行前改写命令、cwd 或环境,bash 工具还提供更轻的 spawnHook;user_bash 则可以用 createLocalBashOperations() 复用 Pi 自己的本地 shell 后端再包一层。

旧的 OS 级沙箱思路 仍然可以用这套机制实现。v0.84 时代的官方 sandbox 示例(1.5 已删除)用 @anthropic-ai/sandbox-runtime 包装 bash:macOS 上是 sandbox-exec + Seatbelt profile,Linux 上是 bubblewrap + 网络命名空间 + 宿主代理。思路是不改程序本身,只在进程启动前用系统原生机制把它和整棵子进程树圈起来,配置里写网络域名白名单(allowedDomains)、读黑名单(denyRead: ~/.ssh, ~/.aws)、写白名单与写黑名单(allowWrite: ., /tmp,denyWrite: .env, *.pem)。实现上它覆盖了 bash 工具,在自定义 BashOperations.exec 里先 wrapWithSandbox(command) 再 spawn("bash", ["-c", wrapped], { detached: true }),用独立进程组实现超时和中止时整组 kill。与 Docker 的区别是:它是"给现有进程加边界",而不是"运行一个完整的容器环境",启动快、不需要镜像,但只覆盖它包装的那条执行路径。

把它和 Gondolin 对比,能看出两个值得注意的设计取舍:

  • 覆盖面。旧示例只包装了 bash,read、write、edit 仍在宿主直接读写,所以文件系统规则只对 shell 命令生效。Gondolin 把七个内置工具全部路由进 VM,边界才是完整的"工具执行面"。
  • 失败时往哪走。旧示例在平台不支持或沙箱初始化失败时会回退到普通 bash,即 fail-open。1.5 的核心机制则倾向 fail-closed:user_bash 处理器抛错或返回非法结果时,命令直接中止,不再调用后续处理器,也不会回退到本地执行(CHANGELOG 明确记录了这次改动);tool_call 处理器抛错同样会阻止工具执行。扩展文档也要求 gate 类处理器"在无法做出安全判断时 fail closed"。Gondolin 的 ensureVm() 失败会让工具直接报错,而不是退回宿主执行。

tool_call 拦截 适合做策略而不是换后端:在执行前检查参数,返回 { block: true, reason } 拒绝。旧 sandbox 示例的注释里也提过另一种做法:不替换 bash 工具,而是在 tool_call 里改写命令输入。它简单,但只能管到"命令字符串"这一层,拦不住命令内部再起的进程,因此更适合确认弹窗、保护路径这类防误操作的场景。

无论用哪种钩子,有一条边界始终存在:扩展本身运行在 Pi 进程里,拥有宿主权限。工具级隔离只能约束经过被覆盖工具的动作;MCP 工具、codemode、其他扩展注册的工具,只要没有走隔离后端,就仍在宿主执行。真正需要强边界时,应该选整进程隔离。

项目信任:另一层,管的是"加载什么"

项目信任解决的是另一个问题:克隆一个陌生仓库后,里面的 .pi/extensions 是可执行代码,不能一启动就悄悄加载。存在 .pi/settings.json、.pi/mcp.json、.pi/extensions|skills|prompts|themes、.pi/SYSTEM.md 或 APPEND_SYSTEM.md、项目 .agents/skills 这些资源时,Pi 需要一个信任决定,才会加载项目设置、MCP 服务器、扩展、skills 等。决定的来源依次是命令行 --approve/--no-approve、用户级或 -e 扩展处理的 project_trust 事件、~/.pi/agent/trust.json 里最近的已保存决定、全局 defaultProjectTrust(默认 "ask")。非交互模式无法弹窗,ask 等同于跳过。

它的边界要说清楚:项目信任只控制启动时加载哪些资源,不限制工具调用能访问什么;AGENTS.md、CLAUDE.md 这类上下文文件不受它管,照样加载;而且 Pi 在解析信任之前就会读取项目的 sessionDir 设置,这一步无法被拒绝信任撤销。所以它和沙箱是互补的两层:信任挡住"仓库自带的代码在宿主进程里运行",沙箱限制"模型生成的动作能影响什么"。细节见 资源发现、项目信任与系统提示词。

常见追问

Gondolin 和直接把 Pi 跑在 Docker 里,怎么选? 想要最强边界、扩展也一起关进去,选整进程隔离;想保留宿主上的 TUI、已登录凭证,又只担心模型跑的命令,选 Gondolin。后者要接受"其他扩展工具仍在宿主"和"环境变量会进 VM"两个限制。

为什么 Gondolin 用 operations 而不是从零写七个工具? 工具的提示词、参数 schema、截断规则、渲染是模型行为和用户体验的一部分,重写一份就会和内置版本产生细微差异。operations 只替换 I/O,模型看到的工具完全不变,扩展也能随 Pi 升级自动拿到工具逻辑的改进。

为什么 user_bash 要 fail closed? 用户装隔离扩展,期望的就是"命令不在宿主跑"。如果 VM 起不来时悄悄回退到本地执行,用户以为在沙箱里,实际在宿主上,这比明确报错危险得多。

源码入口(v1.5):packages/coding-agent/docs/security.md、packages/coding-agent/docs/containerization.md、packages/coding-agent/examples/extensions/gondolin/index.ts、packages/coding-agent/src/core/tools/bash.ts、packages/coding-agent/src/core/extensions/runner.ts