跳转到内容

扩展

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 -rfsudo 等之前确认)
  • Git 检查点(每轮 stash,切换分支时恢复)
  • 路径保护(阻止写入 .envnode_modules/
  • 自定义上下文压缩(以你自己的方式总结对话)
  • 对话摘要(参见 summarize.ts 示例)
  • 交互式工具(提问、向导、自定义对话框)
  • 有状态工具(待办列表、连接池)
  • 外部集成(文件监视器、Webhook、CI 触发器)
  • 等待时的游戏(参见 snake.ts 示例)

参见 examples/extensions/ 中的可运行实现。

创建 ~/.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)标志测试:

Terminal window
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": [
"npm:@foo/[email protected]",
"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 扩展类型(ExtensionAPIExtensionContext、事件)
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:fsnode: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_startresources_discover 之前,以及通过 pi.registerProvider() 排队的模型提供方注册生效之前完成。

对于一次性启动工作(如获取远程配置或动态发现可用模型),请使用异步工厂。

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 中都可用。

扩展工厂可能会在从未启动会话的调用中运行。不要在工厂中启动后台资源,如进程、套接字、文件监视器或计时器。

将后台资源的启动推迟到 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
package.json
{
"name": "my-extension",
"dependencies": {
"zod": "^3.0.0",
"chalk": "^5.0.0"
},
"pi": {
"extensions": ["./src/index.ts"]
}
}

在扩展目录中运行 npm install,之后来自 node_modules/ 的导入即可自动工作。

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_shutdown

在 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 默认是询问、信任还是拒绝。

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 请参阅 会话格式

在会话启动、加载或重载时触发。

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");
});

当通过 /name、RPC 或 pi.setSessionName() 设置当前会话的显示名称时触发。

pi.on("session_info_changed", async (event, ctx) => {
// event.name - 当前规范化名称,若已清除则为 undefined
ctx.ui.notify(`Session renamed: ${event.name ?? "(none)"}`, "info");
});

在启动新会话(/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 中重建任何内存中的状态。

在通过 /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 中重建任何内存中的状态。

在上下文压缩时触发。详情请参阅 上下文压缩

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 - 中止的轮次是否在上下文压缩后重试(溢出恢复)
});

/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_start 或其他会话级钩子中打开的资源。

pi.on("session_shutdown", async (event, ctx) => {
// event.reason - "quit" | "reload" | "new" | "resume" | "fork"
// event.targetSessionFile - 会话替换流程中的目标会话
// 清理、保存状态等
});

在用户提交提示词之后、智能体循环开始之前触发。可以注入消息和/或修改系统提示词。

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.systemPromptctx.getSystemPrompt() 都反映截至当前处理器的串联系统提示词。后续的 before_agent_start 处理器仍可再次修改它。

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,除非另一个扩展启动了新的运行。
});

每轮(一次 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_startmessage_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
});

在每次 LLM 调用之前触发。非破坏性地修改消息。消息类型请参阅 会话格式

pi.on("context", async (event, ctx) => {
// event.messages - 深拷贝,可安全修改
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});

在出站 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;
});

每次模型提供方请求运行一次;重试时复用相同的请求头,而不是重新触发该钩子。

在构建好模型提供方特有的 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 };
});

这主要用于调试模型提供方的序列化和缓存行为。

