Pi 官方文档
扩展
pi 可以创建扩展。让它按你的使用场景帮你写一个。
扩展
扩展是能扩展 pi 行为的 TypeScript 模块。它们可以订阅生命周期事件,注册 LLM 可调用的自定义工具,添加命令,等等。
/reload 的放置位置: 把扩展放在
~/.pi/agent/extensions/(全局)或.pi/extensions/(项目本地),就能被自动发现。只在快速测试时使用pi -e ./path.ts。位于自动发现位置的扩展可以通过/reload热重载。
主要能力:
- 自定义工具 - 通过
pi.registerTool()注册 LLM 可以调用的工具 - 事件拦截 - 阻止或修改 tool call,带入上下文,自定义上下文压缩
- 用户交互 - 通过
ctx.ui提示用户(select、confirm、input、notify) - 自定义 UI 组件 - 通过
ctx.ui.custom()构建支持键盘输入的完整 TUI 组件,用于复杂交互 - 自定义命令 - 通过
pi.registerCommand()注册像/mycommand这样的命令 - 会话持久化 - 通过
pi.appendEntry()保存重启后仍能保留的状态 - 自定义渲染 - 控制 tool call/result 和消息在 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) {
// React to events
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" };
}
});
// Register a custom tool
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: {},
};
},
});
// Register a command
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": [
"npm:@foo/[email protected]",
"git:github.com/user/repo@v1"
],
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}
如果要通过 npm 或 git 以 pi packages 的形式共享扩展,见 packages.md。
可用导入
| Package | 用途 |
|---|---|
@earendil-works/pi-coding-agent | 扩展类型(ExtensionAPI、ExtensionContext、事件) |
typebox | tool 参数的 Schema 定义 |
@earendil-works/pi-ai | AI 工具(用于 Google 兼容枚举的 StringEnum) |
@earendil-works/pi-tui | 用于自定义渲染的 TUI 组件 |
npm 依赖也可以。把 package.json 放在扩展旁边(或上层目录中),运行 npm install,就会自动解析来自 node_modules/ 的导入。
对于通过 pi install 安装的分发式 pi packages,运行时依赖必须放在 dependencies 里。Package 安装默认使用生产环境安装(npm install --omit=dev),因此运行时拿不到 devDependencies;当配置了 npmCommand 时,为了兼容 wrappers,git packages 会直接使用普通的 install。
Node.js 内置模块(node:fs、node:path 等)也可以使用。
编写扩展
扩展会导出一个默认的工厂函数,它接收 ExtensionAPI。这个工厂函数可以是同步的,也可以是异步的:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// Subscribe to events
pi.on("event_name", async (event, ctx) => {
// ctx.ui for user interaction
const ok = await ctx.ui.confirm("Title", "Are you sure?");
ctx.ui.notify("Done!", "info");
ctx.ui.setStatus("my-ext", "Processing..."); // Footer status
ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default)
});
// Register tools, commands, shortcuts, flags
pi.registerTool({ ... });
pi.registerCommand("name", { ... });
pi.registerShortcut("ctrl+x", { ... });
pi.registerFlag("my-flag", { ... });
}
扩展通过 jiti 加载,所以 TypeScript 无需编译也能直接运行。
如果工厂函数返回 Promise,pi 会先等待它完成,再继续启动。这意味着异步初始化会在 session_start、resources_discover 之前完成,并且通过 pi.registerProvider() 排队的 Provider 注册也会在继续之前统一刷新。
异步工厂函数
当你需要做一次性的启动工作时,可以使用异步工厂,比如拉取远程配置,或者动态发现可用的模型。
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 使用。
长生命周期资源与关闭
扩展工厂可能会在一些根本不会启动会话的调用里运行。不要在工厂里启动后台资源,比如进程、socket、文件监听器或定时器。
把后台资源的启动推迟到 session_start,或者推迟到真正需要这个资源的命令、tool call 或事件。注册一个幂等的 session_shutdown 处理器,关闭你启动的任何会话作用域资源。
扩展形式
单文件 - 适合小型扩展,最简单:
~/.pi/agent/extensions/
└── my-extension.ts
带有 index.ts 的目录 - 适合多文件扩展:
~/.pi/agent/extensions/
└── my-extension/
├── index.ts # Entry point (exports default function)
├── tools.ts # Helper module
└── utils.ts # Helper module
带依赖的 Package - 适合需要 npm 包的扩展:
~/.pi/agent/extensions/
└── my-extension/
├── package.json # Declares dependencies and entry points
├── package-lock.json
├── node_modules/ # After 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_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 │
│
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" }
/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
启动事件
project_trust
在 pi 决定是否信任带有动态配置(.pi 或 .agents/skills)的项目之前触发。它会在启动时运行;当会话替换(例如 /resume)进入一个当前进程里还没有完成信任判断的 cwd 时,也会运行。只有用户/全局扩展和 CLI -e 扩展会参与;项目本地扩展要等信任判断完成后才会加载。
pi.on("project_trust", async (event, ctx) => {
// event.cwd - current working directory
// ctx has a limited trust context: cwd, mode, hasUI, and select/confirm/input/notify UI helpers
if (await ctx.ui.confirm("Trust project?", event.cwd)) {
return { trusted: "yes", remember: true };
}
return { trusted: "undecided" };
});
project_trust 处理器必须返回 { trusted: "yes" | "no" | "undecided" }。用户/全局扩展或 CLI 扩展一旦返回 "yes" 或 "no",就拥有这个决定权;第一个 yes/no 决定会生效,并抑制内置的信任提示。使用 remember: true 可以把 yes/no 决定持久化;否则它只对当前进程生效。返回 "undecided",就让后面的处理器或内置的信任流程来决定。提示用户前先检查 ctx.hasUI。如果没有任何处理器返回 yes/no,正常的信任判断会继续:先应用已保存的 trust.json 决策,然后由 defaultProjectTrust 控制 pi 默认是询问、信任还是拒绝。
资源事件
resources_discover
在 session_start 之后触发,这样扩展就可以补充额外的 skill、prompt 和 theme 路径。
启动路径使用 reason: "startup"。重新加载使用 reason: "reload"。
pi.on("resources_discover", async (event, _ctx) => {
// event.cwd - current working directory
// event.reason - "startup" | "reload"
return {
skillPaths: ["/path/to/skills"],
promptPaths: ["/path/to/prompts"],
themePaths: ["/path/to/themes"],
};
});
会话事件
关于会话存储内部实现和 SessionManager API,请参见 Session Format。
session_start
在会话启动、加载或重新加载时触发。
pi.on("session_start", async (event, ctx) => {
// event.reason - "startup" | "reload" | "new" | "resume" | "fork"
// event.previousSessionFile - present for "new", "resume", and "fork"
ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
session_before_switch
在启动新会话(/new)或切换会话(/resume)之前触发。
pi.on("session_before_switch", async (event, ctx) => {
// event.reason - "new" or "resume"
// event.targetSessionFile - session we're switching to (only for "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
在通过 /fork 分叉或通过 /clone 克隆时触发。
pi.on("session_before_fork", async (event, ctx) => {
// event.entryId - ID of the selected entry
// event.position - "before" for /fork, "at" for /clone
return { cancel: true }; // Cancel fork/clone
// OR
return { skipConversationRestore: true }; // Reserved for future conversation restore control
});
在分叉或克隆成功后,pi 会先为旧的扩展实例发出 session_shutdown,再为新会话重新加载并重新绑定扩展,然后发出带有 reason: "fork" 和 previousSessionFile 的 session_start。
请在 session_shutdown 中完成清理工作,再在 session_start 中重新建立任何内存态。
session_before_compact / session_compact
在上下文压缩时触发。详情请参见 compaction.md。
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, signal } = event;
// Cancel:
return { cancel: true };
// Custom summary:
return {
compaction: {
summary: "...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
}
};
});
pi.on("session_compact", async (event, ctx) => {
// event.compactionEntry - the saved compaction
// event.fromExtension - whether extension provided it
});
session_before_tree / session_tree
在 /tree 导航时触发。关于会话树导航概念,请参见 Sessions。
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
return { cancel: true };
// OR provide custom summary:
return { summary: { summary: "...", details: {} } };
});
pi.on("session_tree", async (event, ctx) => {
// event.newLeafId, oldLeafId, summaryEntry, fromExtension
});
session_shutdown
在已启动的会话运行时被拆除之前触发。可用它清理在 session_start 或其他会话作用域钩子中打开的资源。
pi.on("session_shutdown", async (event, ctx) => {
// event.reason - "quit" | "reload" | "new" | "resume" | "fork"
// event.targetSessionFile - destination session for session replacement flows
// Cleanup, save state, etc.
});
Agent 事件
before_agent_start
在用户提交 prompt 之后、agent 循环开始之前触发。可以带入一条消息,并/或修改 system prompt。
pi.on("before_agent_start", async (event, ctx) => {
// event.prompt - user's prompt text
// event.images - attached images (if any)
// event.systemPrompt - current chained system prompt for this handler
// (includes changes from earlier before_agent_start handlers)
// event.systemPromptOptions - structured options used to build the system prompt
// .customPrompt - any custom system prompt (from --system-prompt, SYSTEM.md, or custom templates)
// .selectedTools - tools currently active in the prompt
// .toolSnippets - one-line descriptions for each tool
// .promptGuidelines - custom guideline bullets
// .appendSystemPrompt - text from --append-system-prompt flags
// .cwd - working directory
// .contextFiles - AGENTS.md files and other loaded context files
// .skills - loaded skills
return {
// Inject a persistent message (stored in session, sent to LLM)
message: {
customType: "my-extension",
content: "Additional context for the LLM",
display: true,
},
// Replace the system prompt for this turn (chained across extensions)
systemPrompt: event.systemPrompt + "\n\nExtra instructions for this turn...",
};
});
systemPromptOptions 字段让扩展可以访问 Pi 用来构建 system prompt 的同一份结构化数据。这使你可以查看 Pi 已加载的内容——自定义 prompts、guidelines、tool snippets、context files、skills——而不必重新发现资源或重新解析 flags。当前当你需要在尊重用户提供配置的前提下,对 system prompt 做深入且有依据的修改时,就该使用它。
在 before_agent_start 内,event.systemPrompt 和 ctx.getSystemPrompt() 都会反映当前处理器执行到此时的链式 system prompt。后续的 before_agent_start 处理器仍然可以再次修改它。
agent_start / agent_end
每个用户 prompt 触发一次。
pi.on("agent_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => {
// event.messages - messages from this prompt
});
turn_start / turn_end
每个 turn 触发一次(一次 LLM 响应 + tool calls)。
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
在消息生命周期更新时触发。
message_start和message_end会对 user、assistant 和 toolResult 消息触发。message_update会对 assistant 的流式更新触发。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-by-token stream event)
});
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
在 tool 执行生命周期更新时触发。
在并行 tool 模式下:
tool_execution_start会在预检阶段按 assistant 源顺序发出tool_execution_update事件可能在不同 tool 之间交错出现- 在每个 tool 完成定稿后,
tool_execution_end会按 tool 完成顺序发出 - 最终的
toolResult消息事件仍然会在之后按 assistant 源顺序发出
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
在每次 LLM 调用之前触发。请以非破坏性的方式修改消息。关于消息类型,请参见 Session Format。
pi.on("context", async (event, ctx) => {
// event.messages - deep copy, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});
before_provider_request
在构建完 Provider-specific payload 之后、请求发送之前触发。处理器按扩展加载顺序运行。返回 undefined 会保持 payload 不变。返回任何其他值都会把该 payload 传给后续处理器,并作为实际请求使用的 payload。
这个钩子可以重写 Provider 层的 system instructions,也可以把它们完全移除。这类 payload 级别的变更不会反映到 ctx.getSystemPrompt(),后者返回的是 Pi 的 system prompt 字符串,而不是最终序列化后的 Provider payload。
pi.on("before_provider_request", (event, ctx) => {
console.log(JSON.stringify(event.payload, null, 2));
// Optional: replace payload
// return { ...event.payload, temperature: 0 };
});
这主要用于调试 Provider 序列化和缓存行为。
after_provider_response
在收到 HTTP 响应之后、其流式正文被消费之前触发。处理器按扩展加载顺序运行。
pi.on("after_provider_response", (event, ctx) => {
// event.status - HTTP status code
// event.headers - normalized response headers
if (event.status === 429) {
console.log("rate limited", event.headers["retry-after"]);
}
});
header 是否可用取决于 Provider 和传输方式。对 HTTP 响应做了抽象的 Provider 可能不会暴露 header。
模型事件
model_select
当通过 /model 命令、模型切换(Ctrl+P)或会话恢复改变模型时触发。
pi.on("model_select", async (event, ctx) => {
// event.model - newly selected model
// event.previousModel - previous model (undefined if first selection)
// 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
当思考级别发生变化时触发。这只是通知,处理函数返回值会被忽略。
pi.on("thinking_level_select", async (event, ctx) => {
// event.level - newly selected thinking level
// event.previousLevel - previous thinking level
ctx.ui.setStatus("thinking", `thinking: ${event.level}`);
});
当 pi.setThinkingLevel()、模型变化,或内置的思考级别控件改变当前激活的思考级别时,可用它来更新扩展 UI。
工具事件
tool_call
在 tool_execution_start 之后、工具执行之前触发。可阻塞。 使用 isToolCallEventType 做类型收窄,并获取带类型的输入。
在 tool_call 运行之前,pi 会先等待之前发出的 Agent 事件通过 AgentSession 处理完毕。这意味着 ctx.sessionManager 已经更新到当前 assistant 的 tool-calling 消息。
在默认的并行 tool 执行模式下,来自同一条 assistant 消息的同级 tool call 会先按顺序做预检查,再并发执行。tool_call 不保证能在 ctx.sessionManager 中看到同一条 assistant 消息里其他同级 tool call 的结果。
event.input 是可变的。你可以直接就地修改它,在执行前补丁化 tool 参数。
行为保证:
- 对
event.input的修改会影响实际的 tool 执行 - 后面的
tool_call处理函数会看到前面处理函数做出的修改 - 修改后不会再次做校验
tool_call的返回值只通过{ block: true, reason?: string }控制是否阻塞
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
pi.on("tool_call", async (event, ctx) => {
// event.toolName - "bash", "read", "write", "edit", etc.
// event.toolCallId
// event.input - tool parameters (mutable)
// Built-in tools: no type params needed
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" };
}
}
if (isToolCallEventType("read", event)) {
// event.input is { path: string; offset?: number; limit?: number }
console.log(`Reading: ${event.input.path}`);
}
});
为自定义 tool input 定义类型
自定义工具应导出它的输入类型:
// 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; // typed
}
});
tool_result
在工具执行完成后、tool_execution_end 以及最终的 tool result 消息事件发出之前触发。可修改结果。
在并行 tool 模式下,tool_result 和 tool_execution_end 可能会按 tool 完成顺序交错执行,而最终的 toolResult 消息事件仍会稍后按 assistant 源顺序发出。
tool_result 处理函数像中间件一样串联:
- 处理函数按扩展加载顺序运行
- 每个处理函数都能看到上一个处理函数修改后的最新结果
- 处理函数可以返回部分补丁(
content、details或isError);未提供的字段会保留当前值
在处理函数内部的嵌套异步工作中使用 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
if (isBashToolResult(event)) {
// event.details is typed as BashToolDetails
}
const response = await fetch("https://example.com/summarize", {
method: "POST",
body: JSON.stringify({ content: event.content }),
signal: ctx.signal,
});
// Modify result:
return { content: [...], details: {...}, isError: false };
});
用户 Bash 事件
user_bash
当用户执行 ! 或 !! 命令时触发。可拦截。
import { createLocalBashOperations } from "@earendil-works/pi-coding-agent";
pi.on("user_bash", (event, ctx) => {
// event.command - the bash command
// event.excludeFromContext - true if !! prefix
// event.cwd - working directory
// Option 1: Provide custom operations (e.g., SSH)
return { operations: remoteBashOps };
// Option 2: Wrap pi's built-in local bash backend
const local = createLocalBashOperations();
return {
operations: {
exec(command, cwd, options) {
return local.exec(`source ~/.profile\n${command}`, cwd, options);
}
}
};
// Option 3: Full replacement - return result directly
return { result: { output: "...", exitCode: 0, cancelled: false, truncated: false } };
});
输入事件
input
当收到用户输入时触发,发生在检查扩展命令之后、Skill 和模板展开之前。这个事件看到的是原始输入文本,所以 /skill:foo 和 /template 还没有展开。
处理顺序:
- 先检查扩展命令(
/cmd)——如果命中,就运行处理函数并跳过 input 事件 - 触发
input事件——可拦截、转换或直接处理 - 如果未处理:将 Skill 命令(
/skill:name)展开为 Skill 内容 - 如果未处理:将提示词模板(
/template)展开为模板内容 - 开始 Agent 处理(
before_agent_start等)
pi.on("input", async (event, ctx) => {
// event.text - raw input (before skill/template expansion)
// event.images - attached images, if any
// event.source - "interactive" (typed), "rpc" (API), or "extension" (via sendUserMessage)
// event.streamingBehavior - "steer" | "followUp" | undefined
// undefined when idle, "steer" for mid-stream interrupts,
// "followUp" for messages queued until the agent finishes
// Transform: rewrite input before expansion
if (event.text.startsWith("?quick "))
return { action: "transform", text: `Respond briefly: ${event.text.slice(7)}` };
// Handle: respond without LLM (extension shows its own feedback)
if (event.text === "ping") {
ctx.ui.notify("pong", "info");
return { action: "handled" };
}
// Route by source: skip processing for extension-injected messages
if (event.source === "extension") return { action: "continue" };
// Intercept skill commands before expansion
if (event.text.startsWith("/skill:")) {
// Could transform, block, or let pass through
}
return { action: "continue" }; // Default: pass through to expansion
});
结果:
continue- 原样继续传递(如果处理函数没有返回值,这是默认值)transform- 修改文本/图片,然后继续展开handled- 完全跳过 agent(第一个返回它的处理函数获胜)
多个处理函数之间会串联转换。关于感知 streamingBehavior 的路由,请参见 input-transform.ts 和 input-transform-streaming.ts。
ExtensionContext
所有 handler 都会收到 ctx: ExtensionContext。
ctx.ui
用于与用户交互的 UI 方法。完整细节见 Custom UI。
ctx.mode
当前运行模式:"tui"、"rpc"、"json" 或 "print"。当你要使用只适用于终端的功能时,可以用 ctx.mode === "tui" 做保护,例如 custom()、component factories、终端输入,以及直接的 TUI 渲染。
ctx.hasUI
在 TUI 和 RPC 模式下为 true。在 print 模式(-p)和 JSON 模式下为 false。可用它来保护对话方法(select、confirm、input、editor)以及无需等待返回的方法(notify、setStatus、setWidget、setTitle、setEditorText);这些方法在 TUI 和 RPC 模式下都能工作。在 RPC 模式下,部分仅适用于 TUI 的方法不会执行任何操作,或返回默认值(见 rpc.md)。
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()
返回当前会话上下文下,项目本地信任是否已生效。这包括临时信任决定和 CLI 信任覆盖,不只是全局信任存储里已保存的决定。
在读取仅应对受信任项目生效的项目本地扩展配置之前,请先检查它。
ctx.sessionManager
对 session 状态的只读访问。完整的 SessionManager API 和 entry 类型见 Session Format。
对于 tool_call,在 handler 运行之前,这个状态会通过当前 assistant 消息完成同步。但在并行执行 tool 的模式下,它仍然不能保证包含同一条 assistant 消息里其他 tool 的结果。
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.getLeafId() // Current leaf entry ID
ctx.modelRegistry / ctx.model
访问模型和 API 密钥。
ctx.signal
当前 agent 的 abort signal;如果没有 active agent turn,则为 undefined。
可用于由 extension handler 启动的、需要感知中止信号的嵌套工作,例如:
fetch(..., { signal: ctx.signal })- 接受
signal的模型调用 - 接受
AbortSignal的文件或进程辅助方法
ctx.signal 通常在活跃轮次事件中定义,例如 tool_call、tool_result、message_update 和 turn_end。
在空闲或非轮次上下文中,它通常是 undefined,例如 session 事件、扩展命令,以及 pi 空闲时触发的快捷键。
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()
控制流辅助方法。
ctx.shutdown()
请求优雅关闭 pi。
- 交互模式: 延后到 agent 进入空闲后执行(在处理完所有排队的 steering 和 follow-up messages 之后)。
- RPC 模式: 延后到下一个空闲状态(在完成当前命令响应、等待下一条命令时)。
- Print 模式: 无操作。所有 prompt 处理完成后,进程会自动退出。
在退出前,会向所有扩展发出 session_shutdown event。可在所有上下文中使用(event handlers、tools、commands、shortcuts)。
pi.on("tool_call", (event, ctx) => {
if (isFatal(event.input)) {
ctx.shutdown();
}
});
ctx.getContextUsage()
返回当前模型的上下文使用情况。优先使用最后一次 assistant 的 usage;如果没有,则估算尾部消息的 token 数。
const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
// ...
}
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()
返回 Pi 当前的 system prompt 字符串。
- 在
before_agent_start期间,这会反映当前轮次中截至目前已经串联起来的 system prompt 变更。 - 它不包含后续对
context消息的修改。 - 它不包含
before_provider_request对 payload 的重写。 - 如果后加载的扩展在你之后运行,它们仍然可以改变最终发送的内容。
pi.on("before_agent_start", (event, ctx) => {
const prompt = ctx.getSystemPrompt();
console.log(`System prompt length: ${prompt.length}`);
});
ExtensionCommandContext
命令 handler 会收到 ExtensionCommandContext。它在 ExtensionContext 的基础上增加了会话控制方法。它们只在命令中可用,因为如果在 event handlers 中调用,可能会造成死锁。
ctx.getSystemPromptOptions()
返回 Pi 当前用于构建 system prompt 的基础输入。
const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];
它与 before_agent_start 的 event.systemPromptOptions 具有相同的结构和可变性:custom prompt、active tools、tool snippets、prompt guidelines、追加的 system prompt 文本、cwd、已加载的 context 文件,以及已加载的 Skills。它可能包含完整的 context 文件内容,因此请将其视为敏感的扩展本地数据,避免通过命令列表、日志或自动补全元数据暴露出去。
它反映的是当前的基础 prompt 输入。它不包含按轮次发生的 before_agent_start 串联式 system prompt 变更,也不包含后续 context 事件中的消息修改,或 before_provider_request 的 payload 重写。
ctx.waitForIdle()
等待 agent 完成流式输出:
pi.registerCommand("my-cmd", {
handler: async (args, ctx) => {
await ctx.waitForIdle();
// Agent is now idle, safe to modify session
},
});
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) => {
// Use only the replacement-session ctx here.
await ctx.sendUserMessage(kickoff);
},
});
if (result.cancelled) {
// An extension cancelled the new session
}
Options:
parentSession: 写入新会话 header 的父会话文件setup: 在withSession运行之前,修改新会话的SessionManagerwithSession: 在切换后,针对一个全新的替换会话上下文执行后续工作。不要使用捕获到的旧pi/ 命令ctx;见 Session replacement lifecycle and footguns。
ctx.fork(entryId, options?)
从指定 entry 分叉,创建一个新的会话文件:
const result = await ctx.fork("entry-id-123", {
withSession: async (ctx) => {
// Use only the replacement-session ctx here.
ctx.ui.notify("Now in the forked session", "info");
},
});
if (result.cancelled) {
// An extension cancelled the fork
}
const cloneResult = await ctx.fork("entry-id-456", { position: "at" });
if (cloneResult.cancelled) {
// An extension cancelled the clone
}
Options:
position:"before"(默认)会在选中的用户消息之前分叉,并把该提示词恢复到编辑器中position:"at"会沿着选中的 entry 复制当前路径,但不恢复编辑器文本withSession: 在切换后,针对一个全新的替换会话上下文执行后续工作。不要使用捕获到的旧pi/ 命令ctx;见 Session replacement lifecycle and footguns。
ctx.navigateTree(targetId, options?)
切换到会话树中的另一个位置:
const result = await ctx.navigateTree("entry-id-456", {
summarize: true,
customInstructions: "Focus on error handling changes",
replaceInstructions: false, // true = replace default prompt entirely
label: "review-checkpoint",
});
Options:
summarize: 是否为被放弃的分支生成摘要customInstructions: 给摘要器的自定义指令replaceInstructions: 如果为 true,customInstructions会替换默认提示词,而不是追加到默认提示词后面label: 要附加到分支摘要条目的标签(如果不生成摘要,则附加到目标 entry)
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) {
// An extension cancelled the switch via session_before_switch
}
Options:
withSession: 在切换后,针对一个全新的替换会话上下文执行后续工作。不要使用捕获到的旧pi/ 命令ctx;见 Session replacement lifecycle and footguns。
要查找可用的会话,可以使用静态 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、旧 runtime 已经拆除、替换会话已重新绑定,并且新的 extension 实例已经收到session_start之后才会运行。- 这个回调仍然在原来的闭包里执行,而不是在新的 extension 实例内部。这意味着,在
withSession开始之前,你的旧 extension 实例可能已经执行过 shutdown 清理。 - 替换后,之前捕获的旧
pi/ 旧命令ctx这类会话绑定对象已经过期,使用时会抛错。只使用传给withSession的ctx来处理会话绑定工作。 - 之前提取出来的原始对象仍然要由你自己负责。比如,如果你在替换前捕获了
const sm = ctx.sessionManager,那sm仍然是旧的SessionManager对象。替换后不要再复用它。 withSession里的代码应该假设,凡是会被你的session_shutdownhandler 失效的状态,都已经不在了。只捕获能够在 shutdown 后干净保留下来的普通数据,比如字符串、id 和序列化后的 config。
安全模式:
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) => {
// stale old objects: do not do this
oldSessionManager.getSessionFile();
pi.sendUserMessage("wrong");
},
});
},
});
ctx.reload()
执行与 /reload 相同的重新加载流程。
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, and themes",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
重要行为:
await ctx.reload()会为当前 extension runtime 发出session_shutdown- 然后它会重新加载资源,并发出
reason: "reload"的session_start,以及 reason 为"reload"的resources_discover - 当前正在运行的 command handler 仍会在旧的调用栈帧中继续执行
await ctx.reload()之后的代码仍然执行的是重载前的版本await ctx.reload()之后的代码不能假设旧的内存态 extension 状态仍然有效- handler 返回后,后续的 command、event 和 tool call 都会使用新的 extension 版本
为了让行为可预测,应把 reload 视为该 handler 的终止点(await ctx.reload(); return;)。
Tool 运行在 ExtensionContext 下,因此不能直接调用 ctx.reload()。应当用一个 command 作为 reload 入口,再暴露一个 tool,把这个 command 作为后续用户消息排入队列。
LLM 可以调用来触发 reload 的示例 tool:
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, and themes",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
pi.registerTool({
name: "reload_runtime",
label: "Reload Runtime",
description: "Reload extensions, skills, prompts, and themes",
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 方法
pi.on(event, handler)
订阅事件。有关事件类型和返回值,请参见 Events。
pi.registerTool(definition)
注册一个可由 LLM 调用的自定义 tool。完整说明请参见 Custom Tools。
pi.registerTool() 在扩展加载期间和启动后都可用。你可以在 session_start、命令处理器或其他事件处理器中调用它。新 tool 会在同一会话中立即刷新,因此它们会出现在 pi.getAllTools() 中,并且无需 /reload 就能被 LLM 调用。
使用 pi.setActiveTools(),可以在运行时启用或禁用 tool(包括动态添加的 tool)。
使用 promptSnippet,可以让自定义 tool 以单行条目显示在 Available tools 中;使用 promptGuidelines,则可以在 tool 激活时,把该 tool 专属的要点追加到默认的 Guidelines 部分。
Important: promptGuidelines 的要点会平铺追加到 Guidelines 部分,不会带 tool 名称前缀。每条 guideline 都必须明确写出它引用的 tool——不要写 "Use this tool when...",因为 LLM 无法判断 "this" 指的是哪个 tool。应该改写成 "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) {
// Optional compatibility shim. Runs before schema validation.
// Return the current schema shape, for example to fold legacy fields
// into the modern parameter object.
return args;
},
async execute(toolCallId, params, signal, onUpdate, ctx) {
// Stream progress
onUpdate?.({ content: [{ type: "text", text: "Working..." }] });
return {
content: [{ type: "text", text: "Done" }],
details: { result: "..." },
};
},
// Optional: Custom rendering
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});
pi.sendMessage(message, options?)
向会话中带入一条自定义消息。
pi.sendMessage({
customType: "my-extension",
content: "Message text",
display: true,
details: { ... },
}, {
triggerTurn: true,
deliverAs: "steer",
});
Options:
deliverAs- 发送模式:"steer"(默认)- 在流式输出期间排队发送。会在当前 assistant 轮次执行完它的 tool call 之后、下一次 LLM 调用之前送达。"followUp"- 等待 agent 完成。只有在 agent 不再有更多 tool call 时才送达。"nextTurn"- 排到下一次用户提示词时发送。不会打断,也不会触发任何事。
triggerTurn: true- 如果 agent 空闲,就立即触发一次 LLM 响应。仅适用于"steer"和"followUp"模式("nextTurn"会忽略)。
pi.sendUserMessage(content, options?)
向 agent 发送一条用户消息。不同于 sendMessage() 发送的自定义消息,这里发送的是一条真实的用户消息,看起来就像用户手动输入的一样。它总会触发一个轮次。
// Simple text message
pi.sendUserMessage("What is 2+2?");
// With content array (text + images)
pi.sendUserMessage([
{ type: "text", text: "Describe this image:" },
{ type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } },
]);
// During streaming - must specify delivery mode
pi.sendUserMessage("Focus on error handling", { deliverAs: "steer" });
pi.sendUserMessage("And then summarize", { deliverAs: "followUp" });
Options:
deliverAs- 在 agent 流式输出时必填:"steer"- 将消息排队,在当前 assistant 轮次执行完它的 tool call 之后送达"followUp"- 等待 agent 完成所有 tool
在非流式输出时,消息会立即发送并触发一个新轮次。在流式输出时如果没有 deliverAs,则会抛出错误。
完整示例请参见 send-user-message.ts。
pi.appendEntry(customType, data?)
持久化扩展状态(不会参与 LLM 上下文)。
pi.appendEntry("my-state", { count: 42 });
// Restore on reload
pi.on("session_start", async (_event, ctx) => {
for (const entry of ctx.sessionManager.getEntries()) {
if (entry.type === "custom" && entry.customType === "my-state") {
// Reconstruct from entry.data
}
}
});
pi.setSessionName(name)
设置会话显示名称(在会话选择器中显示,而不是首条消息)。
pi.setSessionName("Refactor auth module");
pi.getSessionName()
获取当前会话名称(如果已设置)。
const name = pi.getSessionName();
if (name) {
console.log(`Session: ${name}`);
}
pi.setLabel(entryId, label)
为一个条目设置或清除标签。标签是用户自定义的标记,用于书签和导航(会显示在 /tree 选择器中)。
// Set a label
pi.setLabel(entryId, "checkpoint-before-refactor");
// Clear a label
pi.setLabel(entryId, undefined);
// Read labels via sessionManager
const label = ctx.sessionManager.getLabel(entryId);
标签会保留在会话中,并在重启后继续存在。可用它们在会话树中标记重要位置(轮次、检查点)。
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()
获取当前会话中可通过 prompt 调用的斜杠命令。包括扩展命令、提示词模板和 Skill 命令。
列表顺序与 RPC get_commands 一致:先是扩展,然后是提示词模板,最后是 Skill。
const commands = pi.getCommands();
const bySource = commands.filter((command) => command.source === "extension");
const userScoped = commands.filter((command) => command.sourceInfo.scope === "user");
每个条目长这样:
{
name: string; // Invokable command name without the leading slash. May be suffixed like "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)
注册一个自定义 TUI 渲染器,用于渲染 customType 消息。参见 Custom UI。
pi.registerShortcut(shortcut, options)
注册键盘快捷键。参见 keybindings.md 了解快捷键格式和内置按键绑定。
pi.registerShortcut("ctrl+shift+p", {
description: "Toggle plan mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled!");
},
});
pi.registerFlag(name, options)
注册一个 CLI flag。
pi.registerFlag("plan", {
description: "Start in plan mode",
type: "boolean",
default: false,
});
// Check value
if (pi.getFlag("plan")) {
// Plan mode enabled
}
pi.exec(command, args, options?)
执行一个 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)
管理当前激活的工具。这个接口同时适用于内置工具和动态注册的工具。pi.getActiveTools() 返回当前激活的 tool 名称,类型为 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"])]); // Keep current tools and enable my_custom_tool
pi.setActiveTools(["read", "bash"]); // Switch to read-only
pi.getAllTools() 返回 name、description、parameters、promptGuidelines 和 sourceInfo。
常见的 sourceInfo.source 值:
builtin:内置工具sdk:通过createAgentSession({ customTools })传入的工具- 扩展注册的工具所使用的扩展来源元数据
pi.setModel(model)
设置当前模型。如果该模型没有可用的 API key,返回 false。自定义模型配置请参见 models.md。
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)
获取或设置思考等级。该等级会被限制在模型能力范围内(非推理模型始终使用 "off")。修改会触发 thinking_level_select。
const current = pi.getThinkingLevel(); // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
pi.setThinkingLevel("high");
pi.events
用于扩展之间通信的共享事件总线:
pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });
pi.registerProvider(name, config)
动态注册或覆盖一个模型 Provider(模型提供方)。适用于代理、自定义端点,或团队级模型配置。
在扩展工厂函数执行期间发起的调用会被排队,等 runner 初始化完成后统一应用。初始化之后再发起的调用——例如在用户设置流程结束后,由命令处理器触发的调用——会立即生效,不需要 /reload。
如果你需要从远程端点发现模型,优先使用异步的扩展工厂函数,而不是把 fetch 延后到 session_start。pi 会在启动继续之前等待工厂函数完成,所以注册好的模型会立即可用,包括 pi --list-models 也能直接看到。
// Register a new provider with custom models
pi.registerProvider("my-proxy", {
name: "My Proxy",
baseUrl: "https://proxy.example.com",
apiKey: "$PROXY_API_KEY", // env var reference
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
}
]
});
// Override baseUrl for an existing provider (keeps all models)
pi.registerProvider("anthropic", {
baseUrl: "https://proxy.example.com"
});
// Register provider with OAuth support for /login
pi.registerProvider("corporate-ai", {
baseUrl: "https://ai.corp.com",
api: "openai-responses",
models: [...],
oauth: {
name: "Corporate AI (SSO)",
async login(callbacks) {
// Custom OAuth flow
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) {
// Refresh logic
return credentials;
},
getApiKey(credentials) {
return credentials.access;
}
}
});
Config options:
name- UI 中显示的 Provider 名称,例如/login。baseUrl- API 终端 URL。定义模型时必填。apiKey- API key 字面量、环境变量插值($ENV_VAR或${ENV_VAR}),或以!command开头的命令。定义模型时必填(除非提供了oauth)。$$用来转义$,$!用来转义字面量!,不会触发命令执行。api- API 类型:"anthropic-messages"、"openai-completions"、"openai-responses"等。headers- 请求中要带上的自定义请求头。authHeader- 如果为 true,会自动添加Authorization: Bearer请求头。models- 模型定义数组。如果提供,会替换该 Provider 下现有的所有模型。模型定义可以设置baseUrl,以覆盖该模型的 Provider 端点。oauth- 用于/login支持的 OAuth Provider 配置。提供后,该 Provider 会出现在登录菜单中。streamSimple- 用于非标准 API 的自定义流式实现。
参见 custom-provider.md 了解高级主题:自定义流式 API、OAuth 细节、模型定义参考。
pi.unregisterProvider(name)
移除之前注册的 Provider 及其模型。被该 Provider 覆盖过的内置模型会恢复回来。如果这个 Provider 从未注册过,则不会有任何影响。
和 registerProvider 一样,在初始加载阶段之后调用时会立即生效,所以不需要 /reload。
pi.registerCommand("my-setup-teardown", {
description: "Remove the custom proxy provider",
handler: async (_args, _ctx) => {
pi.unregisterProvider("my-proxy");
},
});
状态管理
带有状态的扩展,应把状态存到 tool result 的 details 中,这样才能正确支持分支:
export default function (pi: ExtensionAPI) {
let items: string[] = [];
// Reconstruct state from session
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] }, // Store for reconstruction
};
},
});
}
自定义工具
通过 pi.registerTool() 注册 LLM 可以调用的工具。工具会出现在系统提示词中,也可以有自定义渲染。
用 promptSnippet 在默认系统提示词的 Available tools 部分放一条简短的单行条目。如果省略,自定义工具就不会出现在这部分里。
用 promptGuidelines 往默认系统提示词的 Guidelines 部分添加某个工具专用的项目符号。这些项目只会在工具处于激活状态时包含进去(例如在 pi.setActiveTools([...]) 之后)。
重要: promptGuidelines 的项目符号会平铺追加到 Guidelines 部分,不会带工具名作为前缀,也不会分组。每条指南都必须写明它对应的工具——不要写“Use this tool when...”,因为 LLM 无法判断“this”指的是哪个工具。应该改成“Use my_tool when...”。
注意:有些模型会把 @ 前缀也带进 tool path 参数里。内置工具在解析路径前会自动去掉开头的 @。如果你的自定义工具接受路径,也要自己把开头的 @ 规范化掉。
如果你的自定义工具会修改文件,就要用 withFileMutationQueue(),让它和内置 edit、write 走同一个按文件排队机制。这个很重要,因为 tool call 默认是并行执行的。没有这个队列时,两个工具可能读到同一份旧文件内容,各自算出不同修改,最后写入的那个会把另一个覆盖掉。
一个失败例子是:你的自定义工具在同一次 assistant 轮次里修改 foo.ts,而内置 edit 也同时改了 foo.ts。如果你的工具不参与队列,两个工具都可能读到原始的 foo.ts,分别应用修改,最后只有其中一个改动会留下来。
传给 withFileMutationQueue() 的要是真正的目标文件路径,不要直接传用户原始参数。要先相对于 ctx.cwd 或工具自己的工作目录解析成绝对路径。对于已存在的文件,helper 会通过 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), // Use StringEnum for Google compatibility
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) {
// Check for cancellation
if (signal?.aborted) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
// Stream progress updates
onUpdate?.({
content: [{ type: "text", text: "Working..." }],
details: { progress: 50 },
});
// Run commands via pi.exec (captured from extension closure)
const result = await pi.exec("some-command", [], { signal });
// Return result
return {
content: [{ type: "text", text: "Done" }], // Sent to LLM
details: { data: result }, // For rendering & state
// Optional: stop after this tool batch when every finalized tool result
// in the batch also returns terminate: true.
terminate: true,
};
},
// Optional: Custom rendering
renderCall(args, theme, context) { ... },
renderResult(result, options, theme, context) { ... },
});
错误上报: 要把一次工具执行标记为失败(会在结果上设置 isError: true,并把它报告给 LLM),请在 execute 里抛出错误。无论你在返回对象里放了什么属性,只要是返回值,都不会设置错误标记。
提前结束: 在 execute() 里返回 terminate: true,可以提示当前这批 tool call 结束后,自动跟进的 LLM 调用可以跳过。只有当这一批里每个已最终确定的 tool result 都是 terminating 时,这个提示才会生效。参见 examples/extensions/structured-output.ts,这里有一个最小示例,展示 agent 在最后一次结构化输出的 tool call 上结束。
// Correct: throw to signal an error
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 恢复一个旧会话,而其中保存的 tool call 参数已经和当前 schema 不一致时,可以用它去模拟旧版曾接受的输入形状。返回你希望按照 parameters 校验的对象。对外的 schema 要保持严格。不要为了让旧会话还能跑,就把已废弃的兼容字段加进 parameters。
例如:旧会话里可能保存着一个 edit tool call,顶层有 oldText 和 newText,而当前 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 now matches the current schema
return {
content: [{ type: "text", text: `Applying ${params.edits.length} edit block(s)` }],
details: {},
};
},
});
覆盖内置工具
扩展可以通过注册同名工具来覆盖内置工具(read、bash、edit、write、grep、find、ls)。在交互模式下,发生这种情况时会显示警告。
# Extension's read tool replaces built-in read
pi -e ./tool-override.ts
或者,使用 --no-builtin-tools 启动,这样不会加载任何内置工具,但仍会启用扩展工具:
# No built-in tools, only extension tools
pi --no-builtin-tools -e ./my-extension.ts
一个完整示例见 examples/extensions/tool-override.ts,其中通过日志记录和访问控制覆盖了 read。
渲染: 内置渲染器的继承是按 slot 解析的。执行覆盖和渲染覆盖彼此独立。如果你的覆盖没有提供 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";
// Create tool with custom operations
const remoteRead = createReadTool(cwd, {
operations: {
readFile: (path) => sshExec(remote, `cat ${path}`),
access: (path) => sshExec(remote, `test -r ${path}`).then(() => {}),
}
});
// Register, checking flag at execution time
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,扩展可以直接复用 pi 的本地 shell 后端 createLocalBashOperations(),而不必重新实现本地进程拉起、shell 解析和进程树终止。
bash 工具还支持一个 spawn hook,用于在执行前调整命令、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" },
}),
});
完整的 SSH 示例见 examples/extensions/ssh.ts,其中使用了 --ssh 标志。
输出截断
工具必须截断输出,以免淹没 LLM 上下文。过大的输出可能导致:
- 上下文溢出错误(prompt 过长)
- 上下文压缩失败
- 模型性能下降
内置限制是 50KB(约 10k tokens)和 2000 行,以先触发者为准。请使用导出的截断工具:
import {
truncateHead, // Keep first N lines/bytes (good for file reads, search results)
truncateTail, // Keep last N lines/bytes (good for logs, command output)
truncateLine, // Truncate a single line to maxBytes with ellipsis
formatSize, // Human-readable size (e.g., "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();
// Apply truncation
const truncation = truncateHead(output, {
maxLines: DEFAULT_MAX_LINES,
maxBytes: DEFAULT_MAX_BYTES,
});
let result = truncation.content;
if (truncation.truncated) {
// Write full output to temp file
const tempFile = writeTempFile(output);
// Inform the LLM where to find complete output
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,并说明完整版本在哪里可以找到
- 在工具描述中写明截断限制
完整示例见 examples/extensions/truncated-tool.ts,其中对 rg(ripgrep)做了带有正确截断处理的封装。
多个工具
一个扩展可以注册多个共享状态的工具:
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 显示。完整的 component API 见 tui.md,tool row 的组合方式见 tool-execution.ts。
默认情况下,tool 输出会包在一个 Box 中,由它负责内边距和背景。已定义的 renderCall 或 renderResult 必须返回一个 Component。如果某个 slot 没有定义 renderer,tool-execution.ts 会为该 slot 使用回退渲染。
当工具需要自己渲染 shell,而不是使用默认的 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- 当前 tool call 的参数state- 在renderCall和renderResult之间共享的行级状态lastComponent- 该 slot 之前返回的 component,如有invalidate()- 请求重新渲染这条 tool rowtoolCallId,cwd,executionStarted,argsComplete,isPartial,expanded,showImages,isError
跨 slot 共享的数据,请放在 context.state 里。如果想在多次渲染之间复用并修改同一个 component 实例,就把 slot 级缓存保存在返回的 component 实例上。
renderCall
渲染 tool call 或标题:
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
渲染 tool 结果或输出:
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);
}
如果某个 slot 有意不显示内容,就返回一个空的 Component,例如空的 Container。
按键提示
使用 keyHint() 来展示会遵循当前 keybinding 配置的按键提示:
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)- 将已配置的 keybinding id 格式化,例如"app.tools.expand"或"tui.select.confirm"keyText(keybinding)- 返回某个 keybinding id 对应的原始配置按键文本rawKeyHint(key, description)- 格式化原始按键字符串
使用带命名空间的 keybinding 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
完整的 keybinding id 和默认值列表见 keybindings.md。keybindings.json 使用的是同一套带命名空间的 id。
自定义 editor 和 ctx.ui.custom() 组件会收到 keybindings: KeybindingsManager 作为注入参数。它们应直接使用这个注入进来的 manager,不要调用 getKeybindings() 或 setKeybindings()。
最佳实践
Text使用(0, 0)的 padding。默认的Box会处理 padding。- 多行内容请使用
\n。 - 处理流式进度时,要考虑
isPartial。 - 支持
expanded,以便按需显示细节。 - 默认视图要保持紧凑。
- 在
renderResult中直接读取context.args,不要把 args 复制到context.state。 - 只有需要在 call 和 result slot 之间共享数据时,才使用
context.state。 - 当同一个 component 实例可以原地更新时,复用
context.lastComponent。 - 只有默认的 boxed shell 妨碍布局时,才使用
renderShell: "self"。在 self-shell 模式下,工具需要自己负责边框、内边距和背景。
回退
如果某个 slot renderer 没有定义,或者抛错:
renderCall: 显示工具名称renderResult: 显示来自content的原始文本
自定义 UI
Extensions 可以通过 ctx.ui 的方法与用户交互,并自定义消息和 tool 的渲染方式。
自定义组件请参见 tui.md,里面提供可直接复制的模式,包括:
- 选择对话框(SelectList)
- 支持取消的异步操作(BorderedLoader)
- 设置项开关(SettingsList)
- 状态指示器(setStatus)
- 流式传输期间的工作中消息、可见性和指示器(
setWorkingMessage、setWorkingVisible、setWorkingIndicator) - 编辑器上方/下方的 widget(setWidget)
- 叠加在内置 slash/path 补全之上的自动补全 provider(addAutocompleteProvider)
- 自定义页脚(setFooter)
对话框
// Select from options
const choice = await ctx.ui.select("Pick one:", ["A", "B", "C"]);
// Confirm dialog
const ok = await ctx.ui.confirm("Delete?", "This cannot be undone");
// Text input
const name = await ctx.ui.input("Name:", "placeholder");
// Multi-line editor
const text = await ctx.ui.editor("Edit:", "prefilled text");
// Notification (non-blocking)
ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
带倒计时的定时对话框
对话框支持 timeout 选项。它会在显示实时倒计时的同时自动关闭:
// Dialog shows "Title (5s)" → "Title (4s)" → ... → auto-dismisses at 0
const confirmed = await ctx.ui.confirm(
"Timed Confirmation",
"This dialog will auto-cancel in 5 seconds. Confirm?",
{ timeout: 5000 }
);
if (confirmed) {
// User confirmed
} else {
// User cancelled or timed out
}
超时后的返回值:
select()返回undefinedconfirm()返回falseinput()返回undefined
使用 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) {
// User confirmed
} else if (controller.signal.aborted) {
// Dialog timed out
} else {
// User cancelled (pressed Escape or selected "No")
}
完整示例见 examples/extensions/timed-confirm.ts。
Widgets、状态和页脚
// Status in footer (persistent until cleared)
ctx.ui.setStatus("my-ext", "Processing...");
ctx.ui.setStatus("my-ext", undefined); // Clear
// Working loader (shown during streaming)
ctx.ui.setWorkingMessage("Thinking deeply...");
ctx.ui.setWorkingMessage(); // Restore default
ctx.ui.setWorkingVisible(false); // Hide the built-in working loader row entirely
ctx.ui.setWorkingVisible(true); // Show the built-in working loader row
// Working indicator (shown during streaming)
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] }); // Static dot
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: [] }); // Hide indicator
ctx.ui.setWorkingIndicator(); // Restore default spinner
// Widget above editor (default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);
// Widget below editor
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); // Clear
// Custom footer (replaces built-in footer entirely)
ctx.ui.setFooter((tui, theme) => ({
render(width) { return [theme.fg("dim", "Custom footer")]; },
invalidate() {},
}));
ctx.ui.setFooter(undefined); // Restore built-in footer
// Terminal title
ctx.ui.setTitle("pi - my-project");
// Editor text
ctx.ui.setEditorText("Prefill text");
const current = ctx.ui.getEditorText();
// Paste into editor (triggers paste handling, including collapse for large content)
ctx.ui.pasteToEditor("pasted content");
// Stack custom autocomplete behavior on top of the built-in provider
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;
},
}));
// Tool output expansion
const wasExpanded = ctx.ui.getToolsExpanded();
ctx.ui.setToolsExpanded(true);
ctx.ui.setToolsExpanded(wasExpanded);
// Custom editor (vim mode, emacs mode, etc.)
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); // Restore default editor
// Theme management (see themes.md for creating themes)
const themes = ctx.ui.getAllThemes(); // [{ name: "dark", path: "/..." | undefined }, ...]
const lightTheme = ctx.ui.getTheme("light"); // Load without switching
const result = ctx.ui.setTheme("light"); // Switch by name
if (!result.success) {
ctx.ui.notify(`Failed: ${result.error}`, "error");
}
ctx.ui.setTheme(lightTheme!); // Or switch by Theme object
ctx.ui.theme.fg("accent", "styled text"); // Access current theme
自定义的工作指示器帧会原样渲染。如果你想要颜色,需要自己把颜色写进帧字符串里,例如用 ctx.ui.theme.fg(...)。
自动补全 Providers
使用 ctx.ui.addAutocompleteProvider(),把自定义自动补全逻辑叠加在内置的斜杠命令和路径 Provider 之上。为像 $ 这样的自定义自然触发字符设置 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) {
// User pressed Enter
}
回调会收到:
tui- TUI 实例(用于屏幕尺寸、焦点管理)theme- 当前主题,用于样式设置keybindings- 应用的按键绑定管理器(用于检查快捷键)done(value)- 用来关闭组件并返回值的调用
完整的组件 API 见 tui.md。
覆盖层模式(实验性)
传入 { 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(); // focus this overlay and bring it to the visual front
// handle.unfocus({ target: editorComponent }); // release input to a specific component
// handle.setHidden(true/false); // toggle visibility
// handle.hide(); // permanently remove
}
}
);
一个处于聚焦且可见的 overlay,可以在临时的非 overlay 自定义 UI 关闭后重新接管输入。如果你有意让另一个组件在 overlay 仍然可见时继续持有输入,就调用 handle.unfocus({ target })。传入 { target: null } 会释放 overlay,但不会聚焦到其他组件。
完整的 OverlayOptions 和 OverlayHandle API 见 tui.md,示例见 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); // App keybindings + text editing
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", (_event, ctx) => {
ctx.ui.setEditorComponent((_tui, theme, keybindings) =>
new VimEditor(theme, keybindings)
);
});
}
要点:
- 继承
CustomEditor,不要继承基础Editor,这样才能拿到应用的键位绑定(escape 取消、ctrl+d、模型切换) - 对于你没处理的按键,调用
super.handleInput(data) - 工厂会从应用拿到
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.md 中的 Pattern 7。
消息渲染
使用你的 customType 注册一个自定义消息渲染器:
import { Text } from "@earendil-works/pi-tui";
pi.registerMessageRenderer("my-extension", (message, options, theme) => {
const { expanded } = 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, 0, 0);
});
消息通过 pi.sendMessage() 发送:
pi.sendMessage({
customType: "my-extension", // Matches registerMessageRenderer
content: "Status update",
display: true, // Show in TUI
details: { ... }, // Available in renderer
});
主题颜色
所有渲染函数都会收到一个 theme 对象。关于创建自定义主题和完整配色表,请看 themes.md。
// Foreground colors
theme.fg("toolTitle", text) // Tool names
theme.fg("accent", text) // Highlights
theme.fg("success", text) // Success (green)
theme.fg("error", text) // Errors (red)
theme.fg("warning", text) // Warnings (yellow)
theme.fg("muted", text) // Secondary text
theme.fg("dim", text) // Tertiary text
// Text styles
theme.bold(text)
theme.italic(text)
theme.strikethrough(text)
在自定义 tool 渲染器里做语法高亮时:
import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent";
// Highlight code with explicit language
const highlighted = highlightCode("const x = 1;", "typescript", theme);
// Auto-detect language from file path
const lang = getLanguageFromPath("/path/to/file.rs"); // "rust"
const highlighted = highlightCode(code, lang, theme);
错误处理
- 扩展错误会被记录,agent 会继续运行
tool_call错误会阻止该 tool(fail-safe)- Tool 的
execute错误必须通过抛出来上报;抛出的错误会被捕获,以isError: true报给 LLM,然后继续执行
模式行为
| 模式 | ctx.mode | ctx.hasUI | 说明 |
|---|---|---|---|
| Interactive | "tui" | true | 带终端渲染的完整 TUI |
RPC (--mode rpc) | "rpc" | true | 通过 JSON 协议提供对话框和通知;custom() 返回 undefined。见 rpc.md |
JSON (--mode json) | "json" | false | 输出事件流到 stdout;UI 方法不生效 |
Print (-p) | "print" | false | 扩展会运行,但不能弹出提示 |
在使用仅限 TUI 的功能(custom()、组件工厂、终端输入)之前,先判断 ctx.mode === "tui"。对于在 TUI 和 RPC 模式下都可用的对话框和通知方法,先判断 ctx.hasUI。
示例参考
所有示例都在 examples/extensions/ 中。
| 示例 | 说明 | 关键 API |
|---|---|---|
| 工具 | ||
hello.ts | 最小化的 tool 注册 | registerTool |
question.ts | 带用户交互的 tool | registerTool, ui.select |
questionnaire.ts | 多步骤向导式 tool | registerTool, ui.custom |
todo.ts | 带持久化的有状态 tool | registerTool, appendEntry, renderResult, session 事件 |
dynamic-tools.ts | 在启动后和命令执行期间注册 tools | registerTool, session_start, registerCommand |
structured-output.ts | 最终 structured-output tool,terminate: true | registerTool, 终止型 tool 结果 |
truncated-tool.ts | 输出截断示例 | registerTool, truncateHead |
tool-override.ts | 覆盖内置的 read tool | registerTool(与内置同名) |
| 命令 | ||
pirate.ts | 按轮次修改 system prompt | registerCommand, before_agent_start |
summarize.ts | 对话摘要命令 | registerCommand, ui.custom |
handoff.ts | 跨 Provider 的模型切换 | registerCommand, ui.editor, ui.custom |
qna.ts | 带自定义 UI 的问答 | registerCommand, ui.custom, setEditorText |
send-user-message.ts | 带入用户消息 | registerCommand, sendUserMessage |
reload-runtime.ts | 重载命令和 LLM tool 交接 | registerCommand, ctx.reload(), sendUserMessage |
shutdown-command.ts | 优雅关闭命令 | registerCommand, shutdown() |
| 事件与 Gate | ||
permission-gate.ts | 拦截危险命令 | on("tool_call"), ui.confirm |
project-trust.ts | 从用户/全局设置或 CLI 扩展中决定或延后项目信任 | on("project_trust"), trust UI, 所需的 trust 结果 |
protected-paths.ts | 阻止向特定路径写入 | on("tool_call") |
confirm-destructive.ts | 确认 session 变更 | 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 | 检查 payload 和 Provider 响应头 | on("before_provider_request"), on("after_provider_response") |
system-prompt-header.ts | 展示 system prompt 信息 | on("agent_start"), getSystemPrompt |
claude-rules.ts | 从文件加载规则 | on("session_start"), on("before_agent_start") |
prompt-customizer.ts | 使用 systemPromptOptions 添加带上下文的 tool 指引 | on("before_agent_start"), BuildSystemPromptOptions |
file-trigger.ts | 文件 watcher 触发消息 | 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, session 事件 |
working-indicator.ts | 自定义流式工作指示器 | setWorkingIndicator, registerCommand |
github-issue-autocomplete.ts | 通过预加载 gh issue list 中最近的 open issues,在内置自动补全之上添加 #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 | 编辑器上方/下方的 widget | setWidget |
overlay-test.ts | Overlay 组件 | ui.custom,带 overlay 选项 |
overlay-qa-tests.ts | 全面的 overlay 测试 | ui.custom,所有 overlay 选项 |
notify.ts | 简单通知 | ui.notify |
timed-confirm.ts | 带超时的对话框 | 带超时/信号的 ui.confirm |
mac-system-theme.ts | 自动切换主题 | setTheme, exec |
| 复杂扩展 | ||
plan-mode/ | 完整的 plan mode 实现 | 所有事件类型,registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools |
preset.ts | 可保存的预设(model、tools、thinking) | registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry |
tools.ts | 用于开关 tools 的 UI | registerCommand, setActiveTools, SettingsList, session 事件 |
| 远程与沙箱 | ||
ssh.ts | SSH 远程执行 | registerFlag, on("user_bash"), on("before_agent_start"), tool 操作 |
interactive-shell.ts | 持久 shell 会话 | on("user_bash") |
sandbox/ | 沙箱化的 tool 执行 | tool 操作 |
gondolin/ | 把内置 tools 和 ! 命令路由到 Gondolin micro-VM | tool 操作、内置 tool 覆盖、on("user_bash") |
subagent/ | 启动子 agent | registerTool, exec |
| 游戏 | ||
snake.ts | 贪吃蛇游戏 | registerCommand, ui.custom, 键盘处理 |
space-invaders.ts | 太空侵略者游戏 | registerCommand, ui.custom |
doom-overlay/ | 在 overlay 中运行 Doom | 带 overlay 的 ui.custom |
| Providers | ||
custom-provider-anthropic/ | 自定义 Anthropic 代理 | registerProvider |
custom-provider-gitlab-duo/ | GitLab Duo 集成 | 带 OAuth 的 registerProvider |
| 消息与通信 | ||
message-renderer.ts | 自定义消息渲染 | registerMessageRenderer, sendMessage |
event-bus.ts | 扩展间事件 | pi.events |
| 会话元数据 | ||
session-name.ts | 为 selector 中的会话命名 | setSessionName, getSessionName |
bookmark.ts | 为 /tree 标记条目 | setLabel |
| 其他 | ||
inline-bash.ts | 在 tool call 中内联 bash | on("tool_call") |
bash-spawn-hook.ts | 在执行前调整 bash 命令、cwd 和 env | createBashTool, spawnHook |
with-deps/ | 包含 npm 依赖的扩展 | 带 package.json 的 Package 结构 |