Pi Agent Extensions 开发与实现机制¶
Pi 的 extension 不是一个单纯的事件监听器,也不是传统意义上带独立沙箱的插件。它是一段与 Pi 同进程运行的 TypeScript/JavaScript 代码,通过统一 API 向 AgentSession 注册工具、命令、事件处理器、Provider 和 UI 能力。
从实现上看,完整的扩展系统由五层组成:
DefaultPackageManager解析扩展来源、作用域、启用状态和优先级。DefaultResourceLoader处理 project trust、两阶段加载、缓存和动态资源。loader.ts用jiti导入模块,执行 factory,生成静态注册表。ExtensionRunner把注册表绑定到当前 session,并按不同规则分发事件。AgentSession把 runner 接入 prompt、agent loop、工具、Provider、会话和 UI 生命周期。
本章重点解释这条实现链路。API 的最终类型定义以
packages/coding-agent/src/core/extensions/types.ts 为准,相关文件与符号位置见源码索引。
1. 总体架构¶
这里最重要的设计是“注册”和“运行”分离:
- factory 执行时主要构造注册表。
- session 创建后,runner 才把
pi.sendMessage()、pi.setModel()等动作绑定到真实对象。 - 每次 new/resume/fork 都会创建新的 session、resource loader 结果和 runner。
- 同一个扩展可以在 TUI、RPC、JSON、print 模式运行,UI 能力由 mode 注入。
2. 扩展模块的最小形态¶
扩展默认导出一个同步或异步 factory:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function extension(pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "bash") return;
const command = String(event.input.command ?? "");
if (!command.includes("rm -rf")) return;
if (!ctx.hasUI) {
return { block: true, reason: "非交互模式拒绝危险命令" };
}
const allowed = await ctx.ui.confirm("危险命令", command);
if (!allowed) {
return { block: true, reason: "用户拒绝执行" };
}
});
pi.registerTool({
name: "project_summary",
label: "Project Summary",
description: "Summarize the current project",
parameters: Type.Object({}),
async execute(_toolCallId, _params, signal, _onUpdate, ctx) {
const result = await pi.exec("git", ["status", "--short"], {
cwd: ctx.cwd,
signal,
});
return {
content: [{ type: "text", text: result.stdout || "Working tree clean" }],
details: { exitCode: result.code },
};
},
});
pi.registerCommand("extension-info", {
description: "Show extension runtime information",
handler: async (_args, ctx) => {
ctx.ui.notify(`mode=${ctx.mode}, cwd=${ctx.cwd}`);
},
});
}
factory 可以是 async,loader 会等待它完成。不过加载阶段还没有可用的 session runtime,不应该在 factory 顶层调用 sendMessage、setModel、setActiveTools 等 session 动作。
加载阶段可以安全完成的工作主要是:
pi.on(...)pi.registerTool(...)pi.registerCommand(...)pi.registerShortcut(...)pi.registerFlag(...)pi.registerMessageRenderer(...)pi.registerEntryRenderer(...)pi.registerProvider(...),此时先进入待处理队列pi.exec(...),它直接使用扩展加载时的 cwd 执行,不依赖 session bind
3. 资源来源、发现规则与优先级¶
3.1 扩展来源¶
Pi 可以从以下入口得到 extension:
| 来源 | 典型位置或入口 | 作用域 |
|---|---|---|
| 项目自动发现 | <cwd>/.pi/extensions/ |
project |
| 用户自动发现 | ~/.pi/agent/extensions/ |
user |
| settings 显式路径 | .pi/settings.json 或用户 settings.json 的 extensions |
project / user |
| Pi package | settings 的 packages,来源可为 npm、git、本地路径 |
project / user |
| CLI 临时扩展 | pi -e ./extension.ts |
temporary |
| SDK 路径 | additionalExtensionPaths |
temporary |
| SDK factory | extensionFactories |
inline temporary |
--no-extensions 会关闭常规 settings、package 和自动发现扩展,但 CLI 临时扩展仍然加载;SDK 的 inline factory 也由 SDK 显式控制。
3.2 目录发现¶
对 extensions/ 目录,自动发现只检查当前目录及下一层入口,不做无限递归:
extensions/
├── audit.ts # 直接加载
├── metrics.js # 直接加载
├── deploy/
│ └── index.ts # 作为一个扩展加载
└── package-style/
├── package.json # 若有 pi.extensions,按 manifest 加载
└── src/...
一个目录的入口解析顺序是:
package.json中非空的pi.extensions。index.ts。index.js。
如果目录本身已经命中入口,就把它作为一个扩展单元,不再扫描其下所有 .ts 文件。自动扫描会忽略隐藏项、node_modules,并应用 .gitignore、.ignore、.fdignore 规则。
3.3 资源优先级¶
Package manager 给每个路径附加 PathMetadata:
interface PathMetadata {
source: string;
scope: "user" | "project" | "temporary";
origin: "package" | "top-level";
baseDir?: string;
}
常规资源从高到低排序为:
- project settings 显式资源
- project 自动发现资源
- user settings 显式资源
- user 自动发现资源
- package 内资源
在 ResourceLoader 组装 extension 列表时,CLI/SDK 的临时路径放在常规资源之前,inline factory 放在路径扩展之后。路径还会经过 canonical path 去重。
优先级不仅影响显示顺序,也影响冲突结果,因为大部分 extension registry 都按加载顺序解析。
4. Project Trust 与两阶段加载¶
项目扩展拥有进程权限,因此 Pi 不能在用户确认信任之前执行 <cwd>/.pi/extensions/。为了解决“扩展本身可能想实现 trust UI”这个循环依赖,ResourceLoader 使用两阶段加载。
具体规则:
- bootstrap pass 强制
projectTrusted=false。 project_trust只由 user/global、CLI 和 inline 扩展参与。- handler 使用受限的
ProjectTrustContext,只能访问 cwd、mode、hasUI 和少量 dialog API。 - 按扩展和注册顺序串行执行;第一个返回
yes或no的 handler 胜出,undecided继续。 - handler 都不决定时,再查询 trust store、
defaultProjectTrust和内置确认 UI。 - 信任后,project settings、project package、
.pi/extensions、.agents/skills等资源才进入最终解析。 - bootstrap 已成功导入的扩展对象会被复用,避免 factory 在一次启动中执行两次;其余路径使用同一个
ExtensionRuntime继续加载。 - 最后重新按最终的 extension path 顺序排列,保证优先级稳定。
如果项目没有任何需要 trust 的动态资源,Pi 直接视为 trusted,不显示确认。
5. 模块加载:jiti、alias 与 cache¶
loader.ts 用 jiti 导入 extension,所以开发者可以直接编写 TypeScript,不需要先手工编译。
加载过程如下:
- 相对路径按当前 runtime cwd 解析,并规范 Unicode 空格。
- 为本次导入创建
jiti实例。 - Node/开发模式使用 alias,把 Pi 核心包解析到当前 workspace 或已安装包。
- Bun 单文件二进制使用
virtualModules,把编译进二进制的模块暴露给扩展。 - 以 default export 导入模块。
- 验证 default export 是函数。
- 创建空的
Extension对象和对应的ExtensionAPI。 await factory(api),把注册结果写入该Extension。- 导入或 factory 失败时记录到
LoadExtensionsResult.errors,继续加载其他扩展。
Pi 为扩展暴露的核心 import 包括:
@earendil-works/pi-coding-agent@earendil-works/pi-agent-core@earendil-works/pi-ai@earendil-works/pi-ai/compat@earendil-works/pi-ai/oauth@earendil-works/pi-ai/providers/all@earendil-works/pi-tuitypebox、typebox/compile、typebox/value
同时保留旧 @mariozechner/* 名称的兼容 alias。
jiti 的 moduleCache 被关闭,但 ResourceLoader 另有一层 factory cache:
- cache key 是解析后的 extension path。
- cache 绑定 cwd;cwd 改变会清空。
/reload会显式清空。- cache 保存的是 factory,不是已经注册完成的
Extension对象。 - project-trust 两阶段加载还会直接复用 bootstrap pass 的
Extension对象。
有第三方依赖的目录应带自己的 package.json 和 node_modules。通过 npm/git 安装的 Pi package 会安装其 runtime dependencies。
6. 内部数据结构¶
每个 factory 最终产生一个 Extension:
interface Extension {
path: string;
resolvedPath: string;
hidden?: boolean;
sourceInfo: SourceInfo;
handlers: Map<string, HandlerFn[]>;
tools: Map<string, RegisteredTool>;
messageRenderers: Map<string, MessageRenderer>;
entryRenderers: Map<string, EntryRenderer>;
commands: Map<string, RegisteredCommand>;
flags: Map<string, ExtensionFlag>;
shortcuts: Map<KeyId, ExtensionShortcut>;
}
这是一份“扩展自己的注册表”,不是全局 registry。好处是 Pi 始终知道某个工具、命令或错误来自哪个文件,并能在最后统一处理冲突。
所有 extension API 共享一个 ExtensionRuntime:
ExtensionRuntime
├── flagValues
├── pendingProviderRegistrations
├── pendingNativeProviderRegistrations
├── assertActive / invalidate
└── session actions
├── sendMessage / sendUserMessage / appendEntry
├── setSessionName / setLabel
├── getAllTools / setActiveTools / refreshTools
├── getCommands
└── setModel / getThinkingLevel / setThinkingLevel
共享 runtime 解决了两个问题:
- factory 创建出来的所有
pi.*闭包能在稍后统一绑定到真实 session。 - runtime 被 invalidate 后,旧
pi对象的 session 动作会一致失败,而不是悄悄写到错误会话。
7. 两阶段 API:注册期与绑定期¶
createExtensionAPI() 中有两类方法。
7.1 注册方法¶
注册方法直接写入当前 Extension 的 Map:
pi.on -> extension.handlers
pi.registerTool -> extension.tools
pi.registerCommand -> extension.commands
pi.registerShortcut -> extension.shortcuts
pi.registerFlag -> extension.flags + runtime.flagValues
pi.register*Renderer -> extension.*Renderers
同一个扩展内,Map 类型的同名注册通常是后一次覆盖前一次;事件 handler 使用数组,因此同一事件可以注册多个 handler。
registerTool() 在 runtime 已绑定后仍然可调用。它更新 extension registry 后调用 runtime.refreshTools(),因此动态工具可以在当前 session 立即进入工具 registry。
7.2 动作方法¶
动作方法闭包只保存共享 runtime 的引用:
sendMessage(message, options) {
runtime.assertActive();
runtime.sendMessage(message, options);
}
初始 runtime 中的动作是 throwing stub。AgentSession._bindExtensionCore() 调用 runner.bindCore() 后,才替换成真实实现。
Provider 是特殊情况:
- factory 中注册 Provider 时先进入 pending queue。
bindCore()得到ModelRegistry/ModelRuntime后按顺序 flush。- bind 完成后再次注册或注销 Provider 会立即生效,不需要
/reload。
8. Runner 如何绑定当前 Session¶
AgentSession 创建 runner 时注入:
- 最终
Extension[] - 共享
ExtensionRuntime - 当前 cwd
- 当前
SessionManager - 当前
ModelRegistry
随后完成三组绑定。
8.1 Core actions¶
bindCore() 将 API 动作指向当前 AgentSession:
- message API ->
sendCustomMessage()/sendUserMessage() - entry API ->
SessionManager.appendCustomEntry() - session metadata ->
setSessionName()/appendLabelChange() - tools -> AgentSession 的 tool registry
- model/thinking -> 当前 Agent state 和 ModelRuntime
- provider -> ModelRuntime 的 register/unregister
8.2 Context actions¶
事件 handler 的 ExtensionContext 不是静态快照。createContext() 用 getter 和函数在访问时读取:
- 当前 model、thinking level
- agent 是否 idle
- 当前 abort signal
- pending message
- context usage
- system prompt
- project trust 状态
每次访问前还会执行 assertActive()。
8.3 Mode bindings¶
TUI、RPC、JSON、print 模式通过 bindExtensions() 注入:
ExtensionUIContextExtensionMode- command-only session actions
- abort/shutdown handler
- extension error listener
然后触发 session_start,再触发 resources_discover。
9. ExtensionContext 与 CommandContext¶
普通事件和工具拿到 ExtensionContext。它适合当前 turn 内的动作,但不允许替换整个 session。
扩展命令拿到 ExtensionCommandContext,额外提供:
waitForIdle()newSession()fork()navigateTree()switchSession()reload()getSystemPromptOptions()
这个权限差异是刻意的。命令由用户显式发起,适合执行改变会话拓扑的操作;LLM 可直接调用的工具不应随意切换或重载 session。
createCommandContext() 不使用普通对象 spread,而是复制 property descriptor。这样 model、cwd、sessionManager 等 guarded getter 保持惰性,不会在创建 command context 时被固化成旧值。
10. 事件分发不是统一的 EventEmitter¶
Runner 始终按“extension 加载顺序 → 同一 extension 内 handler 注册顺序”串行执行,但不同事件有不同结果代数。
| 分发模型 | 事件 | 规则 |
|---|---|---|
| 首个决定 | project_trust |
undecided 继续,第一个 yes/no 返回 |
| 首个结果 | user_bash |
第一个非空结果返回 |
| 可取消 | session_before_switch/fork/compact/tree |
保存 handler 结果,遇到 cancel 立即短路 |
| 可阻断 | tool_call |
参数对象可原地修改;遇到 block 立即短路 |
| 可接管 | input |
transform 串行传递,handled 立即短路 |
| 串行变换 | context |
当前 messages 传给下一个 handler |
| 串行变换 | before_provider_request |
当前 payload 传给下一个 handler |
| 原地变换 | before_provider_headers |
handler 直接改共享 headers,返回值忽略 |
| 串行变换 | message_end |
replacement 传给下一个 handler,且 role 必须不变 |
| 字段累积 | tool_result |
content/details/isError/usage 按返回字段逐步覆盖 |
| 多结果聚合 | before_agent_start |
custom message 全部收集,system prompt 串行替换 |
| 路径聚合 | resources_discover |
汇总全部 skill/prompt/theme path,并保留来源 |
| 广播通知 | 其他 lifecycle 事件 | 调用全部 handler,不消费返回值 |
这种实现比一个通用 emit() 更复杂,但能明确表达每个插入点的安全语义。
11. 完整事件生命周期¶
11.1 启动¶
project_trust 仅在需要解析信任时,且只运行 bootstrap 扩展
session_start startup / reload / new / resume / fork
resources_discover startup / reload
resources_discover 发生在 session_start 之后。扩展返回的路径会进入 ResourceLoader,重新装载 skills、prompts、themes,并重建 system prompt。它不能再动态返回 extension path,从而避免运行中递归加载扩展。
11.2 Prompt 与 Agent¶
关键边界:
- extension command 在
input之前匹配,命中后不进入 agent loop。 input在 skill 和 prompt template 展开之前发生。before_agent_start每次用户启动一次 agent run 时发生,可改本次 system prompt。context在每次 LLM 调用前发生;一个 agent run 内调用工具后可能再次触发。before_provider_headers/request和after_provider_response以真实 provider 请求为粒度。message_update是流式高频事件,handler 不应执行重 I/O。agent_end后仍可能发生自动重试、压缩或 queued continuation;只有agent_settled表示整个 run 已无自动后续。
11.3 Session¶
主要会话事件包括:
session_before_switchsession_before_forksession_before_compactsession_before_treesession_compactsession_treesession_info_changedsession_shutdown
before 事件用于 cancel 或替换摘要结果;after 事件用于同步扩展状态和 UI。
12. Tool 扩展链路¶
12.1 注册与覆盖¶
扩展工具先保存为 ToolDefinition。AgentSession._refreshToolRegistry() 会:
- 创建内置工具定义。
- 收集 extension 注册工具和 SDK
customTools。 - 用自定义工具覆盖同名内置定义。
- 构建 prompt snippets 和 prompt guidelines。
- 通过
wrapRegisteredTools()转成AgentTool。 - 更新 active tool names 和 Agent state。
注册与激活是两回事:
pi.getAllTools()返回所有已配置定义和来源信息。pi.getActiveTools()返回当前暴露给模型的工具名。pi.setActiveTools()修改当前 active 集合并重建 system prompt。- 动态
registerTool()会刷新 registry;一个此前不存在的新工具会自动激活,但仍受 SDK 的 allow/exclude 配置约束。同名覆盖会保留原有 active 状态。
同名 extension 工具冲突时,ResourceLoader 记录诊断;ExtensionRunner.getAllRegisteredTools() 对扩展之间采用 first-wins。进入 AgentSession 后,自定义工具可覆盖同名内置工具。
12.2 参数校验与 tool_call¶
真实执行顺序是:
找到 AgentTool
-> prepareArguments(rawArgs)
-> TypeBox schema 校验
-> tool_call hook
-> execute(validatedArgs)
-> tool_result hook
-> 生成 ToolResultMessage
tool_call 的 event.input 就是已校验后的参数对象。handler 可以原地修改它,后续 handler 和 execute() 会看到修改结果。
Warning
tool_call 修改参数后不会再次 schema 校验。用于安全策略时应自行验证修改值,不要把未检查的外部输入写回 event.input。
tool_call handler 返回 { block: true, reason } 后,agent-core 生成一个 error tool result,真实工具不会执行。handler 自己抛错也会被 agent loop 转成 error tool result,因此该路径采用失败关闭。
12.3 执行与结果¶
扩展工具的 execute() 比低层 AgentTool.execute() 多一个 ExtensionContext。wrapper 在调用时通过 runner 创建当前 context。
工具结果分成:
content: 发给模型的 text/image。details: 给 renderer、session、导出和扩展状态恢复的结构化数据。usage: 工具内部调用模型时的用量。terminate: 当前批次所有工具都为 true 时,可停止自动 follow-up LLM call。
tool_result handler 可串行改写 content、details、isError 和 usage。未返回的字段保留前一个 handler 的值。
如果工具执行期间动态增加了 active tools,wrapper 会在结果中补充 addedToolNames,供支持 deferred tool loading 的模型感知新增工具。
12.4 并发¶
agent-core 支持同一 assistant turn 中并行执行工具。扩展可以用:
executionMode: "sequential"
强制某工具与其他调用顺序执行。若自定义工具会读改写文件,还应使用 coding-agent 暴露的 withFileMutationQueue(),按真实目标路径串行化完整的 read-modify-write 窗口。
13. Registry 冲突规则¶
不同 registry 的冲突规则并不相同:
| 注册项 | 冲突行为 |
|---|---|
| tool | 扩展间 first-wins 并记录诊断;自定义工具覆盖同名 built-in |
| flag | 汇总时 first-wins,重复名称记录诊断 |
| command | 全部保留;重名时 invocation name 变为 name:1、name:2 |
| shortcut | 后加载扩展覆盖先加载扩展并报警;保留级内置快捷键不可覆盖 |
| message renderer | first-wins |
| entry renderer | first-wins |
| event handler | 全部保留并按顺序执行 |
快捷键的保留列表包含 interrupt、clear、exit、submit、select confirm/cancel 等核心操作,避免扩展让终端失去基本可控性。非保留内置快捷键可以被 extension 覆盖,但会产生 warning。
14. 消息、持久化与分支安全状态¶
扩展有三种常见状态通道。
14.1 sendMessage¶
写入 CustomMessage,可显示并进入 LLM context。它支持:
steerfollowUpnextTurn- idle 时用
triggerTurn启动模型
14.2 appendEntry¶
写入 CustomEntry,持久化到 session JSONL,但不进入 LLM context。适合书签、UI 状态、扩展配置快照等。
14.3 Tool result details¶
如果状态必须正确跟随 branch/fork,优先把它保存在相关 tool result 的 details,然后在 session_start 中从当前 branch 重建:
export default function extension(pi: ExtensionAPI) {
let items: string[] = [];
pi.on("session_start", (_event, ctx) => {
items = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (
entry.type === "message" &&
entry.message.role === "toolResult" &&
entry.message.toolName === "todo"
) {
items = entry.message.details?.items ?? items;
}
}
});
// execute() 返回 details: { items: [...items] }
}
只保存在模块全局变量或闭包里的状态无法正确跟随 session tree,也会在 /reload 后丢失。
registerMessageRenderer() 渲染 CustomMessage;registerEntryRenderer() 渲染 CustomEntry。renderer 只影响表现,不改变持久化内容或 LLM context。
15. Session 替换、失效与清理¶
new/resume/fork 由 AgentSessionRuntime 完成,不是在旧 AgentSession 上原地换文件:
session_before_switch / session_before_fork
-> session_shutdown(old)
-> host 同步拆除旧 extension UI
-> old AgentSession.dispose()
-> old runner/runtime invalidate
-> 创建新 Settings/Resources/AgentSession/Runner
-> host rebind 新 session
-> session_start(new)
-> resources_discover(new)
-> withSession(freshCtx)
dispose() 会使旧 runner 和共享 runtime 失效。旧 pi、旧 command ctx 的 guarded API 再被访问时会抛出 stale context 错误。
会话替换后继续工作的正确方式是 withSession:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const kickoff = "Continue in the new session";
await ctx.newSession({
withSession: async (freshCtx) => {
await freshCtx.sendUserMessage(kickoff);
},
});
},
});
注意:
withSession在新 session 完成 bind、session_start和动态资源发现后调用。- callback 仍属于旧 factory 的 JavaScript 闭包,只应捕获 string、id、序列化配置等纯数据。
- 不要捕获旧
SessionManager、旧 UI component 或旧pi后在 callback 中使用。 - 长期 timer、watcher、socket 应在
session_shutdown中清理。
ctx.reload() 会发出 session_shutdown,重新加载 settings、extensions、skills、prompts、themes 和 context files,再用 reason: "reload" 发出 session_start/resources_discover。当前 command handler 的调用栈仍来自旧代码,因此应把 reload 当成 terminal operation:
await ctx.reload();
return;
16. 动态资源发现¶
扩展可以在 session_start 后提供附加资源:
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const extensionDir = dirname(fileURLToPath(import.meta.url));
pi.on("resources_discover", (_event, _ctx) => ({
skillPaths: [join(extensionDir, "skills")],
promptPaths: [join(extensionDir, "prompts")],
themePaths: [join(extensionDir, "themes")],
}));
Runner 聚合每个 handler 的路径,并记录提供该路径的 extension。AgentSession 将它们转换成 temporary PathMetadata,ResourceLoader 随后:
- 解析成当前 cwd 下的绝对路径。
- 重新加载对应资源类型。
- 为资源附加 extension source info。
- 重建 base system prompt。
返回的相对路径按当前 session cwd 解析,而不是按 extension 文件目录解析。因此 package 内的动态资源通常应像上例一样先构造绝对路径。
该事件在 startup 和 reload 时运行。返回路径应该是幂等的;ResourceLoader 会做 canonical path 去重,但扩展仍不应在 handler 中重复创建不可回收资源。
17. Provider 扩展¶
pi.registerProvider() 支持两种形式:
- 原生
pi-ai Provider name + ProviderConfig
ProviderConfig 可以:
- 覆盖已有 provider 的 base URL。
- 注册一组新模型。
- 提供 API key 表达式和自定义 headers。
- 提供自定义
streamSimple。 - 注册 OAuth login/refresh/getApiKey。
- 动态
refreshModels()。
加载期 Provider 先排队,runner.bindCore() 时 flush 到 ModelRuntime。运行期注册立即更新 registry,并刷新当前 model 引用;unregisterProvider() 会删除扩展 Provider,并恢复被覆盖的内置模型。
Provider 请求还可通过事件扩展:
before_provider_headers: 在认证、attribution header 组装后原地增删 header;值为null表示删除。before_provider_request: 串行替换最终 provider payload。after_provider_response: 在响应流被消费前读取 status 和 response headers。
18. UI 抽象与模式退化¶
扩展不直接依赖 InteractiveMode,而是面向 ExtensionUIContext:
| 模式 | ctx.mode |
ctx.hasUI |
行为 |
|---|---|---|---|
| TUI | tui |
true | 完整 dialog、widget、component、editor、footer/header、terminal input |
| RPC | rpc |
true | dialog/notify/status/string widget/title/editor 通过 JSONL bridge |
| JSON | json |
false | UI 为 no-op,agent event 输出 JSON |
print |
false | UI 为 no-op |
RPC 中只有可序列化能力可以跨进程:
select/confirm/input/editor发extension_ui_request并等待 response。notify/setStatus/setWidget/setTitle/set_editor_textfire-and-forget。- TUI component factory、custom footer/header、raw terminal input、自定义 editor component 不可用。
custom()在 RPC 中返回undefined。
因此:
if (ctx.mode === "tui") {
// TUI component、terminal input 等终端专属能力
} else if (ctx.hasUI) {
// TUI 和 RPC 都支持的 dialog
} else {
// JSON/print 的非交互策略
}
安全 extension 不应在 headless 模式把 no-op confirm 的 false 当成异常;应明确选择拒绝、使用默认值或跳过交互。
19. 错误隔离与失败策略¶
错误被分成几个层次:
| 位置 | 处理方式 |
|---|---|
| 模块 import / factory | 写入 LoadExtensionsResult.errors,继续加载其他扩展 |
| 普通 lifecycle handler | runner 捕获并发出 ExtensionError,继续下一个 handler |
project_trust |
记录错误并继续寻找下一个决定 |
input/context/result/provider 变换 |
记录错误,保留当前累计值并继续 |
message_end role 改变 |
拒绝 replacement,记录 extension error |
tool_call |
抛错进入 agent-core tool error result,真实工具不执行 |
| command handler | AgentSession 捕获并发出 extension error,命令仍视为已处理 |
tool execute() |
agent-core 转为 isError: true 的 tool result,反馈给模型 |
扩展错误由不同 mode 显示:
- TUI 通常进入诊断/通知界面。
- RPC 发出
extension_error。 - JSON/print 写标准错误输出或 mode 对应诊断。
安全相关逻辑应优先放在 tool_call,并在无法完成检查时 block。仅监听 tool_execution_start 不足以阻止执行。
20. Pi Package 分发¶
Pi package 可以同时分发 extensions、skills、prompts、themes:
{
"name": "@example/pi-team-workflow",
"keywords": ["pi-package"],
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
},
"pi": {
"extensions": ["./extensions"],
"skills": ["./skills"],
"prompts": ["./prompts"],
"themes": ["./themes"]
}
}
没有 pi manifest 时,Pi 使用同名约定目录。manifest 路径相对 package root,可使用 glob 和 !exclude。settings 中针对 package 的资源过滤器还支持 +exact-path 与 -exact-path。
Package 来源支持:
npm:@scope/pkg@versiongit:github.com/org/repo@refhttps://...ssh://...- 本地文件或目录
project package 只有在项目 trusted 后才会访问或安装。同一 package 同时出现在 user 和 project settings 时,project 配置优先;autoload:false 可作为 user package 上的 project delta。
Extension 与 Pi 同进程运行,package 安装不是权限隔离。安装第三方包前必须审查其源码和依赖。
21. 推荐的开发模式¶
21.1 目录结构¶
简单扩展用单文件即可;有依赖和测试时建议:
my-extension/
├── package.json
├── index.ts
├── src/
├── test/
└── README.md
开发时可临时加载:
pi -e ./my-extension/index.ts
稳定后再放入用户目录、项目目录或打成 Pi package。
21.2 生命周期清单¶
开发 extension 时至少确认:
- factory 顶层是否只做注册和轻量初始化。
- watcher、timer、socket 是否在
session_shutdown清理。 - 状态能否从当前 branch 的 session entry 重建。
- TUI 专属代码是否使用
ctx.mode === "tui"保护。 tool_call修改参数后是否自行验证。- 文件 mutation 是否考虑并行调用。
ctx.reload()后是否立即 return。- new/fork/switch 后是否只使用
withSession的 fresh context。 - command、tool、flag、shortcut 名称是否可能冲突。
- headless/RPC 模式是否有明确退化策略。
22. 关键源码阅读顺序¶
建议按以下顺序阅读实现:
packages/coding-agent/src/core/extensions/types.tspackages/coding-agent/src/core/extensions/loader.tspackages/coding-agent/src/core/resource-loader.tspackages/coding-agent/src/core/package-manager.tspackages/coding-agent/src/core/extensions/runner.tspackages/coding-agent/src/core/extensions/wrapper.tspackages/coding-agent/src/core/sdk.tspackages/coding-agent/src/core/agent-session.tspackages/coding-agent/src/core/agent-session-runtime.tspackages/coding-agent/src/modes/rpc/rpc-mode.ts
示例入口:
packages/coding-agent/examples/extensions/README.mdpackages/coding-agent/examples/extensions/permission-gate.tspackages/coding-agent/examples/extensions/dynamic-tools.tspackages/coding-agent/examples/extensions/dynamic-resources/index.tspackages/coding-agent/examples/extensions/reload-runtime.tspackages/coding-agent/examples/extensions/tool-override.tspackages/coding-agent/examples/extensions/subagent/
23. 设计总结¶
Pi extensions 的核心机制可以概括为:
- PackageManager 负责“哪些代码有资格加载”。
- ResourceLoader 负责“何时加载”,尤其是 project trust 和 reload。
- Loader 负责“把模块变成注册表”。
- Runtime 负责“把加载期闭包延迟绑定到当前 session”。
- Runner 负责“按事件语义组合多个扩展”。
- AgentSession 负责“把扩展插到真实产品生命周期里”。
- AgentSessionRuntime 负责“替换 session,并让旧上下文失效”。
- UIContext 负责“让相同扩展跨 TUI、RPC 和 headless 运行”。
它的强大之处不只在 hook 数量,而在于每个 hook 都有清晰的执行顺序、合并规则、生命周期和信任边界。