在收到 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 命令、模型循环切换(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 元素(状态栏、底部栏)或执行特定于模型的初始化。

当思考级别变化时触发。这仅为通知;处理器的返回值会被忽略。

pi.on("thinking_level_select", async (event, ctx) => {
// event.level - 新选择的思考级别
// event.previousLevel - 之前的思考级别
ctx.ui.setStatus("thinking", `thinking: ${event.level}`);
});

pi.setThinkingLevel()、模型变化或内置思考级别控件改变活动思考级别时,用它来更新扩展 UI。

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}`);
}
});

自定义工具应导出其输入类型:

my-extension.ts
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_execution_end 以及最终的 tool result 消息事件发出之前触发。可以修改结果。

在并行工具模式下,tool_resulttool_execution_end 可能按工具完成顺序交错,而最终的 toolResult 消息事件稍后仍按助手的源顺序发出。

tool_result 处理器像中间件一样串联:

  • 处理器按扩展加载顺序运行
  • 每个处理器都看到前一个处理器修改后的最新结果
  • 处理器可以返回部分补丁(contentdetailsisErrorusage);省略的字段保持当前值

在处理器内部使用 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 };
});

当用户执行 !!! 命令时触发。可以拦截。

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 尚未展开。

处理顺序:

  1. 首先检查扩展命令(/cmd)——若匹配,则运行对应处理函数并跳过 input 事件
  2. 触发 input 事件——可拦截、改写或处理
  3. 若未被处理:技能命令(/skill:name)展开为技能内容
  4. 若未被处理:提示词模板(/template)展开为模板内容
  5. 智能体处理开始(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.tsinput-transform-streaming.ts

所有处理函数都会收到 ctx: ExtensionContext

用于用户交互的 UI 方法。完整细节参见自定义 UI

当前运行模式:"tui""rpc""json""print"。用 ctx.mode === "tui" 来守护仅终端可用的特性,例如 custom()、组件工厂、终端输入以及直接的 TUI 渲染。

在 TUI 和 RPC 模式下为 true,在打印模式(-p)和 JSON 模式下为 false。用该属性来守护同时适用于 TUI 和 RPC 模式的对话框方法(selectconfirminputeditor)以及即发即忘方法(notifysetStatussetWidgetsetTitlesetEditorText)。在 RPC 模式下,某些 TUI 专属方法会变成空操作(no-op)或返回默认值(参见 rpc.md)。

当前工作目录。

构造项目本地配置路径时,应使用 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");
// ...
});
}

返回当前会话上下文是否启用了项目本地信任。这包括临时信任决定和 CLI 信任覆盖,而不仅仅是全局信任存储中已保存的决定。

在读取仅应针对受信任项目生效的项目本地扩展配置之前,请使用该方法。

对会话状态的只读访问。完整的 SessionManager API 和条目类型参见会话格式

对于 tool_call,该状态会在处理函数运行之前通过当前助手消息同步。在并行工具执行模式下,仍不保证会包含来自同一助手消息的其他工具结果。

ctx.sessionManager.getEntries() // 所有条目
ctx.sessionManager.getBranch() // 当前分支
ctx.sessionManager.buildContextEntries() // 应用了上下文压缩的活动分支条目
ctx.sessionManager.getLeafId() // 当前叶子条目 ID

ctx.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() 枚举整个目录。

当前的智能体中止信号,在没有智能体回合进行时为 undefined

将其用于扩展处理函数启动的、支持中止的嵌套任务,例如:

  • fetch(..., { signal: ctx.signal })
  • 接受 signal 的模型调用
  • 接受 AbortSignal 的文件或进程辅助函数

ctx.signal 通常在活跃回合事件(如 tool_calltool_resultmessage_updateturn_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

请求 Pi 优雅关闭。

  • 交互模式: 延迟到智能体进入空闲状态(在处理完所有排队的转向(steering)和后续(follow-up)消息之后)。
  • RPC 模式: 延迟到下一个空闲状态(在完成当前命令响应之后、等待下一条命令时)。
  • 打印模式: 空操作。所有提示处理完毕后进程自动退出。

退出前向所有扩展发出 session_shutdown 事件。可在所有上下文中使用(事件处理函数、工具、命令、快捷键)。

pi.on("tool_call", (event, ctx) => {
if (isFatal(event.input)) {
ctx.shutdown();
}
});

返回当前活动模型的上下文使用情况。尽可能使用上一次助手消息的使用数据,然后估算尾部消息的 token 数。

const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
// ...
}

触发上下文压缩而无需等待其完成。用 onCompleteonError 处理后续操作。

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");
},
});

返回 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,它在 ExtensionContext 的基础上扩展了会话控制方法。这些方法只在命令中可用,因为如果从事件处理函数调用它们可能导致死锁。

返回 Pi 当前用于构建系统提示词的基础输入。

const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];

其结构与可变性与 before_agent_startevent.systemPromptOptions 相同:自定义提示词、活动工具、工具片段、提示词指南、追加的系统提示词文本、cwd、已加载的上下文文件和已加载的技能。它可能包含完整的上下文文件内容,因此请将其视为敏感的扩展本地数据,避免通过命令列表、日志或自动补全元数据暴露。

这里报告的是当前的基础提示词输入。它不包含每回合 before_agent_start 的链式系统提示词更改、之后 context 事件的消息变更,或 before_provider_request 的载荷重写。

等待智能体完全空闲,包括自动重试、自动压缩重试和排队中的继续操作:

pi.registerCommand("my-cmd", {
handler: async (args, ctx) => {
await ctx.waitForIdle();
// 智能体现在已空闲,可以安全地修改会话
},
});

创建新会话:

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 运行之前修改新会话的 SessionManager
  • withSession:在切换后使用全新的替换会话上下文运行后续工作。不要使用捕获到的旧 pi / 命令 ctx;参见会话替换生命周期与陷阱

从特定条目分叉,创建新的会话文件:

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;参见会话替换生命周期与陷阱

导航到会话树中的不同位置:

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:附加到分支摘要条目(若不生成摘要则附加到目标条目)的标签

切换到不同的会话文件:

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");
},
});
}
},
});

withSession 会收到全新的 ReplacedSessionContext,它在 ExtensionCommandContext 的基础上扩展了绑定到替换会话的异步 sendMessage()sendUserMessage() 辅助方法。

生命周期与陷阱:

  • withSession 仅在旧会话发出 session_shutdown、旧运行时已拆除、替换会话已重新绑定、且新的扩展实例已收到 session_start 之后才会运行。
  • 回调仍会在原始闭包中执行,而不是在新的扩展实例内部。这意味着你的旧扩展实例在 withSession 开始之前可能已经运行了其关闭清理。
  • 替换后,捕获到的旧 pi / 旧命令 ctx 等绑定到会话的对象已失效,使用时会抛出错误。对于绑定到会话的工作,只能使用传给 withSessionctx
  • 之前解构出的原始对象仍由你负责。例如,如果你在替换前捕获了 const sm = ctx.sessionManagersm 仍然是旧的 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");
},
});
},
});

运行与 /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." }],
};
},
});
}

订阅事件。事件类型和返回值参见事件

注册一个可供 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) { ... },
});

向会话注入自定义消息。自定义消息会参与 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" 忽略)。

向智能体发送用户消息。与发送自定义消息的 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

持久化扩展数据。自定义条目参与 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("Refactor auth module");

获取当前会话名称(如果已设置)。

const name = pi.getSessionName();
if (name) {
console.log(`Session: ${name}`);
}

设置或清除条目上的标签。标签是用户定义的书签和导航标记(显示在 /tree 选择器中)。

// 设置标签
pi.setLabel(entryId, "checkpoint-before-refactor");
// 清除标签
pi.setLabel(entryId, undefined);
// 通过 sessionManager 读取标签
const label = ctx.sessionManager.getLabel(entryId);

标签会持久化在会话中,并在重启后仍然保留。用它们标记会话树中的重要节点(回合、检查点)。

注册命令。

如果多个扩展注册了相同的命令名,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");
},
});

获取当前会话中可通过 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;对用户消息、已完成的助手消息和恢复的消息为 false
  • availableWidth — 转换后的 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("ctrl+shift+p", {
description: "Toggle plan mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled!");
},
});

注册 CLI 标志。

pi.registerFlag("plan", {
description: "Start in plan mode",
type: "boolean",
default: false,
});
// 检查值
if (pi.getFlag("plan")) {
// 计划模式已启用
}

执行 shell 命令。

const result = await pi.exec("git", ["status"], { signal, timeout: 5000 });
// result.stdout, result.stderr, result.code, result.killed

pi.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_tool
pi.setActiveTools(["read", "bash"]); // 切换为只读

pi.getAllTools() 返回 namedescriptionparameterspromptGuidelinessourceInfo

sourceInfo.source 的典型值:

  • builtin:内置工具
  • sdk:通过 createAgentSession({ customTools }) 传入的工具
  • 扩展来源的元数据:由扩展注册的工具

设置当前模型。如果该模型没有可用的 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.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });

动态注册或覆盖模型提供方。适用于代理、自定义端点或团队级模型配置。

在扩展工厂函数执行期间发起的调用会被排队,待 runner 初始化后统一应用。之后发起的调用——例如用户设置流程之后在命令处理器中调用——会立即生效,无需 /reload

动态提供方可以实现 refreshModels。Pi 在模型刷新时调用它,通过提供方同步发布返回的列表,并传入规范的凭据/存储目录/网络/信号上下文。扩展通过 generation-checked 的 context.publish({ persist: entry }) 决定是否持久化目录元数据;llama.cpp 等实时服务器可以不持久化就直接返回模型。

context.signal 始终是一个具体信号,提供方回调必须把它传给阻塞式 I/O。公开的 ModelRuntime.refresh()ModelRegistry.refresh() 调用接受可选信号,省略信号时无界;扩展和应用自行决定各自的超时。即使提供方忽略信号,取消也能停止调用方的等待,但若要停止底层工作仍需协作配合。

需要原生提供方身份验证、过滤、刷新或流式行为的扩展,可以注册一个完整的来自 @earendil-works/pi-aiProvider。该提供方成为组合基础,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,包括原生的 authgetModelsrefreshModelsfilterModelsstreamstreamSimple 行为。

旧版配置选项:

  • 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 细节、模型定义参考)见 自定义模型提供方

移除先前注册的提供方及其模型。被该提供方覆盖的内置模型会恢复。若该提供方未注册,则无任何效果。

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(),使其与内置的 editwrite 参与同一按文件队列。这一点很重要,因为工具调用默认并行执行。没有队列时,两个工具可能读取到相同的旧文件内容、计算出不同的更新,然后后落盘的那次写入会覆盖另一方的改动。

失败示例:在同一个智能体回合中,你的自定义工具编辑 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 中的 StringEnumType.Union/Type.Literal 与 Google 的 API 不兼容。

参数准备: prepareArguments(args) 是可选的。若已定义,它会在 schema 校验之前、execute() 之前运行。当 pi 恢复一个存储的工具调用参数已不再匹配当前 schema 的旧会话时,用它来模拟旧版可接受的输入形状。返回你想要对照 parameters 校验的对象。保持公开 schema 的严格性。不要仅仅为了让旧的已恢复会话正常工作,就把废弃的兼容字段添加到 parameters

示例:旧会话中可能包含一个带有顶层 oldTextnewTextedit 工具调用,而当前 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: {},
};
},
});

扩展可以通过注册同名工具来覆盖内置工具(readbasheditwritegrepfindls)。交互模式下发生这种情况时会显示警告。

Terminal window
# 扩展的 read 工具替换内置的 read
pi -e ./tool-override.ts

或者使用 --no-builtin-tools 启动,不加载任何内置工具,同时保持扩展工具可用:

Terminal window
# 无内置工具,仅扩展工具
pi --no-builtin-tools -e ./my-extension.ts

完整的带日志记录与访问控制来覆盖 read 的示例见 tool-override.ts 示例

渲染: 内置渲染器的继承按槽位解析。执行覆盖与渲染覆盖相互独立。如果你的覆盖省略了 renderCall,则使用内置的 renderCall。如果你的覆盖省略了 renderResult,则使用内置的 renderResult。如果两者都省略,则自动使用内置渲染器(语法高亮、diff 等)。这让你可以在不重写 UI 的情况下包装内置工具以添加日志记录或访问控制。

提示词元数据: promptSnippetpromptGuidelines 不会从内置工具继承。如果你的覆盖需要保留这些提示词指令,请在覆盖中显式定义。

你的实现必须匹配确切的结果形状,包括 details 类型。UI 和会话逻辑依赖这些形状进行渲染与状态跟踪。

内置工具实现:

内置工具支持可插拔操作,用于将执行委托给远程系统(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);
},
});

操作接口: ReadOperationsWriteOperationsEditOperationsBashOperationsLsOperationsGrepOperationsFindOperations

对于 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_IDPI_SESSION_FILEPI_PROVIDERPI_MODELPI_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();
});
}

工具可以提供 renderCallrenderResult 实现自定义的 TUI 显示。完整的组件 API 见 TUI 文档,工具行的组合方式见 tool-execution.ts

默认情况下,工具输出被包裹在负责内边距和背景的 Box 中。已定义的 renderCallrenderResult 必须返回一个 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);
},
});

renderCallrenderResult 各自接收一个 context 对象,包含:

  • args — 当前的工具调用参数
  • state — 跨 renderCallrenderResult 共享的行局部状态
  • lastComponent — 该槽位上先前返回的组件(如有)
  • invalidate() — 请求重新渲染此工具行
  • toolCallIdcwdexecutionStartedargsCompleteisPartialexpandedshowImagesisError

使用 context.state 保存跨槽位共享状态。当你想在多次渲染中复用并修改同一个组件时,把槽位局部缓存保存在返回的组件实例上。

渲染工具调用或其标题:

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(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

使用 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.expandapp.editor.externalapp.session.rename
  • 共享 TUI 的 id 使用 tui.* 命名空间,例如 tui.select.confirmtui.select.canceltui.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 的原始文本

扩展可以注册大量工具,同时只保持一个较小的初始激活集合。某个工具可以在执行期间通过 pi.setActiveTools() 添加更多工具。Pi 会检测纯增量式的变更,在对应工具结果上记录新可用的工具名,并在下一次模型请求前应用更新后的激活集合。

这对所有模型都适用。支持原生延迟加载的模型会保留稳定的提示词前缀,并在工具结果位置加载新定义。其他模型使用下文描述的回退方案。

生命周期如下:

  1. pi.registerTool() 注册每个工具,使其出现在 pi.getAllTools() 中。
  2. 保持加载器工具(如 search_tools)处于激活状态,并让可搜索工具保持非激活。
  3. 在加载器执行期间,调用 pi.setActiveTools([...currentTools, ...matchingTools])。变更必须是增量式的:不要在同一次调用中移除当前已激活的工具。
  4. Pi 会在加载器的工具结果上记录新增了哪些工具。
  5. 在下一次模型响应前,Pi 在受支持时使用原生延迟加载暴露新增的定义,否则使用普通的激活工具列表。

你无需返回提供方特定的工具引用,也无需把加载器标记为特殊的搜索工具。激活工具集的变更本身就是信号。传给 pi.setActiveTools() 的名字必须已经注册;未知名字会被忽略。

  • Anthropic
    • 模型: Sonnet、Opus、Fable 4.5 或更高版本(不含 Haiku)
    • 原生表示: 延迟定义使用 defer_loading;加载点使用 tool_reference 内容。
  • OpenAI
    • 模型: gpt-5.4 及更新系列
    • 原生表示: Pi 在加载点添加已完成的客户端 tool_search_calltool_search_output 条目。

对于已验证的自定义模型或代理,可通过对 anthropic-messages 设置 compat.supportsToolReferences: true,或对 openai-responsesopenai-codex-responses 设置 compat.supportsToolSearch: true 来启用原生处理。除非端点和模型接受相应的原生协议,否则保持禁用。

对于其他所有模型和模型提供方,动态激活依然有效:Pi 会在下一次请求时正常发送完整的当前激活工具列表。模型可以调用新激活的工具,但加入这些工具的定义可能会使模型提供方缓存的提示词前缀失效。

当激活集合不是纯粹的增量变更(例如用一组工具替换另一组)时,Pi 也会使用这一安全回退。因此移除工具是可行的,但不使用延迟加载。

为获得最佳缓存行为,请在整个会话期间保持加载器工具处于激活状态,并用「添加工具」代替「替换激活集合」。另外注意,激活带 promptSnippetpromptGuidelines 的工具会重建系统提示词;即使模型提供方支持延迟 schema,这种系统提示词变更也可能使前缀失效。延迟加载的工具通常应依赖工具自身的 description,省略仅在激活时生效的提示词元数据。

下面的扩展注册了两个可搜索的工具,把它们从初始激活集合中移除,只保留 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 前缀。在其他模型上,定义会出现在同一次后续请求的普通工具列表中。

扩展可以通过 ctx.ui 的方法与用户交互,并自定义消息/工具的渲染方式。

自定义组件见 TUI 组件,那里提供了可直接复制粘贴的模式,涵盖:

  • 选择对话框(SelectList)
  • 带取消的异步操作(BorderedLoader)
  • 设置开关(SettingsList)
  • 状态指示器(setStatus)
  • 流式输出期间的工作消息、可见性与指示器(setWorkingMessagesetWorkingVisiblesetWorkingIndicator
  • 编辑器上方/下方的组件(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"

对话框支持 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() 返回 undefined
  • confirm() 返回 false
  • input() 返回 undefined

如需更多控制(例如区分超时与用户取消),可以使用 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

// 页脚中的状态(持续显示直到清除)
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(...)

使用 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 组件

传入 { 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 } 会释放覆盖层且不聚焦任何其他组件。

完整的 OverlayOptionsOverlayHandle API 见 TUI 组件,示例见 overlay-qa-tests.ts

用自定义实现替换主输入编辑器(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)
  • 工厂函数从应用接收 tuithemekeybindings
  • 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。

为你的 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/ 完整的计划模式实现 全部事件类型、registerCommandregisterShortcutregisterFlagsetStatussetWidgetsendMessagesetActiveTools
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 的包结构