Skills、Extensions 与 MCP 边界
本文是固定源码版本 8ce69e9d2 的学习笔记。代码调用链与类型来自该提交;
safety-gate、security-review 是帮助理解的设计案例,不是已完成集成测试的插件。
本章要解决的问题
Section titled “本章要解决的问题”阶段 5 已经解释过 Skill 索引怎样进入 System Prompt,也见过 Extension 可以改写输入和
Context;阶段 6 又遇到了 session_before_compact、session_before_tree。本章不重复罗列
这些 hook,而是继续追踪一套真实扩展能力怎样从磁盘进入 Pi:
Pi 启动 → 判断项目资源是否需要信任 → trust 前只加载用户级、CLI 和 inline Extension → Extension 可以参与 project_trust 决策 → 根据最终 trust 重新解析资源集合 → 加载 Extension module,执行 factory,收集注册项 → 发现并校验 Skill,只保留元数据 → AgentSession 建立 ExtensionRunner 和工具注册表 → session_start 后,Extension 还能贡献额外 Skill → 重建 System Prompt → 用户输入命中 Skill、命令或普通 Prompt → Extension 在 Agent、Context、Provider、Tool、Session 生命周期介入 → reload 或切换 Session 时销毁旧 runtime,再建立新 runtime为了保持上下文连贯,本章始终使用同一个例子:
security-review Skill 提供安全审查步骤、参考资料和检查脚本
safety-gate Extension 注册 scan_secrets Tool 在 tool_call 阶段拦截危险 bash 参数 在 tool_result 阶段给扫描结果补充说明 在 resources_discover 阶段贡献 security-review Skill读完后,你应该不需要逐行精读源码,就能回答:
- Skill 和 Extension 分别在启动的哪一步被发现、解析和保存;
- Project Trust 为什么需要两阶段 Extension 加载;
- Skill 的“只常驻元数据、按需读取正文”具体由哪些函数实现;
- 模型自动使用 Skill 与用户执行
/skill:name的数据流有什么不同; - Extension factory 为什么以注册能力为主,真正的 Session 操作要等
bindCore(); - 自定义 Tool 怎样进入 Agent 的结构化 tools,并经过
tool_call、execute、tool_result; - 不同 hook 是通知、链式变换、短路还是可取消事务;
- reload 和 Session replacement 为什么必须让旧 Extension Context 失效;
- Project Trust、Prompt 规则、Tool 拦截和 OS sandbox 分别保护什么;
- 内置 MCP、codemode 和工具搜索怎样复用 Extension 与 Tool 链。
一、先把五种扩展机制放回同一条执行链
Section titled “一、先把五种扩展机制放回同一条执行链”Pi 的扩展能力不是五套互相竞争的插件系统。它们处于不同层次:
| 机制 | 本质 | 谁触发 | 是否执行代码 | 是否常驻模型 Context |
|---|---|---|---|---|
| Prompt Template | 用户输入文本宏 | 用户输入 /name args |
否 | 展开后作为本轮 UserMessage |
| Skill | 可按需读取的工作流、知识和配套资源 | 模型选择或用户 /skill:name |
Skill 本身不执行;可指示模型调用工具或脚本 | 默认索引包含名称、描述和路径 |
| Tool | 模型可调用的确定性运行时能力 | 模型生成 Tool Call | 是 | schema 常驻本次 Provider tools |
| Extension | 修改产品运行时行为的 TypeScript 模块 | 启动注册,生命周期事件触发 | 是,拥有 Pi 进程权限 | 自身代码不进入 Context;可修改 Prompt、消息和 tools |
| MCP | 外部能力协议 | 取决于接入它的客户端或 Extension | 通常通过外部进程或网络 | 取决于适配层暴露的工具和资源 |
1.1 五者在一次请求中的位置
Section titled “1.1 五者在一次请求中的位置”flowchart TB
Disk["磁盘与包<br/>Skills / Prompts / Extensions"]
Trust["Project Trust"]
Loader["ResourceLoader"]
Factory["Extension factory 注册"]
Runner["ExtensionRunner"]
Prompt["System Prompt<br/>Skill 元数据 + Tool 文字说明"]
Input["用户原始输入"]
Command{"输入命中什么?"}
ExtensionCommand["Extension Command<br/>直接执行 handler"]
Skill["Skill 展开<br/>或模型稍后 read SKILL.md"]
Template["Prompt Template 展开"]
Agent["Agent Run"]
Tools["结构化 Tool schema"]
ToolCall["Tool Call"]
External["本地函数 / CLI / 服务 / MCP server"]
Disk --> Trust --> Loader
Loader --> Factory --> Runner
Loader --> Prompt
Runner --> Prompt
Input --> Command
Command --> ExtensionCommand
Command --> Skill
Command --> Template
Skill --> Agent
Template --> Agent
Prompt --> Agent
Runner --> Agent
Tools --> Agent
Agent --> ToolCall
ToolCall --> External这里最重要的边界是:
Skill 改变模型“怎样思考和工作”,Tool 提供“可以确定性调用什么”,Extension 改变 “Pi 在什么时候做什么”,MCP 是外部能力协议,当前 CLI 已通过内置 Extension 接入。
1.2 贯穿案例的合理拆分
Section titled “1.2 贯穿案例的合理拆分”security-review 不应该全部写成一个 Extension,也不应该全部塞进一个 Tool:
security-review Skill - 审查顺序 - 风险分类标准 - 什么情况下读取哪些 reference - 何时调用 scan_secrets
scan_secrets Tool - 接收 path - 执行确定性扫描 - 返回结构化结果
safety-gate Extension - 注册 scan_secrets - 拦截危险 bash - 修改扫描结果 - 参与 lifecycleSkill 适合保存可阅读的工作流;Tool 适合把一个动作做成模型可调用的接口;Extension 负责把 Tool 和 hook 接到 Pi Runtime。
1.3 Extension 到底是什么:由 Pi 加载并调用的程序模块
Section titled “1.3 Extension 到底是什么:由 Pi 加载并调用的程序模块”Extension 是运行在 Pi 进程内的插件代码。它通过 Pi 提供的 API 注册能力,再由 Pi 在相应时机调用。
用户通常提供一个导出默认函数的 TypeScript 文件;Pi 加载文件后,把 ExtensionAPI 传给这个函数。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { ctx.ui.notify("扩展已进入当前 Session", "info"); });}这个例子没有新模型、没有自己的 Agent Loop,也没有要求模型遵守额外 Prompt。 它只是告诉 Pi:“当 Session 启动时,调用这段代码显示一条通知。”
这里的几个名字要分开:
| 名称 | 在例子里是什么 | 什么时候使用 |
|---|---|---|
| Extension 文件 | 用户写的 .ts 模块 |
Pi 启动或 reload 时加载 |
| factory | export default function |
加载后执行,用来注册能力 |
pi / ExtensionAPI |
Pi 传给 factory 的接口 | 注册 Tool、Command、hook,或在绑定后发起运行时动作 |
| handler | pi.on() 的第二个参数 |
对应事件发生后,由 Pi 调用 |
ctx / ExtensionContext |
事件或 Tool 执行时提供的上下文 | 访问当前 Session、模型、UI 和取消信号等 |
factory 执行不等于 handler 执行。上面加载时执行的是 pi.on(...);只有随后发生
session_start,通知代码才运行。第六部分会沿这两个时机继续看 loader 源码。
一个 Extension 可以同时提供多种能力:
// 接口形状示意,省略业务实现。pi.registerCommand("review", { handler: runReviewCommand }); // 用户输入 /reviewpi.registerTool(scanToolDefinition); // 模型生成 Tool Callpi.on("tool_call", checkBeforeExecution); // Pi 执行 Tool 前触发所以 Extension 是组织和接入这些能力的模块,Tool 只是它可以注册的一种能力。 Skill 则是模型按需读取的工作流文本;即使模型没有读取任何 Skill,已注册的 Extension hook 仍可运行。
1.4 怎样接入:先试一个文件,再选择加载范围
Section titled “1.4 怎样接入:先试一个文件,再选择加载范围”固定版本的 Extension 文档 给出了以下接入方式。先用显式路径试验最容易观察行为:
# 假设已把下面的示例保存为当前目录中的 extension-demo.ts。pi -e ./extension-demo.ts-e 是 --extension 的简写。Pi 负责加载 TypeScript 和调用默认导出,用户不需要先为这个文件
单独生成 .js 构建产物。确认行为后,再按用途选择位置:
| 方式 | 配置或位置 | 适合的范围 |
|---|---|---|
| 显式加载 | pi -e ./extension-demo.ts |
本次启动指定,便于试验 |
| 用户级自动发现 | ~/.pi/agent/extensions/extension-demo.ts |
默认配置下跨项目使用 |
| 项目级自动发现 | <cwd>/.pi/extensions/extension-demo.ts |
当前项目使用,受 Project Trust 控制 |
| 多文件扩展 | 上述 extensions 目录下的 extension-demo/index.ts |
把入口、helper、Skill 资源放在一起 |
| settings / package | settings 的 extensions 或 packages |
显式管理外部路径或分发的包 |
这些是不同加载入口,不需要全部配置。同一个演示文件选择一种方式加载即可。
项目未获信任时,项目自动发现的扩展不加载;显式 -e 是用户提供的 CLI 来源,不能用这个
trust 规则推导它也会被阻止。第二部分解释来源过滤,第六部分解释入口发现。
开发时可以把扩展放在自动发现目录,改完后执行 /reload。它会重新加载扩展并重建运行时,
不是只替换某个函数的代码。第十四部分会解释为何旧 pi、旧 ctx 之后不能继续使用。
1.5 最小例子:Command、Tool、hook 怎么一起工作
Section titled “1.5 最小例子:Command、Tool、hook 怎么一起工作”下面是独立的教学示例,不是后文完整安全扫描器。它只阻止参数中含 PI_EXTENSION_DEMO_BLOCK
这个演示标记的 bash Tool Call,并让用户和模型分别查询拦截次数。
这条字符串规则用于观察 hook,不是通用危险命令检测。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";
export default function (pi: ExtensionAPI) { let blockedCount = 0; // 本次 Extension 实例的内存状态。
pi.on("session_start", (_event, ctx) => { blockedCount = 0; ctx.ui.setStatus("extension-demo", "演示扩展已就绪"); });
pi.on("tool_call", event => { if (event.toolName !== "bash") return; const command = event.input.command; if (typeof command === "string" && command.includes("PI_EXTENSION_DEMO_BLOCK")) { blockedCount++; return { block: true, reason: "命中了演示拦截标记" }; } });
pi.registerCommand("gate-status", { description: "查看演示扩展的拦截次数", handler: async (_args, ctx) => { ctx.ui.notify(`已拦截 ${blockedCount} 次`, "info"); }, });
pi.registerTool({ name: "get_gate_status", label: "Gate status", description: "查询当前演示扩展拦截的 bash 调用次数", parameters: Type.Object({}), async execute() { return { content: [{ type: "text", text: `已拦截 ${blockedCount} 次` }], details: { blockedCount }, }; }, });
pi.on("session_shutdown", (_event, ctx) => { ctx.ui.setStatus("extension-demo", undefined); });}加载后可以在交互模式中观察三个入口。以下为预期行为;本文没有报告真实模型一定会发出指定调用:
- 输入
/gate-status:Pi 直接执行 Command handler,显示计数,不必请求模型。 - 请模型调用
get_gate_status:模型实际发出该 Tool Call 后,Tool 返回内容进入对话;这需要可用的模型与该 Tool 处于 active 状态。 - 请模型通过 bash 执行
echo PI_EXTENSION_DEMO_BLOCK:若它实际发出这次 bash Tool Call,hook 会在执行前阻止它,计数增加。
不要用用户的 !echo ... 来验证第三步:用户直执行 shell 走 user_bash 路径,不是模型的
tool_call。这正说明扩展接在具体事件上,其效果由接入点决定。
例子的 blockedCount 只在当前实例内保存,reload 或 Session replacement 后重新计数。
如果要恢复业务状态,需要写入 Session entry 或 ToolResult.details,并按当前分支重建,见第十二部分。
UI 状态与通知以交互模式为主要观察方式,Print/RPC 的 UI 能力不同,不能照搬 TUI 展示效果。
1.6 生命周期:谁创建、谁触发、什么时候结束
Section titled “1.6 生命周期:谁创建、谁触发、什么时候结束”“扩展已加载”只表示代码和注册项已经准备好,不等于已经能操作当前 Session。 按正常 CLI 启动和使用顺序,生命周期可以拆成下面几段:
| 阶段 | Pi 做什么 | 扩展适合做什么 |
|---|---|---|
| 发现与信任 | 解析来源,过滤受 trust 控制的项目资源 | 此时项目扩展未必会被加载 |
| import 与 factory | 加载模块、创建注册容器、执行默认函数 | 注册 handler、Tool、Command;避免直接启动长期资源 |
| bindCore / 宿主绑定 | 把共享 runtime 的动作接到 AgentSession,绑定 UI 等能力 | 注册时保存的 API 现在有了当前 Session 的操作目标 |
| session_start | 发出 Session 启动事件 | 恢复分支状态,按需建立连接或 watcher |
| resources_discover | 收集扩展贡献的本地资源路径 | 返回 Skill、Prompt、Theme 路径 |
| 处理用户请求 | 触发 input、context、Tool 等适用事件 | 转换输入、执行 Tool、检查参数或修饰结果 |
| agent_settled | 当前运行包装流程收尾,包括异常退出 | 同步本次运行状态;不代表 Session 已销毁或任务成功 |
| session_shutdown | 正常退出、reload 或替换 Session 前通知 | 幂等释放本实例创建的连接、订阅等资源 |
一次 Session 可以包含许多次用户请求,因此 session_start 不会每个 Turn 都触发,
session_shutdown 也不应每次模型回答完成就触发。
资源管理可以按以下模式组织。openConnection 是示例业务函数,不是 Pi 内置 API:
let connection;pi.on("session_start", async () => { connection = await openConnection();});pi.on("session_shutdown", async () => { const old = connection; connection = undefined; await old?.close();});这描述的是正常生命周期通知;进程被强杀或崩溃时,不能保证异步 shutdown handler 有机会执行。
对于同一个 Session 内的 /tree,扩展实例通常仍在,业务状态要在 session_tree 后按新 branch
恢复;对于 new/resume/fork 的 Session replacement,旧实例会 shutdown/失效,新实例重新初始化。
/reload 的关键交接则是:
旧 session_shutdown → 旧 Runner / API / ctx 失效 → 重新加载资源、执行 factory → 新 Runner 绑定 → 新 session_start 与 resources_discover这不是把旧闭包里的变量自动搬到新闭包。需要保留的数据必须有明确的持久化或交接方式。 裸 SDK 的绑定与事件触发还依赖宿主,细节见第八、十四部分;上表以正常 CLI 宿主为主线。
1.7 优点、代价与适用边界
Section titled “1.7 优点、代价与适用边界”下面是从上述实现推导出的工程取舍,不是性能或可靠性的实测结论:
| 能力带来的优点 | 对应代价或限制 |
|---|---|
| 不修改 Pi 核心就能加入 Tool、命令和组织流程 | 依赖 Extension API,需要核对版本兼容性 |
| 同一模块可以把模型能力、用户入口、UI 和 hooks 组织在一起 | 也更容易耦合,需要明确各个入口的职责 |
| tool_call 能在执行前做程序判断,不依赖模型是否读过 Skill | 只覆盖经过该 hook 的路径,不等于拦住所有 shell、文件或网络操作 |
| 启动发现与 reload 降低本地迭代成本 | reload 会重建实例,旧状态、连接与闭包需正确处理 |
| 可在 Session 事件里恢复状态、参与分支与摘要 | 要自己定义持久化格式和分支恢复逻辑 |
| 与宿主同进程,可直接调用本地库和进程能力 | 没有插件沙箱;阻塞代码、未处理副作用或错误设计会影响宿主 |
| 多个扩展可组合并按顺序处理事件 | 同名冲突、handler 顺序、参数/结果修改可能相互影响 |
因此,只有一段复用提问时用 Prompt Template;主要提供工作步骤与资料时用 Skill; 需要“增加一个可执行动作”或“在 Pi 的特定时刻改变行为”时,才进入 Extension。 接入外部服务、组织策略、交互命令或自定义状态恢复都是它的适用场景。
如果需求是隔离不可信代码或对进程做强权限控制,需要在执行环境层解决; Extension 自身就在被保护的进程里,不能同时充当那个隔离边界。
读到这里再进入源码:第二部分解释它何时获准加载,第六、七部分解释注册, 第八、九部分解释绑定与调用,第十一至十四部分解释运行和销毁。这样后续函数名对应的是 同一套生命周期,而不是一组分散的 API。
二、启动总线:资源不是一次扫描完就结束
Section titled “二、启动总线:资源不是一次扫描完就结束”阶段 5 从 DefaultResourceLoader.reload() 看过资源加载。本章要补上 trust、Extension factory
和 resources_discover,得到完整顺序。先用缩略代码把交接串起来,后面各小节再展开。
以下是跨文件流程示意,省略构造参数、诊断和 UI 细节,不是可以直接运行的 SDK 示例:
// 1. CLI runtime factory 已根据目标 Session 确定 cwd。const loader = new DefaultResourceLoader({ cwd, settingsManager, /* ... */ });
// 2. reload 先加载 bootstrap Extension,再把它们交给 trust 决策。await loader.reload({ resolveProjectTrust: ({ extensionsResult }) => resolveProjectTrusted({ cwd, extensionsResult, /* trustStore、UI 等 */ }),});// 内部:bootstrap factory → trust → 最终 Extension 集合 → Skill 元数据等。
// 3. AgentSession 的构造过程使用上面的 loader。// _buildRuntime() → new ExtensionRunner(...) → bindCore() → 刷新 Tool registry。const session = new AgentSession({ resourceLoader: loader, /* ... */ });
// 4. 宿主绑定交互能力,启动 Extension 的 Session 生命周期。await session.bindExtensions({ /* uiContext、mode、commandContextActions 等 */ });// 内部:session_start → resources_discover → loader.extendResources()// → 重建 System Prompt(只有贡献了资源才走这次增量更新)。这里“资源快照”指 loader 实例字段中的 Skill[]、Extension 注册结果、路径和诊断等内存状态,
没有额外写出一份快照文件。Skill 正文只在扫描时临时读取;保存的 Skill 对象不含正文。
2.1 CLI 主路径先确定最终 cwd
Section titled “2.1 CLI 主路径先确定最终 cwd”关键入口:
- main.ts 的 runtime factory;
- agent-session-services.ts 的
createAgentSessionServices(); - resource-loader.ts 的
DefaultResourceLoader.reload()。
--session 或 --resume 可能打开另一个项目的 Session,所以 Pi 不是直接使用进程启动时的
cwd。它先得到目标 Session 的 cwd,再为这个 cwd 创建:
SettingsManagerModelRuntimeDefaultResourceLoader这是 trust 和资源都必须绑定 cwd 的原因。否则从项目 A 恢复项目 B 的 Session 时,可能错误
加载 A 的 .pi/extensions 和 .pi/skills。
2.2 hasTrustRequiringProjectResources() 只决定是否需要询问
Section titled “2.2 hasTrustRequiringProjectResources() 只决定是否需要询问”实现位于 trust-manager.ts。
它检查:
cwd/.pi/settings.jsoncwd/.pi/extensionscwd/.pi/skillscwd/.pi/promptscwd/.pi/themescwd/.pi/SYSTEM.mdcwd/.pi/APPEND_SYSTEM.md
以及 cwd 向上的 .agents/skills用户级 ~/.agents/skills 被明确排除,因为它属于用户资源,不是当前仓库偷偷提供的内容。
发现这些路径只说明“这里有需要 trust gate 的输入”,不说明项目已经可信。
2.3 trust 前的 bootstrap Extension 集合
Section titled “2.3 trust 前的 bootstrap Extension 集合”准确触发条件是调用方给 reload() 传入了 resolveProjectTrust callback。CLI 主路径这样启动时,
loader 先 bootstrap,随后 callback 才检查 CLI override、trust store 等;并不是 loader 先判断
“没有已存决定”才 bootstrap:
loadProjectTrustExtensions() → settingsManager.setProjectTrusted(false) → reload settings → loadCurrentExtensionSet()这次解析刻意排除:
项目 .pi/extensions项目 package-managed extensions项目 .pi/settings.json 中声明的扩展但保留:
用户级 ExtensionCLI -e ExtensionSDK inline Extension原因不是优化,而是安全上的自举约束:
项目自己的 Extension 不能参与“是否信任这个项目”的决定,否则恶意仓库只要放入
.pi/extensions,就能在用户同意前执行。
这个保证针对项目自动发现和项目配置来源。用户显式用 -e ./local-extension.ts 指向项目文件时,
它作为 CLI 输入进入 bootstrap;trust 不是按物理路径禁止所有项目文件。
2.4 project_trust 事件怎样得出决定
Section titled “2.4 project_trust 事件怎样得出决定”bootstrap Extension 被加载后,主路径调用
project-trust.ts 的
resolveProjectTrusted():
显式 CLI trust override → 有则直接采用
没有需要 trust 的资源 → 直接 true
bootstrap Extension project_trust handlers → 第一个 yes/no 胜出 → undecided 继续
trust.json 中 cwd 或最近祖先的决定 → 有则采用
defaultProjectTrust → always / never / ask
ask 且有 UI → 显示选择器
无 UI 且没有已有决定 → falseemitProjectTrustEvent() 与普通 ExtensionRunner.emit() 不同。它不需要完整 Session Runtime,
使用受限的 ProjectTrustContext,并在 Extension 加载结果上直接遍历 handler。
2.5 trust 后进行最终资源解析
Section titled “2.5 trust 后进行最终资源解析”得到决定后:
settingsManager.setProjectTrusted(projectTrusted) → settingsManager.reload() → packageManager.resolve() → 形成最终 extensions / skills / prompts / themes 路径若可信,受 trust 控制的项目资源加入;若不可信,这些自动发现和项目配置来源继续被排除。随后
loadFinalExtensionSet() 会复用 trust 前已经加载的用户级和 CLI Extension,只补加载剩余
Extension,避免同一个 factory 在一次启动中因为两阶段 trust 被无意义执行两次。
2.6 完整启动时序
Section titled “2.6 完整启动时序”sequenceDiagram
participant Main as main/runtime factory
participant RL as DefaultResourceLoader
participant PM as PackageManager
participant EL as Extension Loader
participant Trust as resolveProjectTrusted
participant AS as AgentSession
participant ER as ExtensionRunner
Main->>RL: reload(resolveProjectTrust)
RL->>PM: 以 projectTrusted=false 解析路径
PM-->>RL: 用户级 + CLI Extension
RL->>EL: loadExtensionsCached()
EL-->>RL: bootstrap extensions + shared runtime
RL->>Trust: extensionsResult
Trust->>EL: project_trust handlers
Trust-->>RL: trusted true/false
RL->>PM: 按最终 trust 再解析全部资源
PM-->>RL: 最终 Extension / Skill / Prompt / Theme
RL->>EL: 补加载剩余 Extension
RL-->>Main: 资源快照
Main->>AS: create AgentSession
AS->>ER: new ExtensionRunner + bindCore
Main->>AS: bindExtensions(UI/mode/actions)
AS->>ER: session_start
AS->>ER: resources_discover
ER-->>AS: 额外 Skill/Prompt/Theme paths
AS->>RL: extendResources()
AS->>AS: rebuild System Prompt启动至此才真正完成。也就是说:
ResourceLoader.reload() 发现静态资源
session_start 后的 resources_discover 允许 Extension 动态补充资源三、Skill 的第一段生命周期:从文件到轻量元数据
Section titled “三、Skill 的第一段生命周期:从文件到轻量元数据”这一段从 PackageManager 的路径发现进入 skills.ts。
先记住三个交接对象:候选路径 → Skill 元数据 → 按名称去重的 Skill[]。
下面各节代码按固定版本缩写,保留关键判断,省略异常处理或诊断字段时会注明;它们是源码片段,
不是可独立运行的完整实现。
PackageManager.resolve() collectSkillEntries() // 3.2 至 3.4:从目录得到候选路径 resourcePrecedenceRank() + sort() // 3.1:按来源排列候选路径 ↓DefaultResourceLoader.reload() 合并 CLI、已排序资源和 additionalSkillPaths updateSkillsFromPaths() ↓loadSkills({ skillPaths, includeDefaults: false }) loadSkillFromFile() // 3.5 至 3.6:路径变成元数据 addSkills() // 3.7:按真实路径、名称去重 ↓ResourceLoader 保存 Skill[] // 3.8:后续构建 Prompt 的输入3.1 Skill 来源和优先级
Section titled “3.1 Skill 来源和优先级”Pi 可以从以下来源得到 Skill:
项目 settings 显式路径项目自动发现目录用户 settings 显式路径用户自动发现目录Pi packageCLI --skillExtension resources_discoverSDK ResourceLoaderPackageManager 在交给 ResourceLoader 前会按优先级排序。Skill 名称冲突遵循“先出现者
获胜”,所以排序直接决定 Skill winner:
项目 settings > 项目自动发现 > 用户 settings > 用户自动发现 > package这个顺序由 package-manager.ts 的 resourcePrecedenceRank() 编码,数值越小越靠前:
function resourcePrecedenceRank(m: PathMetadata): number { if (m.origin === "package") return 4; const scopeBase = m.scope === "project" ? 0 : 2; return scopeBase + (m.source === "local" ? 0 : 1);}
resolved.sort((a, b) => resourcePrecedenceRank(a.metadata) - resourcePrecedenceRank(b.metadata));例如项目 settings 路径是 0 + 0,用户自动发现路径是 2 + 1。排序只决定谁先被处理;
真正保留哪一个同名 Skill,要等 3.7 的 Map 判断。
CLI、SDK 和 Extension 提供的路径还会经过 ResourceLoader 的合并和 source metadata
标注。在默认 loader、未关闭 Skill 自动加载时,路径顺序是:
CLI Extension 来源中附带的 Skill → PackageManager 已排序的 Skill → additionalSkillPaths(包括 CLI --skill) → resources_discover 后追加的 Skill所以 --skill 不是“同名时必定覆盖”的开关;追加的 Extension Skill 也不会自动覆盖已有同名项。
SDK 自定义 ResourceLoader 或 override 可以改变集合。不能仅凭物理位置推断最终来源。
3.2 两种目录模式
Section titled “3.2 两种目录模式”自动发现有 pi 和 agents 两种模式:
~/.pi/agent/skills 和 .pi/skills → 根目录直接 .md 文件可以是 Skill → 也递归发现 */SKILL.md
~/.agents/skills 和项目祖先 .agents/skills → 根目录普通 .md 不算 Skill → 子目录可发现 SKILL.md,也可发现其他 .md → 当前目录(包括扫描根)存在未被 ignore 的 SKILL.md 时,优先以它作为 Skill root 并停止深入差异落在 collectSkillEntries() 的一个条件中。先看普通 .md 分支,
SKILL.md 的提前返回在下一节解释:
const shouldIncludeMarkdownFile = isFile && entry.name.endsWith(".md") && !ig.ignores(relPath) && ((mode === "pi" && dir === root) || (mode === "agents" && dir !== root));
if (shouldIncludeMarkdownFile) { entries.push(fullPath); continue;}这里 root 是本次 Skill 扫描的起点,比如 .agents/skills,不是 Git repo root。
所以 dir === root 与 dir !== root 就解释了两种模式对普通 Markdown 的不同选择。
项目 .agents/skills 从 cwd 向祖先查找:
如果处于 Git repo → 到 repo root 为止
如果不处于 Git repo → 到文件系统根为止这个边界与阶段 5 的 Context 文件查找不同:AGENTS.md 会一直走到文件系统根;项目
.agents/skills 在 Git repo 内会停在 repo root。
例如 .agents/skills/root.md 不被自动发现,
.agents/skills/vendor/review.md 会成为候选文件,再检查 frontmatter 的 description。
因此不能概括为“agents 模式只认 SKILL.md”。.pi/skills 不向祖先查找。
3.3 一个目录有 SKILL.md 时不再向下递归
Section titled “3.3 一个目录有 SKILL.md 时不再向下递归”collectSkillEntries() 和 loadSkillsFromDirInternal() 都优先检查当前目录的
SKILL.md:
当前目录存在可读取为文件、未被 ignore 的 SKILL.md → 当前目录就是一个 Skill root → 返回这一个 Skill → 不继续发现它内部 references/assets 子目录中的其他 SKILL.mdcollectSkillEntries() 会先遍历一次目录找 SKILL.md,之后才进入普通文件与递归分支:
// 缩略:fullPath、isFile、relPath 的计算与 symlink 检查省略。for (const entry of dirEntries) { if (entry.name !== "SKILL.md") continue; if (isFile && !ig.ignores(relPath)) { entries.push(fullPath); return entries; // 退出整个目录扫描,不只是跳过当前文件 }}
// 只有上面没找到可用入口,才继续普通 Markdown 和子目录扫描。// 子目录分支:entries.push(...collectSkillEntries(fullPath, mode, ig, root));这里的 return 是 Skill package 的封装边界。一个 Skill 内部可以自由放:
scripts/references/assets/这些目录是当前 Skill 的资源,不应该被误识别成一组嵌套 Skill。
即使这个 SKILL.md 最终因 description 无效而被跳过,加载器也不会回头把其内部文件当作替代 Skill。
3.4 ignore、隐藏目录、node_modules 和 symlink
Section titled “3.4 ignore、隐藏目录、node_modules 和 symlink”发现过程还执行以下约束:
.gitignore / .ignore / .fdignore → 规则按扫描根目录统一匹配
隐藏目录 → 跳过
node_modules → 跳过
symlink → 跟随以判断文件或目录 → 之后用 canonical path 去重这些约束就在同一个目录循环中,不是加载完再统一过滤:
// collectSkillEntries() 普通扫描分支的关键判断,省略路径计算与 try/catch。if (entry.name.startsWith(".")) continue;if (entry.name === "node_modules") continue;
if (entry.isSymbolicLink()) { const stats = statSync(fullPath); // 跟随链接判断真实类型 isDir = stats.isDirectory(); isFile = stats.isFile();}
// 文件使用 relPath 匹配;递归目录使用末尾带 / 的路径匹配。if (!isDir) continue;if (ig.ignores(`${relPath}/`)) continue;entries.push(...collectSkillEntries(fullPath, mode, ig, root));symlink 的类型判断与去重是两件事:这里解决“要不要进入”,3.7 才解决“是否已加载”。 所以“路径列表里出现两次”不必然得到两份 Skill;同一真实文件通过 symlink 重复暴露时会被 静默去重。
3.5 loadSkillFromFile() 只解析 frontmatter,不保存正文
Section titled “3.5 loadSkillFromFile() 只解析 frontmatter,不保存正文”核心类型:
interface Skill { name: string; description: string; filePath: string; baseDir: string; sourceInfo: SourceInfo; disableModelInvocation: boolean;}注意没有 content 字段。加载阶段会读取文件以解析 frontmatter,但返回的长期对象只保存:
模型选择所需的 name / description稍后读取正文所需的 filePath / baseDir诊断和 UI 所需的 sourceInfo是否允许模型自主发现的 disableModelInvocation从 loadSkillFromFile() 的输入和返回值可以直接看出这个边界:
// 缩略:读取/解析的异常处理、name 回退和 3.6 的校验暂时省略。const rawContent = readFileSync(filePath, "utf-8");const { frontmatter } = parseFrontmatter<SkillFrontmatter>(rawContent);
return { skill: { name, description: frontmatter.description, filePath, baseDir: dirname(filePath), sourceInfo: createSkillSourceInfo(filePath, dirname(filePath), source), disableModelInvocation: frontmatter["disable-model-invocation"] === true, }, diagnostics,};rawContent 读到了整份文件,但返回对象没有把它带出去。正文没有常驻内存中的 Skill 对象,
更不会在此时直接放入 System Prompt。
3.6 校验是宽松的,但 description 是硬门槛
Section titled “3.6 校验是宽松的,但 description 是硬门槛”名称规则:
最多 64 字符只允许小写字母、数字和连字符不能以连字符开头或结尾不能连续两个连字符描述最多 1024 字符。
大多数违规:
产生 warning → Skill 仍然加载声明为 SKILL.md 的文件缺失、空白或非字符串 description:
产生 warning → 返回 skill: null → 不加载对应的控制流在构造上节的返回对象之前:
const isDeclaredSkill = basename(filePath) === "SKILL.md";const description = frontmatter.description;const hasDescription = typeof description === "string" && description.trim() !== "";
if (!isDeclaredSkill && !hasDescription) { return { skill: null, diagnostics }; // 普通 Markdown:静默跳过}
// 缩略:name 已按 frontmatter 或父目录名确定;合并展示两段 warning 循环。for (const message of [...validateDescription(description), ...validateName(name)]) { diagnostics.push({ type: "warning", message, path: filePath });}if (!hasDescription) return { skill: null, diagnostics };// 有有效 description:继续构造 Skill,即使前面已有 name/长度 warning。决定是否加载的是最后的 hasDescription,不是 diagnostics.length === 0。
原因是 description 不只是展示文本,而是 progressive disclosure 的路由条件。没有描述,
模型无法判断何时读取正文。
普通 .md 若没有有效 description,会被静默当作非 Skill;它的 YAML 解析失败也不会产生
SKILL.md 那样的 warning。因此“所有缺 description 的 Markdown 都报错”并不成立。
Pi 允许 frontmatter name 与父目录名不同;未给有效字符串 name 时使用父目录名。
这让共享目录中的 Skill 不必依赖目录名作为唯一名称。
3.7 同名冲突为什么是 first wins
Section titled “3.7 同名冲突为什么是 first wins”loadSkills() 维护:
skillMap: name → SkillrealPathSet: canonical file path它的内部 addSkills() 按传入顺序处理,关键代码如下(只省略 collision 的展示字段):
for (const skill of result.skills) { const realPath = canonicalizePath(skill.filePath); if (realPathSet.has(realPath)) continue; // 同一已加载文件:静默跳过
const existing = skillMap.get(skill.name); if (existing) { collisionDiagnostics.push({ type: "collision", // 省略 message/path;collision 记录 existing.filePath 与 skill.filePath。 }); } else { skillMap.set(skill.name, skill); realPathSet.add(realPath); }}例如先处理项目里的 security-review,后处理用户目录的同名 Skill:第二次会进入 existing
分支,只记诊断,没有再次 set()。换成同一文件的 symlink,则在更早的 realPathSet 检查就跳过。
因此 first wins 不是偶然的 Map 行为;PackageManager 必须先按资源优先级排序,才能保证
项目 Skill 覆盖用户 Skill、用户 Skill 覆盖 package Skill。
3.8 Skill 发现阶段的输出
Section titled “3.8 Skill 发现阶段的输出”贯穿案例在这一阶段从:
/extensions/safety-gate/skills/security-review/SKILL.md变成:
Skill { name: "security-review", description: "审查认证、凭据和命令执行风险...", filePath: "/.../security-review/SKILL.md", baseDir: "/.../security-review", sourceInfo: { source: "extension:index", ... }, disableModelInvocation: false}正文仍在文件里。下一步才决定模型如何得知它存在。
后续各部分沿用第三节的代码约定:源码缩略标出文件/函数和省略项;自定义 Extension 用法与业务函数另外标为示例。先读代码里的对象与分支,再对应正文结论。
四、Skill 的第二段生命周期:元数据怎样进入 System Prompt
Section titled “四、Skill 的第二段生命周期:元数据怎样进入 System Prompt”先看三个函数如何接力。以下跨函数缩略保留 Skill 索引的两个开关,省略其他 Prompt 区块:
// agent-session.ts:把 loader 的结果交给 Prompt 构造器。const loadedSkills = this._resourceLoader.getSkills().skills;// _baseSystemPromptOptions 还包括 cwd、contextFiles、customPrompt 等。this._baseSystemPromptOptions.skills = loadedSkills;
// system-prompt.ts:默认 Prompt 分支,tools 来自 selectedTools 或默认工具。const fileReadTool = (["read", "bash"] as const).find(name => selectedTools.includes(name));if (fileReadTool && skills.length > 0) { promptSections.skills = formatSkillsForPrompt(skills, fileReadTool).trim();}// 自定义 Prompt 也经过这个判断;read 优先,bash 可作为回退。
// skills.ts:只把允许模型自主发现的条目写入 XML。const visibleSkills = skills.filter(s => !s.disableModelInvocation);for (const skill of visibleSkills) { lines.push(` <name>${escapeXml(skill.name)}</name>`); lines.push(` <description>${escapeXml(skill.description)}</description>`); lines.push(` <location>${escapeXml(skill.filePath)}</location>`);}先检查 active read 或 bash,再经过 disableModelInvocation 过滤,最后只有元数据进入字符串。
4.1 _rebuildSystemPrompt() 是资源汇合点
Section titled “4.1 _rebuildSystemPrompt() 是资源汇合点”定位:
- agent-session.ts 的
_rebuildSystemPrompt(); - system-prompt.ts 的
buildSystemPrompt(); - skills.ts 的
formatSkillsForPrompt()。
AgentSession 从 ResourceLoader 取得 Skill[],放入 BuildSystemPromptOptions.skills。
buildSystemPrompt() 在默认 Prompt 或自定义 Prompt 后追加 Skill 区块。
4.2 常驻的是选择索引
Section titled “4.2 常驻的是选择索引”formatSkillsForPrompt() 生成近似:
<available_skills> <skill> <name>security-review</name> <description>审查认证、凭据和命令执行风险...</description> <location>/abs/path/security-review/SKILL.md</location> </skill></available_skills>同时明确告诉模型:
任务匹配 description 时,使用 read 读取文件Skill 引用相对路径时,以 Skill 所在目录为基准这就是 progressive disclosure 的成本模型:
N 个 Skill 常驻成本 ≈ N 份 name + description + location
只有实际读取的 Skill → 才把读到的正文放入对话,reference 还需另行读取4.3 读取能力现在包括 read 和 bash
Section titled “4.3 读取能力现在包括 read 和 bash”buildSystemPromptSections() 优先选 active read,没有时选 active bash,再调用 formatSkillsForPrompt(skills, fileReadTool)。两者都没有才不生成索引。
有 read:发现元数据 → read(location)只有 bash:发现元数据 → 用 bash 读取文件两者都没有:不加入 Skill 索引这项检查是 Prompt 的读取指引,与 /skill:name 在宿主侧直接展开是两条路径。
4.4 disable-model-invocation 隐藏的只是自动入口
Section titled “4.4 disable-model-invocation 隐藏的只是自动入口”formatSkillsForPrompt() 先过滤:
disableModelInvocation === true → 不出现在 available_skills但 Skill 仍存在于 ResourceLoader.getSkills().skills,默认输入展开流程仍能匹配 /skill:name。
这不是 factory 用 registerCommand() 注册的一组 Extension Command。因此它表达的是:
不允许模型仅凭 description 自主选择不等于禁用 Skill用户仍可显式触发这适合:
成本很高的工作流可能改变外部状态的流程只应在用户明确要求时加载的组织流程但它仍不是安全授权。用户显式调用后,正文只是进入 Prompt,后续工具权限仍由运行时代码和 环境决定。
五、Skill 的第三段生命周期:自动读取与显式调用
Section titled “五、Skill 的第三段生命周期:自动读取与显式调用”Skill 有两条完全不同的数据流。只说“Skill 按需加载”会掩盖它们的差异。
显式路径的主体在 agent-session.ts:_expandSkillCommand()。下面只省略空格解析的细节和错误对象字段:
private _expandSkillCommand(text: string): string { if (!text.startsWith("/skill:")) return text; // 源码从第一个空格切出 skillName 和 args。 const skill = this.resourceLoader.getSkills().skills.find(s => s.name === skillName); if (!skill) return text; try { const body = stripFrontmatter(readFileSync(skill.filePath, "utf-8")).trim(); const block = `<skill name="${skill.name}" location="${skill.filePath}">References are relative to ${skill.baseDir}.
${body}</skill>`; return args ? `${block}
${args}` : block; } catch (err) { this._extensionRunner.emitError({ /* skill_expansion 及文件路径、错误 */ }); return text; }}函数只返回展开后的字符串;prompt() 之后才创建 UserMessage。自动选择路径没有调用这个函数,而是模型发出普通 read Tool Call(仅有 bash 时按指引使用 bash 读取)。
5.1 模型自动选择 Skill
Section titled “5.1 模型自动选择 Skill”普通输入:
请检查这个认证改动是否泄露密钥模型第一轮看到:
System Prompt → security-review 的 name / description / location
UserMessage → 用户原始问题
tools → read 等结构化 schema如果模型判断匹配:
AssistantMessage → ToolCall read("/.../security-review/SKILL.md")
ToolResultMessage → read 返回的 SKILL.md 内容(大文件可能被截断,需继续读取)
下一 Turn → 模型根据 Skill 正文继续 read reference、运行脚本或调用其他 Tool正文进入 Context 的方式是普通 Tool Result。它会和其他 Tool Call 一样进入 Agent 消息、 Session 和后续 Provider Context。
这条路径的“不确定性”在选择阶段:模型可能没有按 description 读取。Pi 提供索引和指导, 但不强制模型一定选择正确 Skill。
5.2 用户显式执行 /skill:security-review
Section titled “5.2 用户显式执行 /skill:security-review”输入处理顺序沿用阶段 5:
Extension command → input hook → _expandSkillCommand() → Prompt Template → 创建 UserMessage_expandSkillCommand():
解析 skillName 和 args → 在 ResourceLoader Skill[] 中按 name 查找 → readFileSync(skill.filePath) → stripFrontmatter() → 构造 <skill> block → 在 block 后追加用户参数例如:
/skill:security-review packages/auth展开为:
<skill name="security-review" location="/.../SKILL.md">References are relative to /.../security-review.
这里是去掉 frontmatter 后的完整 Skill 正文</skill>
packages/auth这整段直接成为当前 UserMessage,不需要模型先调用 read。
上述顺序以默认 expandPromptTemplates: true、当前没有活动 Run 为前提。SDK 把该选项设为
false 时,也会跳过 Extension command lookup 与 Skill 展开;若当前正在 streaming,展开后的
内容按 streamingBehavior 入队,不会立即启动第二个 Run。
5.3 两条路径的准确对比
Section titled “5.3 两条路径的准确对比”| 维度 | 模型自动选择 | /skill:name |
|---|---|---|
| 触发者 | 模型根据 description 判断 | 用户 |
| 正文第一次出现的位置 | read 的 ToolResult |
当前 UserMessage |
| 是否多一个 LLM/Tool 往返 | 是 | 否 |
是否受 disable-model-invocation 影响 |
是 | 否 |
是否要求 active read |
是 | 否 |
| 用户参数 | 普通问题原文 | Skill block 后追加参数 |
| 是否持久化 | read ToolCall/Result 持久化 | 展开后的 UserMessage 持久化 |
5.4 找不到或读失败时如何退化
Section titled “5.4 找不到或读失败时如何退化”未知 /skill:name → 保留原始文本 → 后续可能作为普通 slash 文本发送给模型
读取 Skill 文件失败 → ExtensionRunner.emitError(skill_expansion) → 保留原始文本Skill 在启动时已经被发现,不代表执行时文件一定还存在。显式调用会重新读取文件,因此能看到 启动后对正文的修改;但 Skill 元数据和路径列表仍来自最近一次 reload。
5.5 Skill 引用其他资源时,谁负责继续加载
Section titled “5.5 Skill 引用其他资源时,谁负责继续加载”Pi 不会递归解析 references/ 或自动运行 scripts/:
SKILL.md 正文 → 告诉模型下一步应读哪个 reference、运行哪个 script → 模型调用 read/bash 等 ToolbaseDir 和 <skill location> 只是给模型正确的路径基准。实际文件访问仍通过 Tool,受 active
tool、hook、文件权限和 sandbox 约束。
六、Extension 的第一段生命周期:从入口文件到注册结果
Section titled “六、Extension 的第一段生命周期:从入口文件到注册结果”第四、五部分已经把 Skill 从发现讲到了使用。现在回到第二部分的启动流程,补上另一条分支:
ResourceLoader 找到 Extension 路径后,怎样把一个 .ts 文件变成可交给 Runner 的注册结果?
先用本章的 safety-gate/index.ts 举例。假设文件内容只有一个注册动作:
// Extension 用法示例:这段代码由插件作者编写。export default function safetyGate(pi) { pi.on("tool_call", event => { // 等真正发生 Tool Call 时,再执行这里的检查。 });}这里有三个不同对象,后文一直围绕它们转换:
文件路径:/.../safety-gate/index.ts ↓ 加载模块,取得 default exportfactory 函数:safetyGate(pi) ↓ Pi 调用 factory,把注册 API 传进去Extension 对象:handlers 中保存了 tool_call 回调factory 是这里对“接收 Pi API、注册扩展能力的函数”的称呼。它不是新的语法,
就是上面的 safetyGate 函数;Extension 对象则是 Pi 用来保存注册结果的容器。
先看正常路径的总代码。下面是跨函数缩略,省略缓存、批量错误处理和加载事务,随后逐步补回:
// 1. 资源发现阶段得到入口路径。const extensionPath = "/.../safety-gate/index.ts";
// 2. loadExtension() 调用:加载文件,拿到 default export。const factory = await loadExtensionModule(extensionPath, cacheToken);if (!factory) { // 源码在这里返回“没有有效 factory”的加载错误。 return;}
// 3. initializeExtension() 内部:为这次加载创建注册容器和 API。const extension = createExtension(extensionPath, resolvedPath);const load = createExtensionAPI(extension, runtime, cwd, eventBus);await factory(load.api); // 执行 safetyGate(pi),把 hook 注册进 extension。load.commit();
// 4. 返回注册结果,之后交给 AgentSession 创建 ExtensionRunner。return extension;6.1 第一步:找到应该加载的入口文件
Section titled “6.1 第一步:找到应该加载的入口文件”此时 Pi 需要的是文件路径,还没有执行上面的 safetyGate()。自动发现支持:
extensions/safety-gate.ts → 直接使用这个文件extensions/safety-gate/index.ts → 使用目录中的约定入口extensions/safety-gate/package.json → 使用 pi.extensions 声明的入口路径发现涉及 package-manager.ts
和 extensions/loader.ts。
以 loader 的 resolveExtensionEntries(dir) 为例,目录入口的选择缩略如下:
// 省略 package.json 读取和路径拼接;declaredEntries 指存在的声明入口。if (manifest?.extensions?.length && declaredEntries.length > 0) { return declaredEntries;}if (fs.existsSync(indexTs)) return [indexTs];if (fs.existsSync(indexJs)) return [indexJs];return null;即优先使用 manifest 中能找到的入口,否则尝试 index.ts、index.js。
discoverExtensionsInDir() 只检查直接文件和一层子目录的入口,不递归把所有 helper .ts
都当作独立 Extension。
这一步的输出仍然是 string[] 路径列表。 本章例子得到的是
/.../safety-gate/index.ts,下一步才加载这个文件。
6.2 第二步:用 jiti 加载文件,取得 factory
Section titled “6.2 第二步:用 jiti 加载文件,取得 factory”Pi 安装完成后,用户仍可新增 TypeScript Extension。这个文件不在 Pi 原先的构建产物里,
需要在运行时加载。loadExtensionModule() 为此使用 jiti:一个支持 TypeScript 和模块兼容处理的运行时加载器。
它在这里的用法接近带额外转换、解析能力的 import():
// extensions/loader.ts:loadExtensionModule(),暂时省略缓存和环境选项。const createJitiImpl = await getCreateJiti();const jiti = createJitiImpl(import.meta.url, { moduleCache: false, // 此处还会按 Pi 运行环境配置模块映射,见下文。});const factory = await jiti.import(extensionPath, { default: true });if (typeof factory !== "function") return undefined;return factory;对本章例子,返回的 factory 就是 safetyGate。加载模块时会执行文件的顶层代码,
但不会因为读到了 export default function 就自动调用这个函数。真正的
await factory(load.api) 在下一步。
为什么还要配置环境选项?因为 Extension 可能导入 Pi 的公共包,而 Pi 既可能从源码运行, 也可能作为打包程序分发。同一个 import 名称,需要在不同宿主环境中找到正确的模块。 源码通过以下分支选择映射方式:
// createJiti() 的选项片段。...(isBunBinary || isNodeSeaBinary || isBundledNode ? { virtualModules: await getVirtualModules(), tryNative: false } : isTypeScriptSourceRuntime ? { virtualModules: await getVirtualModules(), tsconfigPaths: true } : { alias: getAliases() }),virtualModules:由宿主提供相应模块,供插件 import 时使用。tsconfigPaths:源码运行时配合 tsconfig 中的路径映射。alias:为其他未打包 Node 构建提供模块别名映射。
getCreateJiti() 按宿主选择动态加载 jiti-loader.ts 或 jiti-static-loader.ts;虚拟模块也由 getVirtualModules() 延迟加载并缓存 Promise。映射内容移到了 virtual-modules.ts。
这些选项服务于同一件事:让用户提供的 Extension 文件及其依赖能够被加载。宿主提供的 Pi/TypeBox 包应声明为 peerDependencies: "*";ResourceLoader 会警告把它们放入 dependencies 的扩展包,因为独立安装副本可能绕过加载器并形成重复模块。
这一步的输出是函数,还不是注册完毕的 Extension。
6.3 第三步:执行 factory,把注册项收进 Extension 对象
Section titled “6.3 第三步:执行 factory,把注册项收进 Extension 对象”拿到函数后,loadExtension() 调用 initializeExtension()。Pi 先创建容器,
再把“向这个容器登记能力”的 API 传给 factory:
// extensions/loader.ts:initializeExtension(),省略耗时记录。const extension = createExtension(extensionPath, resolvedPath);const load = createExtensionAPI(extension, runtime, cwd, eventBus);try { await factory(load.api); load.commit();} catch (error) { load.discard(); throw error;}return extension;现在代入例子,safetyGate(pi) 中的 pi.on("tool_call", handler) 才真正执行。
注册 API 的核心是把 handler 放进当前 extension.handlers:
// createExtensionAPI() 中的 on 方法,省略有效性检查。on(event, handler) { const list = extension.handlers.get(event) ?? []; list.push(handler); extension.handlers.set(event, list);}因此本次结果可以理解为:
extension.handlers.get("tool_call") → [插件作者写的那个回调函数]此时只登记了回调,没有发生 Tool Call,也不会执行回调里的安全检查。 等第八部分建立 Runner、第九部分发生实际 Tool Call 后,才会调用它。
commit/discard 管理加载期暂存的注册变更和订阅:factory 成功后提交,失败后丢弃相关暂存项。
它不能回滚 factory 自己直接写出的文件或其他任意副作用。
这一步的输出才是 Extension 注册结果。 第七部分会继续拆解这个容器保存了什么, 以及 API 为什么有些操作此时可用、有些还要等绑定。
6.4 外层怎样批量加载,并处理单个失败
Section titled “6.4 外层怎样批量加载,并处理单个失败”前面沿一个 safety-gate/index.ts 走完了正常路径。实际启动会有多个入口,
loadExtensionsInternal() 用同一个 runtime 依次加载它们:
// extensions/loader.ts,省略 cwd/EventBus/runtime 的默认值构造。for (const extPath of paths) { const { extension, error } = await loadExtension( extPath, resolvedCwd, resolvedEventBus, resolvedRuntime, cacheToken, ); if (error) { errors.push({ path: extPath, error }); continue; } if (extension) extensions.push(extension);}return { extensions, errors, runtime: resolvedRuntime };loadExtension() 会把 module import 失败、无有效默认导出、factory 抛错等转成加载错误。
所以某一个插件失败时,这里记录它的 path/error,再尝试后面的插件。
最终 ResourceLoader 保存的是三个结果:成功的 extensions[]、失败的 errors[],
以及供之后绑定使用的共享 runtime。到这里,才与第二部分的启动主流程重新接上。
6.5 缓存只省掉哪一步
Section titled “6.5 缓存只省掉哪一步”理解前三步之后,缓存的位置就容易判断:它插在第二步,尝试复用已经取得的 factory 函数。
// loadExtensionModule() 内部的 Pi 自有缓存。if (isCurrentCacheToken(cacheToken)) { const cachedFactory = extensionCache.get(extensionPath); if (cachedFactory) return cachedFactory;}
// 未命中才用 jiti import;确认是函数后再保存。const factory = await jiti.import(extensionPath, { default: true });if (typeof factory !== "function") return undefined;if (isCurrentCacheToken(cacheToken)) { extensionCache.set(extensionPath, factory);}return factory;这与 jiti 配置中的 moduleCache: false 不矛盾:上面是 Pi 自己维护的 factory 缓存。
cache token 检查 cwd 和 generation,Map 再按入口路径查找。
缓存命中 → 省掉第二步的再次 import → 第三步仍然创建新的 Extension/API → 仍然 await factory(load.api),重新收集注册结果clearExtensionCache() 在 reload 时清缓存并提升 generation;cwd 变化也会清缓存。
另外,第二部分介绍的 trust 两阶段启动会由 ResourceLoader 直接复用 bootstrap 已加载的
Extension,避免同一次启动里重复执行 factory。那是更外层的复用,和这里“缓存函数、重新初始化”
不是同一个分支。
因此本节最终交给下一节的是:已经执行过 factory、收集了 handler/Tool/Command 的 Extension 对象。 接下来要看这些注册方法具体写入哪里,以及 runtime action 何时接上真实 Session。
七、Extension 的第二段生命周期:factory 注册能力,Runner 绑定动作
Section titled “七、Extension 的第二段生命周期:factory 注册能力,Runner 绑定动作”createExtensionAPI() 的注册容器和 createExtensionRuntime() 的动作占位是两层对象。对照下面的源码缩略:
// createExtensionAPI():写入本次 Extension 的注册表。on(event, handler) { assertActive(); const list = extension.handlers.get(event) ?? []; list.push(handler); extension.handlers.set(event, list);}registerTool(tool) { assertActive(); extension.tools.set(tool.name, { definition: tool, sourceInfo: extension.sourceInfo }); runtime.refreshTools(); // 加载期 no-op;bind 后刷新真实 registry。}
// createExtensionRuntime():此时尚未绑定当前 Session。sendMessage: notInitialized,appendEntry: notInitialized,refreshTools: () => {},registerProvider: (name, config, extensionPath) => { runtime.pendingProviderRegistrations.push({ name, config, extensionPath });},上半段为 API 对象的方法片段,下半段为 runtime 对象字段;省略其他注册项。API 的 Provider 注册还先经加载事务暂存,factory 成功 commit 后才进入 runtime 队列。
7.1 Extension 是注册结果容器
Section titled “7.1 Extension 是注册结果容器”createExtension() 创建:
handlerstoolsmessageRenderersentryRendererscommandsflagsshortcutssourceInfofactory 收到的 ExtensionAPI 并不是 AgentSession 本身。注册方法写入当前 Extension
对象:
pi.on() → extension.handlers[event].push(handler)
pi.registerTool() → extension.tools.set(name, registeredTool)
pi.registerCommand() → extension.commands.set(name, command)
pi.registerShortcut()pi.registerFlag()pi.registerMessageRenderer()pi.registerEntryRenderer() → 分别写入对应 Mapfactory 本身已经在执行 JavaScript,可以读文件、访问网络甚至创建子进程;“注册阶段”不是
限制副作用的沙箱。与此同时,pi.registerTool() 也并非只能在 factory 中调用:bind 后它会经
runtime.refreshTools() 立即刷新 registry,允许 session_start 或命令按需注册 Tool。
因此 factory 的主要职责是声明:
我关心哪些事件我提供哪些 Tool、Command、UI renderer 和 flag7.2 为什么 runtime action 在加载期默认抛错
Section titled “7.2 为什么 runtime action 在加载期默认抛错”createExtensionRuntime() 先给以下动作安装 notInitialized:
sendMessagesendUserMessageappendEntrysetSessionNameget/setActiveToolsgetCommandssetModelget/setThinkingLevel因为此时只有资源加载器,还没有:
当前 Agent当前 SessionManageractive tool registryUI modeabort signalsession replacement handlers如果 factory 在加载阶段就发消息或修改 Session,它没有明确目标。
7.3 为什么注册 Provider 是例外
Section titled “7.3 为什么注册 Provider 是例外”registerProvider() 和 registerNativeProvider() 在 bind 前可以调用,但先进入:
pendingProviderRegistrationspendingNativeProviderRegistrationscreateAgentSessionServices() 或 ExtensionRunner.bindCore() 在 ModelRuntime/ModelRegistry
可用后统一 flush。
这支持如下初始化流程:
async Extension factory → 启动时拉取模型目录 → 注册 Provider → 初始模型选择就能看到这些模型7.4 factory 不适合启动长生命周期资源
Section titled “7.4 factory 不适合启动长生命周期资源”factory 可能在:
--list-modelspackage manager 命令trust bootstrap真正 Session 启动等不同场景执行。此时直接启动 watcher、socket、子进程会缺少稳定的 Session 生命周期。
正确边界:
factory → 注册 session_start / session_shutdown
session_start → 启动 Session-scoped 资源
session_shutdown → 幂等清理八、Extension 的第三段生命周期:Runner 绑定真实产品状态
Section titled “八、Extension 的第三段生命周期:Runner 绑定真实产品状态”agent-session.ts:_buildRuntime() 与 extensions/runner.ts:bindCore() 的交接可缩写为:
// AgentSession:对象来自本次 loader,注册表不会从旧 Runner 继承。const loaded = this._resourceLoader.getExtensions();this._extensionRunner = new ExtensionRunner( loaded.extensions, loaded.runtime, this._cwd, this.sessionManager, new ModelRegistry(this._modelRuntime),);this._bindExtensionCore(this._extensionRunner);this._applyExtensionBindings(this._extensionRunner);this._refreshToolRegistry({ /* activeToolNames、includeAllExtensionTools */ });
// Runner.bindCore():替换同一个 runtime 对象上的函数。this.runtime.sendMessage = actions.sendMessage;this.runtime.appendEntry = actions.appendEntry;this.runtime.setActiveTools = actions.setActiveTools;// 省略其他动作与 pending Provider 注册的 flush。关键是替换 runtime 的函数引用。factory 留下的 API 闭包继续读取这个对象,因而获得了刚绑定的真实 Session 动作。
8.1 AgentSession._buildRuntime() 创建 Runner
Section titled “8.1 AgentSession._buildRuntime() 创建 Runner”AgentSession 构造时:
读取 ResourceLoader.getExtensions() → new ExtensionRunner( extensions, shared runtime, cwd, sessionManager, modelRegistry ) → _bindExtensionCore() → _applyExtensionBindings() → _refreshToolRegistry()ExtensionRunner 的职责不是发现文件,也不是执行 Agent Loop。它负责:
聚合各 Extension 注册项构造最新 ExtensionContext按加载顺序分发事件组合 handler 返回结果隔离大多数 handler 错误连接 Extension API 动作与 AgentSession8.2 bindCore() 把占位 runtime 换成真实动作
Section titled “8.2 bindCore() 把占位 runtime 换成真实动作”AgentSession._bindExtensionCore() 把闭包绑定到 Runner:
pi.sendMessage → AgentSession.sendCustomMessage()
pi.sendUserMessage → AgentSession.sendUserMessage()
pi.appendEntry → SessionManager.appendCustomEntry()
pi.setActiveTools → AgentSession.setActiveToolsByName()
pi.setModel / setThinkingLevel → AgentSession 对应状态切换
ctx.isIdle / ctx.signal / ctx.abort → 当前 Agent Run 状态
ctx.compact → AgentSession.compact()
ctx.getSystemPrompt → 当前 base 或本轮 override加载期 API 和运行期 API 使用同一个 ExtensionRuntime 对象。factory 创建的 API 闭包一直
引用它,所以 bindCore() 替换函数后,旧闭包自然获得真实能力,不需要重新执行 factory。
8.3 bindExtensions() 补上宿主表面能力
Section titled “8.3 bindExtensions() 补上宿主表面能力”Interactive、Print、RPC 各自在 Session 创建后调用:
session.bindExtensions({ uiContext, mode, commandContextActions, abortHandler, shutdownHandler, onError})这一步把宿主差异接入 Extension:
TUI → 真正的 select / confirm / widget / footer
RPC → 映射成 RPC UI protocol
Print/JSON → no-op UI 或默认返回值Core 不需要知道具体 UI 组件。
再看 createContext() 与 createCommandContext() 保持“读取时取当前值”的做法:
// createContext() 返回对象中的片段。get signal() { runner.assertActive(); return runner.getSignalFn();},get sessionManager() { runner.assertActive(); return runner.sessionManager;},
// createCommandContext():保留 getter,不在创建时把值读出来。const context = Object.defineProperties( {}, Object.getOwnPropertyDescriptors(this.createContext()),);context.waitForIdle = () => { this.assertActive(); return this.waitForIdleFn();};8.4 createContext() 为什么使用动态 getter
Section titled “8.4 createContext() 为什么使用动态 getter”每次事件和 Tool 执行前,Runner 调用 createContext()。其中:
ctx.modelctx.thinkingLevelctx.signalctx.cwdctx.sessionManagerctx.ui都在读取时从 Runner 当前绑定取值,而不是 factory 执行时做快照。因此模型切换、abort controller 更新、UI 绑定更新后,handler 看到的是最新状态。
createCommandContext() 还刻意保留这些 getter 的 property descriptor,不使用 object
spread。否则 spread 会提前读取一次 getter,把旧值冻结到 Command Context。
九、Extension Tool 如何进入 Agent
Section titled “九、Extension Tool 如何进入 Agent”现在追踪 safety-gate 注册的 scan_secrets。
先把注册、执行适配和 hook 三层用缩略代码连起来,下面各节再解释冲突和失败路径:
// agent-session.ts:_refreshToolRegistry(),省略 allowlist 与提示文本提取。const allCustomTools = [...registeredTools, ...sdkRegisteredTools];const toolRegistry = new Map(wrappedBuiltInTools.map(t => [t.name, t]));for (const tool of wrapRegisteredTools(allCustomTools, runner)) { toolRegistry.set(tool.name, tool);}this._toolRegistry = toolRegistry;this.setActiveToolsByName([...new Set(nextActiveToolNames)]);
// tools/tool-definition-wrapper.ts:参数末尾补上执行时创建的 ctx。execute: (id, params, signal, onUpdate, ctx) => definition.execute(id, params, signal, onUpdate, ctx ?? ctxFactory?.()),
// AgentSession 安装的执行前 hook,省略 hasHandlers 和异常规范化。this.agent.beforeToolCall = async ({ toolCall, args }) => this._extensionRunner.emitToolCall({ type: "tool_call", toolName: toolCall.name, toolCallId: toolCall.id, input: args, });sdkRegisteredTools 是这里对 SDK Tool 包装结果的简称;Tool registry 合并并不直接执行 Tool,实际调用仍由 Agent Loop 发起。
9.1 factory 注册的是 ToolDefinition
Section titled “9.1 factory 注册的是 ToolDefinition”典型字段:
namelabeldescriptionparametersexecutionMode?prepareArguments?execute()promptSnippet?promptGuidelines?renderCall?renderResult?三组信息不要混淆:
description + parameters → 结构化 Tool schema,模型生成合法 Tool Call
promptSnippet + promptGuidelines → 默认 System Prompt 的文字提示
renderCall + renderResult → UI 展示没有 promptSnippet 的 Tool 仍可通过 schema 被模型调用。
9.2 _refreshToolRegistry() 合并三类 Tool
Section titled “9.2 _refreshToolRegistry() 合并三类 Tool”AgentSession 同时处理:
Built-in ToolExtension 注册 ToolSDK customTools合并步骤:
应用 allowlist / exclude list → 构造 definition registry → 后加入的自定义定义可按 name 覆盖 built-in → 提取 promptSnippet / promptGuidelines → wrapRegisteredTools() → 构造 name → AgentTool registry → 计算 active tool names → setActiveToolsByName()同一 Extension 内重复注册同名 Tool,Map 中后一次替换前一次;不同 Extension 之间则保留
加载顺序中第一个提供者。之后合并层的同名优先级是 SDK customTools > Extension > built-in。
扩展 Tool 和 SDK Tool 在 definition registry 中覆盖同名 built-in,因此可替换 read、
bash 等执行逻辑。若只想扩展能力,应使用新的唯一名称。
9.3 registered、active、callable 与请求声明
Section titled “9.3 registered、active、callable 与请求声明”registered = Pi 当前知道这个 Tool 的定义
active = 本轮激活的 Tool;prepareLoadout 可以进一步隐藏它在请求里的声明pi.getAllTools() 读取 registered;pi.getActiveTools() 读取 active。
pi.setActiveTools(names):
过滤未知与 hidden 名称 → _applyToolLoadout() 处理 exposure、prepareLoadout → agent.state.tools = 对应 AgentTool[] → _rebuildSystemPrompt()因此 active tools 改变会同时更新:
下一 Turn 的 Provider tool schemas下一 Turn 的 System Prompt 工具说明_installAgentNextTurnRefresh() 确保同一个 Agent Run 中,某个 Tool 执行后动态增加的 Tool
能在下一 Turn 生效,而不是等下一个用户请求。
9.4 wrapRegisteredTool() 注入 ExtensionContext
Section titled “9.4 wrapRegisteredTool() 注入 ExtensionContext”Extension 的 execute() 比底层 AgentTool.execute() 多一个 ctx。wrapper:
AgentTool.execute(id, params, signal, onUpdate) → ToolDefinition.execute(id, params, signal, onUpdate, runner.createContext())当前 wrapper 只调用 wrapToolDefinition() 注入 ctx。旧版的 addedToolNames 已删除;Loop 的 declareToolChanges() 比较 transcript 中的工具声明与可执行集合,在请求前生成 SystemMessage 的 toolsAdded / toolsRemoved。新增和移除都走这条通道。
9.5 一次 Extension Tool Call 的完整流水线
Section titled “9.5 一次 Extension Tool Call 的完整流水线”sequenceDiagram
participant M as Model
participant A as Agent Loop
participant AS as AgentSession
participant ER as ExtensionRunner
participant T as scan_secrets
participant S as SessionManager
M-->>A: ToolCall scan_secrets(args)
A->>A: prepareArguments + schema validation
A->>AS: beforeToolCall
AS->>ER: emitToolCall(tool_call)
ER-->>AS: allow / mutate args / block
alt blocked
AS-->>A: block reason
A-->>S: error ToolResult 经 message_end 持久化
else allowed
A->>ER: wrapper 调用 runner.createContext()
A->>T: execute(args, ..., ctx)
T-->>A: ToolResult
A->>AS: afterToolCall
AS->>ER: emitToolResult(tool_result)
ER-->>AS: chained partial patch
AS-->>A: finalized ToolResult
A-->>S: message_end 持久化
end9.6 tool_call 是 policy gate
Section titled “9.6 tool_call 是 policy gate”AgentSession._installAgentToolHooks() 把 Agent 的 beforeToolCall 接到
ExtensionRunner.emitToolCall()。
handler 可以:
读取 event.toolName / toolCallId / input原地修改 input返回 { block: true, reason }多个 handler 按 Extension 加载顺序执行,后面的 handler 能看到前面的参数修改。任一返回
block: true 时立即短路,Tool 不执行,Agent 得到错误 ToolResult。
这里有一个重要风险:
参数在进入
tool_call前已经通过 Tool 的prepareArguments和 schema 校验;hook 原地 修改后不会再次校验。拦截器必须自己保证修改后的结构仍符合 Tool 约定。
9.7 为什么 tool_call 前要等 Agent 事件屏障
Section titled “9.7 为什么 tool_call 前要等 Agent 事件屏障”AssistantMessage 中包含 Tool Call。进入实际 Tool 前,Agent 会等待先前 Agent 事件被 AgentSession 消费完成。因此 handler 读取:
ctx.sessionManager.getBranch()时,当前 Assistant Tool Call 消息已经持久化。
并行 Tool 模式下:
所有 sibling tool_call preflight 顺序执行 → 通过后 sibling tools 并发 execute所以 policy 检查顺序稳定,但一个 sibling 的 tool_call 不应假设另一个 sibling 已经产生
ToolResult。
结果组合可直接对照 extensions/runner.ts:emitToolResult()。这里把两层遍历写在一起,保留字段级 patch 语义:
const currentEvent = { ...event };for (const ext of this.extensions) { for (const handler of ext.handlers.get("tool_result") ?? []) { try { const patch = await handler(currentEvent, ctx); if (!patch) continue; if (patch.content !== undefined) { currentEvent.content = patch.content; if (patch.structuredContent === undefined) delete currentEvent.structuredContent; } if (patch.structuredContent !== undefined) currentEvent.structuredContent = patch.structuredContent; if (patch.details !== undefined) currentEvent.details = patch.details; if (patch.isError !== undefined) currentEvent.isError = patch.isError; if (patch.usage !== undefined) currentEvent.usage = patch.usage; } catch (err) { this.emitError({ /* extensionPath、event、error */ }); } }}// 原方法还记录 modified;无修改返回 undefined,有修改返回结果字段。9.8 tool_result 是链式结果变换
Section titled “9.8 tool_result 是链式结果变换”每个 handler 可返回部分字段:
contentdetailsstructuredContentisErrorusageRunner 把补丁写入 currentEvent,再交给后续 handler,因此后续看到的是累计结果。未返回的
字段通常保留;例外是仅替换 content 时丢弃旧 structuredContent。
错误处理不同于 tool_call:
tool_result handler 抛错 → emitError → 继续后续 handler → 保留已有 ToolResult这避免结果装饰器失败后丢失真正的 Tool 执行结果。
但不是每个错误 ToolResult 都会触发 tool_result。未知 Tool、参数校验失败、policy block 等
preflight 失败直接形成结果,不执行 Tool,也不走 afterToolCall;模型输出被 length 截断时,
其中的 Tool Call 会被直接失败处理。上面的结果变换针对实际进入 execute 后的成功、显式错误返回或 throw 路径。
9.9 Tool 的业务失败可以 throw,也可以显式返回错误
Section titled “9.9 Tool 的业务失败可以 throw,也可以显式返回错误”当前 AgentToolResult 已支持 isError: true。不能只在 content 写“失败”而不设置状态:
// agent-loop.ts:executePreparedToolCall 的结果归一化缩写。const result = await tool.execute(/* id、参数、signal、onUpdate 省略 */);return { result, isError: result.isError === true };// throw 仍会被 catch 转成错误结果。直接返回错误可以保留 details 和 structuredContent;after hook 还能修改最终 isError。
structuredContent 是程序调用方使用的 JSON,不自动作为模型正文发送。若 tool_result hook 替换 content
却不同时返回 structuredContent,旧结构化结果会被丢弃,避免两种表示不一致。
涉及写文件时,Extension 的使用方式应覆盖整个读改写窗口。以下是调用示例,不是 Pi 内置 Tool 的完整实现:
await withFileMutationQueue(absoluteTargetPath, async () => { const oldText = await readFile(absoluteTargetPath, "utf-8"); const nextText = transform(oldText); // 示例业务变换 await writeFile(absoluteTargetPath, nextText, "utf-8");});
// file-mutation-queue.ts 的执行核心:await currentQueue;try { return await fn();} finally { releaseNext(); // 省略队列 key 计算、注册串行化与已完成队列清理。}9.10 文件修改 Tool 必须参与 mutation queue
Section titled “9.10 文件修改 Tool 必须参与 mutation queue”Extension Tool 与 built-in Tool 默认可并行执行;若本批任一 Tool 的 executionMode 为
sequential,或 Agent 配置为串行,整个 sibling 批次按源顺序执行。如果 scan_secrets 只读,不需要文件写
队列;若自定义 Tool 修改文件,必须把整个 read-modify-write 窗口放进
withFileMutationQueue(absoluteTargetPath)。
否则:
Extension Tool 读旧文件Built-in edit 同时读旧文件两者分别写回 → 后写者覆盖前写者Extension 能注册 Tool,不意味着可以绕过阶段 3 的并发一致性约束。
十、Extension 事件不是一种统一语义
Section titled “十、Extension 事件不是一种统一语义”Runner 看起来都是 pi.on(event, handler),但不同事件的组合规则不同。理解 hook 不能只背
事件名,必须先判断它属于哪类控制流。
直接比较 extensions/runner.ts 的两种分发,差异就在返回值处理和 catch 的位置:
// emit():同一事件依加载/注册顺序处理;省略外层两重循环。try { const handlerResult = await handler(event, ctx); if (this.isSessionBeforeEvent(event) && handlerResult) { result = handlerResult; // 整个对象替换 if (result.cancel) return result; }} catch (err) { this.emitError({ /* 记录后继续下一个 handler */ });}
// emitToolCall():这里没有 catch,异常会交给调用方处理。const handlerResult = await handler(event, ctx);if (handlerResult) { result = handlerResult; if (result.block) return result;}10.1 四种返回语义
Section titled “10.1 四种返回语义”| 类别 | 代表事件 | 组合方式 |
|---|---|---|
| 通知 | agent_start、session_start |
依次 await,返回值忽略 |
| 链式变换 | input、context、context_with_system、before_agent_start、tool_result、before_provider_request |
前一个输出成为后一个输入 |
| 短路决策 | tool_call、project_trust、user_bash |
首个决定性结果可停止后续 |
| 可取消事务 | session_before_switch/fork/tree/compact |
最后有效返回值整体替换;cancel 立即返回 |
不要把所有 handler 都理解为 Express middleware。每个专用 emitXxx() 都编码了自己的
不变量。
10.2 普通 emit() 的失败隔离
Section titled “10.2 普通 emit() 的失败隔离”ExtensionRunner.emit():
按 Extension 加载顺序 → 按同一 Extension 内 handler 注册顺序 → await handler → 抛错时 emitError,继续Session before 事件若返回 cancel,立即结束。否则最后一个有效结果可成为事务的自定义
配置。这里不是逐字段合并,也不会把上一 handler 的返回值写回 event 后再传递:
// 示意:两个 session_before_compact handlerpi.on("session_before_compact", () => ({ compaction: customResult }));pi.on("session_before_compact", () => ({ cancel: false }));// 最终采用 B 整个对象;A 的 compaction 不会保留下来。相比之下,tool_result 的 content/details/isError/usage/structuredContent 按结果合并规则累积(替换 content 会使旧 structuredContent 失效)。
10.3 tool_call 为什么允许异常阻断执行
Section titled “10.3 tool_call 为什么允许异常阻断执行”emitToolCall() 没有像多数 emit 方法那样吞掉 handler 异常。异常向 AgentSession
传播,并转换成阻止 Tool 执行的失败。
这是 fail-closed:
安全 policy hook 自己崩溃 → 不继续执行可能危险的 Tool而通知或结果美化 hook 通常 fail-open:
某个扩展失败 → 记录错误 → 主流程继续10.5 turn_end 和 agent_before_settle 是可提交的边界
Section titled “10.5 turn_end 和 agent_before_settle 是可提交的边界”这两个事件现在交给 emitBoundary(),不能再归为“返回值忽略”的普通通知。handler 可以返回 entries 草稿和 continue 决定;每个 handler 后都会重建预览,后续 handler 看到前面的结果。
// runner.ts:emitBoundary() 缩写const result = await handler({ ...baseEvent, entries, continue: shouldContinue, context }, ctx);if (result?.entries !== undefined) entries = result.entries;if (result?.continue !== undefined) shouldContinue = result.continue;context = await buildContext(entries); // 在临时 SessionManager 中校验和投影// 最终有效时由 AgentSession 提交;无效草稿最终丢弃并报告错误。可提交草稿包括 custom、custom_message、context_edit 和 compaction。context_edit 用 entry ID 改变模型投影,原始消息仍保留。继续执行还要通过 canContinue 校验;不能只返回 true 就保证能从任意末尾角色续跑。
Assistant/ToolResults message_end 已持久化 → Agent.finishTurn → Extension turn_end → 提交草稿 → Core turn_end → 调度下一轮或结束 RunRun 后恢复与队列均完成 → agent_before_settle → 提交草稿并检查继续条件 → 若不继续,agent_settledpi.on() 现在返回取消订阅函数。Runner 为一次分发拍摄 handlers 快照,因此分发期间新增或移除 handler 不会修改当前快照;后续事件才使用更新后的集合。
十一、一次用户请求中所有 Extension 介入点
Section titled “十一、一次用户请求中所有 Extension 介入点”现在把阶段 5、6 出现过的 hook 放回同一个时间轴。
这部分有输入、请求、消息三个不同对象。先用 prompt() 和 Runner 的缩略代码定位变换发生在哪里:
// AgentSession.prompt():默认开启展开;省略 Run 状态检查与图片处理。if (expandPromptTemplates && text.startsWith("/")) { if (await this._tryExecuteExtensionCommand(text)) return;}// 有 input handler 时先变换,handled 则 return。// currentText 是 input 链的最终文本。let expandedText = currentText;if (expandPromptTemplates) { expandedText = this._expandSkillCommand(expandedText); expandedText = expandPromptTemplate(expandedText, [...this.promptTemplates]);}// 之后才创建 UserMessage;streaming 时改为按配置入队。
// Runner.emitInput() 内部每个 handler 的返回值处理:if (result?.action === "handled") return result;if (result?.action === "transform") { currentText = result.text; currentImages = result.images ?? currentImages;}11.1 输入到 Run
Section titled “11.1 输入到 Run”原始输入 → Extension command lookup → input → Skill expansion → Prompt Template expansion → before_agent_start → agent_startExtension command
Section titled “Extension command”命中后 handler 直接执行,输入不进入 Agent。Command Context 比普通 ExtensionContext 多:
waitForIdlenewSessionforknavigateTreeswitchSessionreloadgetSystemPromptOptions这些方法只开放给 command,是因为在普通 Agent event handler 中等待当前 Agent idle 或替换 当前 Session 容易形成自等待和 stale state。
看原始文本,发生在 Skill/Template 展开之前:
continue → 原样继续
transform → 改写 text/images,后续 handler 继续处理
handled → 短路,不启动 Agentbefore_agent_start
Section titled “before_agent_start”看到展开后的 Prompt 和当前 base System Prompt,可以:
追加持久化 CustomMessage修改本次产品请求的 systemPromptOptions,或返回强制 System PromptSystem Prompt 变换按 Extension 顺序串联;每个 handler 的 ctx.getSystemPrompt() 与
event.systemPrompt 都按当前选项即时渲染。普通选项修改经 SystemMessage sections patch 持久化;返回的 systemPrompt 字符串存到 forceSystemPrompt,只在本次产品请求及其自动续跑的请求投影中覆盖,不把强制正文写 Session。
context 和 Provider hooks 修改的层次不同,不能把它们都理解为修改 Session 历史:
// runner.ts:emitContext(),省略遍历与异常隔离。let currentMessages = structuredClone(messages);// context handler 只接收非 system 消息。const visible = currentMessages.filter(m => m.role !== "system");const snapshot = visible.slice();const result = await handler({ type: "context", messages: visible }, ctx);// 实际实现也识别原地修改列表;变化后重放系统状态接回头部。currentMessages = restoreSystemMessages(currentMessages, snapshot, result?.messages ?? visible);// 随后 context_with_system handlers 可以处理完整 transcript。// 返回本次模型调用的工作副本,不在这里写 Session。
// sdk.ts:onPayload 在 API 实现已构造厂商 payload 后被调用。onPayload: async payload => { const runner = extensionRunnerRef.current; if (!runner?.hasHandlers("before_provider_request")) return payload; return runner.emitBeforeProviderRequest(payload);},
// runner.ts:emitBeforeProviderHeaders():返回值忽略,修改共享 headers。await handler({ type: "before_provider_headers", headers }, ctx);11.2 每个 Turn 和 Provider 请求
Section titled “11.2 每个 Turn 和 Provider 请求”turn_start → 新增消息事件与持久化 → prepareRequest → context(对话)→ context_with_system(完整 transcript) → 可选 forceSystemPrompt 请求投影 → convertToLlm → ModelRuntime 解析认证、合并 headers → before_provider_headers → Provider 兼容转换和序列化 → before_provider_request → 发 HTTP 请求 → after_provider_response → 流式 message_update → Tool 执行与结果持久化 → finishTurn 内 Extension turn_end 边界 → Core turn_endcontext
Section titled “context”Runner 先 structuredClone(messages)。context handlers 只看到非 system 对话;列表变化后 Pi 会接回重放后的系统状态。后续 context_with_system 可改完整 transcript,输出按原样使用,若丢掉头部 system 则报错但不自动修复。两者都只处理本次请求的副本:
可以过滤、重排、注入 AgentMessage默认不写 AgentState默认不写 Session它适合临时 Context policy,不适合长期记忆。需要持久化应使用 custom message 或 Session entry。
before_provider_headers
Section titled “before_provider_headers”接收 ModelRuntime 合并出的请求 headers,handler 可以原地增删。它在 Provider API 实现开始序列化 payload 前执行,并不意味着此时已经包含 HTTP SDK 最后补齐的所有线级 headers。 同一次 Provider 调用内部的 retry 可复用这些 headers;AgentSession 发起新的模型调用时会重新 经过它,不能把这两层重试混为一谈。
before_provider_request
Section titled “before_provider_request”发生在 Provider-specific payload 已经构造后。handler 返回的新 payload 继续传给后续 handler,最终直接发送。
它能看到序列化细节,但修改不会反映到:
ctx.getSystemPrompt()AgentStateSession所以它适合调试、gateway 参数和 cache policy,不应承担产品级对话记忆。
provider_stream_event 与缓存预热
Section titled “provider_stream_event 与缓存预热”SDK 还把 onProviderStreamEvent 桥接成 provider_stream_event,用于观察适配器原始流事件;data 是适配器所有的只读数据,不是修改 finalized message 的入口。缓存预热另有 cache_warming_decision,最后一个显式 action 覆盖生效;它不等于一次用户 Turn。
after_provider_response
Section titled “after_provider_response”HTTP 响应已经到达、流 body 还没消费时触发,可读取 status 和暴露的 response headers。 它不能回滚已经发送的请求。
11.3 消息和 Tool 生命周期
Section titled “11.3 消息和 Tool 生命周期”message_start → message_update* → message_end
tool_execution_start → tool_call → tool execute / progress → tool_result → tool_execution_end → 最终 ToolResult message_start/message_endmessage_end 可以替换 finalized message,但 role 必须保持不变。AgentSession 会把替换对象
同步到 AgentState,随后持久化替换后的消息。
并行 Tool 下:
tool_execution_start → Assistant 源顺序
tool_execution_update / tool_result / tool_execution_end → 可按完成顺序交错
最终 ToolResult message → 仍按 Assistant 源顺序进入 ContextExtension 如果维护状态,不能把并发完成顺序误当成模型上下文顺序。
agent-session.ts:_runAgentPrompt() 的 finally 是 settled 语义的直接证据:
this._isAgentRunActive = true;try { await this.agent.prompt(messages); while (!this._agentRunAbortRequested) { const recover = await this._handlePostAgentRun(); if (this._agentRunAbortRequested) break; if (!recover && !(await this._runBeforeSettleBoundary())) break; if (this._agentRunAbortRequested) break; await this.agent.continue(); }} finally { this._runSystemPromptOptions = undefined; this._flushPendingBashMessages(); this._flushPendingCustomMessages(); await this._emitAgentSettled();}11.4 Run 结束与 settled
Section titled “11.4 Run 结束与 settled”agent_end → 低层 Agent Run 已结束 → AgentSession 仍可能 retry、compact-and-retry、处理 follow-up → agent_before_settle 仍可提交编辑并申请继续
agent_settled → 没有自动 continuation → 当前 AgentSession 运行包装流程已退出(包括异常退出)agent_settled 从 _runAgentPrompt() 的 finally 发出,所以它不等于“任务成功完成”。
按任务结束做状态同步可以观察它;Session 级连接和 watcher 的释放应放在 session_shutdown,
避免每次回答结束都断开。
UI 事件为何不是执行屏障,也能从 runner.ts 的这几行看出:
private emitUIPromptEvent(event) { queueMicrotask(() => { void this.emit(event); // 不 await handler 完成 });}
// withUIPrompt() 内部的简写;finish 负责 depth 归零时发 end。const outerPrompt = this.uiPromptDepth++ === 0;if (outerPrompt) this.emitUIPromptEvent({ /* ui_prompt_start、kind、title */ });try { return run().finally(finish);} catch (err) { finish(); // run 同步 throw 时也回收 depth throw err;}11.5 阻塞 UI 的 start/end 是异步通知
Section titled “11.5 阻塞 UI 的 start/end 是异步通知”当前版本还用 wrapUIPromptContext() 包装 select、confirm、input、editor 和 custom:
第一个尚未结束的 UI prompt 打开 → ui_prompt_start
嵌套或重叠的 prompt → 只增加 depth,不为每个 prompt 单独成对发事件
最后一个 prompt 结束(包括 reject) → ui_prompt_end事件通过 queueMicrotask(() => void emit(...)) 分发。UI 操作不等待这些 handler 全部完成,
所以这是观察“当前正在等用户输入”的通知,不是阻止 UI 出现的审批 hook。事件内部的 handler
仍按顺序 await,但不能把这个顺序扩大为 UI 与事件之间的同步屏障。
十二、Extension 怎样介入 Session 事务
Section titled “十二、Extension 怎样介入 Session 事务”阶段 6 已经解释 Session 本身。本节只说明 Extension 的事务边界。
以 AgentSession._runAutoCompaction() 为例:下面只保留 Extension 与核心的交接,省略准备、认证、取消通知和摘要附加字段。
const extensionResult = await this._extensionRunner.emit({ type: "session_before_compact", preparation, branchEntries: pathEntries, reason, willRetry, signal: this._autoCompactionAbortController.signal,});if (extensionResult?.cancel) { // 实际代码还发送 compaction_end 等取消通知。 return false;}// 有自定义结果就采用,否则走默认 compact();这里概括两条分支。const result = extensionResult?.compaction ?? await compact(/* preparation 等 */);this.sessionManager.appendCompaction( result.summary, result.firstKeptEntryId, result.tokensBefore, /* details、fromExtension、usage */,);this.agent.state.messages = this.sessionManager.buildSessionContext().messages;// 之后找到已保存的 entry,发送 session_compact。12.1 before/after 成对事件
Section titled “12.1 before/after 成对事件”| 操作 | before | 成功后 |
|---|---|---|
| new/resume | session_before_switch |
旧 runtime shutdown,新 runtime session_start |
| fork/clone | session_before_fork |
旧 runtime shutdown,新 runtime session_start |
| tree navigation | session_before_tree |
session_tree |
| compaction | session_before_compact |
session_compact |
before handler 可以取消或提供自定义结果;after 事件是已提交事实的通知。
12.2 Extension 不能绕过 AgentSession 的提交步骤
Section titled “12.2 Extension 不能绕过 AgentSession 的提交步骤”例如自定义 Compaction:
Extension 返回 summary / firstKeptEntryId / tokensBefore → AgentSession 采用自定义结果(不等于全面校验摘要质量或切点) → SessionManager.appendCompaction() → buildSessionContext() → 替换 AgentState.messagesExtension 只替换“怎样产生结果”,持久化和运行时状态切换仍由 AgentSession 统一完成。
Tree summary 同理。这样 Extension 不必自己维护 leafId、branch 投影和事件顺序。
下面是 Extension 自身恢复状态的示例。rebuildReviewState 是案例函数,不是 Pi 内置 API:
const restore = (_event, ctx) => { reviewState = rebuildReviewState(ctx.sessionManager.getBranch());};pi.on("session_start", restore);pi.on("session_tree", restore);
// 保存不应直接成为模型消息的扩展状态。pi.appendEntry("security-review-state", { lastCheckedPath });代码同时监听启动与树导航,否则只在启动读一次 branch,会遗漏同 Session 内的分支切换。
12.3 Extension 状态怎样支持分支
Section titled “12.3 Extension 状态怎样支持分支”只保存在闭包变量:
程序退出即丢失切换 branch 时也不知道应恢复到哪份状态推荐两种持久化方式:
状态与某次模型 Tool 行为相关 → 放入 ToolResult.details
纯 Extension metadata,不应进入模型 Context → pi.appendEntry(customType, data)session_start 时遍历 ctx.sessionManager.getBranch() 重建内存状态。同一个 Session 内执行
/tree 不会重新发 session_start,还应在 session_tree 之后重建。必须读取 current branch,
而不是全部 file entries,否则另一条分支的状态会污染当前路线。
十三、Extension 还能反过来贡献 Skill
Section titled “十三、Extension 还能反过来贡献 Skill”这是 Skills 与 Extensions 真正汇合的位置。
bindExtensions() 到资源重建的真实交接,缩略后只有几步:
// AgentSession.bindExtensions():先给 Extension 一个已绑定的 ctx。this._applyExtensionBindings(this._extensionRunner);await this._extensionRunner.emit(this._sessionStartEvent);await this.extendResourcesFromExtensions( this._sessionStartEvent.reason === "reload" ? "reload" : "startup",);
// extendResourcesFromExtensions():省略无 handler/空路径的提前返回。const { skillPaths, promptPaths, themePaths } = await this._extensionRunner.emitResourcesDiscover(this._cwd, reason);this._resourceLoader.extendResources({ skillPaths: this.buildExtensionResourcePaths(skillPaths), promptPaths: this.buildExtensionResourcePaths(promptPaths), themePaths: this.buildExtensionResourcePaths(themePaths),});this._rebuildSystemPrompt(this.getActiveToolNames());// 后续 prompt / 下一 Turn:_preparePromptAndToolLoadout() 生成 system sections patch。13.1 resources_discover 发生在 session_start 之后
Section titled “13.1 resources_discover 发生在 session_start 之后”AgentSession.bindExtensions():
应用 UI / mode / command bindings → emit(session_start) → extendResourcesFromExtensions(reason)ExtensionRunner.emitResourcesDiscover() 收集:
skillPathspromptPathsthemePaths每条结果都保留贡献它的 extensionPath。
13.2 ResourceLoader.extendResources() 增量重载资源
Section titled “13.2 ResourceLoader.extendResources() 增量重载资源”AgentSession 把 Extension path 转成:
pathmetadata { source: "extension:index", scope: "temporary", origin: "top-level", baseDir: Extension 所在目录}还要区分来源 metadata 与路径解析:baseDir 记录 Extension 所在目录,
但 normalizeExtensionPaths() 实际用 loader 的 cwd 解析返回路径,不以 metadata.baseDir 解析。
因此分发在 Extension 目录内的 Skill 应返回绝对路径,第十九部分用 import.meta.url 构造它。
// resource-loader.ts 的两处真实片段。path: this.resolveResourcePath(entry.path),
private resolveResourcePath(p: string): string { return resolvePath(p, this.cwd, { trim: true });}这里按本章的 safety-gate/index.ts 入口计算,source 是 extension:index:
getExtensionSourceLabel() 使用入口 basename 去掉 .ts/.js,不是父目录名。
若入口是 safety-gate.ts 才得到 extension:safety-gate;完整来源还要结合 path/baseDir 判断。
ResourceLoader:
合并并去重 paths → updateSkillsFromPaths() → 给加载结果匹配 Extension SourceInfo随后 AgentSession:
_rebuildSystemPrompt(active tools) → 更新 _baseSystemPromptOptions;请求前用 SystemMessage 声明差量所以 safety-gate 可以把随 Extension package 分发的 security-review Skill 暴露给模型,
同时保持两者职责不同。这里增量的是“路径集合”,updateSkillsFromPaths() 实际会重新解析
合并后的全部 Skill 路径,并非只解析刚加入的文件;没有新的同名覆盖规则。
13.3 为什么不是 factory 直接修改 Skill registry
Section titled “13.3 为什么不是 factory 直接修改 Skill registry”resources_discover 统一经过 ResourceLoader,因此仍然获得:
路径规范化Skill frontmatter 校验名称冲突诊断SourceInfoPrompt 重建reload 生命周期若 Extension 直接向数组 push 一个伪 Skill,这些不变量会散落到每个 Extension。
十四、reload 与 Session replacement:为什么旧 Context 必须失效
Section titled “十四、reload 与 Session replacement:为什么旧 Context 必须失效”AgentSession.reload() 的缩略主体如下。注意旧 Runner 失效发生在加载新资源之前:
const oldRunner = this._extensionRunner;const previousFlagValues = oldRunner.getFlagValues();await emitSessionShutdownEvent(oldRunner, { type: "session_shutdown", reason: "reload" });oldRunner.invalidate();await this.settingsManager.reload();this.syncQueueModesFromSettings();resetApiProviders();await this._resourceLoader.reload();this._buildRuntime({ activeToolNames: this.getActiveToolNames(), flagValues: previousFlagValues, includeAllExtensionTools: true,});if (hasBindings) { // 源码由已有 UI/命令/shutdown/error 绑定计算。 await options?.beforeSessionStart?.(); await this._extensionRunner.emit({ type: "session_start", reason: "reload" }); await this.extendResourcesFromExtensions("reload");}14.1 /reload 不是只重新读取一个 TypeScript 文件
Section titled “14.1 /reload 不是只重新读取一个 TypeScript 文件”AgentSession.reload():
保存 Extension flag values → session_shutdown(reason=reload) → oldRunner.invalidate(),旧 runtime/API/ctx 失效 → settingsManager.reload() → 同步 queue modes → reset Provider registrations → resourceLoader.reload() → 用新 extensions/runtime 重建 ExtensionRunner → 恢复 flag values → 重建 Tool registry 和 System Prompt → session_start(reason=reload) → resources_discover(reason=reload)其中最后的 session_start/resources_discover 在该方法中受 hasBindings 条件控制;
正常 CLI 宿主已绑定 UI、命令或错误回调,裸 SDK 使用者不能只构造对象就假设这些事件必定发生。
reload 复用当前 trust 状态,不传入新的 trust 决策 callback。
因此 reload 同时刷新:
ExtensionsSkillsPrompt TemplatesThemesContext filesSYSTEM / APPEND_SYSTEMExtension 提供的 Provider 和资源14.2 Command 调用 ctx.reload() 后为什么必须立即 return
Section titled “14.2 Command 调用 ctx.reload() 后为什么必须立即 return”正在执行 reload 的 command handler 属于旧 Extension instance 的调用栈。await ctx.reload()
完成后:
新 Extension instance 已经建立旧 handler 仍会从 await 后继续执行reload 在读取新资源之前已经主动 invalidate 旧 Runner。此后通过旧 pi 或 ctx 的受保护
入口操作会抛 stale error;但它不能撤销之前拿到的文件句柄、独立连接或直接执行的普通代码。
因此推荐:
await ctx.reload();return;14.3 Session replacement 比 reload 更彻底
Section titled “14.3 Session replacement 比 reload 更彻底”newSession()、fork()、switchSession() 会:
触发 before 事务 → abort 并等待旧 Run 收尾 → 旧 session_shutdown → 旧 AgentSession.dispose() → runtime.invalidate() → 为新 cwd/session 创建 ResourceLoader、Runner、AgentSession → 新 session_start旧 ExtensionContext 的 getter 和 API action 之后会通过 assertActive() 抛出 stale error。
这是必要的,因为旧对象包含:
旧 SessionManager旧 cwd旧 Agent旧 active tools旧 model registry bindingSession replacement 的 callback 时序来自 agent-session-runtime.ts:finishSessionReplacement():
private async finishSessionReplacement(withSession?) { if (this.rebindSession) { await this.rebindSession(this.session); } if (withSession) { await withSession(this.session.createReplacedSessionContext()); }}callback 收到的是新 Session 构造出的 ctx,不是原 command handler 捕获的那个 ctx。
14.4 withSession 是安全交接点
Section titled “14.4 withSession 是安全交接点”Command Context 的:
ctx.newSession({ withSession })ctx.fork(..., { withSession })ctx.switchSession(..., { withSession })宿主的 rebindSession 完成后,runtime 才把新的 ReplacedSessionContext 传给 callback。
正常宿主在 rebind 中执行 bind 和 session_start;自行构建 runtime 的 SDK 使用者需要提供这个交接。
callback 仍位于旧 JavaScript closure 中,所以只能捕获:
字符串ID序列化配置不能复用:
旧 pi旧 ctx旧 SessionManager旧连接池或 watcher这些资源应在旧 session_shutdown 释放,再由新 Extension instance 的 session_start 重建。
十五、安全边界:Project Trust 不等于 Sandbox
Section titled “十五、安全边界:Project Trust 不等于 Sandbox”trust 是否控制某类输入,必须看加载点有没有判断。以下并列两段真实分支:
// package-manager.ts:项目祖先 Skill 路径受 trust 控制。const projectTrusted = this.settingsManager.isProjectTrusted();const projectAgentsSkillDirs = projectTrusted ? collectAncestorAgentsSkillDirs(this.cwd) .filter(dir => resolve(dir) !== resolve(userAgentsSkillsDir)) : [];
// resource-loader.ts:Context 文件走另一分支,没有传入 trust 条件。const agentsFiles = { agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir }),};false 能让上面的项目 Skill 列表变空,却不会使下面的 Context 文件加载自动停止。
15.1 Project Trust 保护的是“启动时输入加载”
Section titled “15.1 Project Trust 保护的是“启动时输入加载””拒绝 trust 会跳过以下项目自动发现或项目配置来源:
项目 .pi/settings.json项目 .pi/extensions项目 package-managed Extension项目 Skills / Prompts / Themes项目 SYSTEM / APPEND_SYSTEM项目祖先 .agents/skills但 AGENTS.md、AGENTS.override.md、CLAUDE.md 仍按阶段 5 的 Context 文件选择规则加载,
除非显式禁用 Context 文件。reload() 调用 loadProjectContextFiles({ cwd, agentDir }) 时没有
传入 trust 条件;这能说明实现边界,不能据此推断作者认为项目文档没有注入风险。
CLI 显式路径和已加载 Extension 贡献的资源也不等于项目自动发现来源,不能用 trust 拒绝推导
“任何来自项目磁盘的内容都无法进入 Pi”。
Project Trust 解决:
进入一个仓库时,这个仓库能否在用户确认前改变 Pi 配置、加载代码或增加动态资源。
它不解决:
已信任 Extension 的恶意代码模型生成的危险命令项目文档中的 Prompt Injection工具访问宿主凭据操作系统级隔离15.2 Skill 与 Extension 的风险不同
Section titled “15.2 Skill 与 Extension 的风险不同”Extension → TypeScript 直接在 Pi 进程执行 → 可访问进程拥有的文件、网络、环境和子进程能力
Skill → 先成为模型指令 → 可诱导模型调用已有 Tool、运行配套脚本 → 最终能力取决于模型可调用的 Tool 和运行环境Skill 不是“纯文档所以安全”。它可能包含“运行 scripts/install.sh”之类的指令。
15.3 Prompt rule、hook 和 sandbox 是三层
Section titled “15.3 Prompt rule、hook 和 sandbox 是三层”Prompt / Skill → 告诉模型应该怎样做 → 软约束
tool_call hook / allowlist → 在执行前验证、修改或阻止 → 进程内运行时约束
container / VM / OS permission → 即使 Extension 或 Tool 想越界也无法访问 → 真正隔离边界safety-gate 的危险命令检查必须放在 tool_call,不能只写进
security-review/SKILL.md。如果需要对不可信代码做强隔离,还必须在容器或 VM 中执行。
十六、内置 MCP、codemode 与工具搜索怎样协作
Section titled “十六、内置 MCP、codemode 与工具搜索怎样协作”先分清这四个词:它们回答的是不同层的问题,不是四种同级的工具。
| 概念 | 回答的问题 |
|---|---|
| MCP | Pi 怎么连接外部服务、发现并调用它提供的工具? |
| direct | 一个工具是否直接声明给模型,让模型直接生成调用? |
| codemode | 模型能不能写一段代码,组合调用工具并处理结果? |
| tool_search | 工具很多时,能不能先找到需要的,再把定义提供给模型? |
本节始终用同一个例子:外部工单服务 tickets 提供查询、详情、更新等几十个工具,用户要求
“查出分配给我的高优先级工单并汇总”。工单服务与下面的工具名、业务数据是教学示例;Pi 的加载、
搜索、激活与执行机制按当前源码解释。
16.1 MCP 怎么内置:Pi 随包提供客户端扩展
Section titled “16.1 MCP 怎么内置:Pi 随包提供客户端扩展”内置的是 MCP 客户端及适配实现。工单服务器仍是另一个进程或远端服务,用户需要配置它的地址、 启动命令和认证信息。
CLI 启动时把随包提供的扩展加入加载列表。下面是 main.ts 与 extensions/index.ts 的缩写,省略
其他内置项和启动参数:
const builtInExtensions = [ { name: "mcp", factory: mcpExtension, replaceable: true, builtin: true }, { name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true }, { name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true },];const extensionFactories = [...builtInExtensions, ...(options?.extensionFactories ?? [])];// CLI 把这些 factories 交给 ResourceLoader。所以“随 Pi 内置提供”和“通过 Extension 实现”可以同时成立。MCP 不需要写进通用 agentLoop, 但用户也不必再自己实现客户端。SDK 自行组装资源加载器时,仍应显式选择所需 factory,不能假设所有 CLI 内置项都会自动进入任意 SDK 宿主。
工单工具进入 Pi 的真实交接关系是:
读取 mcp.json 中 tickets 的配置 → 通过 stdio 或 streamable HTTP 连接工单服务器 → listTools 获取查询、详情等工具定义 → 转换为 Pi ToolDefinition,注册到工具表 → 根据暴露方式决定模型如何访问 → 真正执行时调用 MCP callTool → 将远端结果转成 Pi 工具结果到“注册到工具表”为止,只说明 Pi 知道这些工具,不代表全部 schema 已发送给模型。下面三节解释 模型怎样使用同一批已注册工具。
16.2 direct 是暴露方式:模型直接看见工具定义
Section titled “16.2 direct 是暴露方式:模型直接看见工具定义”假设查询工具设为 direct。在通常的声明路径下,它激活后,Pi 会把名称、描述和参数 schema 放入
模型请求。模型无需先搜索,就可以生成对该工具的调用:
模型请求包含:mcp__tickets__search 的名称、描述、参数 schema → 模型生成 ToolCall(name=mcp__tickets__search, arguments=...) → Pi 执行工具,经 MCP 请求工单服务器 → ToolResult 返回模型远端定义转换的源码缩写如下,省略名称规范化、渲染和结果转换:
const definition = { name: normalizedName, // 例如 mcp__tickets__search description: remote.description, parameters: toParameters(remote.inputSchema), outputSchema: createMcpResultSchema(remote.outputSchema), exposure: toToolExposure(exposure), execute: async (...args) => { /* getClient → callTool → toToolResult */ },};“direct”不表示本地工具:本地 read 可以直接暴露,远端 MCP 查询工具也可以。它描述的是模型如何
访问工具。好处是简单;若几百个工具都直接声明,定义会占用大量上下文。后面的 codemode only
模式还可进一步隐藏直接声明,因此 exposure 也不能替代最终请求投影的检查。
16.3 codemode 是什么:让模型用代码组织工具调用
Section titled “16.3 codemode 是什么:让模型用代码组织工具调用”codemode 本身是一个模型可调用的工具,输入是一段 JavaScript。模型通过它,把已知流程中的查询、 循环、过滤和组合交给代码完成。
普通直接调用可能需要:
模型请求查询工单 → 返回列表 → 模型请求若干详情 → 返回大量详情 → 模型筛选、汇总如果模型已经知道筛选条件和数据结构,可以把部分操作放进一次 codemode 调用:
// codemode 脚本示例:假设已通过 describeTool 确认工具名与参数,// 且该工具的结构化结果含 items;这不是某个真实工单服务的 API。const result = await tools.mcp__tickets__search({ assignee: "me" });const selected = result.items.filter(item => item.priority === "high");text(selected.map(item => ({ id: item.id, title: item.title })));现在完整结果先进入脚本,只把筛选后的 id/title 输出给模型。若还需批量读取详情,代码可以继续组织 调用。codemode 的价值是减少不必要的模型往返和进入上下文的中间数据,而不是保证所有任务一次完成。 未知接口或需要根据结果重新决策时,模型仍可能先探索,再生成下一段脚本。
源码中的执行链是:
模型调用 codemode → QuickJS/WASM worker 执行脚本 → tools.<name>(args) → ExtensionToolContext.executeTool(name, args) → NestedToolCallRunner → runToolCall:prepareArguments → 校验 → before hook → execute → after hook → 结果回到脚本;text()/return 等输出进入外层 ToolResult.content脚本不直接获得任意 Node API,但桥接工具会访问真实文件、进程或远端服务。权限 hooks 仍经过同一条 工具流水线;若脚本中途失败,前面已经完成的外部操作不会自动回滚。
嵌套执行事件带 parentToolCallId,不额外创建一组模型 Assistant/ToolResult 消息;有限调用记录和
usage 汇入外层 ToolResult。工具有 outputSchema 且返回 structuredContent 时,脚本可获得结构化数据;
模型看到的仍是脚本最终输出,不是每个嵌套结果自动全部进入上下文。
16.4 tool_search 是渐进式披露:搜索后启用工具声明
Section titled “16.4 tool_search 是渐进式披露:搜索后启用工具声明”是的,可以把 tool_search 理解成工具定义的渐进式披露。假设工具采用 deferred 方式,开始不把全部 工单工具 schema 交给模型,而提供 tool_search 入口及相关服务器能力提示:
模型调用 tool_search:搜索“查询我的高优先级工单” → Pi 从尚未激活的可搜索工具中找匹配项 → 激活 mcp__tickets__search → 下一次模型请求携带查询工具的 schema → 模型直接调用查询工具关键在于它不只返回搜索结果文字,还修改 active tools。searchAndLoad() 的源码缩写是:
const active = tools.getActiveTools();const candidates = tools.getAllTools().filter(tool => (tool.exposure === "codemode" || tool.exposure === "deferred") && !active.includes(tool.name));const documents = candidates.map(tool => createToolSearchDocument(tool, tool.namespace));const matches = new Bm25Ranker().rank(query, documents, limit);if (matches.length > 0) { tools.setActiveTools([...active, ...matches.map(tool => tool.name)]);}// 原码返回匹配工具的 name/description;激活变化按普通工具变更进入 transcript。当前用 BM25 对工具元数据排序,不是让另一个 LLM 选择。激活后 schema 经后续请求声明链进入模型; 上图讲通常的直接调用路径,prepareLoadout 的隐藏声明规则仍然适用。
| 渐进式披露机制 | 最初提供 | 按需补充 |
|---|---|---|
| Skill | 名称和描述 | 工作流说明与知识正文 |
| tool_search | 搜索入口与相关能力提示 | 工具参数 schema,以及直接调用该工具的能力 |
这里还有一个名字相近但作用不同的 helper:codemode 脚本里的 searchTools()。
tool_search(模型调用的工具) → 找到并激活工具 → 下一次模型请求可直接声明它
searchTools()(codemode 脚本内的函数) → 找到脚本可调用工具的 name/description → describeTool(name) 查看完整定义 → 脚本通过 tools.<name>(args) 调用后者的发现动作本身不负责激活直接声明。模型可以先调用 codemode 输出搜索结果和定义,再在后一次 codemode 调用里使用它们;如果已经知道接口,也可以在同一段脚本内完成操作。
16.5 回到源码:注册、激活、可调用和声明的对应关系
Section titled “16.5 回到源码:注册、激活、可调用和声明的对应关系”理解上述三条使用路径后,再看 Extension Tool 的五种 exposure:
| exposure | 默认激活 | 其他工具通过 ctx.executeTool 调用 |
|---|---|---|
| direct | 是 | 激活时可调用 |
| model-only | 是 | 不可调用,适合编排入口 |
| codemode | 否,可显式激活 | 注册后可调用,通常在 codemode 描述中列出 |
| deferred | 否,可由搜索激活 | 注册后可调用,codemode 不直接列出 |
| hidden | 否,激活无效 | 不可调用 |
这是 exposure 的通常规则;注册项的 defaultActive 等设置还会影响初始激活。codemode 和 tool_search 作为编排入口使用 model-only,避免脚本把这些入口作为普通嵌套工具反复调用。
MCP 配置中默认的 codemode exposure 特意映射为内部 deferred:
function toToolExposure(exposure) { return exposure === "codemode" ? "deferred" : exposure;}这样即使工单服务器有几百个工具,也不会逐个列进 codemode 描述。模型先知道 server 的能力,再在
脚本中通过 searchTools、describeTool 或 ALL_TOOLS 发现工具。MCP 配置里的 deferred 则配合
模型可调用的 tool_search 入口。不要把配置层与内部 ToolExposure 的同名值完全等同。
codemode.mode = only 会通过 prepareLoadout 隐藏相应直接工具声明,使模型经脚本访问;工具仍可
保持 active/callable。请求投影过滤整个系统工具历史,Prompt 同步移除相关工具介绍,但 Session 中的
声明历史保留,以便恢复 active 集合。
16.6 生命周期:什么时候连接、等待和关闭
Section titled “16.6 生命周期:什么时候连接、等待和关闭”配置来自用户级 ~/.pi/agent/mcp.json 和受 Project Trust 控制的 .pi/mcp.json,项目同名项覆盖用户项。
这与 AGENTS/CLAUDE 文本的加载规则不同。Extension 也可通过 pi.registerMcpServer 注册服务。
// extensions/mcp/index.ts 缩写,省略连接、异常与取消处理。pi.on("session_start", (_event, ctx) => { // 读取配置,异步启动 enabled server 连接。});pi.on("before_agent_start", async (event, ctx) => { await waitForDirectServers(ctx); const section = renderServersSection(servers); if (section) event.systemPromptOptions.sections[MCP_SERVERS_SECTION] = section; else delete event.systemPromptOptions.sections[MCP_SERVERS_SECTION];});pi.on("session_shutdown", async () => { // 失效旧 generation,关闭连接与 stdio 子进程。});首个 prompt 最多等待 direct 工具 server 10 秒;其工具需要进入直接声明。codemode 指定某个 namespace
时等对应 server,搜索或枚举全部工具时等全部 server;tool_search 与资源工具也等待全部 server。
因此 session_start 不代表远端工具已全部就绪。mcp_servers section 更新通过后续系统消息记录。
MCP resources 由专门工具访问,不能把远端 URI 塞进 resources_discover 的本地路径字段。/mcp
提供连接和登录管理,命令行 pi mcp 可以不启动会话就管理配置。
HTTP OAuth 凭据保存在用户目录 mcp-auth.json,刷新有跨进程串行化。借用 Provider 登录凭据的
auth.provider 不允许由项目文件配置,避免仓库指定凭据发送目的地。自定义认证元数据 URL、回调
issuer 检查与空可选字段处理均有实现;本节不声称已完成真实远端 OAuth 联调。
内置扩展采用 builtin:
16.7 源码回查
Section titled “16.7 源码回查”少量常用工具可以直接暴露;大量工具可以先搜索再声明;需要组合调用和过滤结果时,可使用 codemode。 这些方式都能调用同一个 MCP 服务,MCP 负责底层连接。
按上面的例子回查:
main.ts、extensions/index.ts、core/resource-loader.ts:内置 factory 怎样进入 CLI。extensions/mcp/{config,index,runtime,tools}.ts:工单配置怎样变成连接与 Pi 工具。extensions/codemode/{tool,execute}.ts、core/nested-tool-calls.ts:脚本怎样调用工具并输出结果。extensions/tool-search/tool.ts:searchAndLoad:搜索怎样改变 active tools。core/extensions/types.ts:ToolExposure、core/agent-session.ts:可见性和请求投影。extensions/mcp/{oauth,resources}.ts:认证与资源访问。
十七、面对新能力时怎样选择实现机制
Section titled “十七、面对新能力时怎样选择实现机制”选型落实到代码后,区别是“返回文字”“注册模型能力”还是“介入运行时”。以下是 Extension 用法示例,业务函数均为占位:
// 用户直接执行 /review-status,不需要生成模型 Tool Call。pi.registerCommand("review-status", { handler: async (_args, ctx) => { ctx.ui.notify(readReviewStatus(), "info"); },});
// 模型调用:必须有结构化参数和执行函数。pi.registerTool({ name: "scan_secrets", label: "Scan secrets", description: "Scan a path for secrets", parameters: scanParameters, execute: async (id, args, signal) => runScan(args, signal),});
// 所有经过此 hook 的相关 Tool Call:在执行前做策略判断。pi.on("tool_call", event => { if (isBlockedCall(event)) return { block: true, reason: "Blocked by review policy" };});Prompt Template 和 Skill 不需要这些注册代码,分别提供文本模板或 SKILL.md;不要为了展示代码把纯文本资源伪装成另一套 API。
17.1 决策顺序
Section titled “17.1 决策顺序”flowchart TB
Need["要新增一种能力"]
Text{"只是复用一段<br/>用户输入文本?"}
Workflow{"主要是可阅读的<br/>知识或工作流?"}
Deterministic{"模型是否需要调用<br/>确定性函数?"}
Runtime{"是否要介入生命周期、<br/>状态、UI 或 policy?"}
External{"能力是否已由外部<br/>MCP server 提供?"}
Need --> Text
Text -->|"是"| Template["Prompt Template"]
Text -->|"否"| Workflow
Workflow -->|"是"| Skill["Skill"]
Workflow -->|"否"| Deterministic
Deterministic -->|"是,已有 Extension 可注册"| Tool["Tool"]
Deterministic -->|"否"| Runtime
Runtime -->|"是"| Extension["Extension"]
Runtime -->|"否"| External
External -->|"是"| MCP["配置内置 MCP Extension"]
External -->|"否"| Combined["组合 Skill + Tool + Extension"]17.2 典型判断
Section titled “17.2 典型判断”| 需求 | 选择 | 原因 |
|---|---|---|
/review src/auth.ts 展开固定审查提问 |
Prompt Template | 只是文本宏 |
| 教模型遵循团队发布流程 | Skill | 工作流长、只在发布任务需要 |
| 查询内部工单状态 | Tool | 输入输出确定,可结构化调用 |
| 每次 bash 前执行组织 policy | Extension hook | 必须介入所有调用,不依赖模型自觉 |
添加 /deploy 命令和确认 UI |
Extension command | 用户直接触发并需要 UI/runtime |
| 接入已有 Jira MCP server | MCP Extension | 外部能力已用 MCP 暴露,需映射到 Pi Tool |
| 完整安全审查能力 | Skill + Tool + Extension | 流程、确定性扫描、强制 policy 各自归位 |
17.3 三个常见误选
Section titled “17.3 三个常见误选”把强制 policy 写成 Skill
Section titled “把强制 policy 写成 Skill”问题:
模型可能没加载 Skill模型可能误解或忽略文本强制执行检查应放 tool_call 或真实 sandbox。
把长工作流写进 Tool description
Section titled “把长工作流写进 Tool description”问题:
所有请求都支付 Prompt 成本Tool schema 变得难以选择工作流与执行接口耦合长步骤放 Skill,Tool description 只说明能力和输入输出。
为简单 CLI 包一层 MCP
Section titled “为简单 CLI 包一层 MCP”如果成熟 CLI 已有稳定帮助和文本输出:
Skill 告诉模型怎样调用 CLI → bash 执行可能比引入 server transport、进程生命周期和 schema 同步更简单。只有当 MCP 的结构化发现、 远端连接或生态复用有实际价值时再引入。
十八、失败、取消和并发边界
Section titled “十八、失败、取消和并发边界”失败和并发分支可分别在 loader 与 Agent Loop 中定位。下面保留决定是否继续、是否串行的判断:
// extensions/loader.ts:initializeExtension()try { await factory(load.api); load.commit();} catch (error) { load.discard(); throw error; // 外层 loadExtension 转成诊断,后续 Extension 继续。}
// packages/agent/src/agent-loop.ts:executeToolCalls()const hasSequentialToolCall = toolCalls.some(tc => currentContext.tools?.find(t => t.name === tc.name)?.executionMode === "sequential");if (config.toolExecution === "sequential" || hasSequentialToolCall) { return executeToolCallsSequential(/* 当前上下文、toolCalls、signal、emit 等 */);}return executeToolCallsParallel(/* 同上 */);这里不是只把某一个 Tool 改成串行;条件作用于本次 Assistant 的整批 Tool Call。
18.1 Skill 失败
Section titled “18.1 Skill 失败”| 失败 | 行为 |
|---|---|
| 路径不存在 | diagnostic,不加载 |
| YAML 无法解析 | 不加载;SKILL.md 产生 warning,普通 .md 静默跳过 |
| description 缺失 | 不加载;SKILL.md 产生 warning,普通 .md 静默跳过 |
| 名称不规范 | warning,仍加载 |
| 同名 | first wins,记录 collision |
/skill:name 执行时文件消失 |
emit error,保留原输入 |
| 模型没自动读取 | 继续普通推理;用户可显式调用 |
18.2 Extension 加载失败
Section titled “18.2 Extension 加载失败”| 失败 | 行为 |
|---|---|
| module import 失败 | 记录 path/error,跳过该 Extension |
| default export 不是函数 | 记录无有效 factory |
| async factory reject | 记录加载失败 |
| 一个 Extension 失败 | 后续 Extension 继续加载 |
| 注册名冲突 | 诊断;具体 winner 由注册类型和加载顺序决定 |
Tool 冲突中,ExtensionRunner.getAllRegisteredTools() 对 Extension 之间采取 first registration
wins;AgentSession 随后把 Extension/SDK Tool 放到 built-in 之上,所以“Extension 之间”
和“Extension 对 built-in”是两层不同规则。
18.3 Handler 失败
Section titled “18.3 Handler 失败”普通通知、context、tool_result、input → emitError → 隔离失败并继续
tool_call → 异常传播 → fail-closed,阻止 Tool
user_bash → 返回值必须为 undefined 或有效的 operations/result 二选一 → 非法返回或抛错:报告并向外传播,不回退本地执行
project_trust → 记录错误 → 继续后续 handler 或内建 trust 流程取消需要把 signal 传入真正的异步操作。以下是 Tool 使用示例;不假设 idle Session 事件有 signal:
async execute(id, args, signal, onUpdate, ctx) { const response = await fetch(buildScanUrl(args), { signal }); if (!response.ok) throw new Error(`Scan failed: ${response.status}`); return { content: [{ type: "text", text: await response.text() }], details: undefined };}buildScanUrl 是示例业务函数。传递 signal 让支持它的 fetch 可被取消;仅在函数参数里声明 signal 不会自动取消所有子任务。
18.4 取消
Section titled “18.4 取消”Extension handler 里的嵌套异步工作应使用:
ctx.signal例如传给 fetch、子模型调用或支持 AbortSignal 的进程 helper。
Session 事件通常发生在 idle 或替换边界,ctx.signal 可能是 undefined。不能假设所有 hook
都有活动 Agent AbortSignal。
18.5 并发
Section titled “18.5 并发”Extension 事件 handler → 同一事件按加载和注册顺序 await
sibling Tool preflight → 顺序
sibling Tool execute → 默认并发
Tool progress/result/end → 可交错
最终 ToolResult messages → 源顺序Extension 共享闭包状态时,必须根据这一时序设计锁、队列或幂等更新。
十九、把贯穿案例从启动推导到结束
Section titled “十九、把贯穿案例从启动推导到结束”先把贯穿案例缩成一个 Extension 骨架,再按 19.1 至 19.7 追踪。以下是设计示例,scanParameters/runScan/isDangerousBash 均为待实现的业务逻辑:
import { fileURLToPath } from "node:url";
export default function safetyGate(pi) { pi.registerTool({ name: "scan_secrets", label: "Scan secrets", description: "Scan for exposed secrets", parameters: scanParameters, execute: async (id, args, signal) => runScan(args, signal), }); pi.on("resources_discover", () => ({ skillPaths: [fileURLToPath(new URL("./skills", import.meta.url))], })); pi.on("tool_call", event => { if (event.toolName === "bash" && isDangerousBash(event.input)) { return { block: true, reason: "Unsafe shell command" }; } }); pi.on("tool_result", event => { if (event.toolName === "scan_secrets") { return { details: { ...event.details, reviewedBy: "safety-gate" } }; } });}这里没有创建连接或 watcher,所以骨架没有空的 shutdown handler;若 runScan 管理长期资源,再在 session_start/session_shutdown 中成对创建和释放。
19.1 文件布局
Section titled “19.1 文件布局”~/.pi/agent/extensions/└── safety-gate/ ├── index.ts └── skills/ └── security-review/ ├── SKILL.md ├── references/ │ └── auth-checklist.md └── scripts/ └── scan.sh19.2 启动
Section titled “19.2 启动”PackageManager 发现 safety-gate/index.ts → loader 用 jiti import → factory 注册: resources_discover tool_call tool_result scan_secrets Tool session_shutdown → AgentSession 创建 ExtensionRunner → bindCore 连接真实 Agent/Session → session_start → resources_discover 返回 skills/ → ResourceLoader 加载 security-review 元数据 → AgentSession 重建 System Prompt19.3 用户请求
Section titled “19.3 用户请求”“审查认证模块” → Extension command 未命中 → input hook 未处理 → 非 /skill,保持原文 → before_agent_start 可追加本轮规则 → Agent Run19.4 模型选择 Skill
Section titled “19.4 模型选择 Skill”System Prompt 中 description 匹配 → read(SKILL.md) → ToolResult 带回完整工作流 → 模型继续 read(auth-checklist.md) → 模型调用 scan_secrets({ path: "packages/auth" })19.5 Extension Tool
Section titled “19.5 Extension Tool”tool_execution_start → tool_call policy 检查 path → scan_secrets.execute() → progress update → 原始扫描结果 → tool_result 补充组织风险级别 → tool_execution_end → 最终 ToolResult 按源顺序进入 Context 和 Session19.6 危险 bash
Section titled “19.6 危险 bash”如果模型调用:
bash("curl ... | sh")safety-gate 的 tool_call:
识别 bash command → 返回 block: true → bash 不执行 → Agent 收到 isError ToolResult 和 reason → 下一 Turn 改用安全流程这一步保证的是这次经过 hook 且被识别为危险的 bash Tool Call 不执行。它不覆盖用户 !command 的 user_bash 路径、其他 shell Tool 或 Extension 自己启动的进程。
19.7 结束和 reload
Section titled “19.7 结束和 reload”agent_end → 没有 retry/compaction/follow-up → agent_settled
/reload → safety-gate session_shutdown 清理资源 → 清 Extension module cache → 重新加载 Extension 和 Skill → 新 Runner / Tool registry / System Prompt → session_start + resources_discover至此,两条必做调用链闭合:
Skill 发现 → 元数据提示 → 正文按需加载 → 按工作流调用 Tool
Extension 发现 → module/factory → 注册 → Runner 绑定 → hook → Tool 执行 → shutdown二十、按运行顺序回查源码
Section titled “二十、按运行顺序回查源码”不要按目录逐个文件通读。每一遍只验证一条数据链。
源码回查时可以先记住以下跨文件调用骨架,再用下面六遍阅读逐段还原参数。变量名作了简化,省略构造过程:
await resourceLoader.reload({ resolveProjectTrust });// loader 内部:发现路径、执行 factory、保存注册结果与 Skill 元数据。await session.bindExtensions(bindings);// session 内部:session_start → resources_discover → 重建 Prompt。await session.prompt(userText);// prompt 内部:command/input/Skill → Agent → Provider → Tool hooks。await session.reload();// reload 内部:shutdown → invalidate → 重载 → bind → start/discover。这些调用不在同一个源码函数中;这里是阅读顺序,不是应该在一次用户请求里执行 reload 的使用建议。
第一遍:Project Trust 如何控制资源
Section titled “第一遍:Project Trust 如何控制资源”main.ts的 runtime factory 看最终 Session cwd 在何时确定,以及何时构造resolveProjectTrustcallback。trust-manager.ts:hasTrustRequiringProjectResources()看哪些项目路径触发 trust gate。resource-loader.ts:loadProjectTrustExtensions()看为什么先强制projectTrusted=false。project-trust.ts:resolveProjectTrusted()看 override、Extension、trust store、default 和 UI 的优先级。resource-loader.ts:reload()看最终 trust 怎样进入 PackageManager 和资源加载。
完成后,应能解释项目自动发现的 Extension 为什么不能审批自己,以及显式 CLI 输入为何是另一种来源。
第二遍:一个 Skill 怎样进入 System Prompt
Section titled “第二遍:一个 Skill 怎样进入 System Prompt”package-manager.ts:collectSkillEntries()package-manager.ts:collectAncestorAgentsSkillDirs()package-manager.ts:resourcePrecedenceRank()skills.ts:loadSkillsFromDirInternal()skills.ts:loadSkillFromFile()skills.ts:loadSkills()agent-session.ts:_rebuildSystemPrompt()skills.ts:formatSkillsForPrompt()system-prompt.ts:buildSystemPrompt()
完成后,应能从 SKILL.md 推导出 <available_skills>,并说明为什么正文不在其中。
第三遍:/skill:name 与自动读取
Section titled “第三遍:/skill:name 与自动读取”agent-session.ts:prompt()extensions/runner.ts:emitInput()agent-session.ts:_expandSkillCommand()prompt-templates.ts:expandPromptTemplate()agent-loop.ts:streamAssistantResponse()agent-loop.ts:executeToolCalls()中 read Tool 路径
完成后,应能画出“两条 Skill 正文进入 Context 的路径”。
第四遍:一个 Extension 怎样注册
Section titled “第四遍:一个 Extension 怎样注册”extensions/loader.ts:discoverExtensionsInDir()extensions/loader.ts:loadExtensionModule()extensions/loader.ts:createExtension()extensions/loader.ts:createExtensionAPI()extensions/loader.ts:loadExtension()extensions/runner.ts:ExtensionRunner.constructoragent-session.ts:_buildRuntime()agent-session.ts:_bindExtensionCore()agent-session.ts:bindExtensions()
完成后,应能解释 factory 注册期和 Runner 运行期的分界。
第五遍:Extension Tool 怎样执行
Section titled “第五遍:Extension Tool 怎样执行”agent-session.ts:_refreshToolRegistry()extensions/wrapper.ts:wrapRegisteredTool()agent-session.ts:_installAgentToolHooks()extensions/runner.ts:emitToolCall()- Tool 的
execute() extensions/runner.ts:emitToolResult()agent-loop.ts:executeToolCalls()
完成后,应能指出参数校验、policy、执行、结果变换和持久化分别在哪里。
第六遍:reload 和 Session replacement
Section titled “第六遍:reload 和 Session replacement”agent-session.ts:reload()extensions/loader.ts:clearExtensionCache()extensions/runner.ts:invalidate()agent-session.ts:dispose()extensions/runner.ts:createCommandContext()- runtime host 的
newSession/fork/switchSession
完成后,应能解释旧 ctx 为什么必须抛 stale error,以及 withSession 解决什么。
rg -n "hasTrustRequiringProjectResources|resolveProjectTrusted|loadProjectTrustExtensions" \ packages/coding-agent/src
rg -n "collectSkillEntries|loadSkillFromFile|formatSkillsForPrompt|_expandSkillCommand" \ packages/coding-agent/src/core
rg -n "discoverExtensionsInDir|loadExtensionModule|createExtensionAPI|loadExtension" \ packages/coding-agent/src/core/extensions
rg -n "_buildRuntime|_bindExtensionCore|_refreshToolRegistry|_installAgentToolHooks" \ packages/coding-agent/src/core/agent-session.ts
rg -n "emitInput|emitBeforeAgentStart|emitContext|emitToolCall|emitToolResult" \ packages/coding-agent/src/core/extensions/runner.ts
rg -n "resources_discover|extendResourcesFromExtensions|invalidate|session_shutdown" \ packages/coding-agent/src/core二十一、用测试验证每个交接点
Section titled “二十一、用测试验证每个交接点”下面的测试表是源码回查入口,不代表本次运行了 Pi 全套测试。旧版发现函数与 Runner 方法的验证保留在 docs/reviews/pi-07-skills-extensions-mcp-audit.md;本轮针对当前版本的验证记录在 docs/reviews/pi-2026-09-27-source-refresh-audit.md,不可把旧断言数当作新版运行结果。
本阶段只修改学习文档,不需要调用真实 Provider。测试阅读重点是先预测顺序和 winner,再看 断言。
| 行为 | 测试 | 关键断言 |
|---|---|---|
| Skill 校验与递归发现 | test/skills.test.ts |
缺 description 跳过;多数名称问题只 warning |
| Skill 元数据格式 | test/skills.test.ts |
XML 转义;隐藏 Skill 不进入 Prompt |
| 显式 Skill 调用 | test/suite/agent-session-prompt.test.ts |
/skill:test 在 Provider 前展开成完整 UserMessage |
| Skill 来源优先级 | test/suite/regressions/2781-skill-collision-precedence.test.ts |
project > user > package |
| Extension 入口发现 | test/extensions-discovery.test.ts |
manifest > index.ts > index.js;只扫描一层 |
| Extension 初始化失败 | 同上 | 单个错误进入 diagnostics,其他 Extension 继续 |
| trust 资源过滤 | test/resource-loader.test.ts |
untrusted 时跳过项目动态资源,但仍加载 AGENTS |
| trust 继承 | test/trust-manager.test.ts |
cwd 继承最近祖先决定 |
| trust bootstrap 复用 | test/resource-loader.test.ts |
用户 Extension trust 前加载,trust 后不重复 factory |
| Extension 贡献 Skill | test/resource-loader.test.ts 的 extendResources |
Skill 获得 Extension SourceInfo |
| input 变换链 | test/extensions-input-event.test.ts |
transform 串联;handled 短路;异常隔离 |
| Tool 名冲突 | test/extensions-runner.test.ts |
Extension 之间 first registration wins |
before_agent_start 链 |
同上 | 后续 handler 的 ctx.getSystemPrompt() 看见前序修改 |
tool_result 链 |
同上 | 部分 patch 累积,不覆盖未修改字段 |
| Tool policy block | test/suite/agent-session-model-extension.test.ts |
Tool 不执行,模型收到 error ToolResult |
| tool_call 前持久化屏障 | test/agent-session-concurrent.test.ts |
handler 读取 Session 时已有 AssistantMessage |
| active Tool 同 Run 刷新 | test/suite/regressions/6162-extension-active-tools-next-turn.test.ts |
下一 Provider request 立即看到新 Tool |
| allowlist | test/suite/regressions/2835-tools-allowlist-filters-extension-tools.test.ts |
built-in 与 Extension Tool 使用同一过滤 |
| Extension cache | test/suite/regressions/extension-factory-cache.test.ts |
module 可缓存,factory 每次重跑,reload 清缓存 |
建议验证顺序:
先读 Skill 测试 → 能预测发现和展开
再读 discovery/runner 测试 → 能预测 Extension 注册和 hook 组合
最后读 suite regression → 能验证 AgentSession 与 Agent Loop 的交接以 test/package-manager.test.ts 的真实用例看如何把文档结论写成断言。下面省略临时目录和 frontmatter 的创建:
// rootSkill = .agents/skills/root-file.md// nestedSkill = .agents/skills/nested-skill/SKILL.md// nestedMarkdownSkill = .agents/skills/third-party/child-skill.mdconst result = await pm.resolve();expect(result.skills.some(r => r.path === rootSkill)).toBe(false);expect(result.skills.some(r => r.path === nestedSkill && r.enabled)).toBe(true);expect(result.skills.some(r => r.path === nestedMarkdownSkill && r.enabled)).toBe(true);这些是源码中已有测试的断言摘录,和本次在审计记录中报告的隔离函数验证分开计数。
二十二、理解检查
Section titled “二十二、理解检查”尝试不看前文,用贯穿案例回答:
- 为什么 Project Trust 需要先加载一小部分 Extension?
- 为什么项目自动发现的 Extension 不能处理自己的
project_trust,显式 -e 又有什么不同? hasTrustRequiringProjectResources()返回 false 为什么可以直接 trusted?.pi/skills与.agents/skills对根目录.md的发现规则有什么不同?- 为什么一个目录存在
SKILL.md后不再递归发现内部 Skill? - Skill 对象为什么没有正文
content字段? - 为什么缺 description 会跳过,而非法 name 通常只 warning?
- Skill 同名时 winner 由路径排序与哪层去重共同决定?CLI –skill 是否总能覆盖?
disable-model-invocation禁止了什么,没有禁止什么?- 没有 active
read但有bash时,Skill 索引如何变化? - 模型自动使用 Skill 时,正文以什么消息类型进入 Context?
/skill:name时,正文以什么消息类型进入 Context?- Skill 中的相对 reference 为什么必须以
baseDir解析? - Extension 自动发现为什么不递归加载所有
.ts? - Extension module cache 和 Extension instance 有什么区别?
- factory 阶段为什么不能直接
sendMessage()? - Provider 注册为什么可以在 factory 阶段排队?
bindCore()怎样让 factory 时创建的 API 获得真实 Session 能力?- registered Tool 与 active Tool 有什么区别?
- Extension Tool 为什么需要 wrapper?
tool_call修改参数后为什么存在 schema 风险?tool_callhandler 崩溃为什么应阻止 Tool?tool_resulthandler 崩溃为什么通常不应丢弃原结果?- 并行 Tool 下,policy 顺序、执行顺序和 Context 顺序分别是什么?
contexthook 和before_provider_request修改的是哪一层?agent_end与agent_settled对 Extension 有什么区别?- Extension 状态为什么应从 current branch 重建?
resources_discover后为什么必须重建 System Prompt?- reload 后旧 command handler 为什么还能继续运行?
runtime.invalidate()防止了哪类跨 Session 错误?- Project Trust 为什么不是 sandbox?
- MCP server tool 最终怎样变成模型可调用的 Pi Tool?
- 哪类能力只需 Prompt Template,哪类必须使用 Extension?
- 为什么安全规则不能只写 Skill?
能沿“启动 → 资源 → Prompt → Agent → Tool → Session → reload”回答,而不是逐个背 API, 才算理解阶段 7。
把其中几个问题落实到代码预测。以下为阅读练习,省略注册上下文:
pi.on("session_before_compact", () => ({ compaction: summaryA }));pi.on("session_before_compact", () => ({ cancel: false }));// 最终是否还带 summaryA?
pi.on("tool_result", () => ({ details: { stage: "checked" } }));pi.on("tool_result", () => ({ isError: true }));// 第二个返回值会不会丢掉 details?
await ctx.reload();pi.getActiveTools();// 此处还能使用旧 pi 吗?答案分别是:不保留 summaryA;details 继续保留;旧 pi 的受保护入口抛 stale error。对应第十、九、十四部分的实现。
二十三、章末功能总览
Section titled “二十三、章末功能总览”章末总览可以压缩为两种数据如何进入模型。下面是 AgentSession 与 wrapper 的跨函数摘录,省略其他配置字段:
// Skill:只先进入文字 Prompt;正文等 read 或 /skill 展开。const skills = this._resourceLoader.getSkills().skills;this._baseSystemPromptOptions.skills = skills;const prompt = buildSystemPrompt(this._baseSystemPromptOptions);
// Tool:定义适配成 AgentTool,active 集合进入 AgentState.tools。const agentTool = wrapRegisteredTool(registeredTool, runner);// AgentSession.setActiveToolsByName() 从 registry 选出工具后更新 Agent。// 真正调用时:definition.execute(id, params, signal, onUpdate, runner.createContext());最后一行概括 wrapper 的执行转发;源码变量名见第九部分。两条路径都依赖 Extension/ResourceLoader 的前序结果,但进入模型的载体不同。
23.1 一项能力怎样进入 Pi
Section titled “23.1 一项能力怎样进入 Pi”flowchart LR
Source["用户目录 / 项目目录 / Package / CLI / SDK"]
Gate["Project Trust 与资源优先级"]
Loaded["ResourceLoader 快照"]
Runtime["ExtensionRunner + Tool Registry"]
Model["System Prompt + Tool schemas"]
Work["Agent Run"]
Source --> Gate --> Loaded
Loaded -->|"Skill 元数据"| Model
Loaded -->|"Extension factory 注册结果"| Runtime
Runtime -->|"Hook、Tool、动态资源"| Model
Model --> Work
Runtime --> Work23.2 Skill 和 Extension 如何协作
Section titled “23.2 Skill 和 Extension 如何协作”flowchart TB
Ext["Extension<br/>提供运行时代码"]
Discover["resources_discover"]
Skill["Skill<br/>提供工作流和知识"]
Prompt["模型看到 Skill 元数据"]
Read["模型按需读取正文"]
Tool["Extension Tool<br/>提供确定性动作"]
Policy["tool_call hook<br/>提供强制 policy"]
Result["ToolResult 返回模型"]
Ext --> Discover --> Skill --> Prompt --> Read
Ext --> Tool
Ext --> Policy
Read --> Tool
Policy --> Tool --> ResultSkill 决定“应该怎样完成安全审查”,Extension Tool 提供扫描动作,Extension hook 拦截其策略识别到的危险 Tool Call。三者不是重复实现。
23.3 最小核心与外部协议
Section titled “23.3 最小核心与外部协议”Pi Core Agent Loop Tool contract Session Provider abstraction Extension seams
可选工作流层 Skills Prompt Templates 自定义 Tool MCP adapter 权限 UI 组织 policyPi 通过稳定 seam 支持多种工作流,而不替所有用户决定唯一的 MCP、权限、计划或子 Agent 方案。
二十四、面试表达
Section titled “二十四、面试表达”面试表达可以围绕这四个源码位置展开;以下保留关键语句,省略各自函数的其余部分:
// 1. extensions/loader.ts:加载时执行的是 factory。await factory(load.api);
// 2. extensions/runner.ts:bind 让 API 能操作当前 Session。this.runtime.sendMessage = actions.sendMessage;
// 3. system-prompt.ts:模型先获得 Skill 索引。const fileReadTool = (["read", "bash"] as const).find(name => selectedTools.includes(name));if (fileReadTool && skills.length) formatSkillsForPrompt(skills, fileReadTool);
// 4. extensions/runner.ts:执行前 policy 可以短路。if (result.block) return result;讲解时分别说明“注册什么、何时能操作 Session、模型先看到什么、执行前谁能阻止”,就能把下面的口述版本落回代码。
30 秒版本
Section titled “30 秒版本”Pi 把 Skill 和 Extension 放在不同层。Skill 是按需加载的工作流包,启动时只解析
name、description、location 放进 System Prompt,正文由模型通过 read 或用户通过
/skill:name 加载。Extension 是拥有进程权限的 TypeScript 模块;loader 执行 factory
收集 hook、Tool 和命令,AgentSession 再通过 ExtensionRunner 绑定真实 Session、UI 和
Agent 状态。bootstrap 保留用户级、CLI 和 inline Extension;项目自动发现和项目配置来源的动态资源受 trust 控制。MCP 已由内置 Extension 接入,工具经 exposure 决定直接声明还是通过脚本/搜索访问,通用 Agent Loop 不处理 transport。
2 分钟版本
Section titled “2 分钟版本”启动时 Pi 先根据最终 Session cwd 检查 .pi 和祖先 .agents/skills。CLI 传入 trust callback 时,
ResourceLoader 先以 untrusted 状态只加载用户级、CLI 和 inline Extension,让它们处理
project_trust;得到决定后再解析最终资源集合。Skill 经过路径发现、ignore、frontmatter
校验和名称冲突处理后,只保留元数据。formatSkillsForPrompt() 在 active read 存在时把
可由模型调用的 Skill 写成 XML 索引。模型自动选择时,完整正文通过 read ToolResult 进入
Context;用户 /skill:name 时,AgentSession 直接把去掉 frontmatter 的正文展开成当前
UserMessage。
Extension loader 用 jiti import TypeScript,执行 async factory,把 handler、Tool、Command、
renderer 等收集进 Extension 对象。加载期 Runtime 的 Session action 还不可用;AgentSession
创建 ExtensionRunner 后用 bindCore() 连接 sendMessage、SessionManager、active tools、
model 和 abort signal,再由不同模式绑定 UI。自定义 Tool 与 built-in、SDK Tool 合并成统一
registry,经过 allowlist 后写入 AgentState。Tool 执行前 tool_call 可修改或阻止参数,执行后
tool_result 按加载顺序链式修改结果。Agent、Context、Provider 和 Session hook 都由 Runner
按各自语义组合。
Project Trust 只是启动输入 gate,不是 sandbox;Skill 是可影响模型行为的文本,Extension 是 直接执行的代码。强制 policy 要写在 Tool hook 或 OS 隔离中。MCP 主要是外部能力协议,可由 内置 MCP Extension 管理连接、工具注册与 shutdown 清理,因此 Pi 保持通用核心 最小而不牺牲接入能力。
下一阶段连接
Section titled “下一阶段连接”SDK 的连接代码为下一章提供了入口。下面摘取 sdk.ts 中两个回调的核心分支,省略 handler 存在性检查:
onPayload: async payload => { return extensionRunnerRef.current?.emitBeforeProviderRequest(payload);},onResponse: async response => { await extensionRunnerRef.current?.emit({ type: "after_provider_response", status: response.status, headers: response.headers, });},一个回调把 payload 的处理结果交还 API 层,另一个把 HTTP status/headers 转成 Extension 事件。
本章已经走到 Provider 请求边界:
context hook → convertToLlm → ModelRuntime 认证与 headers → before_provider_headers → Provider serializer → before_provider_request → HTTP response → after_provider_response下一阶段将暂时放下 Skill 和 Extension,进入这条链内部:
统一 Model / Context / Message → 目标 Provider payload → 流式 text / thinking / toolcall events → 统一 AssistantMessage → error、abort、retry 和 usage届时要回答的是:Extension 能在 Provider 边界前后观察和修改数据,而 Pi-AI 本身怎样把不同 厂商协议收敛为 Agent Loop 可以稳定消费的一套事件模型。
当前实现与另一套 Durable Extension 的边界
Section titled “当前实现与另一套 Durable Extension 的边界”本章 pi.registerTool()、pi.on() 属于 coding-agent Extension。新 pi-durable 的
defineExtension() / registry.install() / 每会话 pi.agent 是另一套 API,不能直接替换本章 factory。
二者的生命周期与持久化边界见第十章。