Extensions:TypeScript 扩展点全景
Pi 的 extensions 是 TypeScript 模块,不是 YAML/JSON 也不是 hook 脚本。它们能注册工具、命令、快捷键、provider、UI 渲染器,订阅 30+ 种生命周期事件——这套 API 是 Pi 「minimal core」哲学的具体兑现。本章拆解扩展点的全景。
什么是 Extension
Extension 是 default-export function (pi: ExtensionAPI) { ... } 这种形态的 TypeScript 模块。它能做的所有事可以归为三类:
- 订阅生命周期事件:监听 agent 跑过程中的各种钩子
- 注册新能力:工具、命令、provider、UI 渲染、快捷键
- 主动操作 session:发消息、切分支、提交 prompt
安全提示:Extension 以完整系统权限运行,可执行任意代码。只安装可信来源的扩展——这一点 Pi 在文档顶部用粗体强调。
目录约定
| 路径 | 作用域 |
|---|---|
~/.pi/agent/extensions/*.ts | 全局 |
~/.pi/agent/extensions/*/index.ts | 全局(子目录形态) |
.pi/extensions/*.ts | 项目本地 |
.pi/extensions/*/index.ts | 项目本地(子目录形态) |
可以在 settings.json 的 extensions 字段加额外路径。Auto-discovered 的扩展支持 /reload 热重载——改完文件不需要重启。
一个最小 Extension
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Extension loaded!", "info");
});
}
工厂函数也可以是 async——Pi 会在启动前 await,确保异步初始化在 session_start、resources_discover、pi.registerProvider() 这些早期调用之前完成。但不要在工厂函数里启动后台资源(进程、socket、文件监听等)。应该在 session_start 或具体事件里延迟启动,并在 session_shutdown 注册幂等的清理 handler。
生命周期全景
Pi 的事件按阶段划分:
启动阶段
project_trust { reason }— 决定项目是否受信任(仅 user/global 和 CLI 扩展可拦截)session_start { reason }resources_discover { reason }
用户输入处理流程
- Extension commands 优先 — 命中
/mycommand直接跳过 input input— 可拦截 / transform / handled- Skill 展开
- Template 展开
before_agent_start— 注入消息、修改系统提示agent_start→turn_start→context→before_provider_request→ LLM 响应tool_call→tool_execution_start→tool_execution_end→tool_result(并行模式交错)turn_end→agent_end
会话切换
/new、/resume:session_before_switch→session_shutdown→session_start(reason: "new"/"resume")/fork、/clone:session_before_fork→session_shutdown→session_start(reason: "fork")
关键事件速查
| 事件 | 能做什么 |
|---|---|
tool_call | 阻止执行、修改变量 event.input,用 isToolCallEventType 收窄类型 |
tool_result | 修改 tool 输出;tool_result / tool_execution_end 在并行模式下按完成顺序交错 |
before_agent_start | 注入持久消息;systemPromptOptions 提供结构化数据 |
context | 返回 { messages } 非破坏性修改 LLM 输入 |
before_provider_request | 返回替换 payload(最后一道修改机会) |
after_provider_response | 拿到 HTTP 状态/headers |
input | transform / handled / continue 三选一 |
user_bash | 拦截 ! / !! 命令 |
thinking_level_select | 推理等级变化通知 |
session_before_compact | 自定义 compaction 摘要(见第三章) |
session_before_tree | 自定义分支摘要 |
并行工具模式注意:tool_call 不保证看到同一 assistant message 中兄弟工具的结果。tool_execution_start 按 assistant source 顺序发出;tool_execution_end 按完成顺序发出;最终 toolResult 消息事件仍按 assistant source 顺序发出。
注册自定义工具
pi.registerTool(definition) 是最常用的 API:
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
const myTool = defineTool({
name: "my_tool",
label: "My Tool",
description: "Does something useful",
parameters: Type.Object({ input: Type.String() }),
execute: async (_toolCallId, params) => ({
content: [{ type: "text", text: `Result: ${params.input}` }],
details: {},
}),
});
pi.registerTool(myTool);
工具支持两个额外的 prompt 增强字段:
promptSnippet— 在「Available tools」列表里的一行说明promptGuidelines— 附加到「Guidelines」部分的工具特定条目
Guidelines 必须明确工具名(比如 Use my_tool when...),不要写 this tool——LLM 看到的字符串是直接拼到 system prompt 的。
两个容易踩的坑:
- 必须用
withFileMutationQueue(absolutePath, ...)处理文件变更,才能与内置edit/write共享每文件队列,避免并行工具冲突 - 必须截断输出,默认 50KB / 2000 行。Pi 提供
truncateHead(开头重要)或truncateTail(结尾重要),截断时告知 LLM 完整输出位置
注册同名工具可覆盖内置工具;renderCall / renderResult 缺失时分别继承内置版本,但 promptSnippet / promptGuidelines 不继承——需要重新声明。
注册 Provider(动态模型)
pi.registerProvider(name, config) 是 Pi 的杀手锏之一——扩展可以动态注册/覆盖 provider,无需重启:
pi.registerProvider("custom-anthropic", {
baseUrl: "https://api.example.com",
apiKey: "$MY_API_KEY", // 支持 $ENV_VAR / ${ENV_VAR} / !command 三种解析
api: "anthropic-messages",
headers: { "X-Custom": "value" },
authHeader: true, // 把 key 当 Authorization header
models: [
{ id: "custom-opus", name: "Custom Opus", contextWindow: 200000, maxTokens: 8192 }
],
});
apiKey 字段的语法复用 AuthStorage 的 key 语法:环境变量插值($VAR / ${VAR})、shell 命令执行(!command,stdout 作为 key)、转义符($$ → $、$! → !)。
工厂函数中的 registerProvider 调用会排队到启动时统一刷新;之后的调用立即生效,无需 /reload。
OAuth 也支持——配置 oauth 字段后,用户可以 /login 完成认证。
注册命令与快捷键
pi.registerCommand("review", {
description: "Review changed files",
handler: async (args, ctx) => {
const diff = await getGitDiff();
return { messages: [{ role: "user", content: `Review:\n${diff}`, timestamp: Date.now() }] };
},
});
pi.registerShortcut("ctrl+shift+r", {
description: "Quick review",
handler: async (ctx) => { /* ... */ },
});
同名命令保留全部并分配数字后缀——比如两个扩展都注册 /review,最后你会看到 /review:1 和 /review:2。 这是 Pi 处理扩展冲突的默认策略,跟 npm 包名冲突的处理一致。
注册 flag
pi.registerFlag("verbose", {
description: "Verbose output",
handler: async (value, ctx) => { /* ... */ },
});
注册后 CLI 会自动接受 --verbose,handler 在启动时被调用一次。
发送消息
pi.sendMessage 注入「不是用户输入」的消息——比如扩展主动推送的进度通知、主动触发的子任务:
pi.sendMessage(
[{ type: "text", text: "Background task done" }],
{ deliverAs: "steer", triggerTurn: true }
);
deliverAs 控制投递时机:
"steer"(默认)— 当前助手回合工具调用完成后插入"followUp"— agent 全部工作完成后投递"nextTurn"— 等下一条用户提示
triggerTurn: true 在 idle 状态下强制触发一轮 LLM 响应。
pi.sendUserMessage 发送「真实」用户消息——streaming 期间必须指定 deliverAs。
自定义 TUI 渲染
工具可以提供 renderCall 和 renderResult 自定义 TUI 显示。默认包在 Box 中。设置 renderShell: "self" 表示工具自渲染 shell——适合需要完全控制框架/背景的大预览(比如代码 diff、syntax highlight 块)。
也可以注册 pi.registerMessageRenderer(customType, renderer) 给扩展消息(custom / custom_message)做专门的渲染。
State Management
扩展经常需要在多次调用间保留状态。最佳实践:把状态存在 tool result 的 details 里,因为 details 会跟着 entry 进入 JSONL 树,分支切换时可以恢复。
pi.on("session_start", async (_event, ctx) => {
let items: string[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "message" && entry.message.role === "toolResult") {
if (entry.message.toolName === "my_tool") {
items = entry.message.details?.items ?? [];
}
}
}
// 现在 items 已经从 session 恢复
});
绝对不要把状态放在模块级变量里——分支切换、session 切换、/reload 都会丢。
ExtensionContext(ctx)
所有 handler 接收的第二个参数。常用字段:
| 字段 | 说明 |
|---|---|
ctx.ui | UI 方法:select, confirm, input, notify, setStatus, setWidget |
ctx.mode | "tui" / "rpc" / "json" / "print" |
ctx.hasUI | TUI 和 RPC 模式下为 true |
ctx.cwd | 当前工作目录 |
ctx.isProjectTrusted() | 项目本地信任状态 |
ctx.sessionManager | 只读 session 状态访问 |
ctx.modelRegistry / ctx.model | 模型 / API key 访问 |
ctx.signal | 当前 abort signal(active turn 时存在) |
ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages() | 控制流 |
ctx.shutdown() | 优雅关闭(Print 模式下 no-op) |
ctx.getContextUsage() | 上下文使用量 |
ctx.compact() | 触发压缩(带 onComplete / onError) |
ctx.getSystemPrompt() | 当前系统提示字符串 |
构造项目本地路径时使用 CONFIG_DIR_NAME 常量而非硬编码 .pi——让扩展在用户改配置目录时仍能正确工作。
ExtensionCommandContext(命令专属)
ctx 的扩展版,命令 handler 额外拿到:
ctx.getSystemPromptOptions()— 构建系统提示的结构化输入ctx.waitForIdle()— 等待 agent 流式完成ctx.newSession(options?)— 创建新会话,支持parentSession/setup/withSessionctx.fork(entryId, options?)— fork,position: "before"默认,"at"用于 clonectx.navigateTree(targetId, options?)— 树内导航ctx.switchSession(sessionPath, options?)— 切换会话
withSession footgun:原扩展实 例可能已运行 shutdown 清理;捕获的旧 pi / command ctx 的 session-bound 对象替换后会抛错。只用 withSession 传入的 ctx 来做 session-bound 工作。
ctx.reload() 执行 /reload 等价流程;await ctx.reload() 后代码仍在旧调用帧运行,应视为该 handler 的终点。
Errors vs Terminate
扩展抛错 → 标记 isError: true,LLM 会看到失败的工具结果。
返回 { terminate: true } → 跳过该批次的自动后续 LLM 调用。只在批次中所有最终化工具结果都终止时才生效——这是 Pi 避免「一半 terminate 一半不 terminate」的歧义。
几个常用 Pattern
Permission gate
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
if (!ok) return { block: true, reason: "Blocked by user" };
}
});
Input transform
pi.on("input", async (event, ctx) => {
if (event.text.startsWith("?quick "))
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
if (event.text === "ping") {
ctx.ui.notify("pong", "info");
return { action: "handled" };
}
return { action: "continue" };
});
StringEnum(Google 兼容)
必须用 StringEnum from @earendil-works/pi-ai 用于字符串枚举,Type.Union / Type.Literal 不兼容 Google API。
API 速查
// 注册
pi.registerTool(definition)
pi.registerCommand(name, options)
pi.registerShortcut(shortcut, options)
pi.registerFlag(name, options)
pi.registerProvider(name, config)
pi.registerMessageRenderer(customType, renderer)
pi.appendEntry(customType, data?)
// 主动操作
pi.sendMessage(message, options?)
pi.sendUserMessage(content, options?)
pi.setSessionName(name)
pi.getSessionName()
pi.setLabel(entryId, label)
// 事件总线
pi.events.on(eventName, handler)
pi.events.emit(eventName, payload)
// 工具控制
pi.getActiveTools()
pi.getAllTools()
pi.setActiveTools(names)
// 模型控制
pi.setModel(model)
pi.getThinkingLevel()
pi.setThinkingLevel(level)
下一章讲 Providers 与安全模型。Pi 的 provider 系统跟 extensions 紧密耦合——registerProvider 实际上就是「动态注入 provider 入口」;而 Pi 的安全模型——明确告知无沙箱、靠 trust 决策持久化、推荐 Gondolin 容器化——是理解 Pi 设计哲学的另一扇窗。