Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

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

扩展是什么,跑在哪里

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

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

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

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

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

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

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

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

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

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

为什么同时需要 Event 和 Hook

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

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

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

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

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

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

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

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

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

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

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

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

第二层:AgentSession 是桥

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

重要扩展点逐个说明

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

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

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

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

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

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

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

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

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

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

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

容易混淆的几对

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

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

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

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

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

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

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

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

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

设计 Hook 系统的检查清单

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

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

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

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

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

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

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

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

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

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

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

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

常见追问

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

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

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

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

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

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