扩展系统:生命周期事件与 Control Hook
TL;DR:Pi 的扩展(Extension)是加载进同一进程的 TypeScript 模块,通过
pi.on()和pi.registerXxx()给产品加工具、命令、Provider、UI 和策略。扩展点分两类:生命周期 Event 自下而上报告"发生了什么",只能观察;Control Hook 在明确的控制点自上而下注入"接下来怎么做",返回值会改变主流程。agent-core 只暴露通用的 runtime hook,完全不知道扩展的存在;AgentSession 负责把这些 hook 桥接到 ExtensionRunner;ExtensionRunner 对外统一成pi.on(name, handler),内部再按事件选择不同的分发策略:通知、链式转换、gate(任一阻断即停)、fail-closed。统一的是注册方式,不是运行语义。
扩展是什么,跑在哪里
Pi 的核心刻意做得很小:调模型、跑循环、管会话。权限确认、Git checkpoint、自定义压缩、子 agent、远程执行、甚至 MCP 支持,都不写进核心,而是通过扩展提供。所以扩展系统要回答的问题是:怎样在不改核心代码的前提下,让第三方既能加能力,又能干预流程?
一个扩展就是一个默认导出工厂函数的 TypeScript 模块:
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (isToolCallEventType("bash", event) && event.input.command.includes("rm -rf")) {
if (!(await ctx.ui.confirm("Dangerous!", "Allow rm -rf?"))) return { block: true, reason: "Blocked by user" };
}
});
pi.registerTool({ name: "greet", /* ... */ });
pi.registerCommand("hello", { /* ... */ });
}
运行形态要讲清楚:扩展不是独立进程,也没有 RPC 协议。Pi 用 jiti 在运行时直接加载 .ts(无需预编译),并把 @earendil-works/pi-coding-agent、pi-ai、pi-agent-core、pi-tui、typebox 这些 import 映射到 Pi 自己的那份模块,让扩展和宿主共享同一套类与注册表。扩展的 handler 就是普通的进程内 async 函数,返回值是类型化的对象(如 { block: true, reason }),而不是像 Claude Code 的外部 hook 那样靠 stdout JSON 加退出码通信。
扩展的来源有几种:~/.pi/agent/extensions/(全局)、.pi/extensions/(项目,信任后才加载)、settings 里的 extensions 路径、Pi Package(见 Pi Packages 与包管理),以及 pi -e ./ext.ts 临时加载。自动发现位置里的扩展可以用 /reload 热重载。
工厂函数里能做的事分两类:
- 注册:
pi.on()订阅事件;registerTool / registerCommand / registerShortcut / registerFlag增加交互入口;registerProvider / registerVirtualModel / registerMcpServer接入模型和外部工具;registerMessageRenderer / registerToolRenderer / registerEntryRenderer等定制渲染。 - 动作:
sendMessage、sendUserMessage、appendEntry、setModel、setActiveTools等改变会话状态的调用。
加载分两阶段。工厂执行时核心还没就绪,所以 loader 先给扩展一个 runtime,其中动作方法全是"调用即抛错"的占位;注册方法可以正常用,因为它们只是往扩展对象的表里加条目。等 AgentSession 建好,ExtensionRunner.bindCore() 把真实实现换进去,并冲刷工厂里排队的 registerProvider。用抛错占位而不是静默 no-op,是为了让"在 setup 阶段发消息"这种误用立刻暴露。工厂可以是 async 的,Pi 会等它完成再继续启动,因此适合做"启动时拉取远端模型列表再注册 Provider"这类一次性工作;但不应在工厂里起后台进程、socket、定时器,这些应推迟到 session_start,并在 session_shutdown 里清理。
一个值得在面试中提的设计演进:1.5 里 MCP、codemode、tool-search、llama.cpp 都是内置扩展,和第三方扩展走同一套 ExtensionAPI。其中 codemode、tool-search、mcp 标记为可替换:如果第三方扩展注册了同名工具或 /mcp 命令,就由它接管,内置版本让位。这说明扩展 API 的表达力已经足够承载核心功能(详见 MCP 与 Codemode)。
一句话:扩展是进程内、类型化、两阶段初始化的 TS 模块;Pi 把几乎所有产品能力都外置成扩展,连 MCP 都是。
为什么同时需要 Event 和 Hook
先看最容易产生的误解:pi.on("agent_start")、pi.on("tool_call")、pi.on("tool_result") 写法一模一样,那它们是同一种东西吗?注册形式相同,运行时语义不同。
Agent Loop 在运行中同时有两种需求。第一种是向外报告状态:Agent 开始了、模型输出了一个增量、工具开始执行了、Turn 结束了。TUI 渲染、日志、Tracing、Token 统计、会话持久化都靠它。这就是生命周期事件(Lifecycle Event):方向是 Runtime → Harness → Extension/UI,Agent Loop 只管发布,不关心谁在听,也不读返回值。
第二种是让上层影响流程:这次工具调用允许吗?参数要不要改?结果要不要脱敏?下一次请求该看到哪些消息?这个 Turn 之后要不要再跑一轮?这些问题通知解决不了,因为 Agent Loop 必须停下来等答案,并根据答案走不同分支。这就是 Control Hook:先由上层安装到 Runtime,运行时 Runtime 反向调用它,读取返回值再决定后续行为。
Lifecycle Event:Runtime ──────────────────────────→ Harness / Extension(只读)
Control Hook: Runtime ─→ Harness / Extension ─→ 决策 ─→ Runtime
要分清两个维度:是否 await 是时间顺序语义,是否读取返回值 才是控制语义。agent_start 的 handler 也会被等待(保证 Tracing 初始化完成后才开始第一个 Turn,这叫 barrier),但它返回 { block: true } 毫无作用,因为分发代码根本不看返回值。反过来,工具执行前如果只是"发一个通知",扩展还在判断命令是否危险时,工具可能已经开始执行了,这就是 tool_call 必须是 Control Hook 的原因。
一句话:Event 传递已经发生的事实,Hook 位于控制点并改变后续执行;await 只保证顺序,读返回值才构成控制。
三层结构:runtime hook → AgentSession 桥接 → ExtensionRunner
第一层:agent-core 只提供通用 runtime hook
pi-agent-core 是可独立使用的 Agent Runtime(见 pi-agent-core),它不知道 pi.on()、ExtensionRunner、AGENTS.md 或会话 JSONL。它只暴露两样东西:一条 AgentEvent 事件流(agent.subscribe()),和一组可选回调。1.5 中这组回调是:
| runtime hook | 触发时机 | 作用 |
|---|---|---|
transformContext | 每次请求前,convertToLlm 之前 | 改写本次请求看到的消息(不改历史) |
convertToLlm | 每次请求前 | 把 AgentMessage 转成 provider 消息 |
prepareRequest | 每次请求前(含第一次) | 替换本次及后续请求的 context / model / thinking |
beforeToolCall | 参数校验后、执行前 | 返回 { block, reason, terminate } 阻止执行 |
afterToolCall | 执行后、tool_execution_end 前 | 部分覆盖 content / details / isError / usage |
finishTurn | 一个 Turn 的消息都已发出、turn_end 前 | 返回 end 结束 run,或 continue 保证再请求一次 |
prepareNextTurn | 确定继续后、下一 Turn 开始前 | 刷新 context / model / thinking(压缩就挂在这里) |
getSteeringMessages / getFollowUpMessages | Turn 后 / 即将停止时 | 拉取排队的 steering 与 follow-up 消息 |
另外,provider 级的 onPayload、onResponse、transformHeaders 作为流式调用选项传给 pi-ai。版本差异值得一提:早期的 shouldStopAfterTurn 在 0.87 被移除,改由 finishTurn 统一表达"结束 / 继续 / 按默认调度",并且它也能看到出错和中止的响应。
依赖方向因此保持单向:pi-coding-agent → pi-agent-core → pi-ai。底层只定义抽象回调,上层提供实现,绝不会出现 agent-core 反向 import ExtensionRunner。
第二层:AgentSession 是桥
AgentSession(以及创建它的 sdk.ts 装配代码)做两件方向相反的事。一是订阅 AgentEvent,在更新会话状态、持久化、通知 TUI/RPC 之后,把生命周期事件转发给 ExtensionRunner;二是把 ExtensionRunner 的控制入口安装成 Agent 的 runtime hook:
agent.beforeToolCall = (c) => runner.emitToolCall({ toolName, toolCallId, input: c.args });
agent.afterToolCall = (c) => runner.emitToolResult({ ...c.result });
agent.transformContext = (msgs) => runner.emitContext(msgs); // context + context_with_system
agent.finishTurn = (turn) => runner.emitBoundary(turn_end ...); // actionable turn_end
streamOptions.onPayload = (p) => runner.emitBeforeProviderRequest(p);
这些回调在执行时才读取当前的 runner,所以 /reload 换了一个新 runner 也不必重装 hook。还有一部分扩展点根本不进 Agent Loop,由 AgentSession 自己在对应位置调用:input、before_agent_start、session_before_*、agent_before_settle、resources_discover;user_bash 则由交互模式和 RPC 模式在执行 ! 命令前调用。
第三层:ExtensionRunner 与统一的 pi.on()
注册阶段所有事件都一样:pi.on(name, handler) 只是把 handler 按事件名存进当前扩展的表里,并返回一个取消订阅函数。区别全部发生在触发阶段:ExtensionRunner 为不同事件实现了不同的分发方法(emit、emitToolCall、emitToolResult、emitContext、emitInput、emitBoundary、emitUserBash……),它们在"读不读返回值、多个返回值怎么合并、出错怎么办"上各不相同:
| 分发策略 | 代表事件 | 返回值处理 | handler 抛错 |
|---|---|---|---|
| 通知 | agent_start、turn_start、message_update、tool_execution_*、agent_settled、model_select | 忽略 | 记录错误,继续 |
| 原地修改 | tool_call.input、before_provider_headers.headers、before_agent_start.systemPromptOptions | 后面的 handler 看到前面的修改 | 视事件而定 |
| 链式转换(middleware) | input、context、context_with_system、message_end、tool_result、before_provider_request | 前一个的输出是后一个的输入 | 记录错误,跳过该 handler |
| Gate(任一阻断即停) | tool_call、session_before_*、input 的 handled | 第一个 block / cancel 立即返回 | tool_call 出错即阻断工具(fail-safe) |
| 第一个明确决定者胜 | project_trust、user_bash | 第一个 yes/no 或有效结果生效 | user_bash 出错直接中止命令(fail-closed) |
| 收集合并 | resources_discover | 合并所有扩展贡献的路径 | 记录错误 |
| 可行动边界 | turn_end、agent_before_settle | 每个 handler 看到之前的提案并可替换 | 报错后其他 handler 照常运行 |
| 最后覆盖者胜 | cache_warming_decision | 最后一个给出 action 的生效 | 记录错误 |
顺序规则是确定的:先按扩展加载顺序,再按扩展内注册顺序。分发开始时会对 handler 列表做快照,分发中途增删 handler 不影响本次。
错误策略的不对称是有意为之。普通 handler 出错只记录,Agent 照常运行,避免一个写坏的日志扩展拖垮整个会话;但安全相关的 gate 必须 fail-closed:tool_call 的 handler 抛错时工具不执行,user_bash 的 handler 抛错或返回无效结果时命令直接中止,不会回退到本地执行。
一句话:agent-core 提供通用回调,AgentSession 双向桥接,ExtensionRunner 把统一的
pi.on()分发成通知、转换、gate、fail-closed 等不同语义。
一次 session.prompt():每个扩展点在哪里触发
用户在交互模式输入一句话并回车,下面是按发生顺序的完整链路(括号里是对应的 runtime hook):
用户输入
│
├─ /cmd 是扩展命令?→ 直接执行命令 handler,input 事件不触发
├─ input 拦截:continue / transform / handled
├─ 展开 /skill:xxx 与 /template
├─ 发请求前的压缩检查
├─ before_agent_start 注入消息、修改 system prompt 选项
▼
Agent Loop(一次 run)
├─ agent_start
├─ turn_start
│ ├─ context (transformContext) 只看对话消息
│ ├─ context_with_system (transformContext) 看完整 transcript
│ ├─ before_provider_headers (transformHeaders)
│ ├─ before_provider_request (onPayload) 可替换 provider payload
│ ├─ after_provider_response (onResponse) 状态码与响应头
│ ├─ provider_stream_event / message_start / message_update... / message_end
│ └─ 对每个工具调用:
│ tool_execution_start → tool_call (beforeToolCall) → 执行
│ → tool_execution_update... → tool_result (afterToolCall) → tool_execution_end
├─ turn_end (finishTurn) 可追加条目、请求继续
├─ 继续?→ 阈值压缩 (prepareNextTurn) → 下一个 turn_start
└─ agent_end
▼
重试 / 溢出压缩重试 / 队列中的 follow-up?→ 回到 agent_start
├─ agent_before_settle 最后一个可行动边界,可再请求一次继续
└─ agent_settled 真正空闲,只通知
几个细节决定了这条链路的正确理解:
- 工具链的顺序在 1.5 中是先
tool_execution_start再tool_call。也就是说"开始执行"事件代表这次调用进入了执行流程,而tool_call才是真正的放行或阻断点。被阻断、参数校验失败、未知工具的调用会跳过执行和tool_result,但仍然会收到带错误结果的tool_execution_end。所以想观察"每个最终确定的调用",应该监听tool_execution_end。 - 并行工具模式(默认)下,同一条 assistant 消息里的多个工具调用先按顺序做预检(
tool_call逐个执行),然后并发执行;tool_execution_end按完成顺序发出,最终的 toolResult 消息仍按原顺序写入。tool_call运行前,Pi 会等之前的事件在 AgentSession 里处理完,所以 handler 读到的ctx.sessionManager已经包含当前这条 assistant 消息。 agent_end不代表空闲。之后可能还有自动重试、压缩后重试或 follow-up,会再次进入agent_start。要知道"Pi 不会再自己跑了",用agent_settled。- 扩展通过
pi.sendMessage(..., { triggerTurn: true })在空闲时触发的 run,从 1.2 起也会经过before_agent_start,和用户 prompt 走同一套准备流程。事件里的messageType区分"user"(有prompt)与"custom"(有起始message),只想处理用户输入的 handler 要自己判断。 tool_call的terminate: true不是全局break:只有同一批次所有最终结果都要求终止时,Agent 才会提前停。
一句话:Event 和 Hook 不是两套系统,而是同一条执行链上分别负责"状态输出"和"策略输入"的两类节点。
重要扩展点逐个说明
project_trust:项目里有 .pi/settings.json、.pi/extensions 等需要信任的资源时,Pi 在加载它们之前触发。只有用户级和 -e 扩展参与,项目扩展此时还没加载,否则就成了"让被检查者决定是否信任自己"。第一个返回 yes 或 no 的扩展拥有决定权,可以 remember: true 写入 trust.json;都返回 undecided 时走内置流程。详见 资源发现、项目信任与系统提示词。
resources_discover:在 session_start 之后触发,扩展返回额外的 skillPaths / promptPaths / themePaths,所有扩展的贡献合并。公司内部可以用它统一挂载共享的 skills 目录。
input:在扩展命令检查之后、skill/template 展开之前,拿到原始输入。返回 continue(不变)、transform(改写文本)或 handled(扩展自己处理完,不进 Agent Loop)。例如把 ?quick xxx 改写成"请简短回答:xxx"。事件里的 source 区分键盘输入、RPC 和扩展发来的消息。
before_agent_start:run 开始前,可以注入一条持久化的自定义消息,或修改 system prompt。1.5 推荐修改结构化的 systemPromptOptions(sections、selectedTools、promptGuidelines),Pi 会对比新旧 prompt 分段,只追加一条修补变化部分的系统消息,支持中途系统消息的模型可以保住缓存前缀;直接返回整段 systemPrompt 则会替换整个 prompt,变化时就是一次缓存未命中。
context 与 context_with_system:每次请求前触发,都是改写本次请求的视图,不改会话历史。context 拿到不含系统消息的对话深拷贝,返回修改后的列表;如果列表变了,Pi 会把当前 prompt 分段和工具声明重新放到最前面,所以过滤或截断不会误删 prompt 和工具。context_with_system(0.87 新增)在所有 context 之后运行,拿到含系统消息的完整 transcript,返回值原样发送,适合需要精确控制系统消息的场景,规则是首条必须保留系统消息。
before_provider_request 及其他 provider hook:context 改的是 Pi 的统一消息结构,跨 provider 通用;before_provider_request 改的是已经序列化好的 provider 专属请求体(OpenAI / Anthropic 各自的格式),和具体 API 协议耦合,主要用于调试序列化和缓存行为。before_provider_headers 原地改请求头,after_provider_response 在消费流之前看状态码和响应头(比如观察 429),provider_stream_event 看每个原始流事件。
tool_call 与 tool_result:前者是 Guard,后者是 Middleware。tool_call 可以原地修改 event.input(修改后不再重新校验,后续 handler 能看到),返回 { block: true, reason } 阻断;后者每个 handler 看到前一个处理后的结果,可以只返回部分字段(content、details、isError、usage),未返回的字段保持不变。一个细节:替换 content 而不返回 structuredContent 时,结构化内容会被丢弃,因为它可能已经和新内容对不上。
message_end:底层来源是生命周期事件,但 ExtensionRunner 给了它转换语义:handler 可以返回 { message } 替换最终消息(必须保持同一 role),比如修正 usage 中的费用。这说明分类不能只看事件从哪来,还要看 Runner 是否读取返回值。
turn_end 与 agent_before_settle:0.87 起它们从纯通知变成"可行动边界"。turn_end 在 assistant 和工具结果消息都已持久化后运行,handler 可以提议追加结构化条目(custom、custom_message、context_edit、compaction),并请求 continue: true,保证再发一次请求。每个 handler 都能看到前面 handler 的提案,所有提案在全部 handler 结束后统一校验、按顺序追加。agent_before_settle 是整个 run 收尾前最后一次这样的机会,比如"回复前提醒模型再审一遍 diff"。无条件 continue: true 会造成死循环,必须自己加条件。
session_before_*:session_before_switch(/new、/resume)、session_before_fork(/fork、/clone)、session_before_compact、session_before_tree。它们由 AgentSession 自己处理,不进 LLM/工具循环,返回 { cancel: true } 取消操作。session_before_compact 还可以直接返回自定义的压缩摘要,接管压缩(压缩本身见 会话树与上下文压缩)。会话替换成功后,旧扩展实例收到 session_shutdown,新实例重新加载后收到 session_start,所以扩展的内存状态必须能从会话条目中重建。
user_bash:用户在输入框敲 !cmd 时触发。返回 { operations } 把命令转到另一个后端执行(比如 SSH),返回 { result } 直接给出结果,返回 undefined 交给下一个 handler,最后回落到本地执行。它是 fail-closed 的:出错或返回无效对象时命令中止,后续 handler 不再执行,也不会回落到本地执行。原因很直接:一个"把命令转到远程沙箱"的扩展出错时,悄悄在本机执行才是最危险的结果。
容易混淆的几对
tool_call 与 tool_execution_start:后者是生命周期事件,报告"这次调用进入执行流程";前者是 Control Hook,决定"能不能真的执行"。1.5 的顺序是 tool_execution_start 先于 tool_call,所以不能把 tool_execution_start 当作"已经放行"的信号。
tool_result 与 tool_execution_end:前者改结果,后者报告最终结果已确定。顺序是执行 → tool_result → tool_execution_end,后者看到的是被 middleware 改过的结果,并带有 durationMs。
before_agent_start 与 agent_start:前者在 AgentSession 层、agent.prompt() 之前,能改 system prompt 和注入消息;后者在 agent-core 层,Loop 已经开始,只用于通知。
context 与压缩:context 只改本次请求的消息视图,临时、不持久化;压缩会生成摘要、追加 compaction 条目,改变之后每次重建上下文的方式,是会话级的持久效果。
agent_end 与 agent_settled:前者是一次底层 run 结束,后面可能还有重试或 follow-up;后者才是"不会再自动运行"的最终信号。
为什么不只用 Event,也不只用 Hook
只用 Event 不行。普通事件分发器可能不等待 handler、忽略返回值、不知道多个返回值如何合并,也没有明确的 block 协议。对安全控制来说这就是竞态:扩展还在判断命令是否危险,工具已经开始执行了。阻断工具、改写结果、转换上下文、取消会话切换、决定是否继续下一轮,这些都需要同步的控制屏障。
只用 Hook 也不行。如果 message_update、tool_execution_update 这种高频节点也做成 Control Hook,每个节点都要定义:能返回什么、多个返回值怎么合并、冲突时谁优先、失败是否中断、改了状态如何校验。扩展 A 返回 block、扩展 B 返回替换后的 context、扩展 C 要求重试,Runtime 就得裁决优先级和回滚。结果是返回协议膨胀、扩展之间更容易冲突、流式性能下降、Agent 状态更难保持一致。
理论上当然可以把所有东西都叫 Hook,再给每个 Hook 配一个 reducer(忽略、first-block-wins、pipeline、merge……)。但统一命名并没有消除语义差异,只是把差异挪进了每个 Hook 的分发策略里。Pi 的选择是:只有确实需要改变后续行为的位置才定义 Control Hook,其余保持为通知;对外用统一的 pi.on() 降低学习成本,再由 TypeScript 按事件名推导各自的 handler 返回类型,所以统一 API 并不意味着统一类型。
设计 Hook 系统的检查清单
自己设计一个 Agent 扩展系统时,只定义 on(name, handler) 远远不够,每个扩展点都要明确下面这些契约。括号里是 Pi 的做法:
- 触发时机:操作前还是后,持久化前还是后?(
turn_end在消息持久化之后;tool_result在写入上下文之前) - 是否阻塞:主流程是否 await,多个 handler 串行还是并行?(全部串行 await;
ui_prompt_*例外,是 best-effort 不等待的) - 返回值协议:忽略、block、替换、部分 patch,还是请求继续?(每个事件有自己的结果类型)
- 多 handler 聚合:ignore、first-block-wins、pipeline、merge、last-wins?(见上面的分发策略表)
- 错误策略:出错是跳过、继续,还是阻断?(普通事件记录后继续;安全 gate fail-closed)
- 顺序规则:先脱敏再截断和先截断再脱敏结果不同。(加载顺序 + 注册顺序,分发前快照)
- 状态一致性:改写后 tool call 与 tool result 的 ID 仍要对应,消息顺序对 provider 合法,持久化内容与 Agent 状态一致。(
message_end替换必须同 role;context改动后 Pi 自动恢复 prompt 和工具声明;边界提案先整体校验再追加)
第 7 条也解释了为什么不能在每个生命周期事件上都开放任意修改能力。
扩展能注册什么:工具、Provider、虚拟模型
工具。pi.registerTool() 注册的工具和内置工具在 Agent Loop 的工具分派中一视同仁。除了 schema 和 execute,工具还可以带 promptSnippet 和 promptGuidelines,直接把"说明书"注入 system prompt;带 renderCall / renderResult 定制 TUI 显示。1.5 的工具还有 exposure,控制模型怎样接触到它:direct(默认,直接声明给模型)、codemode(只能在 codemode 脚本里调用)、deferred(需要通过 tool_search 激活)等。工具也可以用 ctx.executeTool() 调用别的工具,嵌套调用同样经过参数校验、tool_call 和 tool_result,事件带 parentToolCallId。这一点很关键:权限扩展不会被"工具调用工具"绕过。注册同名工具还可以覆盖内置的 read、bash 等工具。
状态。扩展的状态建议放在工具结果的 details 里,并在 session_start 和 session_tree 时从当前分支(getBranch())重建,这样 fork 和 /tree 切换后状态自然正确。不想发给模型的持久数据用 pi.appendEntry()。往会话里注入内容有三档:sendMessage 发自定义消息(进入 LLM 上下文,可选择是否触发回复),sendUserMessage 模拟用户输入,appendEntry 只持久化。
Provider 与模型。pi.registerProvider() 可以接入新的模型服务(自定义 baseUrl、API、OAuth),也可以覆盖已有 Provider 的配置;pi.registerVirtualModel() 注册一个"虚拟模型",用户选的是它,路由器在每次请求时选择真实的物理模型和 thinking level,比如简单问题走小模型、难题走大模型。会话记录里,选择(model_change 条目)和实际分派(每条 assistant 消息上的 provider/model)是分开记录的。详见 模型与认证。
MCP 服务器。pi.registerMcpServer() 为当前会话注册 MCP 服务器,不持久化,每次加载都要重新注册。
安全边界:扩展就是你的代码
扩展运行在 Pi 进程内,拥有和 Pi 相同的操作系统权限,能读取 prompt、工具调用、文件、凭证和会话历史。Pi 没有内置沙箱,它选择的是"信任来源 + 项目信任门控":
- 项目级扩展(
.pi/extensions、项目 settings 里的 package)只有在项目被信任后才加载; - 用户应只安装自己信任来源的扩展和包。
要注意 tool_call 上的权限确认扩展只是策略,不是隔离:它能拦住模型发起的 rm -rf,但拦不住另一个扩展、或一个被模型诱导写出来的脚本。真正的隔离要靠操作系统或虚拟化边界。官方给的思路有两种:把整个 Pi 跑在容器或虚拟机里(最强),或者用扩展把内置工具的执行转发进隔离环境。后者的官方示例是 Gondolin:它用 createBashTool、createReadTool 等工厂函数配合自定义的 operations,重新注册同名工具,让 bash、read、write 等都在本地 Gondolin 微虚拟机里执行,宿主工作目录挂载到 /workspace。这种方式只隔离了工具,Pi 本身和其他扩展仍在边界之外。1.5 已经移除了早期基于 OS 原生机制的 sandbox/ 示例,推荐 Gondolin 或容器化方案。
常见追问
Pi 的 hook 和 Claude Code 的 hooks 有什么不同? Claude Code 的 hook 是外部进程,通过 stdin/stdout JSON 和退出码通信,语言无关、隔离性好,但每次调用都有进程开销,能力也受协议限制。Pi 的扩展是进程内 TS 函数,返回类型化对象,能力强、延迟低,还能注册工具、UI 和 Provider,代价是没有隔离、和宿主同生共死。
为什么 tool_call 出错要阻断,而普通事件出错只记录? 普通事件只做观察,出错时继续运行损失最小;安全 gate 出错时如果默认放行,就等于攻击者只要让检查逻辑抛异常就能绕过。所以安全相关的 gate 一律 fail-closed。
扩展能改 Agent Loop 本身吗? 不能。扩展只能通过 runtime hook 暴露的控制点施加影响,循环逻辑、状态归约都不对扩展开放。需要更深度的定制,就要用 SDK 自己组装 Agent,或者 fork。
多个扩展都改 tool_result,顺序谁定? 由扩展加载顺序和注册顺序决定,没有优先级字段。这让行为可预测,但也意味着组合扩展时需要用户自己注意顺序。
/reload 之后扩展状态怎么办? /reload 会触发 session_shutdown,重新执行工厂函数,再触发 session_start,闭包里的内存状态会丢失。所以扩展应在 session_start 时从会话条目中恢复状态,而不是依赖内存。
源码入口(v1.5):packages/agent/src/types.ts、packages/coding-agent/src/core/extensions/runner.ts、packages/coding-agent/src/core/extensions/types.ts、packages/coding-agent/src/core/extensions/loader.ts、packages/coding-agent/src/core/agent-session.ts、packages/coding-agent/docs/extensions.md