扩展
pi 可以创建扩展。让它为你的使用场景构建一个。
扩展是扩展 pi 行为的 TypeScript 模块。它们可以订阅生命周期事件、注册可供 LLM 调用的自定义工具、添加命令等。
/reload 的放置位置: 将扩展放在
~/.pi/agent/extensions/(全局)或.pi/extensions/(项目内)中以实现自动发现。仅将pi -e ./path.ts用于快速测试。位于自动发现位置的扩展可通过/reload热重载。
关键能力:
- 自定义工具 - 通过
pi.registerTool()注册可供 LLM 调用的工具 - 事件拦截 - 阻止或修改工具调用、注入上下文、自定义上下文压缩
- 用户交互 - 通过
ctx.ui提示用户(select、confirm、input、notify) - 自定义 UI 组件 - 通过
ctx.ui.custom()使用支持键盘输入的完整 TUI 组件,用于复杂交互 - 自定义命令 - 通过
pi.registerCommand()注册如/mycommand之类的命令 - 会话持久化 - 通过
pi.appendEntry()存储在重启后仍然保留的状态 - 自定义渲染 - 控制工具调用/结果和消息在 TUI 中的显示方式
示例使用场景:
- 权限闸门(在
rm -rf、sudo等之前确认) - Git 检查点(每轮 stash,切换分支时恢复)
- 路径保护(阻止写入
.env、node_modules/) - 自定义上下文压缩(以你自己的方式总结对话)
- 对话摘要(参见
summarize.ts示例) - 交互式工具(提问、向导、自定义对话框)
- 有状态工具(待办列表、连接池)
- 外部集成(文件监视器、Webhook、CI 触发器)
- 等待时的游戏(参见
snake.ts示例)
参见 examples/extensions/ 中的可运行实现。
- 快速开始
- 扩展位置
- 可用导入
- 编写扩展
- 事件
- ExtensionContext
- ExtensionCommandContext
- ExtensionAPI 方法
- 状态管理
- 自定义工具
- 自定义 UI
- 错误处理
- 模式行为
- 示例参考
创建 ~/.pi/agent/extensions/my-extension.ts:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";
export default function (pi: ExtensionAPI) { // 响应事件 pi.on("session_start", async (_event, ctx) => { ctx.ui.notify("Extension loaded!", "info"); });
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" }; } });
// 注册自定义工具 pi.registerTool({ name: "greet", label: "Greet", description: "Greet someone by name", parameters: Type.Object({ name: Type.String({ description: "Name to greet" }), }), async execute(toolCallId, params, signal, onUpdate, ctx) { return { content: [{ type: "text", text: `Hello, ${params.name}!` }], details: {}, }; }, });
// 注册命令 pi.registerCommand("hello", { description: "Say hello", handler: async (args, ctx) => { ctx.ui.notify(`Hello ${args || "world"}!`, "info"); }, });}使用 --extension(或 -e)标志测试:
pi -e ./my-extension.ts安全: 扩展以你的完整系统权限运行,可以执行任意代码。只从你信任的来源安装。
扩展会从受信任的位置自动发现。项目内的 .pi/extensions 条目仅在项目受信任后才加载。
| 位置 | 作用范围 |
|---|---|
~/.pi/agent/extensions/*.ts |
全局(所有项目) |
~/.pi/agent/extensions/*/index.ts |
全局(子目录) |
.pi/extensions/*.ts |
项目内 |
.pi/extensions/*/index.ts |
项目内(子目录) |
通过 settings.json 添加更多路径:
{ "packages": [ "git:github.com/user/repo@v1" ], "extensions": [ "/path/to/local/extension.ts", "/path/to/local/extension/dir" ]}要通过 npm 或 git 以 Pi 包的形式分享扩展,请参阅 Pi 包。
| 包 | 用途 |
|---|---|
@earendil-works/pi-coding-agent |
扩展类型(ExtensionAPI、ExtensionContext、事件) |
typebox |
工具参数的 schema 定义 |
@earendil-works/pi-ai |
AI 实用工具(StringEnum,用于与 Google 兼容的枚举) |
@earendil-works/pi-tui |
用于自定义渲染的 TUI 组件 |
npm 依赖同样可用。在扩展旁边(或其父目录中)添加 package.json,运行 npm install,来自 node_modules/ 的导入会自动解析。
对于通过 pi install(npm 或 git)安装的发行版 Pi 包,运行时依赖必须放在 dependencies 中。包安装默认使用生产模式安装(npm install --omit=dev),因此 devDependencies 在运行时不可用;当配置了 npmCommand 时,git 包使用普通的 install 命令以兼容各种包装器。
Node.js 内置模块(node:fs、node:path 等)也可用。
扩展导出一个接收 ExtensionAPI 的默认工厂函数。该工厂可以是同步或异步的:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) { // 订阅事件 pi.on("event_name", async (event, ctx) => { // ctx.ui 用于用户交互 const ok = await ctx.ui.confirm("Title", "Are you sure?"); ctx.ui.notify("Done!", "info"); ctx.ui.setStatus("my-ext", "Processing..."); // 底部状态栏 ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // 编辑器上方的组件(默认) });
// 注册工具、命令、快捷键、标志 pi.registerTool({ ... }); pi.registerCommand("name", { ... }); pi.registerShortcut("ctrl+x", { ... }); pi.registerFlag("my-flag", { ... });}扩展通过 jiti 加载,因此 TypeScript 无需编译即可运行。
如果工厂返回 Promise,pi 会在继续启动之前等待其完成。这意味着异步初始化会在 session_start、resources_discover 之前,以及通过 pi.registerProvider() 排队的模型提供方注册生效之前完成。
异步工厂函数
Section titled “异步工厂函数”对于一次性启动工作(如获取远程配置或动态发现可用模型),请使用异步工厂。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default async function (pi: ExtensionAPI) { const response = await fetch("http://localhost:1234/v1/models"); const payload = (await response.json()) as { data: Array<{ id: string; name?: string; context_window?: number; max_tokens?: number; }>; };
pi.registerProvider("local-openai", { baseUrl: "http://localhost:1234/v1", apiKey: "$LOCAL_OPENAI_API_KEY", api: "openai-completions", models: payload.data.map((model) => ({ id: model.id, name: model.name ?? model.id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.context_window ?? 128000, maxTokens: model.max_tokens ?? 4096, })), });}这种模式使获取到的模型在正常启动期间以及 pi --list-models 中都可用。
长生命周期资源与关闭
Section titled “长生命周期资源与关闭”扩展工厂可能会在从未启动会话的调用中运行。不要在工厂中启动后台资源,如进程、套接字、文件监视器或计时器。
将后台资源的启动推迟到 session_start 或需要该资源的命令/工具/事件中。注册一个幂等的 session_shutdown 处理器,以关闭你启动的任何会话级资源。
单文件 - 最简单,适用于小型扩展:
~/.pi/agent/extensions/└── my-extension.ts带 index.ts 的目录 - 适用于多文件扩展:
~/.pi/agent/extensions/└── my-extension/ ├── index.ts # 入口点(导出默认函数) ├── tools.ts # 辅助模块 └── utils.ts # 辅助模块带依赖的包 - 适用于需要 npm 包的扩展:
~/.pi/agent/extensions/└── my-extension/ ├── package.json # 声明依赖和入口点 ├── package-lock.json ├── node_modules/ # npm install 之后 └── src/ └── index.ts{ "name": "my-extension", "dependencies": { "zod": "^3.0.0", "chalk": "^5.0.0" }, "pi": { "extensions": ["./src/index.ts"] }}在扩展目录中运行 npm install,之后来自 node_modules/ 的导入即可自动工作。
生命周期概述
Section titled “生命周期概述”pi starts │ ├─► project_trust (user/global and CLI extensions only, before project resources load) ├─► session_start { reason: "startup" } └─► resources_discover { reason: "startup" } │ ▼user sends prompt ─────────────────────────────────────────┐ │ │ ├─► (extension commands checked first, bypass if found) │ ├─► input (can intercept, transform, or handle) │ ├─► (skill/template expansion if not handled) │ ├─► before_agent_start (can inject message, modify system prompt) ├─► agent_start │ ├─► message_start / message_update / message_end │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ │ │ │ │ │ ├─► turn_start │ │ │ ├─► context (can modify messages) │ │ │ ├─► before_provider_headers (can mutate headers) | │ ├─► before_provider_request (can inspect or replace payload) │ ├─► after_provider_response (status + headers, before stream consume) │ │ │ │ │ │ LLM responds, may call tools: │ │ │ │ ├─► tool_execution_start │ │ │ │ ├─► tool_call (can block) │ │ │ │ ├─► tool_execution_update │ │ │ │ ├─► tool_result (can modify) │ │ │ │ └─► tool_execution_end │ │ │ │ │ │ │ └─► turn_end │ │ │ │ ├─► agent_end │ └─► agent_settled (no retry/compaction/follow-up left) │ │user sends another prompt ◄────────────────────────────────┘
/new (new session) or /resume (switch session) ├─► session_before_switch (can cancel) ├─► session_shutdown ├─► session_start { reason: "new" | "resume", previousSessionFile? } └─► resources_discover { reason: "startup" }
/fork or /clone ├─► session_before_fork (can cancel) ├─► session_shutdown ├─► session_start { reason: "fork", previousSessionFile } └─► resources_discover { reason: "startup" }
/name or pi.setSessionName() └─► session_info_changed
/compact or auto-compaction ├─► session_before_compact (can cancel or customize) └─► session_compact
/tree navigation ├─► session_before_tree (can cancel or customize) └─► session_tree
/model or Ctrl+P (model selection/cycling) ├─► thinking_level_select (if model change changes/clamps thinking level) └─► model_select
thinking level changes (settings, keybinding, pi.setThinkingLevel()) └─► thinking_level_select
exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM) └─► session_shutdownproject_trust
Section titled “project_trust”在 pi 决定是否信任带有动态配置(.pi 或 .agents/skills)的项目之前触发。它在启动期间以及会话替换(例如 /resume)进入当前进程中信任尚未解决的 cwd 时运行。只有用户/全局扩展和 CLI -e 扩展参与;项目内扩展在信任解决之后才会加载。
pi.on("project_trust", async (event, ctx) => { // event.cwd - 当前工作目录 // ctx 具有有限的信任上下文:cwd、mode、hasUI,以及 select/confirm/input/notify UI 辅助函数 if (await ctx.ui.confirm("Trust project?", event.cwd)) { return { trusted: "yes", remember: true }; } return { trusted: "undecided" };});project_trust 处理器必须返回 { trusted: "yes" | "no" | "undecided" }。返回 "yes" 或 "no" 的用户/全局或 CLI 扩展拥有决定权;第一个 yes/no 决定胜出,并会抑制内置的信任提示。使用 remember: true 持久化 yes/no 决定;否则它仅适用于当前进程。返回 "undecided" 以让后续处理器或内置信任流程决定。在提示之前检查 ctx.hasUI。如果没有处理器返回 yes/no,则继续正常的信任解析流程:已保存的 trust.json 决定优先应用,然后由 defaultProjectTrust 控制 pi 默认是询问、信任还是拒绝。
resources_discover
Section titled “resources_discover”在 session_start 之后触发,以便扩展可以贡献额外的技能、提示词和主题路径。启动路径使用 reason: "startup"。重载使用 reason: "reload"。
pi.on("resources_discover", async (event, _ctx) => { // event.cwd - 当前工作目录 // event.reason - "startup" | "reload" return { skillPaths: ["/path/to/skills"], promptPaths: ["/path/to/prompts"], themePaths: ["/path/to/themes"], };});会话存储的内部机制和 SessionManager API 请参阅 会话格式。
session_start
Section titled “session_start”在会话启动、加载或重载时触发。
pi.on("session_start", async (event, ctx) => { // event.reason - "startup" | "reload" | "new" | "resume" | "fork" // event.previousSessionFile - 存在于 "new"、"resume" 和 "fork" 中 ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");});session_info_changed
Section titled “session_info_changed”当通过 /name、RPC 或 pi.setSessionName() 设置当前会话的显示名称时触发。
pi.on("session_info_changed", async (event, ctx) => { // event.name - 当前规范化名称,若已清除则为 undefined ctx.ui.notify(`Session renamed: ${event.name ?? "(none)"}`, "info");});session_before_switch
Section titled “session_before_switch”在启动新会话(/new)或切换会话(/resume)之前触发。
pi.on("session_before_switch", async (event, ctx) => { // event.reason - "new" or "resume" // event.targetSessionFile - 我们正在切换到的会话(仅用于 "resume")
if (event.reason === "new") { const ok = await ctx.ui.confirm("Clear?", "Delete all messages?"); if (!ok) return { cancel: true }; }});在成功的切换或新会话操作之后,pi 会为旧的扩展实例发出 session_shutdown,为新会话重载并重新绑定扩展,然后以 reason: "new" | "resume" 和 previousSessionFile 发出 session_start。在 session_shutdown 中做清理工作,然后在 session_start 中重建任何内存中的状态。
session_before_fork
Section titled “session_before_fork”在通过 /fork 分叉或通过 /clone 克隆时触发。
pi.on("session_before_fork", async (event, ctx) => { // event.entryId - 所选条目的 ID // event.position - /fork 时为 "before",/clone 时为 "at" return { cancel: true }; // 取消 fork/clone // OR return { skipConversationRestore: true }; // 保留用于未来的会话恢复控制});在成功的分叉或克隆之后,pi 会为旧的扩展实例发出 session_shutdown,为新会话重载并重新绑定扩展,然后以 reason: "fork" 和 previousSessionFile 发出 session_start。在 session_shutdown 中做清理工作,然后在 session_start 中重建任何内存中的状态。
session_before_compact / session_compact
Section titled “session_before_compact / session_compact”在上下文压缩时触发。详情请参阅 上下文压缩。
pi.on("session_before_compact", async (event, ctx) => { const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// reason - "manual" (/compact), "threshold", or "overflow" // willRetry - 中止的轮次是否在上下文压缩后重试(溢出恢复)
// 取消: return { cancel: true };
// 自定义摘要: return { compaction: { summary: "...", firstKeptEntryId: preparation.firstKeptEntryId, tokensBefore: preparation.tokensBefore, // usage: summaryResponse.usage, // 可选;计入会话总数 } };});
pi.on("session_compact", async (event, ctx) => { // event.compactionEntry - 保存的上下文压缩 // event.fromExtension - 是否由扩展提供 // event.reason - "manual" (/compact), "threshold", or "overflow" // event.willRetry - 中止的轮次是否在上下文压缩后重试(溢出恢复)});session_before_tree / session_tree
Section titled “session_before_tree / session_tree”在 /tree 导航时触发。树形导航的概念请参阅 会话。
pi.on("session_before_tree", async (event, ctx) => { const { preparation, signal } = event; return { cancel: true }; // 或者提供自定义摘要: return { summary: { summary: "...", // usage: summaryResponse.usage, // 可选;计入会话总数 details: {}, }, };});
pi.on("session_tree", async (event, ctx) => { // event.newLeafId, oldLeafId, summaryEntry, fromExtension});session_shutdown
Section titled “session_shutdown”在已启动的会话运行时被拆除之前触发。用它来清理从 session_start 或其他会话级钩子中打开的资源。
pi.on("session_shutdown", async (event, ctx) => { // event.reason - "quit" | "reload" | "new" | "resume" | "fork" // event.targetSessionFile - 会话替换流程中的目标会话 // 清理、保存状态等});before_agent_start
Section titled “before_agent_start”在用户提交提示词之后、智能体循环开始之前触发。可以注入消息和/或修改系统提示词。
pi.on("before_agent_start", async (event, ctx) => { // event.prompt - 用户的提示词文本 // event.images - 附加的图像(如有) // event.systemPrompt - 当前处理器的串联系统提示词 // (包含来自更早的 before_agent_start 处理器的修改) // event.systemPromptOptions - 用于构建系统提示词的结构化选项 // .customPrompt - 任何自定义系统提示词(来自 --system-prompt、SYSTEM.md 或自定义模板) // .selectedTools - 当前在提示词中激活的工具 // .toolSnippets - 每个工具的一行描述 // .promptGuidelines - 自定义准则要点 // .appendSystemPrompt - 来自 --append-system-prompt 标志的文本 // .cwd - 工作目录 // .contextFiles - AGENTS.md 文件和其他已加载的上下文文件 // .skills - 已加载的技能
return { // 注入一条持久消息(存储在会话中,发送给 LLM) message: { customType: "my-extension", content: "Additional context for the LLM", display: true, }, // 替换本轮的系统提示词(跨扩展串联) systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...", };});systemPromptOptions 字段让扩展可以访问 Pi 用于构建系统提示词的相同结构化数据。这使你无需重新发现资源或重新解析标志,就能检查 Pi 已加载的内容——自定义提示词、准则、工具摘要、上下文文件、技能。当你的扩展需要对系统提示词进行深入而明智的修改,同时尊重用户提供的配置时,请使用它。
在 before_agent_start 内部,event.systemPrompt 和 ctx.getSystemPrompt() 都反映截至当前处理器的串联系统提示词。后续的 before_agent_start 处理器仍可再次修改它。
agent_start / agent_end / agent_settled
Section titled “agent_start / agent_end / agent_settled”agent_start 在底层智能体运行开始时触发。agent_end 在该运行结束时触发,但 Pi 可能仍会自动重试、自动压缩并重试,或继续处理排队的后续消息。对于需要知道 Pi 不会自动继续运行的状态集成,请使用 agent_settled。
pi.on("agent_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => { // event.messages - 本次底层运行的消息});
pi.on("agent_settled", async (_event, ctx) => { // 此处 ctx.isIdle() 为 true,除非另一个扩展启动了新的运行。});turn_start / turn_end
Section titled “turn_start / turn_end”每轮(一次 LLM 响应 + 工具调用)触发。
pi.on("turn_start", async (event, ctx) => { // event.turnIndex, event.timestamp});
pi.on("turn_end", async (event, ctx) => { // event.turnIndex, event.message, event.toolResults});message_start / message_update / message_end
Section titled “message_start / message_update / message_end”在消息生命周期更新时触发。
message_start和message_end针对用户、助手和 toolResult 消息触发。message_update针对助手的流式更新触发。message_end处理器可以返回{ message }来替换最终确定的消息。替换必须保持相同的role。
pi.on("message_start", async (event, ctx) => { // event.message});
pi.on("message_update", async (event, ctx) => { // event.message // event.assistantMessageEvent(逐 token 的流式事件)});
pi.on("message_end", async (event, ctx) => { if (event.message.role !== "assistant") return;
return { message: { ...event.message, usage: { ...event.message.usage, cost: { ...event.message.usage.cost, total: 0.123, }, }, }, };});tool_execution_start / tool_execution_update / tool_execution_end
Section titled “tool_execution_start / tool_execution_update / tool_execution_end”在工具执行生命周期更新时触发。
在并行工具模式下:
tool_execution_start在预检阶段按助手的源顺序发出tool_execution_update事件可能在不同工具间交错tool_execution_end在每个工具完成后按工具完成顺序发出- 最终的
toolResult消息事件稍后仍按助手的源顺序发出
pi.on("tool_execution_start", async (event, ctx) => { // event.toolCallId, event.toolName, event.args});
pi.on("tool_execution_update", async (event, ctx) => { // event.toolCallId, event.toolName, event.args, event.partialResult});
pi.on("tool_execution_end", async (event, ctx) => { // event.toolCallId, event.toolName, event.result, event.isError});context
Section titled “context”在每次 LLM 调用之前触发。非破坏性地修改消息。消息类型请参阅 会话格式。
pi.on("context", async (event, ctx) => { // event.messages - 深拷贝,可安全修改 const filtered = event.messages.filter(m => !shouldPrune(m)); return { messages: filtered };});before_provider_headers
Section titled “before_provider_headers”在出站 HTTP 头组装完成后触发。用它来添加、覆盖或移除请求头。
处理器就地修改 event.headers。将某个键设置为字符串以添加或覆盖它,设置为 null 以删除它。
pi.on("before_provider_headers", (event, ctx) => { // 添加或覆盖 — 例如用于网关追踪/归因的会话 ID event.headers["x-session-id"] = ctx.sessionManager.getSessionId();
// 删除 pi 为本次调用添加的跟踪头 event.headers["X-OpenRouter-Title"] = null;});每次模型提供方请求运行一次;重试时复用相同的请求头,而不是重新触发该钩子。
before_provider_request
Section titled “before_provider_request”在构建好模型提供方特有的 payload 之后、请求发送之前触发。处理器按扩展加载顺序运行。返回 undefined 保持 payload 不变。返回任何其他值都会替换 payload,供后续处理器和实际请求使用。
此钩子可以改写模型提供方级别的系统指令,或完全移除它们。这些 payload 级别的更改不会反映在 ctx.getSystemPrompt() 中,它报告的是 Pi 的系统提示词字符串,而不是最终序列化的模型提供方 payload。
pi.on("before_provider_request", (event, ctx) => { console.log(JSON.stringify(event.payload, null, 2));
// 可选:替换 payload // return { ...event.payload, temperature: 0 };});这主要用于调试模型提供方的序列化和缓存行为。
after_provider_response
Section titled “after_provider_response”在收到 HTTP 响应之后、消费其流式响应体之前触发。处理器按扩展加载顺序运行。
pi.on("after_provider_response", (event, ctx) => { // event.status - HTTP 状态码 // event.headers - 规范化的响应头 if (event.status === 429) { console.log("rate limited", event.headers["retry-after"]); }});请求头的可用性取决于模型提供方和传输层。抽象 HTTP 响应的模型提供方可能不暴露请求头。
model_select
Section titled “model_select”当模型通过 /model 命令、模型循环切换(Ctrl+P)或会话恢复发生变化时触发。
pi.on("model_select", async (event, ctx) => { // event.model - 新选择的模型 // event.previousModel - 之前的模型(首次选择时为 undefined) // event.source - "set" | "cycle" | "restore"
const prev = event.previousModel ? `${event.previousModel.provider}/${event.previousModel.id}` : "none"; const next = `${event.model.provider}/${event.model.id}`;
ctx.ui.notify(`Model changed (${event.source}): ${prev} -> ${next}`, "info");});当活动模型变化时,用它来更新 UI 元素(状态栏、底部栏)或执行特定于模型的初始化。
thinking_level_select
Section titled “thinking_level_select”当思考级别变化时触发。这仅为通知;处理器的返回值会被忽略。
pi.on("thinking_level_select", async (event, ctx) => { // event.level - 新选择的思考级别 // event.previousLevel - 之前的思考级别
ctx.ui.setStatus("thinking", `thinking: ${event.level}`);});当 pi.setThinkingLevel()、模型变化或内置思考级别控件改变活动思考级别时,用它来更新扩展 UI。
tool_call
Section titled “tool_call”在 tool_execution_start 之后、工具执行之前触发。可以阻止。 使用 isToolCallEventType 收窄类型并获取类型化的输入。
在 tool_call 运行之前,pi 会等待先前发出的智能体事件通过 AgentSession 完成排空。这意味着 ctx.sessionManager 在当前的助手工具调用消息中是最新的。
在默认的并行工具执行模式下,来自同一条助手消息的兄弟工具调用会先顺序预检,再并发执行。tool_call 不保证能在 ctx.sessionManager 中看到来自同一条助手消息的兄弟工具结果。
event.input 是可变的。就地修改它,以便在执行前修补工具参数。
行为保证:
- 对
event.input的修改会影响实际的工具执行 - 更晚的
tool_call处理器会看到更早的处理器所做的修改 - 你的修改之后不会执行重新校验
tool_call的返回值通过{ block: true, reason?: string, terminate?: boolean }控制阻止行为terminate仅适用于被阻止的调用;只有当批次中每个最终确定的结果都是终止性的时,智能体才会提前停止
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => { // event.toolName - "bash"、"read"、"write"、"edit" 等 // event.toolCallId // event.input - 工具参数(可修改)
// 内置工具:无需类型参数 if (isToolCallEventType("bash", event)) { // event.input is { command: string; timeout?: number } event.input.command = `source ~/.profile\n${event.input.command}`;
if (event.input.command.includes("rm -rf")) { return { block: true, reason: "Dangerous command", terminate: true }; } }
if (isToolCallEventType("read", event)) { // event.input is { path: string; offset?: number; limit?: number } console.log(`Reading: ${event.input.path}`); }});为自定义工具输入类型化
Section titled “为自定义工具输入类型化”自定义工具应导出其输入类型:
export type MyToolInput = Static<typeof myToolSchema>;使用带显式类型参数的 isToolCallEventType:
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";import type { MyToolInput } from "my-extension";
pi.on("tool_call", (event) => { if (isToolCallEventType<"my_tool", MyToolInput>("my_tool", event)) { event.input.action; // 已类型化 }});tool_result
Section titled “tool_result”在工具执行完成之后、tool_execution_end 以及最终的 tool result 消息事件发出之前触发。可以修改结果。
在并行工具模式下,tool_result 和 tool_execution_end 可能按工具完成顺序交错,而最终的 toolResult 消息事件稍后仍按助手的源顺序发出。
tool_result 处理器像中间件一样串联:
- 处理器按扩展加载顺序运行
- 每个处理器都看到前一个处理器修改后的最新结果
- 处理器可以返回部分补丁(
content、details、isError或usage);省略的字段保持当前值
在处理器内部使用 ctx.signal 进行嵌套的异步工作。这使 Esc 可以取消模型调用、fetch() 以及扩展启动的其他支持中止的操作。
import { isBashToolResult } from "@earendil-works/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => { // event.toolName, event.toolCallId, event.input // event.content, event.details, event.isError, event.usage
if (isBashToolResult(event)) { // event.details 被类型化为 BashToolDetails }
const response = await fetch("https://example.com/summarize", { method: "POST", body: JSON.stringify({ content: event.content }), signal: ctx.signal, });
// 修改结果: return { content: [...], details: {...}, isError: false, usage: nestedModelUsage };});用户 Bash 事件
Section titled “用户 Bash 事件”user_bash
Section titled “user_bash”当用户执行 ! 或 !! 命令时触发。可以拦截。
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
pi.on("user_bash", (event, ctx) => { // event.command - bash 命令 // event.excludeFromContext - 带 !! 前缀时为 true // event.cwd - 工作目录
// 选项 1:提供自定义操作(例如 SSH) return { operations: remoteBashOps };
// 选项 2:包装 pi 的内置本地 bash 后端 const local = createLocalBashOperations(); return { operations: { exec(command, cwd, options) { return local.exec(`source ~/.profile\n${command}`, cwd, options); } } };
// 选项 3:完全替换 - 直接返回结果 return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };});当收到用户输入时触发,在扩展命令检查之后、技能与提示词模板展开之前。该事件看到的是原始输入文本,因此此时 /skill:foo 和 /template 尚未展开。
处理顺序:
- 首先检查扩展命令(
/cmd)——若匹配,则运行对应处理函数并跳过 input 事件 - 触发
input事件——可拦截、改写或处理 - 若未被处理:技能命令(
/skill:name)展开为技能内容 - 若未被处理:提示词模板(
/template)展开为模板内容 - 智能体处理开始(
before_agent_start等)
pi.on("input", async (event, ctx) => { // event.text - 原始输入(技能/提示词模板展开之前) // event.images - 附加的图片(如有) // event.source - "interactive"(用户输入)、"rpc"(API)或 "extension"(通过 sendUserMessage) // event.streamingBehavior - "steer" | "followUp" | undefined // idle 时为 undefined,"steer" 表示流式传输中途的打断, // "followUp" 表示排队等待智能体完成的消息
// 改写:在展开前重写输入 if (event.text.startsWith("?quick ")) return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
// 处理:不经 LLM 直接响应(扩展显示自己的反馈) if (event.text === "ping") { ctx.ui.notify("pong", "info"); return { action: "handled" }; }
// 按来源路由:跳过扩展注入消息的处理 if (event.source === "extension") return { action: "continue" };
// 在展开前拦截技能命令 if (event.text.startsWith("/skill:")) { // 可改写、拦截或放行 }
return { action: "continue" }; // 默认:放行并继续展开});结果:
continue- 原样放行(处理函数未返回任何值时默认)transform- 修改文本/图片,然后继续展开handled- 完全跳过智能体(第一个返回该值的处理函数生效)
改写操作会在多个处理函数之间串联执行。关于感知 streamingBehavior 的路由,参见 input-transform.ts 和 input-transform-streaming.ts。
ExtensionContext
Section titled “ExtensionContext”所有处理函数都会收到 ctx: ExtensionContext。
ctx.ui
Section titled “ctx.ui”用于用户交互的 UI 方法。完整细节参见自定义 UI。
ctx.mode
Section titled “ctx.mode”当前运行模式:"tui"、"rpc"、"json" 或 "print"。用 ctx.mode === "tui" 来守护仅终端可用的特性,例如 custom()、组件工厂、终端输入以及直接的 TUI 渲染。
ctx.hasUI
Section titled “ctx.hasUI”在 TUI 和 RPC 模式下为 true,在打印模式(-p)和 JSON 模式下为 false。用该属性来守护同时适用于 TUI 和 RPC 模式的对话框方法(select、confirm、input、editor)以及即发即忘方法(notify、setStatus、setWidget、setTitle、setEditorText)。在 RPC 模式下,某些 TUI 专属方法会变成空操作(no-op)或返回默认值(参见 rpc.md)。
ctx.cwd
Section titled “ctx.cwd”当前工作目录。
构造项目本地配置路径时,应使用 CONFIG_DIR_NAME 而不是硬编码 .pi。重新品牌化的发行版可能使用不同的配置目录名。
import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent";import { join } from "node:path";
export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, "my-extension.json"); // ... });}ctx.isProjectTrusted()
Section titled “ctx.isProjectTrusted()”返回当前会话上下文是否启用了项目本地信任。这包括临时信任决定和 CLI 信任覆盖,而不仅仅是全局信任存储中已保存的决定。
在读取仅应针对受信任项目生效的项目本地扩展配置之前,请使用该方法。
ctx.sessionManager
Section titled “ctx.sessionManager”对会话状态的只读访问。完整的 SessionManager API 和条目类型参见会话格式。
对于 tool_call,该状态会在处理函数运行之前通过当前助手消息同步。在并行工具执行模式下,仍不保证会包含来自同一助手消息的其他工具结果。
ctx.sessionManager.getEntries() // 所有条目ctx.sessionManager.getBranch() // 当前分支ctx.sessionManager.buildContextEntries() // 应用了上下文压缩的活动分支条目ctx.sessionManager.getLeafId() // 当前叶子条目 IDctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels
Section titled “ctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels”用于访问模型、模型提供方和已解析的身份验证。ctx.modelRegistry.getProvider(id) 返回有效的 pi-ai 提供方,而 getProviderAuth(id) 无需加载模型即可解析其当前的 API 密钥、请求头、基础 URL 以及提供方作用域的环境。ctx.model 是当前活动模型,ctx.thinkingLevel 是其当前生效的思考级别。
ctx.scopedModels 是限定到当前会话的只读模型列表——即 /scoped-models 命令显示的那组模型。它在会话开始时根据 --models CLI 标志和 enabledModels 设置解析(用 minimatch 按 provider/modelId 或裸 modelId 与可用目录匹配)。未配置限定范围时为空,意味着所有可用模型都可使用。每个条目为 { model, thinkingLevel? },其中 thinkingLevel 仅在模式将其固定时才会设置(例如 anthropic/*:high)。用它来填充与内置选择器一致的模型选择器,而不是通过 ctx.modelRegistry.getAvailable() 枚举整个目录。
ctx.signal
Section titled “ctx.signal”当前的智能体中止信号,在没有智能体回合进行时为 undefined。
将其用于扩展处理函数启动的、支持中止的嵌套任务,例如:
fetch(..., { signal: ctx.signal })- 接受
signal的模型调用 - 接受
AbortSignal的文件或进程辅助函数
ctx.signal 通常在活跃回合事件(如 tool_call、tool_result、message_update 和 turn_end)期间定义。在空闲或非回合上下文中(如会话事件、扩展命令,以及 pi 空闲时触发的快捷键)通常是 undefined。
pi.on("tool_result", async (event, ctx) => { const response = await fetch("https://example.com/api", { method: "POST", body: JSON.stringify(event), signal: ctx.signal, });
const data = await response.json(); return { details: data };});ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()
Section titled “ctx.isIdle() / ctx.abort() / ctx.hasPendingMessages()”流程控制辅助方法。当 Pi 正在处理智能体运行、自动重试、自动压缩重试或排队中的继续操作时,ctx.isIdle() 返回 false。
ctx.shutdown()
Section titled “ctx.shutdown()”请求 Pi 优雅关闭。
- 交互模式: 延迟到智能体进入空闲状态(在处理完所有排队的转向(steering)和后续(follow-up)消息之后)。
- RPC 模式: 延迟到下一个空闲状态(在完成当前命令响应之后、等待下一条命令时)。
- 打印模式: 空操作。所有提示处理完毕后进程自动退出。
退出前向所有扩展发出 session_shutdown 事件。可在所有上下文中使用(事件处理函数、工具、命令、快捷键)。
pi.on("tool_call", (event, ctx) => { if (isFatal(event.input)) { ctx.shutdown(); }});ctx.getContextUsage()
Section titled “ctx.getContextUsage()”返回当前活动模型的上下文使用情况。尽可能使用上一次助手消息的使用数据,然后估算尾部消息的 token 数。
const usage = ctx.getContextUsage();if (usage && usage.tokens > 100_000) { // ...}ctx.compact()
Section titled “ctx.compact()”触发上下文压缩而无需等待其完成。用 onComplete 和 onError 处理后续操作。
ctx.compact({ customInstructions: "Focus on recent changes", onComplete: (result) => { ctx.ui.notify("Compaction completed", "info"); }, onError: (error) => { ctx.ui.notify(`Compaction failed: ${error.message}`, "error"); },});ctx.getSystemPrompt()
Section titled “ctx.getSystemPrompt()”返回 Pi 当前的系统提示词字符串。
- 在
before_agent_start期间,这反映当前回合到目前为止已进行的系统提示词链式更改。 - 不包含之后
context消息的变更。 - 不包含
before_provider_request的载荷重写。 - 如果后加载的扩展在你的扩展之后运行,它们仍然可以更改最终发送的内容。
pi.on("before_agent_start", (event, ctx) => { const prompt = ctx.getSystemPrompt(); console.log(`System prompt length: ${prompt.length}`);});ExtensionCommandContext
Section titled “ExtensionCommandContext”命令处理函数会收到 ExtensionCommandContext,它在 ExtensionContext 的基础上扩展了会话控制方法。这些方法只在命令中可用,因为如果从事件处理函数调用它们可能导致死锁。
ctx.getSystemPromptOptions()
Section titled “ctx.getSystemPromptOptions()”返回 Pi 当前用于构建系统提示词的基础输入。
const options = ctx.getSystemPromptOptions();const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];其结构与可变性与 before_agent_start 的 event.systemPromptOptions 相同:自定义提示词、活动工具、工具片段、提示词指南、追加的系统提示词文本、cwd、已加载的上下文文件和已加载的技能。它可能包含完整的上下文文件内容,因此请将其视为敏感的扩展本地数据,避免通过命令列表、日志或自动补全元数据暴露。
这里报告的是当前的基础提示词输入。它不包含每回合 before_agent_start 的链式系统提示词更改、之后 context 事件的消息变更,或 before_provider_request 的载荷重写。
ctx.waitForIdle()
Section titled “ctx.waitForIdle()”等待智能体完全空闲,包括自动重试、自动压缩重试和排队中的继续操作:
pi.registerCommand("my-cmd", { handler: async (args, ctx) => { await ctx.waitForIdle(); // 智能体现在已空闲,可以安全地修改会话 },});ctx.newSession(options?)
Section titled “ctx.newSession(options?)”创建新会话:
const parentSession = ctx.sessionManager.getSessionFile();const kickoff = "Continue in the replacement session";
const result = await ctx.newSession({ parentSession, setup: async (sm) => { sm.appendMessage({ role: "user", content: [{ type: "text", text: "Context from previous session..." }], timestamp: Date.now(), }); }, withSession: async (ctx) => { // 这里只能使用替换会话的 ctx。 await ctx.sendUserMessage(kickoff); },});
if (result.cancelled) { // 某个扩展取消了新会话}选项:
parentSession:要记录到新会话头部的父会话文件setup:在withSession运行之前修改新会话的SessionManagerwithSession:在切换后使用全新的替换会话上下文运行后续工作。不要使用捕获到的旧pi/ 命令ctx;参见会话替换生命周期与陷阱。
ctx.fork(entryId, options?)
Section titled “ctx.fork(entryId, options?)”从特定条目分叉,创建新的会话文件:
const result = await ctx.fork("entry-id-123", { withSession: async (ctx) => { // 这里只能使用替换会话的 ctx。 ctx.ui.notify("Now in the forked session", "info"); },});if (result.cancelled) { // 某个扩展取消了分叉}
const cloneResult = await ctx.fork("entry-id-456", { position: "at" });if (cloneResult.cancelled) { // 某个扩展取消了克隆}选项:
position:"before"(默认)在选中的用户消息之前分叉,将该提示词恢复到编辑器中position:"at"复制经过选中条目的活动路径,但不恢复编辑器文本withSession:在切换后使用全新的替换会话上下文运行后续工作。不要使用捕获到的旧pi/ 命令ctx;参见会话替换生命周期与陷阱。
ctx.navigateTree(targetId, options?)
Section titled “ctx.navigateTree(targetId, options?)”导航到会话树中的不同位置:
const result = await ctx.navigateTree("entry-id-456", { summarize: true, customInstructions: "Focus on error handling changes", replaceInstructions: false, // true = 完全替换默认提示词 label: "review-checkpoint",});选项:
summarize:是否生成被放弃分支的摘要customInstructions:给摘要生成器的自定义指令replaceInstructions:若为 true,customInstructions将替换默认提示词而不是追加label:附加到分支摘要条目(若不生成摘要则附加到目标条目)的标签
ctx.switchSession(sessionPath, options?)
Section titled “ctx.switchSession(sessionPath, options?)”切换到不同的会话文件:
const result = await ctx.switchSession("/path/to/session.jsonl", { withSession: async (ctx) => { await ctx.sendUserMessage("Resume work in the replacement session"); },});if (result.cancelled) { // 某个扩展通过 session_before_switch 取消了切换}选项:
withSession:在切换后使用全新的替换会话上下文运行后续工作。不要使用捕获到的旧pi/ 命令ctx;参见会话替换生命周期与陷阱。
要发现可用的会话,请使用静态方法 SessionManager.list() 或 SessionManager.listAll():
import { SessionManager } from "@earendil-works/pi-coding-agent";
pi.registerCommand("switch", { description: "Switch to another session", handler: async (args, ctx) => { const sessions = await SessionManager.list(ctx.cwd); if (sessions.length === 0) return; const choice = await ctx.ui.select( "Pick session:", sessions.map(s => s.file), ); if (choice) { await ctx.switchSession(choice, { withSession: async (ctx) => { ctx.ui.notify("Switched session", "info"); }, }); } },});会话替换生命周期与陷阱
Section titled “会话替换生命周期与陷阱”withSession 会收到全新的 ReplacedSessionContext,它在 ExtensionCommandContext 的基础上扩展了绑定到替换会话的异步 sendMessage() 和 sendUserMessage() 辅助方法。
生命周期与陷阱:
withSession仅在旧会话发出session_shutdown、旧运行时已拆除、替换会话已重新绑定、且新的扩展实例已收到session_start之后才会运行。- 回调仍会在原始闭包中执行,而不是在新的扩展实例内部。这意味着你的旧扩展实例在
withSession开始之前可能已经运行了其关闭清理。 - 替换后,捕获到的旧
pi/ 旧命令ctx等绑定到会话的对象已失效,使用时会抛出错误。对于绑定到会话的工作,只能使用传给withSession的ctx。 - 之前解构出的原始对象仍由你负责。例如,如果你在替换前捕获了
const sm = ctx.sessionManager,sm仍然是旧的SessionManager对象。替换后不要复用它。 withSession中的代码应假定被你的session_shutdown处理函数作废的任何状态都已消失。只捕获能干净地跨关闭存活的普通数据,例如字符串、ID 和序列化配置。
安全模式:
pi.registerCommand("handoff", { handler: async (_args, ctx) => { const kickoff = "Continue from the replacement session"; await ctx.newSession({ withSession: async (ctx) => { await ctx.sendUserMessage(kickoff); }, }); },});不安全模式:
pi.registerCommand("handoff", { handler: async (_args, ctx) => { const oldSessionManager = ctx.sessionManager; await ctx.newSession({ withSession: async (_ctx) => { // 过期的旧对象:不要这样做 oldSessionManager.getSessionFile(); pi.sendUserMessage("wrong"); }, }); },});ctx.reload()
Section titled “ctx.reload()”运行与 /reload 相同的重载流程。
pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, themes, and context files", handler: async (_args, ctx) => { await ctx.reload(); return; },});重要行为:
await ctx.reload()会为当前扩展运行时发出session_shutdown- 然后它重新加载资源,并以
reason: "reload"发出session_start,以 reason"reload"发出resources_discover - 当前正在运行的命令处理函数仍会在旧调用帧中继续执行
await ctx.reload()之后的代码仍从重载前的版本运行await ctx.reload()之后的代码不能假定旧的进程内扩展状态仍然有效- 处理函数返回后,将来的命令/事件/工具调用将使用新的扩展版本
为了行为可预测,请将重载视为该处理函数的终点(await ctx.reload(); return;)。
工具以 ExtensionContext 运行,因此它们不能直接调用 ctx.reload()。请使用命令作为重载入口,然后暴露一个工具,将该命令作为后续用户消息排队。
LLM 可调用以触发重载的示例工具:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";
export default function (pi: ExtensionAPI) { pi.registerCommand("reload-runtime", { description: "Reload extensions, skills, prompts, themes, and context files", handler: async (_args, ctx) => { await ctx.reload(); return; }, });
pi.registerTool({ name: "reload_runtime", label: "Reload Runtime", description: "Reload extensions, skills, prompts, themes, and context files", parameters: Type.Object({}), async execute() { pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" }); return { content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }], }; }, });}ExtensionAPI 方法
Section titled “ExtensionAPI 方法”pi.on(event, handler)
Section titled “pi.on(event, handler)”订阅事件。事件类型和返回值参见事件。
pi.registerTool(definition)
Section titled “pi.registerTool(definition)”注册一个可供 LLM 调用的自定义工具。完整细节参见自定义工具。
pi.registerTool() 在扩展加载期间和启动之后都可以使用。你可以在 session_start、命令处理函数或其他事件处理函数中调用它。新工具会在同一会话中立即刷新,因此它们会出现在 pi.getAllTools() 中,无需 /reload 即可被 LLM 调用。
在运行时使用 pi.setActiveTools() 启用或禁用工具(包括动态添加的工具)。
使用 promptSnippet 让自定义工具以一行条目出现在 Available tools 中,使用 promptGuidelines 在工具处于活动状态时向默认的 Guidelines 小节追加工具专属的要点。
重要: promptGuidelines 的要点会平铺追加到 Guidelines 小节,不带工具名前缀。每条指南都必须指明它所指的工具——避免写 “Use this tool when…”,因为 LLM 无法判断 “this” 指的是哪个工具。应改为写 “Use my_tool when…”。
完整示例参见 dynamic-tools.ts。
import { Type } from "typebox";import { StringEnum } from "@earendil-works/pi-ai";
pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does", promptSnippet: "Summarize or transform text according to action", promptGuidelines: ["Use my_tool when the user asks to summarize previously generated text."], parameters: Type.Object({ action: StringEnum(["list", "add"] as const), text: Type.Optional(Type.String()), }), prepareArguments(args) { // 可选的兼容性垫片。在 schema 校验之前运行。 // 返回当前的 schema 结构,例如将旧字段合并进 // 新的参数对象。 return args; },
async execute(toolCallId, params, signal, onUpdate, ctx) { // 流式输出进度 onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return { content: [{ type: "text", text: "Done" }], details: { result: "..." }, }; },
// 可选:自定义渲染 renderCall(args, theme, context) { ... }, renderResult(result, options, theme, context) { ... },});pi.sendMessage(message, options?)
Section titled “pi.sendMessage(message, options?)”向会话注入自定义消息。自定义消息会参与 LLM 上下文。对于不应发送给 LLM 的、仅用于 TUI 的持久内容,请将 pi.appendEntry() 与 pi.registerEntryRenderer() 搭配使用。
pi.sendMessage({ customType: "my-extension", content: "Message text", display: true, details: { ... },}, { triggerTurn: true, deliverAs: "steer",});选项:
deliverAs- 投递模式:"steer"(默认) - 流式传输期间将消息排队。在当前助手回合完成其工具调用之后、下一次 LLM 调用之前投递。"followUp"- 等待智能体完成。仅在智能体不再有工具调用时投递。"nextTurn"- 为下一条用户提示排队。不会打断或触发任何操作。
triggerTurn: true- 如果智能体处于空闲状态,立即触发 LLM 响应。仅适用于"steer"和"followUp"模式(对"nextTurn"忽略)。
pi.sendUserMessage(content, options?)
Section titled “pi.sendUserMessage(content, options?)”向智能体发送用户消息。与发送自定义消息的 sendMessage() 不同,该方法发送的是真实的用户消息,看起来就像用户亲自输入的一样。始终会触发一个回合。
// 简单文本消息pi.sendUserMessage("What is 2+2?");
// 带内容数组(文本 + 图片)pi.sendUserMessage([ { type: "text", text: "Describe this image:" }, { type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },]);
// 流式传输期间 - 必须指定投递模式pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });选项:
deliverAs- 智能体流式传输时为必填:"steer"- 将消息排队,在当前助手回合完成其工具调用之后投递"followUp"- 等待智能体完成所有工具
未在流式传输时,消息会立即发送并触发新回合。在流式传输时若未指定 deliverAs,则抛出错误。
完整示例参见 send-user-message.ts。
pi.appendEntry(customType, data?)
Section titled “pi.appendEntry(customType, data?)”持久化扩展数据。自定义条目不参与 LLM 上下文。在交互模式下,与 pi.registerEntryRenderer() 搭配使用时,它们还可以渲染在聊天记录中。
pi.appendEntry("my-state", { count: 42 });pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
// 重载时恢复pi.on("session_start", async (_event, ctx) => { for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === "my-state") { // 从 entry.data 重建 } }});pi.setSessionName(name)
Section titled “pi.setSessionName(name)”设置会话显示名称(在会话选择器中显示,而不是显示第一条消息)。
pi.setSessionName("Refactor auth module");pi.getSessionName()
Section titled “pi.getSessionName()”获取当前会话名称(如果已设置)。
const name = pi.getSessionName();if (name) { console.log(`Session: ${name}`);}pi.setLabel(entryId, label)
Section titled “pi.setLabel(entryId, label)”设置或清除条目上的标签。标签是用户定义的书签和导航标记(显示在 /tree 选择器中)。
// 设置标签pi.setLabel(entryId, "checkpoint-before-refactor");
// 清除标签pi.setLabel(entryId, undefined);
// 通过 sessionManager 读取标签const label = ctx.sessionManager.getLabel(entryId);标签会持久化在会话中,并在重启后仍然保留。用它们标记会话树中的重要节点(回合、检查点)。
pi.registerCommand(name, options)
Section titled “pi.registerCommand(name, options)”注册命令。
如果多个扩展注册了相同的命令名,pi 会保留全部命令,并按照加载顺序分配数字调用后缀,例如 /review:1 和 /review:2。
pi.registerCommand("stats", { description: "Show session statistics", handler: async (args, ctx) => { const count = ctx.sessionManager.getEntries().length; ctx.ui.notify(`${count} entries`, "info"); }});可选:为 /command ... 添加参数自动补全:
import type { AutocompleteItem } from "@earendil-works/pi-tui";
pi.registerCommand("deploy", { description: "Deploy to an environment", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const envs = ["dev", "staging", "prod"]; const items = envs.map((e) => ({ value: e, label: e })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; }, handler: async (args, ctx) => { ctx.ui.notify(`Deploying: ${args}`, "info"); },});pi.getCommands()
Section titled “pi.getCommands()”获取当前会话中可通过 prompt 调用的斜杠命令。包括扩展命令、提示词模板和技能命令。列表顺序与 RPC 的 get_commands 一致:先是扩展,然后是模板,最后是技能。
const commands = pi.getCommands();const bySource = commands.filter((command) => command.source === "extension");const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");每个条目具有如下结构:
{ name: string; // 可调用的命令名,不含前导斜杠。可能带有 "review:1" 之类的后缀 description?: string; source: "extension" | "prompt" | "skill"; sourceInfo: { path: string; source: string; scope: "user" | "project" | "temporary"; origin: "package" | "top-level"; baseDir?: string; };}将 sourceInfo 作为规范的来源字段。不要根据命令名或临时的路径解析来推断归属。
内置的交互式命令(如 /model 和 /settings)不包含在这里。它们只在交互模式下处理,如果通过 prompt 发送则不会执行。
pi.registerMessageRenderer(customType, renderer)
Section titled “pi.registerMessageRenderer(customType, renderer)”为你的 customType 的自定义消息注册自定义 TUI 渲染器。自定义消息通过 pi.sendMessage() 创建并参与 LLM 上下文。参见自定义 UI。
pi.registerMarkdownTransformer(transformer)
Section titled “pi.registerMarkdownTransformer(transformer)”为普通用户文本、助手文本和思考块中的 Markdown 注册转换器。转换器按扩展加载顺序运行,每个转换器都会收到前一个转换器返回的 Markdown。链式处理完成后,Pi 用其内置渲染器渲染转换后的内容。
转换器会收到 Markdown 字符串以及包含以下内容的上下文:
messageType—"user"、"assistant"或"assistant-thinking"isStreaming— 对部分流式的助手更新为true;对用户消息、已完成的助手消息和恢复的消息为falseavailableWidth— 转换后的 Markdown 内容可用的精确终端列数
返回转换后的 Markdown:
pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => { if (isStreaming || messageType === "assistant-thinking") return markdown; return markdown.replaceAll("-->", "→");});如果某个转换器抛出异常,Pi 会保留到目前为止生成的 Markdown,并继续执行下一个转换器。该钩子仅用于显示:会话和模型上下文中的原始消息保持不变。它会针对新的用户消息、助手流式更新、恢复的会话消息以及终端宽度变化运行,因此转换器应保持同步,且开销要低。
pi.registerEntryRenderer(customType, renderer)
Section titled “pi.registerEntryRenderer(customType, renderer)”为你的 customType 的自定义条目注册自定义 TUI 渲染器。自定义条目通过 pi.appendEntry() 创建,不参与 LLM 上下文。
import { Box, Text } from "@earendil-works/pi-tui";
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => { const data = entry.data as { title: string; count: number }; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`)); if (expanded) { box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2)))); } return box;});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });pi.registerShortcut(shortcut, options)
Section titled “pi.registerShortcut(shortcut, options)”注册键盘快捷键。快捷键格式和内置快捷键参见快捷键绑定。
pi.registerShortcut("ctrl+shift+p", { description: "Toggle plan mode", handler: async (ctx) => { ctx.ui.notify("Toggled!"); },});pi.registerFlag(name, options)
Section titled “pi.registerFlag(name, options)”注册 CLI 标志。
pi.registerFlag("plan", { description: "Start in plan mode", type: "boolean", default: false,});
// 检查值if (pi.getFlag("plan")) { // 计划模式已启用}pi.exec(command, args, options?)
Section titled “pi.exec(command, args, options?)”执行 shell 命令。
const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });// result.stdout, result.stderr, result.code, result.killedpi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)
Section titled “pi.getActiveTools() / pi.getAllTools() / pi.setActiveTools(names)”管理活动工具。这对内置工具和动态注册的工具都有效。pi.getActiveTools() 以 string[] 返回活动工具名称;pi.getAllTools() 返回所有已配置工具的元数据。
const active = pi.getActiveTools(); // ["read", "bash", ...]const all = pi.getAllTools();// all = [{// name: "read",// description: "Read file contents...",// parameters: ...,// promptGuidelines: ["Use read to examine files instead of cat or sed."],// sourceInfo: { path: "<builtin:read>", source: "builtin", scope: "temporary", origin: "top-level" }// }, ...]const builtinTools = all.filter((t) => t.sourceInfo.source === "builtin");const extensionTools = all.filter((t) => t.sourceInfo.source !== "builtin" && t.sourceInfo.source !== "sdk");pi.setActiveTools([...new Set([...active, "my_custom_tool"])]); // 保留当前工具并启用 my_custom_toolpi.setActiveTools(["read", "bash"]); // 切换为只读pi.getAllTools() 返回 name、description、parameters、promptGuidelines 和 sourceInfo。
sourceInfo.source 的典型值:
builtin:内置工具sdk:通过createAgentSession({ customTools })传入的工具- 扩展来源的元数据:由扩展注册的工具
pi.setModel(model)
Section titled “pi.setModel(model)”设置当前模型。如果该模型没有可用的 API 密钥,则返回 false。配置自定义模型参见模型。
const model = ctx.modelRegistry.find("anthropic", "claude-sonnet-4-5");if (model) { const success = await pi.setModel(model); if (!success) { ctx.ui.notify("No API key for this model", "error"); }}pi.getThinkingLevel() / pi.setThinkingLevel(level)
Section titled “pi.getThinkingLevel() / pi.setThinkingLevel(level)”获取或设置思考级别。级别会被限制在模型能力范围内(非推理模型始终使用 "off")。变更时触发 thinking_level_select。
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"pi.setThinkingLevel("high");pi.events
Section titled “pi.events”扩展之间通信的共享事件总线:
pi.events.on("my:event", (data) => { ... });pi.events.emit("my:event", { ... });pi.registerProvider(name, config)
Section titled “pi.registerProvider(name, config)”动态注册或覆盖模型提供方。适用于代理、自定义端点或团队级模型配置。
在扩展工厂函数执行期间发起的调用会被排队,待 runner 初始化后统一应用。之后发起的调用——例如用户设置流程之后在命令处理器中调用——会立即生效,无需 /reload。
动态提供方可以实现 refreshModels。Pi 在模型刷新时调用它,通过提供方同步发布返回的列表,并传入规范的凭据/存储目录/网络/信号上下文。扩展通过 generation-checked 的 context.publish({ persist: entry }) 决定是否持久化目录元数据;llama.cpp 等实时服务器可以不持久化就直接返回模型。
context.signal 始终是一个具体信号,提供方回调必须把它传给阻塞式 I/O。公开的 ModelRuntime.refresh() 和 ModelRegistry.refresh() 调用接受可选信号,省略信号时无界;扩展和应用自行决定各自的超时。即使提供方忽略信号,取消也能停止调用方的等待,但若要停止底层工作仍需协作配合。
需要原生提供方身份验证、过滤、刷新或流式行为的扩展,可以注册一个完整的来自 @earendil-works/pi-ai 的 Provider。该提供方成为组合基础,models.json 的覆盖仍在其上生效。
import { createProvider, openAICompletionsApi } from "@earendil-works/pi-ai";
const provider = createProvider({ id: "local-server", name: "Local Server", baseUrl: "http://localhost:8080/v1", auth: { apiKey: { name: "Local server setup", async login(interaction) { return { type: "api_key", key: await interaction.prompt({ type: "secret", message: "API key" }), }; }, async resolve({ credential }) { return credential?.key ? { auth: { apiKey: credential.key }, source: "stored API key" } : undefined; }, }, }, models: [], api: openAICompletionsApi(),});
pi.registerProvider(provider);
// 注册带自定义模型的新提供方pi.registerProvider("my-proxy", { name: "My Proxy", baseUrl: "https://proxy.example.com", apiKey: "$PROXY_API_KEY", // 环境变量引用 api: "anthropic-messages", models: [ { id: "claude-sonnet-4-20250514", name: "Claude 4 Sonnet (proxy)", reasoning: false, input: ["text", "image"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 16384 } ]});
// 注册实时 llama.cpp 目录,不持久化发现的模型pi.registerProvider("llama.cpp", { baseUrl: "http://localhost:8080/v1", apiKey: "local", api: "openai-completions", async refreshModels({ signal }) { const response = await fetch("http://localhost:8080/v1/models", { signal }); const { data } = await response.json(); return data.map(({ id }) => ({ id, name: id, reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16384 })); }});
// 为现有提供方覆盖 baseUrl(保留所有模型)pi.registerProvider("anthropic", { baseUrl: "https://proxy.example.com"});
// 注册支持 /login 的 OAuth 提供方pi.registerProvider("corporate-ai", { baseUrl: "https://ai.corp.com", api: "openai-responses", models: [...], oauth: { name: "Corporate AI (SSO)", async login(callbacks) { // 自定义 OAuth 流程 callbacks.onAuth({ url: "https://sso.corp.com/..." }); const code = await callbacks.onPrompt({ message: "Enter code:" }); return { refresh: code, access: code, expires: Date.now() + 3600000 }; }, async refreshToken(credentials, signal) { signal.throwIfAborted(); // 刷新逻辑 return credentials; }, getApiKey(credentials) { return credentials.access; } }});对象形式接受完整的 pi-ai Provider,包括原生的 auth、getModels、refreshModels、filterModels、stream 和 streamSimple 行为。
旧版配置选项:
name— 提供方在/login等 UI 中显示的名称。baseUrl— API 端点 URL。定义模型时必填。apiKey— API 密钥字面量、环境变量插值($ENV_VAR或${ENV_VAR}),或以!command开头的命令。定义模型时必填(除非提供了oauth)。$$转义$,$!转义字面量!而不触发命令执行。api— API 类型:"anthropic-messages"、"openai-completions"、"openai-responses"等。headers— 请求中包含的自定义请求头。authHeader— 若为 true,自动添加Authorization: Bearer请求头。models— 模型定义数组。若提供,则替换该提供方的所有现有模型。模型定义可以设置baseUrl来覆盖该模型的提供方端点。refreshModels— 异步动态发现回调。其返回的模型会替换扩展提供的模型。context.stored包含已持久化的提供方快照;仅当更新的目录数据需要持久化时才使用 generation-checked 的context.publish({ persist: entry })。使用persist: null删除该快照。oauth— 支持/login的 OAuth 提供方配置。提供后,该提供方会出现在登录菜单中。streamSimple— 针对非标准 API 的自定义流式实现。
高级主题(自定义流式 API、OAuth 细节、模型定义参考)见 自定义模型提供方。
pi.unregisterProvider(name)
Section titled “pi.unregisterProvider(name)”移除先前注册的提供方及其模型。被该提供方覆盖的内置模型会恢复。若该提供方未注册,则无任何效果。
与 registerProvider 一样,在初始加载阶段之后调用会立即生效,无需 /reload。
pi.registerCommand("my-setup-teardown", { description: "Remove the custom proxy provider", handler: async (_args, _ctx) => { pi.unregisterProvider("my-proxy"); },});有状态的扩展应把状态存储在工具结果的 details 中,以正确支持分支:
export default function (pi: ExtensionAPI) { let items: string[] = [];
// 从会话中重建状态 pi.on("session_start", async (_event, ctx) => { items = []; 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 ?? []; } } } });
pi.registerTool({ name: "my_tool", // ... async execute(toolCallId, params, signal, onUpdate, ctx) { items.push("new item"); return { content: [{ type: "text", text: "Added" }], details: { items: [...items] }, // 存储以便重建 }; }, });}通过 pi.registerTool() 注册可供 LLM 调用的工具。工具会出现在系统提示词中,并支持自定义渲染。
使用 promptSnippet 在默认系统提示词的 Available tools(可用工具)部分添加一行简短条目。若省略,自定义工具就不会出现在该部分。
使用 promptGuidelines 在默认系统提示词的 Guidelines(准则)部分添加针对该工具的要点。这些要点仅在工具处于激活状态时包含(例如调用 pi.setActiveTools([...]) 之后)。
重要: promptGuidelines 的要点会平铺追加到 Guidelines 部分,不带有工具名前缀或分组。每条准则都必须指明其对应的工具——避免使用 “Use this tool when…”,因为 LLM 无法分辨 “this” 指的是哪个工具。应改写为 “Use my_tool when…”。
注意:有些模型不太聪明,会在工具路径参数中带上 @ 前缀。内置工具在解析路径前会去除开头的 @。如果你的自定义工具接受路径,也应将开头的 @ 规范化。
如果你的自定义工具会修改文件,请使用 withFileMutationQueue(),使其与内置的 edit 和 write 参与同一按文件队列。这一点很重要,因为工具调用默认并行执行。没有队列时,两个工具可能读取到相同的旧文件内容、计算出不同的更新,然后后落盘的那次写入会覆盖另一方的改动。
失败示例:在同一个智能体回合中,你的自定义工具编辑 foo.ts,同时内置的 edit 也在修改 foo.ts。若你的工具不参与队列,两者都可能读取到原始的 foo.ts、各自应用各自的改动,其中一方的改动会丢失。
将真实的目标文件路径传给 withFileMutationQueue(),而不是原始的用户参数。先将其解析为绝对路径,相对于 ctx.cwd 或工具的工作目录。对于已存在的文件,该辅助函数通过 realpath() 进行规范化,因此同一文件的不同符号链接别名共享同一个队列。对于新文件,它会回退到解析后的绝对路径,因为此时还没有可供 realpath() 的对象。
在目标路径上对整个变更窗口排队,包括读-改-写逻辑,而不仅仅是最终写入。
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";import { mkdir, readFile, writeFile } from "node:fs/promises";import { dirname, resolve } from "node:path";
async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => { await mkdir(dirname(absolutePath), { recursive: true }); const current = await readFile(absolutePath, "utf8"); const next = current.replace(params.oldText, params.newText); await writeFile(absolutePath, next, "utf8");
return { content: [{ type: "text", text: `Updated ${params.path}` }], details: {}, }; });}import { Type } from "typebox";import { StringEnum } from "@earendil-works/pi-ai";import { Text } from "@earendil-works/pi-tui";
pi.registerTool({ name: "my_tool", label: "My Tool", description: "What this tool does (shown to LLM)", promptSnippet: "List or add items in the project todo list", promptGuidelines: [ "Use my_tool for todo planning instead of direct file edits when the user asks for a task list." ], parameters: Type.Object({ action: StringEnum(["list", "add"] as const), // 使用 StringEnum 以保证与 Google 兼容 text: Type.Optional(Type.String()), }), prepareArguments(args) { if (!args || typeof args !== "object") return args; const input = args as { action?: string; oldAction?: string }; if (typeof input.oldAction === "string" && input.action === undefined) { return { ...input, action: input.oldAction }; } return args; },
async execute(toolCallId, params, signal, onUpdate, ctx) { // 检查是否已取消 if (signal?.aborted) { return { content: [{ type: "text", text: "Cancelled" }] }; }
// 流式输出进度更新 onUpdate?.({ content: [{ type: "text", text: "Working..." }], details: { progress: 50 }, });
// 通过 pi.exec 运行命令(从扩展闭包捕获) const result = await pi.exec("some-command", [], { signal });
// 返回结果 return { content: [{ type: "text", text: "Done" }], // 发送给 LLM details: { data: result }, // 用于渲染与状态 // usage: nestedModelResponse.usage, // 可选:嵌套 LLM 的用量 // 可选:当批次中每个已定稿的工具结果都返回 terminate: true 时, // 在本次工具批次之后停止自动的后续 LLM 调用。 terminate: true, }; },
// 可选:自定义渲染 renderCall(args, theme, context) { ... }, renderResult(result, options, theme, context) { ... },});用量统计: 如果工具发起了嵌套 LLM 调用,将其合并的 Usage 作为 usage 返回。Pi 会将其持久化到工具结果上,并计入 footer、/session 和 RPC 会话总量。tool_result 处理器可以检查或替换该值。
错误上报: 若要将工具执行标记为失败(在结果上设置 isError: true 并报告给 LLM),请从 execute 抛出错误。无论返回对象中包含什么属性,返回值都不会设置错误标志。
提前终止: 从 execute() 返回 terminate: true,以提示在当前工具批次之后应跳过自动的后续 LLM 调用。仅当该批次中每个已定稿的工具结果都返回终止时才生效。参见 structured-output.ts 示例,这是一个智能体在最终结构化输出工具调用处结束的最小示例。
// 正确做法:通过抛出错误来上报错误async execute(toolCallId, params) { if (!isValid(params.input)) { throw new Error(`Invalid input: ${params.input}`); } return { content: [{ type: "text", text: "OK" }], details: {} };}重要: 字符串枚举请使用 @earendil-works/pi-ai 中的 StringEnum。Type.Union/Type.Literal 与 Google 的 API 不兼容。
参数准备: prepareArguments(args) 是可选的。若已定义,它会在 schema 校验之前、execute() 之前运行。当 pi 恢复一个存储的工具调用参数已不再匹配当前 schema 的旧会话时,用它来模拟旧版可接受的输入形状。返回你想要对照 parameters 校验的对象。保持公开 schema 的严格性。不要仅仅为了让旧的已恢复会话正常工作,就把废弃的兼容字段添加到 parameters。
示例:旧会话中可能包含一个带有顶层 oldText 和 newText 的 edit 工具调用,而当前 schema 只接受 edits: [{ oldText, newText }]。
pi.registerTool({ name: "edit", label: "Edit", description: "Edit a single file using exact text replacement", parameters: Type.Object({ path: Type.String(), edits: Type.Array( Type.Object({ oldText: Type.String(), newText: Type.String(), }), ), }), prepareArguments(args) { if (!args || typeof args !== "object") return args;
const input = args as { path?: string; edits?: Array<{ oldText: string; newText: string }>; oldText?: unknown; newText?: unknown; };
if (typeof input.oldText !== "string" || typeof input.newText !== "string") { return args; }
return { ...input, edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }], }; }, async execute(toolCallId, params, signal, onUpdate, ctx) { // params 现在与当前 schema 匹配 return { content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }], details: {}, }; },});覆盖内置工具
Section titled “覆盖内置工具”扩展可以通过注册同名工具来覆盖内置工具(read、bash、edit、write、grep、find、ls)。交互模式下发生这种情况时会显示警告。
# 扩展的 read 工具替换内置的 readpi -e ./tool-override.ts或者使用 --no-builtin-tools 启动,不加载任何内置工具,同时保持扩展工具可用:
# 无内置工具,仅扩展工具pi --no-builtin-tools -e ./my-extension.ts完整的带日志记录与访问控制来覆盖 read 的示例见 tool-override.ts 示例。
渲染: 内置渲染器的继承按槽位解析。执行覆盖与渲染覆盖相互独立。如果你的覆盖省略了 renderCall,则使用内置的 renderCall。如果你的覆盖省略了 renderResult,则使用内置的 renderResult。如果两者都省略,则自动使用内置渲染器(语法高亮、diff 等)。这让你可以在不重写 UI 的情况下包装内置工具以添加日志记录或访问控制。
提示词元数据: promptSnippet 和 promptGuidelines 不会从内置工具继承。如果你的覆盖需要保留这些提示词指令,请在覆盖中显式定义。
你的实现必须匹配确切的结果形状,包括 details 类型。UI 和会话逻辑依赖这些形状进行渲染与状态跟踪。
内置工具实现:
- read.ts -
ReadToolDetails - bash.ts -
BashToolDetails - edit.ts
- write.ts
- grep.ts -
GrepToolDetails - find.ts -
FindToolDetails - ls.ts -
LsToolDetails
内置工具支持可插拔操作,用于将执行委托给远程系统(SSH、容器等):
import { createReadTool, createBashTool, type ReadOperations } from "@earendil-works/pi-coding-agent";
// 使用自定义操作创建工具const remoteRead = createReadTool(cwd, { operations: { readFile: (path) => sshExec(remote, `cat ${path}`), access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}), }});
// 注册,在执行时检查标志pi.registerTool({ ...remoteRead, async execute(id, params, signal, onUpdate, _ctx) { const ssh = getSshConfig(); if (ssh) { const tool = createReadTool(cwd, { operations: createRemoteOps(ssh) }); return tool.execute(id, params, signal, onUpdate); } return localRead.execute(id, params, signal, onUpdate); },});操作接口: ReadOperations、WriteOperations、EditOperations、BashOperations、LsOperations、GrepOperations、FindOperations
对于 user_bash,扩展可以通过 createLocalBashOperations() 复用 pi 的本地 shell 后端,而无需重新实现本地进程派生、shell 解析和进程树终止。
bash 工具还支持一个 spawn 钩子,用于在执行前调整命令、cwd 或 env:
import { createBashTool } from "@earendil-works/pi-coding-agent";
const bashTool = createBashTool(cwd, { spawnHook: ({ command, cwd, env }) => ({ command: `source ~/.profile\n${command}`, cwd: `/mnt/sandbox${cwd}`, env: { ...env, CI: "1" }, }),});createBashTool() 通过 PI_SESSION_ID、PI_SESSION_FILE、PI_PROVIDER、PI_MODEL 和 PI_REASONING_LEVEL 向命令暴露当前会话。注入发生在 spawnHook 之前,因此钩子能在 env 中收到这些值,并在像上面那样展开现有环境时保留它们。设置 exposeSessionEnvironment: false 可禁用它们:
const bashTool = createBashTool(cwd, { exposeSessionEnvironment: false,});变量语义见 Bash 工具会话环境。带 --ssh 标志的完整 SSH 示例见 ssh.ts 示例。
工具必须截断其输出,以免淹没 LLM 上下文窗口。过大的输出可能导致:
- 上下文溢出错误(提示词过长)
- 上下文压缩失败
- 模型性能下降
内置限制为 50KB(约 1 万 token)和 2000 行,以先达到者为准。使用导出的截断工具函数:
import { truncateHead, // 保留前 N 行/字节(适合文件读取、搜索结果) truncateTail, // 保留最后 N 行/字节(适合日志、命令输出) truncateLine, // 将单行截断为 maxBytes,并用省略号标记 formatSize, // 人类可读的大小(例如 "50KB"、"1.5MB") DEFAULT_MAX_BYTES, // 50KB DEFAULT_MAX_LINES, // 2000} from "@earendil-works/pi-coding-agent";
async execute(toolCallId, params, signal, onUpdate, ctx) { const output = await runCommand();
// 应用截断 const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES, });
let result = truncation.content;
if (truncation.truncated) { // 将完整输出写入临时文件 const tempFile = writeTempFile(output);
// 告知 LLM 在哪里可以找到完整输出 result += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`; result += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`; result += ` Full output saved to: ${tempFile}]`; }
return { content: [{ type: "text", text: result }] };}要点:
- 开头重要的内容使用
truncateHead(搜索结果、文件读取) - 结尾重要的内容使用
truncateTail(日志、命令输出) - 输出被截断时,务必告知 LLM,并指明完整版本的位置
- 在工具的描述中说明截断限制
包装 rg(ripgrep)并带正确截断的完整示例见 truncated-tool.ts 示例。
单个扩展可以注册多个共享状态的工具:
export default function (pi: ExtensionAPI) { let connection = null;
pi.registerTool({ name: "db_connect", ... }); pi.registerTool({ name: "db_query", ... }); pi.registerTool({ name: "db_close", ... });
pi.on("session_shutdown", async () => { connection?.close(); });}工具可以提供 renderCall 和 renderResult 实现自定义的 TUI 显示。完整的组件 API 见 TUI 文档,工具行的组合方式见 tool-execution.ts。
默认情况下,工具输出被包裹在负责内边距和背景的 Box 中。已定义的 renderCall 或 renderResult 必须返回一个 Component。如果某个槽位渲染器未定义,tool-execution.ts 会为该槽位使用回退渲染。
当工具需要渲染自己的外壳,而不是使用默认的 Box 时,设置 renderShell: "self"。这对于需要完全控制框架或背景行为的工具很有用,例如在工具结束后必须保持视觉稳定的大型预览。
pi.registerTool({ name: "my_tool", label: "My Tool", description: "Custom shell example", parameters: Type.Object({}), renderShell: "self", async execute() { return { content: [{ type: "text", text: "ok" }], details: undefined }; }, renderCall(args, theme, context) { return new Text(theme.fg("accent", "my custom shell"), 0, 0); },});renderCall 和 renderResult 各自接收一个 context 对象,包含:
args— 当前的工具调用参数state— 跨renderCall和renderResult共享的行局部状态lastComponent— 该槽位上先前返回的组件(如有)invalidate()— 请求重新渲染此工具行toolCallId、cwd、executionStarted、argsComplete、isPartial、expanded、showImages、isError
使用 context.state 保存跨槽位共享状态。当你想在多次渲染中复用并修改同一个组件时,把槽位局部缓存保存在返回的组件实例上。
renderCall
Section titled “renderCall”渲染工具调用或其标题:
import { Text } from "@earendil-works/pi-tui";
renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("my_tool ")); content += theme.fg("muted", args.action); if (args.text) { content += " " + theme.fg("dim", `"${args.text}"`); } text.setText(content); return text;}renderResult
Section titled “renderResult”渲染工具结果或其输出:
renderResult(result, { expanded, isPartial }, theme, context) { if (isPartial) { return new Text(theme.fg("warning", "Processing..."), 0, 0); }
if (result.details?.error) { return new Text(theme.fg("error", `Error: ${result.details.error}`), 0, 0); }
let text = theme.fg("success", "✓ Done"); if (expanded && result.details?.items) { for (const item of result.details.items) { text += "\n " + theme.fg("dim", item); } } return new Text(text, 0, 0);}如果某个槽位有意不显示任何可见内容,返回一个空的 Component,例如空的 Container。
快捷键绑定提示
Section titled “快捷键绑定提示”使用 keyHint() 显示遵守当前快捷键绑定配置的提示:
import { keyHint } from "@earendil-works/pi-coding-agent";
renderResult(result, { expanded }, theme, context) { let text = theme.fg("success", "✓ Done"); if (!expanded) { text += ` (${keyHint("app.tools.expand", "to expand")})`; } return new Text(text, 0, 0);}可用函数:
keyHint(keybinding, description)— 格式化一个已配置的快捷键绑定 id,例如"app.tools.expand"或"tui.select.confirm"keyText(keybinding)— 返回某个快捷键绑定 id 对应的原始配置按键文本rawKeyHint(key, description)— 格式化原始按键字符串
使用带命名空间的快捷键绑定 id:
- coding-agent 的 id 使用
app.*命名空间,例如app.tools.expand、app.editor.external、app.session.rename - 共享 TUI 的 id 使用
tui.*命名空间,例如tui.select.confirm、tui.select.cancel、tui.input.tab
快捷键绑定 id 和默认值的完整列表见 快捷键绑定。keybindings.json 使用相同的命名空间 id。
自定义编辑器和 ctx.ui.custom() 组件会把 keybindings: KeybindingsManager 作为注入参数接收。它们应直接使用注入的管理器,而不是调用 getKeybindings() 或 setKeybindings()。
- 使用内边距为
(0, 0)的Text。默认的 Box 负责处理内边距。 - 多行内容使用
\n。 - 处理
isPartial以显示流式进度。 - 支持
expanded按需展示详情。 - 保持默认视图紧凑。
- 在
renderResult中读取context.args,而不要把参数复制进context.state。 - 仅将必须跨调用槽位和结果槽位共享的数据存入
context.state。 - 当同一个组件实例可以原地更新时,复用
context.lastComponent。 - 仅当默认的盒式外壳造成干扰时才使用
renderShell: "self"。在自渲染外壳模式下,工具要自己负责框架、内边距和背景。
如果某个槽位渲染器未定义或抛出异常:
renderCall:显示工具名renderResult:显示来自content的原始文本
动态工具加载
Section titled “动态工具加载”扩展可以注册大量工具,同时只保持一个较小的初始激活集合。某个工具可以在执行期间通过 pi.setActiveTools() 添加更多工具。Pi 会检测纯增量式的变更,在对应工具结果上记录新可用的工具名,并在下一次模型请求前应用更新后的激活集合。
这对所有模型都适用。支持原生延迟加载的模型会保留稳定的提示词前缀,并在工具结果位置加载新定义。其他模型使用下文描述的回退方案。
生命周期如下:
- 用
pi.registerTool()注册每个工具,使其出现在pi.getAllTools()中。 - 保持加载器工具(如
search_tools)处于激活状态,并让可搜索工具保持非激活。 - 在加载器执行期间,调用
pi.setActiveTools([...currentTools, ...matchingTools])。变更必须是增量式的:不要在同一次调用中移除当前已激活的工具。 - Pi 会在加载器的工具结果上记录新增了哪些工具。
- 在下一次模型响应前,Pi 在受支持时使用原生延迟加载暴露新增的定义,否则使用普通的激活工具列表。
你无需返回提供方特定的工具引用,也无需把加载器标记为特殊的搜索工具。激活工具集的变更本身就是信号。传给 pi.setActiveTools() 的名字必须已经注册;未知名字会被忽略。
支持原生延迟加载的模型
Section titled “支持原生延迟加载的模型”- Anthropic
- 模型: Sonnet、Opus、Fable 4.5 或更高版本(不含 Haiku)
- 原生表示: 延迟定义使用
defer_loading;加载点使用tool_reference内容。
- OpenAI
- 模型:
gpt-5.4及更新系列 - 原生表示: Pi 在加载点添加已完成的客户端
tool_search_call和tool_search_output条目。
- 模型:
对于已验证的自定义模型或代理,可通过对 anthropic-messages 设置 compat.supportsToolReferences: true,或对 openai-responses 和 openai-codex-responses 设置 compat.supportsToolSearch: true 来启用原生处理。除非端点和模型接受相应的原生协议,否则保持禁用。
对于其他所有模型和模型提供方,动态激活依然有效:Pi 会在下一次请求时正常发送完整的当前激活工具列表。模型可以调用新激活的工具,但加入这些工具的定义可能会使模型提供方缓存的提示词前缀失效。
当激活集合不是纯粹的增量变更(例如用一组工具替换另一组)时,Pi 也会使用这一安全回退。因此移除工具是可行的,但不使用延迟加载。
为获得最佳缓存行为,请在整个会话期间保持加载器工具处于激活状态,并用「添加工具」代替「替换激活集合」。另外注意,激活带 promptSnippet 或 promptGuidelines 的工具会重建系统提示词;即使模型提供方支持延迟 schema,这种系统提示词变更也可能使前缀失效。延迟加载的工具通常应依赖工具自身的 description,省略仅在激活时生效的提示词元数据。
搜索工具示例
Section titled “搜索工具示例”下面的扩展注册了两个可搜索的工具,把它们从初始激活集合中移除,只保留 search_tools 作为加载器。示例使用简单的关键字匹配,但搜索实现可以使用 BM25、向量嵌入、远程目录或项目特定的路由。
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";import { Type } from "typebox";
const SEARCHABLE_TOOL_NAMES = new Set(["lookup_weather", "search_issues"]);
export default function (pi: ExtensionAPI) { pi.registerTool({ name: "lookup_weather", label: "Lookup Weather", description: "Look up the current weather for a city", parameters: Type.Object({ city: Type.String() }), async execute(_toolCallId, params) { return { content: [{ type: "text", text: `Weather for ${params.city}: sunny` }], details: {}, }; }, });
pi.registerTool({ name: "search_issues", label: "Search Issues", description: "Search project issues by keyword", parameters: Type.Object({ query: Type.String() }), async execute(_toolCallId, params) { return { content: [{ type: "text", text: `No open issues matching ${params.query}` }], details: {}, }; }, });
pi.registerTool({ name: "search_tools", label: "Search Tools", description: "Search for and enable tools relevant to a task", promptSnippet: "Search for additional tools when the active tools cannot perform the task", promptGuidelines: [ "Use search_tools when a task requires a capability that is not currently available.", ], parameters: Type.Object({ query: Type.String({ description: "Capability or task to search for" }), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), }), async execute(_toolCallId, params) { const terms = params.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean); const matches = pi.getAllTools() .filter((tool) => SEARCHABLE_TOOL_NAMES.has(tool.name)) .map((tool) => ({ tool, score: terms.reduce( (score, term) => score + (`${tool.name} ${tool.description}`.toLowerCase().includes(term) ? 1 : 0), 0, ), })) .filter((match) => match.score > 0) .sort((a, b) => b.score - a.score) .slice(0, params.limit ?? 3) .map((match) => match.tool.name);
if (matches.length === 0) { return { content: [{ type: "text", text: `No tools found for: ${params.query}` }], details: { matches: [] }, }; }
const active = pi.getActiveTools(); const added = matches.filter((name) => !active.includes(name)); pi.setActiveTools([...new Set([...active, ...added])]);
return { content: [{ type: "text", text: added.length > 0 ? `Loaded tools: ${added.join(", ")}` : `Matching tools already active: ${matches.join(", ")}`, }], details: { matches, added }, }; }, });
pi.on("session_start", () => { // 保持可搜索工具已注册但初始非激活,保留内置工具 // 以及其他扩展拥有的工具,并让加载器本身保持激活 const initialTools = pi.getActiveTools().filter( (name) => !SEARCHABLE_TOOL_NAMES.has(name), ); pi.setActiveTools([...new Set([...initialTools, "search_tools"])]); });}当 search_tools 找到匹配时,模型会在紧接着的下一次请求中收到该定义。在支持原生延迟加载的模型上,定义会锚定在搜索结果之后,不改变初始工具 schema 前缀。在其他模型上,定义会出现在同一次后续请求的普通工具列表中。
自定义 UI
Section titled “自定义 UI”扩展可以通过 ctx.ui 的方法与用户交互,并自定义消息/工具的渲染方式。
自定义组件见 TUI 组件,那里提供了可直接复制粘贴的模式,涵盖:
- 选择对话框(SelectList)
- 带取消的异步操作(BorderedLoader)
- 设置开关(SettingsList)
- 状态指示器(setStatus)
- 流式输出期间的工作消息、可见性与指示器(
setWorkingMessage、setWorkingVisible、setWorkingIndicator) - 编辑器上方/下方的组件(setWidget)
- 在内置斜杠/路径补全之上叠加的自动补全提供方(addAutocompleteProvider)
- 自定义页脚(setFooter)
// 从选项中选取const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
// 确认对话框const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
// 文本输入const name = await ctx.ui.input("Name:", "placeholder");
// 多行编辑器const text = await ctx.ui.editor("Edit:", "prefilled text");
// 通知(非阻塞)ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"带倒计时的定时对话框
Section titled “带倒计时的定时对话框”对话框支持 timeout 选项,会实时显示倒计时并在到点时自动关闭:
// 对话框显示 "Title (5s)" → "Title (4s)" → ... → 在 0 时自动关闭const confirmed = await ctx.ui.confirm( "Timed Confirmation", "This dialog will auto-cancel in 5 seconds. Confirm?", { timeout: 5000 });
if (confirmed) { // 用户已确认} else { // 用户取消或已超时}超时时的返回值:
select()返回undefinedconfirm()返回falseinput()返回undefined
使用 AbortSignal 手动关闭
Section titled “使用 AbortSignal 手动关闭”如需更多控制(例如区分超时与用户取消),可以使用 AbortSignal:
const controller = new AbortController();const timeoutId = setTimeout(() => controller.abort(), 5000);
const confirmed = await ctx.ui.confirm( "Timed Confirmation", "This dialog will auto-cancel in 5 seconds. Confirm?", { signal: controller.signal });
clearTimeout(timeoutId);
if (confirmed) { // 用户已确认} else if (controller.signal.aborted) { // 对话框超时} else { // 用户取消(按 Escape 或选择了"No")}完整示例见 examples/extensions/timed-confirm.ts。
组件、状态与页脚
Section titled “组件、状态与页脚”// 页脚中的状态(持续显示直到清除)ctx.ui.setStatus("my-ext", "Processing...");ctx.ui.setStatus("my-ext", undefined); // 清除
// 工作加载器(流式输出期间显示)ctx.ui.setWorkingMessage("Thinking deeply...");ctx.ui.setWorkingMessage(); // 恢复默认ctx.ui.setWorkingVisible(false); // 完全隐藏内置的工作加载器行ctx.ui.setWorkingVisible(true); // 显示内置的工作加载器行
// 工作指示器(流式输出期间显示)ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // 静态圆点ctx.ui.setWorkingIndicator({ frames: [ ctx.ui.theme.fg("dim", "·"), ctx.ui.theme.fg("muted", "•"), ctx.ui.theme.fg("accent", "●"), ctx.ui.theme.fg("muted", "•"), ], intervalMs: 120,});ctx.ui.setWorkingIndicator({ frames: [] }); // 隐藏指示器ctx.ui.setWorkingIndicator(); // 恢复默认加载动画
// 编辑器上方的组件(默认)ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);// 编辑器下方的组件ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });ctx.ui.setWidget("my-widget", (tui, theme) => new Text(theme.fg("accent", "Custom"), 0, 0));ctx.ui.setWidget("my-widget", undefined); // 清除
// 自定义页脚(完全替换内置页脚)ctx.ui.setFooter((tui, theme) => ({ render(width) { return [theme.fg("dim", "Custom footer")]; }, invalidate() {},}));ctx.ui.setFooter(undefined); // 恢复内置页脚
// 终端标题ctx.ui.setTitle("pi - my-project");
// 编辑器文本ctx.ui.setEditorText("Prefill text");const current = ctx.ui.getEditorText();
// 粘贴到编辑器(触发粘贴处理,包括对大段内容的折叠)ctx.ui.pasteToEditor("pasted content");
// 在内置提供方之上叠加自定义自动补全行为ctx.ui.addAutocompleteProvider((current) => ({ triggerCharacters: ["#"], async getSuggestions(lines, line, col, options) { const beforeCursor = (lines[line] ?? "").slice(0, col); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); if (!match) { return current.getSuggestions(lines, line, col, options); }
return { prefix: `#${match[1] ?? ""}`, items: [{ value: "#2983", label: "#2983", description: "Extension API for autocomplete" }], }; }, applyCompletion(lines, line, col, item, prefix) { return current.applyCompletion(lines, line, col, item, prefix); }, shouldTriggerFileCompletion(lines, line, col) { return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true; },}));
// 工具输出展开const wasExpanded = ctx.ui.getToolsExpanded();ctx.ui.setToolsExpanded(true);ctx.ui.setToolsExpanded(wasExpanded);
// 自定义编辑器(vim 模式、emacs 模式等)ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings));const currentEditor = ctx.ui.getEditorComponent();ctx.ui.setEditorComponent((tui, theme, keybindings) => new WrappedEditor(tui, theme, keybindings, currentEditor?.(tui, theme, keybindings)));ctx.ui.setEditorComponent(undefined); // 恢复默认编辑器
// 主题管理(创建主题见主题文档)const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...]const lightTheme = ctx.ui.getTheme("light"); // 加载但不切换const result = ctx.ui.setTheme("light"); // 按名称切换if (!result.success) { ctx.ui.notify(`Failed: ${result.error}`, "error");}ctx.ui.setTheme(lightTheme!); // 或通过 Theme 对象切换ctx.ui.theme.fg("accent", "styled text"); // 访问当前主题自定义工作指示器的帧会原样渲染。如果需要颜色,请自行在帧字符串中添加,例如使用 ctx.ui.theme.fg(...)。
自动补全提供方
Section titled “自动补全提供方”使用 ctx.ui.addAutocompleteProvider() 在内置的斜杠命令和路径补全提供方之上叠加自定义自动补全逻辑。设置 triggerCharacters 以添加自定义的自然触发符,例如 $。
典型模式:
- 检查光标之前的文本
- 当你的扩展专属语法匹配时,返回自定义建议
- 否则委托给
current.getSuggestions(...) - 除非需要自定义插入行为,否则委托
applyCompletion(...)
pi.on("session_start", (_event, ctx) => { ctx.ui.addAutocompleteProvider((current) => ({ triggerCharacters: ["#"], async getSuggestions(lines, cursorLine, cursorCol, options) { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); const match = beforeCursor.match(/(?:^|[ \t])#([^\s#]*)$/); if (!match) { return current.getSuggestions(lines, cursorLine, cursorCol, options); }
return { prefix: `#${match[1] ?? ""}`, items: [ { value: "#2983", label: "#2983", description: "Extension API for registering custom @ autocomplete providers" }, { value: "#2753", label: "#2753", description: "Reload stale resource settings" }, ], }; },
applyCompletion(lines, cursorLine, cursorCol, item, prefix) { return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); },
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, }));});完整示例见 github-issue-autocomplete.ts,它会用 gh issue list 预加载最新打开的 GitHub issue,并在本地过滤以实现快速的 #... 补全。该示例需要 GitHub CLI(gh)和 GitHub 仓库的检出。
对于复杂的 UI,请使用 ctx.ui.custom()。它会用你的组件临时替换编辑器,直到调用 done() 为止:
import { Text, Component } from "@earendil-works/pi-tui";
const result = await ctx.ui.custom<boolean>((tui, theme, keybindings, done) => { const text = new Text("Press Enter to confirm, Escape to cancel", 1, 1);
text.onKey = (key) => { if (key === "return") done(true); if (key === "escape") done(false); return true; };
return text;});
if (result) { // 用户按下了 Enter}回调接收以下参数:
tui— TUI 实例(用于屏幕尺寸、焦点管理)theme— 当前主题,用于样式keybindings— 应用快捷键绑定管理器(用于检查快捷键)done(value)— 调用以关闭组件并返回值
完整的组件 API 见 TUI 组件。
覆盖层模式(实验性)
Section titled “覆盖层模式(实验性)”传入 { overlay: true } 即可将组件渲染为悬浮在现有内容之上的浮动模态框,而无需清屏:
const result = await ctx.ui.custom<string | null>( (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), { overlay: true });如需高级定位(锚点、边距、百分比、响应式可见性),请传入 overlayOptions。使用 onHandle 以编程方式控制焦点或可见性:
const result = await ctx.ui.custom<string | null>( (tui, theme, keybindings, done) => new MyOverlayComponent({ onClose: done }), { overlay: true, overlayOptions: { anchor: "top-right", width: "50%", margin: 2 }, onHandle: (handle) => { handle.focus(); // 聚焦此覆盖层并把它带到视觉最前面 // handle.unfocus({ target: editorComponent }); // 将输入释放给特定组件 // handle.setHidden(true/false); // 切换可见性 // handle.hide(); // 永久移除 } });当临时性的非覆盖层自定义 UI 关闭后,聚焦的可见覆盖层可以重新接管输入。如果你有意让另一个组件在覆盖层保持可见的情况下继续持有输入,请调用 handle.unfocus({ target })。传入 { target: null } 会释放覆盖层且不聚焦任何其他组件。
完整的 OverlayOptions 和 OverlayHandle API 见 TUI 组件,示例见 overlay-qa-tests.ts。
自定义编辑器
Section titled “自定义编辑器”用自定义实现替换主输入编辑器(vim 模式、emacs 模式等):
import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";import { matchesKey } from "@earendil-works/pi-tui";
class VimEditor extends CustomEditor { private mode: "normal" | "insert" = "insert";
handleInput(data: string): void { if (matchesKey(data, "escape") && this.mode === "insert") { this.mode = "normal"; return; } if (this.mode === "normal" && data === "i") { this.mode = "insert"; return; } super.handleInput(data); // 应用快捷键绑定 + 文本编辑 }}
export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { ctx.ui.setEditorComponent((tui, theme, keybindings) => new VimEditor(tui, theme, keybindings) ); });}要点:
- 继承
CustomEditor(而不是基类Editor),以获得应用快捷键绑定(escape 中止、ctrl+d、模型切换) - 对于你不处理的按键,调用
super.handleInput(data) - 工厂函数从应用接收
tui、theme和keybindings - 在
setEditorComponent()之前先调用ctx.ui.getEditorComponent(),以包装先前配置的自定义编辑器 - 传入
undefined恢复默认:ctx.ui.setEditorComponent(undefined)
要与已替换编辑器的其他扩展组合,请在设置你自己的工厂之前先捕获先前的工厂:
const previous = ctx.ui.getEditorComponent();ctx.ui.setEditorComponent((tui, theme, keybindings) => new MyEditor(tui, theme, keybindings, { base: previous?.(tui, theme, keybindings) }));带模式指示器的完整示例见 TUI 组件 的模式 7。
消息与条目渲染
Section titled “消息与条目渲染”为你的 customType 注册自定义消息渲染器。对于应参与 LLM 上下文的内容,请使用消息渲染器:
import { Text } from "@earendil-works/pi-tui";
pi.registerMessageRenderer("my-extension", (message, options, theme) => { const { expanded, outputPad } = options; let text = theme.fg("accent", `[${message.customType}] `); text += message.content;
if (expanded && message.details) { text += "\n" + theme.fg("dim", JSON.stringify(message.details, null, 2)); }
return new Text(text, outputPad, 0);});消息通过 pi.sendMessage() 发送:
pi.sendMessage({ customType: "my-extension", // 与 registerMessageRenderer 匹配 content: "Status update", display: true, // 在 TUI 中显示 details: { ... }, // 在渲染器中可用});对于不应发送给 LLM 的、仅限 TUI 显示的内容,请改为渲染自定义条目:
pi.registerEntryRenderer("my-card", (entry, options, theme) => { return new Text(theme.fg("accent", JSON.stringify(entry.data)));});
pi.appendEntry("my-card", { status: "done" });所有渲染函数都会收到一个 theme 对象。创建自定义主题及完整调色板见 主题文档。
// 前景色theme.fg("toolTitle", text) // 工具名theme.fg("accent", text) // 高亮theme.fg("success", text) // 成功(绿色)theme.fg("error", text) // 错误(红色)theme.fg("warning", text) // 警告(黄色)theme.fg("muted", text) // 次要文本theme.fg("dim", text) // 三级文本
// 文本样式theme.bold(text)theme.italic(text)theme.strikethrough(text)自定义工具渲染器中的语法高亮:
import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent";
// 用显式语言高亮代码const highlighted = highlightCode("const x = 1;", "typescript", theme);
// 从文件路径自动检测语言const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"const highlighted = highlightCode(code, lang, theme);- 扩展错误会被记录,智能体继续运行
tool_call错误会阻断该工具(故障安全)- 工具
execute的错误必须通过抛出异常来传达;抛出的错误会被捕获,以isError: true报告给 LLM,然后继续执行
| 模式 | ctx.mode |
ctx.hasUI |
说明 |
|---|---|---|---|
| 交互式 | "tui" |
true |
带终端渲染的完整 TUI |
RPC(--mode rpc) |
"rpc" |
true |
通过 JSON 协议进行对话框与通知;custom() 返回 undefined。见 rpc.md |
JSON(--mode json) |
"json" |
false |
向 stdout 输出事件流;UI 方法为空操作 |
打印(-p) |
"print" |
false |
扩展运行但无法提示 |
在使用 TUI 专属功能(custom()、组件工厂、终端输入)之前,先检查 ctx.mode === "tui"。在调用同时适用于 TUI 和 RPC 模式的对话框与通知方法之前,先检查 ctx.hasUI。
所有示例见 examples/extensions/。
| 示例 | 描述 | 关键 API |
|---|---|---|
| 工具 | ||
hello.ts |
最小的工具注册 | registerTool |
question.ts |
带用户交互的工具 | registerTool, ui.select |
questionnaire.ts |
多步骤向导工具 | registerTool, ui.custom |
todo.ts |
带持久化的有状态工具 | registerTool, appendEntry, renderResult、会话事件 |
dynamic-tools.ts |
启动后及命令期间注册工具 | registerTool, session_start, registerCommand |
structured-output.ts |
带 terminate: true 的最终结构化输出工具 |
registerTool、终止型工具结果 |
truncated-tool.ts |
输出截断示例 | registerTool, truncateHead |
tool-override.ts |
覆盖内置 read 工具 | registerTool(与内置同名) |
| 命令 | ||
pirate.ts |
每轮修改系统提示词 | registerCommand, before_agent_start |
summarize.ts |
对话摘要命令 | registerCommand, ui.custom |
handoff.ts |
跨模型提供方的模型交接 | registerCommand, ui.editor, ui.custom |
qna.ts |
带自定义 UI 的问答 | registerCommand, ui.custom, setEditorText |
send-user-message.ts |
注入用户消息 | registerCommand, sendUserMessage |
reload-runtime.ts |
重载命令与 LLM 工具交接 | registerCommand, ctx.reload(), sendUserMessage |
shutdown-command.ts |
优雅关闭命令 | registerCommand, shutdown() |
| 事件与门控 | ||
permission-gate.ts |
阻断危险命令 | on("tool_call"), ui.confirm |
project-trust.ts |
从用户/全局或 CLI 扩展决定或推迟项目信任 | on("project_trust")、信任 UI、必需的信任结果 |
protected-paths.ts |
阻断对特定路径的写入 | on("tool_call") |
confirm-destructive.ts |
确认会话变更 | on("session_before_switch"), on("session_before_fork") |
dirty-repo-guard.ts |
在 git 仓库状态脏时发出警告 | on("session_before_*"), exec |
input-transform.ts |
转换用户输入 | on("input") |
input-transform-streaming.ts |
感知流式的输入转换 | on("input"), streamingBehavior |
model-status.ts |
响应模型变更 | on("model_select"), setStatus |
provider-payload.ts |
检查请求负载与模型提供方响应头 | on("before_provider_request"), on("after_provider_response") |
system-prompt-header.ts |
显示系统提示词信息 | on("agent_start"), getSystemPrompt |
claude-rules.ts |
从文件加载规则 | on("session_start"), on("before_agent_start") |
prompt-customizer.ts |
使用 systemPromptOptions 添加上下文感知的工具指导 |
on("before_agent_start"), BuildSystemPromptOptions |
file-trigger.ts |
文件监视器触发消息 | sendMessage |
| 上下文压缩与会话 | ||
custom-compaction.ts |
自定义上下文压缩摘要 | on("session_before_compact") |
trigger-compact.ts |
手动触发上下文压缩 | compact() |
git-checkpoint.ts |
轮次间的 git stash | on("turn_start"), on("session_before_fork"), exec |
git-merge-and-resolve.ts |
拉取、合并并解决冲突 | on("agent_end"), exec, sendUserMessage |
auto-commit-on-exit.ts |
关闭时自动提交 | on("session_shutdown"), exec |
| UI 组件 | ||
status-line.ts |
页脚状态指示器 | setStatus、会话事件 |
working-indicator.ts |
自定义流式工作指示器 | setWorkingIndicator, registerCommand |
github-issue-autocomplete.ts |
通过从 gh issue list 预加载近期打开的 issue,在内置自动补全之上添加 #1234 issue 补全 |
addAutocompleteProvider, on("session_start"), exec |
custom-footer.ts |
完全替换页脚 | registerCommand, setFooter |
custom-header.ts |
替换启动页头 | on("session_start"), setHeader |
modal-editor.ts |
Vim 风格的模式化编辑器 | setEditorComponent, CustomEditor |
rainbow-editor.ts |
自定义编辑器样式 | setEditorComponent |
widget-placement.ts |
编辑器上方/下方的组件 | setWidget |
overlay-test.ts |
覆盖层组件 | 带覆盖层选项的 ui.custom |
overlay-qa-tests.ts |
全面的覆盖层测试 | ui.custom、全部覆盖层选项 |
notify.ts |
简单通知 | ui.notify |
timed-confirm.ts |
带超时的对话框 | 带 timeout/signal 的 ui.confirm |
mac-system-theme.ts |
自动切换主题 | setTheme, exec |
| 复杂扩展 | ||
plan-mode/ |
完整的计划模式实现 | 全部事件类型、registerCommand、registerShortcut、registerFlag、setStatus、setWidget、sendMessage、setActiveTools |
preset.ts |
可保存的预设(模型、工具、思考) | registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry |
tools.ts |
工具的开关 UI | registerCommand, setActiveTools, SettingsList、会话事件 |
| 远程与沙箱 | ||
ssh.ts |
SSH 远程执行 | registerFlag, on("user_bash"), on("before_agent_start")、工具操作 |
interactive-shell.ts |
持久的 shell 会话 | on("user_bash") |
sandbox/ |
沙箱化工具执行 | 工具操作 |
gondolin/ |
将内置工具和 ! 命令路由到 Gondolin 微型 VM |
工具操作、内置工具覆盖、on("user_bash") |
subagent/ |
生成子智能体 | registerTool, exec |
| 游戏 | ||
snake.ts |
贪吃蛇游戏 | registerCommand, ui.custom、键盘处理 |
space-invaders.ts |
太空入侵者游戏 | registerCommand, ui.custom |
doom-overlay/ |
覆盖层中的 Doom | 带覆盖层的 ui.custom |
| 模型提供方 | ||
custom-provider-anthropic/ |
自定义 Anthropic 代理 | registerProvider |
custom-provider-gitlab-duo/ |
GitLab Duo 集成 | 带 OAuth 的 registerProvider |
| 消息与通信 | ||
message-renderer.ts |
自定义消息渲染 | registerMessageRenderer, sendMessage |
entry-renderer.ts |
仅限 TUI 的自定义条目渲染 | registerEntryRenderer, appendEntry |
event-bus.ts |
扩展间事件 | pi.events |
| 会话元数据 | ||
session-name.ts |
为选择器命名会话 | setSessionName, getSessionName |
bookmark.ts |
为 /tree 添加书签条目 | setLabel |
| 其他 | ||
inline-bash.ts |
工具调用中的内联 bash | on("tool_call") |
bash-spawn-hook.ts |
在执行前调整 bash 命令、cwd 和环境变量 | createBashTool, spawnHook |
with-deps/ |
带 npm 依赖的扩展 | 含 package.json 的包结构 |