Skip to main content

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.jsonextensions 字段加额外路径。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_startresources_discoverpi.registerProvider() 这些早期调用之前完成。但不要在工厂函数里启动后台资源(进程、socket、文件监听等)。应该在 session_start 或具体事件里延迟启动,并在 session_shutdown 注册幂等的清理 handler。

生命周期全景

Pi 的事件按阶段划分:

启动阶段

  • project_trust { reason } — 决定项目是否受信任(仅 user/global 和 CLI 扩展可拦截)
  • session_start { reason }
  • resources_discover { reason }

用户输入处理流程

  1. Extension commands 优先 — 命中 /mycommand 直接跳过 input
  2. input — 可拦截 / transform / handled
  3. Skill 展开
  4. Template 展开
  5. before_agent_start — 注入消息、修改系统提示
  6. agent_startturn_startcontextbefore_provider_request → LLM 响应
  7. tool_calltool_execution_starttool_execution_endtool_result(并行模式交错)
  8. turn_endagent_end

会话切换

  • /new/resumesession_before_switchsession_shutdownsession_start (reason: "new"/"resume")
  • /fork/clonesession_before_forksession_shutdownsession_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
inputtransform / 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 的。

两个容易踩的坑

  1. 必须用 withFileMutationQueue(absolutePath, ...) 处理文件变更,才能与内置 edit / write 共享每文件队列,避免并行工具冲突
  2. 必须截断输出,默认 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 渲染

工具可以提供 renderCallrenderResult 自定义 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.uiUI 方法:select, confirm, input, notify, setStatus, setWidget
ctx.mode"tui" / "rpc" / "json" / "print"
ctx.hasUITUI 和 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 / withSession
  • ctx.fork(entryId, options?) — fork,position: "before" 默认,"at" 用于 clone
  • ctx.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 设计哲学的另一扇窗。