pi 可以创建扩展。让它为你的用例构建一个。
扩展
扩展是用于扩展 pi 行为的 TypeScript 模块。它们可以订阅生命周期事件、注册可由 LLM 调用的自定义工具、添加命令等。
用于 /reload 的放置位置: 将扩展放入
~/.pi/agent/extensions/(全局)或.pi/extensions/(项目本地)以实现自动发现。使用pi -e ./path.ts仅用于快速测试。位于自动发现位置的扩展可以通过以下命令热重载:/reload.
关键功能:
- 自定义工具 - 注册 LLM 可以通过以下方式调用的工具:
pi.registerTool() - 事件拦截 - 阻止或修改工具调用、注入上下文、自定义压缩
- 用户交互 - 通过以下方式提示用户:
ctx.ui(选择、确认、输入、通知) - 自定义 UI 组件 - 通过以下方式提供完整的 TUI 组件和键盘输入:
ctx.ui.custom()用于复杂交互 - 自定义命令 - 注册命令,例如
/mycommand通过pi.registerCommand() - 会话持久化 - 通过以下方式存储重启后仍保留的状态:
pi.appendEntry() - 自定义渲染 - 控制工具调用/结果和消息在 TUI 中的显示方式
示例用例:
- 权限门控(在以下操作前确认:
rm -rf,sudo等) - Git 检查点(每轮暂存,在分支上恢复)
- 路径保护(阻止写入
.env,node_modules/) - 自定义压缩(按你的方式总结对话)
- 对话摘要(参见
summarize.ts示例) - 交互式工具(问题、向导、自定义对话框)
- 有状态工具(待办事项列表、连接池)
- 外部集成(文件监视器、webhook、CI 触发器)
- 等待时玩游戏(参见
snake.ts示例)
参见 示例/扩展/ 获取可用的实现。
目录
快速开始
创建 ~/.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/bar@1.0.0",
"git:github.com/user/repo@v1"
],
"extensions": [
"/path/to/local/extension.ts",
"/path/to/local/extension/dir"
]
}要通过 npm 或 git 以 pi 包的形式共享扩展,请参阅 packages.md.
可用的导入
| 包 | 用途 |
|---|---|
@earendil-works/pi-coding-agent | 扩展类型(ExtensionAPI, ExtensionContext、事件) |
typebox | 工具参数的架构定义 |
@earendil-works/pi-ai | AI 实用工具(StringEnum 用于 Google 兼容的枚举) |
@earendil-works/pi-tui | 用于自定义渲染的 TUI 组件 |
npm 依赖项也可以使用。在扩展旁边(或父目录中)添加一个 package.json ,运行 npm install,来自 node_modules/ 的导入将自动解析。
对于使用 pi install (npm 或 git)安装的分布式 pi 包,运行时依赖项必须位于 dependencies中。包安装默认使用生产安装(npm install --omit=dev),因此 devDependencies 在运行时不可用;当配置了 npmCommand 时,git 包使用普通的 install 以兼容包装器。
Node.js 内置模块(node:fs, node:path等)也可用。
编写扩展
扩展导出一个默认的工厂函数,该函数接收 ExtensionAPI。工厂函数可以是同步或异步的:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
export default function (pi: ExtensionAPI) {
// 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", { ... });
}扩展通过 即时加载,因此 TypeScript 无需编译即可使用。
如果工厂函数返回一个 Promise,pi 会在继续启动之前等待它。这意味着异步初始化会在 session_start之前、 resources_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 # Entry point (exports default function)
├── tools.ts # Helper module
└── utils.ts # Helper module
带依赖的包 - 适用于需要 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_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 - 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" }。返回 "yes" 或 "no" 的用户/全局或 CLI 扩展拥有决定权;第一个是/否决定胜出并抑制内置的信任提示。使用 remember: true 来持久化是/否决定;否则它仅适用于当前进程。返回 "undecided" 以让后续处理程序或内置信任流程决定。在提示之前检查 ctx.hasUI 。如果没有处理程序返回是/否,正常的信任解析将继续:首先应用已保存的 trust.json 决定,然后 defaultProjectTrust 控制 pi 默认是询问、信任还是拒绝。
资源事件
资源发现
在 session_start 之后触发,以便扩展可以贡献额外的技能、提示和主题路径。
启动路径使用 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。
会话开始
当会话启动、加载或重新加载时触发。
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");
});会话信息已更改
当当前会话显示名称通过 /name、RPC 或 pi.setSessionName().
pi.on("session_info_changed", async (event, ctx) => {
// event.name - current normalized name, or undefined if cleared
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 - 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 ,为新会话重新加载并重新绑定扩展,然后发出 session_start ,其中包含 reason: "new" | "resume" 和 previousSessionFile。
在 session_shutdown中进行清理工作,然后在 session_start.
会话分叉前
当通过 /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 ,为新会话重新加载并重新绑定扩展,然后发出 session_start ,其中包含 reason: "fork" 和 previousSessionFile。
在 session_shutdown,然后在 session_start.
session_before_compact / session_compact
在压缩时触发。详见 compaction.md 了解详情。
pi.on("session_before_compact", async (event, ctx) => {
const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;
// reason - "manual" (/compact), "threshold", or "overflow"
// willRetry - whether the aborted turn is retried after compaction (overflow recovery)
// Cancel:
return { cancel: true };
// Custom summary:
return {
compaction: {
summary: "...",
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
// usage: summaryResponse.usage, // Optional; included in session totals
}
};
});
pi.on("session_compact", async (event, ctx) => {
// event.compactionEntry - the saved compaction
// event.fromExtension - whether extension provided it
// event.reason - "manual" (/compact), "threshold", or "overflow"
// event.willRetry - whether the aborted turn is retried after compaction (overflow recovery)
});session_before_tree / session_tree
在 /tree 导航时触发。详见 会话 了解树导航概念。
pi.on("session_before_tree", async (event, ctx) => {
const { preparation, signal } = event;
return { cancel: true };
// OR provide custom summary:
return {
summary: {
summary: "...",
// usage: summaryResponse.usage, // Optional; included in session totals
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 - destination session for session replacement flows
// Cleanup, save state, etc.
});智能体事件
在代理启动之前
在用户提交提示词后、智能体循环开始前触发。可以注入消息和/或修改系统提示词。
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 用于构建系统提示词的相同结构化数据。这使你可以检查 Pi 已加载的内容——自定义提示词、指南、工具片段、上下文文件、技能——而无需重新发现资源或重新解析标志。当你的扩展需要对系统提示词进行深度、明智的更改,同时尊重用户提供的配置时,可以使用它。
在 before_agent_start, event.systemPrompt 和 ctx.getSystemPrompt() 中,两者都反映当前处理程序中的链式系统提示词。后续的 before_agent_start 处理程序仍然可以再次修改它。
agent_start / agent_end / agent_settled
agent_start 在低级别智能体运行开始时触发。 agent_end 在该运行结束时触发,但 Pi 可能仍会自动重试、自动压缩并重试,或继续处理排队的后续消息。对于需要知道 Pi 不会自动继续运行的状态集成,请使用 agent_settled 用于需要知道 Pi 不会继续自动运行的状态集成。
pi.on("agent_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => {
// event.messages - messages from this low-level run
});
pi.on("agent_settled", async (_event, ctx) => {
// ctx.isIdle() is true here unless another extension started a new run.
});turn_start / turn_end
为每个回合(一次 LLM 响应 + 工具调用)触发。
pi.on("turn_start", async (event, ctx) => {
// event.turnIndex, event.timestamp
});
pi.on("turn_end", async (event, ctx) => {
// event.turnIndex, event.message, event.toolResults
});message_start / message_update / message_end
为消息生命周期更新触发。
message_start和message_end针对用户、助手和 toolResult 消息触发。message_update针对助手流式更新触发。message_end处理程序可以返回{ message }来替换最终的消息。替换的消息必须保持相同的role.
pi.on("message_start", async (event, ctx) => {
// event.message
});
pi.on("message_update", async (event, ctx) => {
// event.message
// event.assistantMessageEvent (token-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_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 - deep copy, safe to modify
const filtered = event.messages.filter(m => !shouldPrune(m));
return { messages: filtered };
});提供者标头之前
在传出 HTTP 头部组装完成后触发。用于添加、覆盖或删除请求头部。
处理程序会就地修改 event.headers 。将键设置为字符串以添加或覆盖,或设置为 null 以删除。
pi.on("before_provider_headers", (event, ctx) => {
// Add or override — e.g. a session id for gateway tracing/attribution
event.headers["x-session-id"] = ctx.sessionManager.getSessionId();
// Drop a tracking header pi adds for this call
event.headers["X-OpenRouter-Title"] = null;
});每个提供商请求运行一次;重试会重用相同的标头,而不会重新触发钩子。
“提供程序请求前”
在构建好特定于提供商的负载之后、发送请求之前触发。处理程序按扩展加载顺序运行。返回 undefined 会保持负载不变。返回任何其他值会为后续处理程序和实际请求替换负载。
此钩子可以重写提供商级别的系统指令或将其完全移除。这些负载级别的更改不会反映在 ctx.getSystemPrompt()中,后者报告的是 Pi 的系统提示字符串,而不是最终序列化的提供商负载。
pi.on("before_provider_request", (event, ctx) => {
console.log(JSON.stringify(event.payload, null, 2));
// Optional: replace payload
// return { ...event.payload, temperature: 0 };
});这主要用于调试提供商序列化和缓存行为。
“提供者响应后”
在收到 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"]);
}
});标头的可用性取决于提供商和传输方式。抽象了 HTTP 响应的提供商可能不会暴露标头。
模型事件
模型选择
当模型通过 /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 元素(状态栏、页脚)或执行特定于模型的初始化。
思考层级选择
当思考级别更改时触发。这仅是通知;处理程序的返回值会被忽略。
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_execution_start之后、工具执行之前触发。 可以阻塞。 使用 isToolCallEventType 来缩小范围并获取类型化的输入。
在 tool_call 运行之前,pi 会等待先前发出的 Agent 事件通过 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", 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", 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; // typed
}
});工具结果
在工具执行完成之后、 tool_execution_end 以及最终的工具结果消息事件被发出。 可以修改结果。
在并行工具模式下, tool_result 和 tool_execution_end 可能按工具完成顺序交错出现,而最终的 toolResult 消息事件仍会稍后按助手源顺序发出。
tool_result 处理器像中间件一样链式调用:
- 处理器按扩展加载顺序运行
- 每个处理器看到的是前一个处理器修改后的最新结果
- 处理器可以返回部分补丁(
content,details,isError、或usage);省略的字段保持其当前值
使用 ctx.signal 在处理器内部进行嵌套异步工作。这允许 Esc 取消模型调用、 fetch()以及扩展启动的其他可中止操作。
import { isBashToolResult } from "@earendil-works/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.toolCallId, event.input
// event.content, event.details, event.isError, event.usage
if (isBashToolResult(event)) {
// event.details 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, usage: nestedModelUsage };
});用户 Bash 事件
用户 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 } };
});输入事件
输入
当收到用户输入时触发,在检查扩展命令之后、但在技能和模板展开之前。事件看到的是原始输入文本,因此 /skill:foo 和 /template 尚未展开。
处理顺序:
- 首先检查扩展命令(
/cmd)——如果找到,则运行处理器并跳过输入事件 input事件触发——可以拦截、转换或处理- 如果未处理:技能命令(
/skill:name)展开为技能内容 - 如果未处理:提示词模板(
/template)展开为模板内容 - 开始智能体处理(
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- 完全跳过智能体(第一个返回此结果的处理器获胜)
转换在处理器之间链式传递。请参阅 input-transform.ts 和 input-transform-streaming.ts 了解 streamingBehavior感知路由。
扩展上下文
所有处理器都接收 ctx: ExtensionContext.
ctx.ui
用于用户交互的 UI 方法。有关完整详细信息,请参阅 自定义 UI 了解完整详情。
ctx.mode
当前运行模式: "tui", "rpc", "json"或 "print"。使用 ctx.mode === "tui" 来保护仅限终端的功能,例如 custom()、组件工厂、终端输入和直接 TUI 渲染。
ctx.hasUI
true 在 TUI 和 RPC 模式下为 true。 false 在打印模式(-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
对会话状态的只读访问。有关完整的 SessionManager API 和条目类型,请参见 会话格式 了解完整的 SessionManager API 和条目类型。
对于 tool_call,此状态在处理程序运行之前通过当前助手消息进行同步。在并行工具执行模式下,仍然不能保证包含来自同一助手消息的兄弟工具结果。
ctx.sessionManager.getEntries() // All entries
ctx.sessionManager.getBranch() // Current branch
ctx.sessionManager.buildContextEntries() // Active branch entries with compaction applied
ctx.sessionManager.getLeafId() // Current leaf entry IDctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels
访问模型、提供商和已解析的身份验证。 ctx.modelRegistry.getProvider(id) 返回有效的 pi-ai 提供商,而 getProviderAuth(id) 解析其当前的 API 密钥、标头、基础 URL 和提供商范围的环境,而无需加载模型。 ctx.model 是活动模型, ctx.thinkingLevel 是其当前有效的思考级别。
ctx.scopedModels 是限定在当前会话范围内的模型的只读列表——与 /scoped-models 命令显示的集合相同。它在会话开始时从 --models CLI 标志和 enabledModels 设置(使用 minimatch 在 provider/modelId 或裸 modelId上与可用目录进行匹配)解析。当未配置范围时,它为空,这意味着每个可用模型都可用。每个条目都是 { model, thinkingLevel? },其中 thinkingLevel 仅在模式固定时设置(例如 anthropic/*:high)。使用它来填充镜像内置选择器的模型选择器,而不是通过 ctx.modelRegistry.getAvailable().
ctx.signal
当前智能体中止信号,当没有智能体回合处于活动状态时为 undefined 当没有智能体轮次处于活动状态时。
将此用于由扩展处理程序启动的可感知中止的嵌套工作,例如:
fetch(..., { signal: ctx.signal })- 接受
signal - 文件或进程帮助程序的模型调用,这些帮助程序接受
AbortSignal
ctx.signal 通常在活动回合事件期间定义,例如 tool_call, tool_result, message_update和 turn_end。
在空闲或非回合上下文中(例如会话事件、扩展命令以及在 pi 空闲时触发的快捷方式),它通常为 undefined 在空闲或非轮次上下文中,例如会话事件、扩展命令以及在 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.isIdle() 在 Pi 正在处理智能体运行、自动重试、自动压缩重试或排队的继续时,为 false。
ctx.shutdown()
请求 pi 的优雅关闭。
- 交互模式: 推迟到智能体变为空闲(在处理完所有排队的引导和后续消息之后)。
- RPC 模式: 推迟到下一个空闲状态(在完成当前命令响应后,等待下一个命令时)。
- 打印模式: 无操作。当所有提示词处理完毕后,进程自动退出。
在退出前向所有扩展发送 session_shutdown 事件。在所有上下文中可用(事件处理器、工具、命令、快捷键)。
pi.on("tool_call", (event, ctx) => {
if (isFatal(event.input)) {
ctx.shutdown();
}
});ctx.getContextUsage()
返回当前活动模型的上下文使用情况。当有上一次助手使用情况时使用它,然后估算后续消息的令牌数。
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 当前的系统提示词字符串。
- 在
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 并增加了会话控制方法。这些方法仅在命令中可用,因为如果从事件处理器中调用可能会导致死锁。
ctx.getSystemPromptOptions()
返回 Pi 当前用于构建系统提示词的基础输入。
const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];这与 before_agent_start event.systemPromptOptions具有相同的结构和可变性:自定义提示词、活动工具、工具片段、提示词指南、附加的系统提示词文本、当前工作目录、加载的上下文文件和加载的技能。它可能包含完整的上下文文件内容,因此请将其视为敏感的扩展本地数据,并避免通过命令列表、日志或自动补全元数据暴露它。
这报告当前的基础提示词输入。它不包括每轮 before_agent_start 链式系统提示词更改、后续 context 事件消息变更或 before_provider_request 负载重写。
ctx.waitForIdle()
等待智能体完全稳定,包括自动重试、自动压缩重试和排队的继续操作:
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
}选项:
parentSession:要记录在新会话头中的父会话文件setup:在SessionManager运行之前修改新会话的withSession运行withSession:针对新的替换会话上下文运行切换后工作。不要使用捕获的旧pi/ 命令ctx;请参阅 会话替换生命周期和陷阱.
ctx.fork(entryId, options?)
从特定条目分叉,创建一个新的会话文件:
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
}选项:
position:"before"(默认)在选定的用户消息之前分叉,将该提示词恢复到编辑器中position:"at"复制通过选定条目的活动路径,而不恢复编辑器文本withSession:针对全新的替换会话上下文运行切换后工作。不要使用捕获的旧pi/ commandctx;参见 会话替换生命周期与陷阱.
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",
});选项:
summarize:是否生成被放弃分支的摘要customInstructions:给摘要器的自定义指令replaceInstructions:如果为 true,customInstructions会替换默认提示词,而不是追加label:附加到分支摘要条目(或在不摘要时附加到目标条目)的标签
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
}选项:
withSession:针对全新的替换会话上下文运行切换后工作。不要使用捕获的旧pi/ commandctx;参见 会话替换生命周期与陷阱.
要发现可用会话,请使用静态方法 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会话绑定对象在替换后已过时,如果使用会抛出错误。仅使用传递给ctx的withSession进行会话绑定工作。 - 之前提取的原始对象仍由你负责。例如,如果你在替换前捕获了
const sm = ctx.sessionManager,那么sm仍然是旧的SessionManager对象。不要在替换后重用它。 - 中的代码
withSession应假设任何被你的session_shutdown处理器无效化的状态已经消失。只捕获能干净地存活于关闭过程的纯数据,例如字符串、ID 和序列化配置。
安全模式:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const kickoff = "Continue from the replacement session";
await ctx.newSession({
withSession: async (ctx) => {
await ctx.sendUserMessage(kickoff);
},
});
},
});不安全模式:
pi.registerCommand("handoff", {
handler: async (_args, ctx) => {
const oldSessionManager = ctx.sessionManager;
await ctx.newSession({
withSession: async (_ctx) => {
// stale old objects: do not do this
oldSessionManager.getSessionFile();
pi.sendUserMessage("wrong");
},
});
},
});ctx.reload()
运行与 /reload.
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, themes, and context files",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});相同的重载流程。重要行为:
await ctx.reload()为当前扩展运行时发出session_shutdown用于当前扩展运行时- 然后重新加载资源并发出
session_start,其中reason: "reload"和resources_discover的原因为"reload" - 。当前正在运行的命令处理程序仍在旧的调用帧中继续。
- 之后的代码
await ctx.reload()仍从重载前的版本运行。 - 代码在
await ctx.reload()不得假定旧的内存扩展状态仍然有效 - 处理程序返回后,后续的命令/事件/工具调用将使用新的扩展版本
为了可预测的行为,将重载视为该处理程序的终止(await ctx.reload(); return;).
工具在 ExtensionContext中运行,因此它们无法直接调用 ctx.reload() 。使用命令作为重载入口点,然后暴露一个工具,该工具将该命令作为后续用户消息排队。
LLM 可以调用以触发重载的示例工具:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
export default function (pi: ExtensionAPI) {
pi.registerCommand("reload-runtime", {
description: "Reload extensions, skills, prompts, themes, and context files",
handler: async (_args, ctx) => {
await ctx.reload();
return;
},
});
pi.registerTool({
name: "reload_runtime",
label: "Reload Runtime",
description: "Reload extensions, skills, prompts, themes, and context files",
parameters: Type.Object({}),
async execute() {
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
return {
content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
};
},
});
}ExtensionAPI 方法
pi.on(event, handler)
订阅事件。有关事件类型和返回值,请参阅 事件 了解事件类型和返回值。
pi.registerTool(definition)
注册一个可由 LLM 调用的自定义工具。有关完整详细信息,请参阅 自定义工具 了解完整详情。
pi.registerTool() 在扩展加载期间和启动后均可使用。您可以在 session_start、命令处理程序或其他事件处理程序中调用它。新工具会在同一会话中立即刷新,因此它们会出现在 pi.getAllTools() 中,并且可由 LLM 调用,无需 /reload.
使用 pi.setActiveTools() 在运行时启用或禁用工具(包括动态添加的工具)。
使用 promptSnippet 将自定义工具加入 Available tools中的单行条目,并使用 promptGuidelines 在工具处于活动状态时,将特定于工具的要点附加到默认的 Guidelines 部分。
重要提示: promptGuidelines 要点会平铺附加到 Guidelines 部分,不带工具名称前缀。每条指南必须指明其引用的工具——避免使用“当...时使用此工具”,因为 LLM 无法判断“此”指的是哪个工具。应改为“当...时使用 my_tool”。
有关完整示例,请参阅 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?)
向会话中注入自定义消息。自定义消息参与 LLM 上下文。对于不应发送给 LLM 的持久性 TUI 内容,请使用 pi.appendEntry() 并设置 pi.registerEntryRenderer().
pi.sendMessage({
customType: "my-extension",
content: "Message text",
display: true,
details: { ... },
}, {
triggerTurn: true,
deliverAs: "steer",
});选项:
deliverAs- 传递模式:"steer"(默认)- 在流式传输时排队消息。在当前助手回合完成执行其工具调用后、下一次 LLM 调用之前传递。"followUp"- 等待智能体完成。仅当智能体没有更多工具调用时才传递。"nextTurn"- 排队等待下一个用户提示。不会中断或触发任何操作。
triggerTurn: true- 如果智能体空闲,立即触发 LLM 响应。仅适用于"steer"和"followUp"模式(对"nextTurn").
pi.sendUserMessage(content, options?)
向智能体发送用户消息。与 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" });
// Opt in to extension command dispatch and skill/prompt template expansion
pi.sendUserMessage("/review src/index.ts", { expandPromptTemplates: true });选项:
deliverAs- 当智能体正在流式传输时必需:"steer"- 将消息排队,在当前助手回合完成执行其工具调用后传递"followUp"- 等待智能体完成所有工具
expandPromptTemplates- 分发扩展命令并展开技能命令和提示词模板。默认为false.
当不流式传输时,消息会立即发送并触发新的一轮。当流式传输但没有 deliverAs时,会抛出错误。
参见 send-user-message.ts 获取完整示例。
pi.appendEntry(customType, data?)
持久化扩展数据。自定义条目不参与 LLM 上下文。在交互模式下,当与 pi.registerEntryRenderer().
pi.appendEntry("my-state", { count: 42 });
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });
// 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 调用的斜杠命令。包括扩展命令、提示词模板和技能命令。
该列表与 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; // 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)
为带有您的 customType的自定义消息注册自定义 TUI 渲染器。自定义消息通过 pi.sendMessage() 创建,并参与 LLM 上下文。参见 自定义 UI.
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)
为自定义条目注册一个自定义 TUI 渲染器,使用你的 customType。自定义条目通过 pi.appendEntry() 创建,并且不参与 LLM 上下文。
import { Box, Text } from "@earendil-works/pi-tui";
pi.registerEntryRenderer("status-card", (entry, { expanded }, theme) => {
const data = entry.data as { title: string; count: number };
const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
box.addChild(new Text(`${theme.bold(data.title)}: ${data.count}`));
if (expanded) {
box.addChild(new Text(theme.fg("dim", JSON.stringify(data, null, 2))));
}
return box;
});
pi.appendEntry("status-card", { title: "Indexed files", count: 17 });pi.registerShortcut(shortcut, options)
注册一个键盘快捷键。请参阅 keybindings.md 了解快捷键格式和内置键绑定。
pi.registerShortcut("ctrl+shift+p", {
description: "Toggle plan mode",
handler: async (ctx) => {
ctx.ui.notify("Toggled!");
},
});pi.registerFlag(name, options)
注册一个 CLI 标志。
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.killedpi.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"])]); // Keep current tools and enable my_custom_tool
pi.setActiveTools(["read", "bash"]); // Switch to read-onlypi.getAllTools() 返回 name, description, parameters, promptGuidelines,而 sourceInfo.
典型的 sourceInfo.source 值:
builtin用于内置工具sdk用于通过createAgentSession({ customTools })- 传递的工具,以及扩展注册工具的扩展源元数据
pi.setModel(model)
设置当前模型。如果该模型没有可用的 API 密钥,则返回 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" | "max"
pi.setThinkingLevel("high");pi.events
共享事件总线,用于扩展之间的通信:
pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });pi.registerProvider(name, config)
动态注册或覆盖模型提供商。适用于代理、自定义端点或团队范围的模型配置。
在扩展工厂函数期间进行的调用会被排队,并在运行器初始化后应用。之后进行的调用——例如在用户设置流程后从命令处理程序进行的调用——会立即生效,无需 /reload.
动态提供商可以实现 refreshModels。Pi 在模型刷新期间调用它,通过提供商同步发布返回的列表,并传递规范的凭证/存储目录/网络/信号上下文。扩展决定是否通过生成检查的 context.publish({ persist: entry })持久化目录元数据;像 llama.cpp 这样的实时服务器可以返回模型而不持久化它们。
context.signal 始终是一个具体的信号,提供商回调必须将其传递给阻塞 I/O。公共的 ModelRuntime.refresh() 和 ModelRegistry.refresh() 调用接受一个可选的信号,省略时则无限制;扩展和应用程序选择自己的截止时间。取消操作会停止调用者等待,即使提供商忽略信号,但仍需要协作来停止底层工作。
需要原生提供商认证、过滤、刷新或流行为的扩展可以注册一个完整的 Provider 从 @earendil-works/pi-ai。该提供商成为组合基础,并且 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);
// 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
}
]
});
// Register a live llama.cpp catalog without persisting discovered models
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
}));
}
});
// 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, signal) {
signal.throwIfAborted();
// Refresh logic
return credentials;
},
getApiKey(credentials) {
return credentials.access;
}
}
});对象形式接受一个完整的 pi-ai Provider,包括原生的 auth, getModels, refreshModels, filterModels, stream、和 streamSimple 行为。
旧版配置选项:
name- 提供商在 UI 中的显示名称,例如/login.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包含持久化的提供商快照;仅在更新的目录数据应持久化时使用代际检查的context.publish({ persist: entry })。使用persist: null删除该快照。oauth- 用于/login支持的 OAuth 提供商配置。提供后,该提供商将出现在登录菜单中。streamSimple- 用于非标准 API 的自定义流式传输实现。
有关高级主题,请参阅 custom-provider.md :自定义流式 API、OAuth 详情、模型定义参考。
pi.unregisterProvider(name)
移除先前注册的提供商及其模型。被该提供商覆盖的内置模型将恢复。如果提供商未注册,则无效。
与 registerProvider类似,在初始加载阶段之后调用时立即生效,因此不需要 /reload 不是必需的。
pi.registerCommand("my-setup-teardown", {
description: "Remove the custom proxy provider",
handler: async (_args, _ctx) => {
pi.unregisterProvider("my-proxy");
},
});状态管理
有状态的扩展应将其状态存储在工具结果 details 中,以支持正确的分支:
export default function (pi: ExtensionAPI) {
let items: string[] = [];
// 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 部分,不带工具名称前缀或分组。每条指南必须指明它所引用的工具——避免使用“当……时使用此工具”,因为LLM无法判断“此”指的是哪个工具。应写成“当……时使用 my_tool”。
注意:有些模型很蠢,会在工具路径参数中包含 @ 前缀。内置工具在解析路径前会去除前导 @。如果你的自定义工具接受路径,也应同样规范化前导 @。
如果你的自定义工具会修改文件,请使用 withFileMutationQueue() ,这样它就能参与和内置 edit 及 write相同的每个文件队列。这很重要,因为工具调用默认并行运行。没有队列,两个工具可能读取相同的旧文件内容,计算出不同的更新,然后最后写入的那个会覆盖另一个。
示例失败场景:你的自定义工具编辑 foo.ts ,而内置 edit 也在同一个助手回合中更改 foo.ts 。如果你的工具不参与队列,两者都可能读取原始的 foo.ts,应用各自的更改,然后其中一个更改会丢失。
将实际目标文件路径传递给 withFileMutationQueue(),而不是原始用户参数。先将其解析为绝对路径,相对于 ctx.cwd 或你的工具的工作目录。对于现有文件,辅助函数通过 realpath()进行规范化,因此同一文件的符号链接别名共享一个队列。对于新文件,它会回退到解析后的绝对路径,因为还没有东西可以 realpath() 尚未。
在该目标路径上排队整个变更窗口。这包括读取-修改-写入逻辑,而不仅仅是最终的写入。
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const absolutePath = resolve(ctx.cwd, params.path);
return withFileMutationQueue(absolutePath, async () => {
await mkdir(dirname(absolutePath), { recursive: true });
const current = await readFile(absolutePath, "utf8");
const next = current.replace(params.oldText, params.newText);
await writeFile(absolutePath, next, "utf8");
return {
content: [{ type: "text", text: `Updated ${params.path}` }],
details: {},
};
});
}工具定义
import { Type } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import { Text } from "@earendil-works/pi-tui";
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "What this tool does (shown to LLM)",
promptSnippet: "List or add items in the project todo list",
promptGuidelines: [
"Use my_tool for todo planning instead of direct file edits when the user asks for a task list."
],
parameters: Type.Object({
action: StringEnum(["list", "add"] as const), // 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
// usage: nestedModelResponse.usage, // Optional nested LLM usage
// 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) { ... },
});用量统计: 如果工具进行嵌套的LLM调用,将其合并的 Usage 作为 usage返回。Pi 会将其持久化在工具结果中,并包含在页脚、 /session和 RPC 会话总计中。 tool_result 处理器可以检查或替换此值。
错误信号: 要将工具执行标记为失败(在结果上设置 isError: true 并报告给LLM),从 execute中抛出错误。返回值绝不会设置错误标志,无论你在返回对象中包含什么属性。
提前终止: 从 terminate: true 返回 execute() ,以提示在当前工具批次后应跳过自动的后续LLM调用。仅当该批次中每个最终确定的工具结果都是终止性的时,此设置才生效。参见 examples/extensions/structured-output.ts 获取一个最小示例,其中智能体在最终的 structured-output 工具调用时结束。
// 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: {} };
}重要: 对于字符串枚举,请使用 StringEnum 中的 @earendil-works/pi-ai 了解字符串枚举。 Type.Union/Type.Literal 不适用于 Google 的 API。
参数准备: prepareArguments(args) 是可选的。如果定义,它会在模式验证之前和 execute()之前运行。当 pi 恢复一个旧会话,且其存储的工具调用参数不再匹配当前模式时,用它来模拟旧的接受的输入形状。返回你希望根据 parameters进行验证的对象。保持公共模式严格。不要仅仅为了让旧的恢复会话继续工作而向 parameters 添加已弃用的兼容性字段。
示例:一个较旧的会话可能包含一个 edit 工具调用,其中包含顶层的 oldText 和 newText,而当前模式只接受 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 并添加了日志记录和访问控制。
渲染: 内置渲染器的继承是按插槽解析的。执行覆盖和渲染覆盖是独立的。如果你的覆盖省略了 renderCall,则使用内置的 renderCall 。如果你的覆盖省略了 renderResult,则使用内置的 renderResult 。如果你的覆盖两者都省略了,则会自动使用内置渲染器(语法高亮、差异等)。这让你可以包装内置工具以实现日志记录或访问控制,而无需重新实现 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,扩展可以通过 createLocalBashOperations() 重用 pi 的本地 shell 后端,而无需重新实现本地进程生成、shell 解析和进程树终止。
bash 工具还支持一个 spawn 钩子,用于在执行前调整命令、cwd 或 env:
import { createBashTool } from "@earendil-works/pi-coding-agent";
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd: `/mnt/sandbox${cwd}`,
env: { ...env, CI: "1" },
}),
});createBashTool() 通过 PI_SESSION_ID, PI_SESSION_FILE, PI_PROVIDER, PI_MODEL,以及 PI_REASONING_LEVEL向命令公开当前会话。注入发生在 spawnHook之前,因此钩子在 env 中接收这些值,并在像上面那样展开现有环境时保留它们。设置 exposeSessionEnvironment: false 可禁用它们:
const bashTool = createBashTool(cwd, {
exposeSessionEnvironment: false,
});参见 Bash 工具会话环境 了解变量语义。参见 examples/extensions/ssh.ts 获取一个完整的 SSH 示例,其中包含 --ssh 标志。
输出截断
工具必须截断其输出 ,以避免淹没 LLM 上下文。过大的输出可能导致:
- 上下文溢出错误(提示词过长)
- 压缩失败
- 模型性能下降
内置限制为 50KB (约 10k 个 token)和 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 显示。参见 tui.md 获取完整的组件 API,以及 tool-execution.ts 了解工具行是如何组成的。
默认情况下,工具输出被包装在一个 Box 中,该组件处理内边距和背景。定义的 renderCall 或 renderResult 必须返回一个 Component。如果未定义插槽渲染器, tool-execution.ts 将对该插槽使用回退渲染。
设置 renderShell: "self" 当工具应渲染自己的外壳而不是使用默认的 Box时。这对于需要完全控制框架或背景行为的工具很有用,例如,在工具稳定后必须保持视觉稳定的大型预览。
pi.registerTool({
name: "my_tool",
label: "My Tool",
description: "Custom shell example",
parameters: Type.Object({}),
renderShell: "self",
async execute() {
return { content: [{ type: "text", text: "ok" }], details: undefined };
},
renderCall(args, theme, context) {
return new Text(theme.fg("accent", "my custom shell"), 0, 0);
},
});renderCall 和 renderResult 各自接收一个 context 对象,其中包含:
args- 当前工具调用参数state- 在renderCall和renderResultlastComponent之间共享的行局部状态 - 该插槽先前返回的组件(如果有)invalidate()- 请求重新渲染此工具行toolCallId,cwd,executionStarted,argsComplete,isPartial,expanded,showImages,isError
使用 context.state 进行跨插槽共享状态。当你想在渲染之间重用和修改同一个组件时,将插槽本地缓存保留在返回的组件实例上。
renderCall
渲染工具调用或头部:
import { Text } from "@earendil-works/pi-tui";
renderCall(args, theme, context) {
const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
let content = theme.fg("toolTitle", theme.bold("my_tool "));
content += theme.fg("muted", args.action);
if (args.text) {
content += " " + theme.fg("dim", `"${args.text}"`);
}
text.setText(content);
return text;
}renderResult
渲染工具结果或输出:
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:
- 编码智能体 ID 使用
app.*命名空间,例如app.tools.expand,app.editor.external,app.session.rename - 共享 TUI ID 使用
tui.*命名空间,例如tui.select.confirm,tui.select.cancel,tui.input.tab
有关键绑定 ID 和默认值的详尽列表,请参阅 keybindings.md. keybindings.json 使用这些相同的命名空间 ID。
自定义编辑器和 ctx.ui.custom() 组件接收 keybindings: KeybindingsManager 作为注入参数。它们应直接使用该注入的管理器,而不是调用 getKeybindings() 或 setKeybindings().
最佳实践
- 使用
Text并设置内边距(0, 0). The default Box handles padding. - 使用
\n处理多行内容。 - 处理
isPartial以实现流式进度。 - 支持
expanded以便按需显示详细信息。 - 保持默认视图紧凑。
- 读取
context.args在renderResult中,而不是将参数复制到context.state. - 使用
context.state仅用于必须在调用和结果槽之间共享的数据。 - 重用
context.lastComponent当相同的组件实例可以就地更新时。 - 使用
renderShell: "self"仅在默认的盒状外壳碍事时才使用。在自外壳模式下,工具负责自身的框架、内边距和背景。
回退
如果槽位渲染器未定义或抛出异常:
renderCall:显示工具名称renderResult:显示原始文本,来自content
动态工具加载
扩展可以注册许多工具,同时仅保持一小部分初始工具处于活动状态。然后,工具可以在执行期间使用 pi.setActiveTools() 添加更多工具。Pi 检测到纯增量更改,在该工具结果上记录新可用的工具名称,并在下一个模型请求之前应用更新后的活动工具集。
这适用于所有模型。具有原生延迟加载支持的模型会保留稳定的提示前缀,并在工具结果位置加载新的定义。其他模型则使用下述回退机制。
生命周期如下:
- 使用
pi.registerTool()注册每个工具,使其出现在pi.getAllTools(). - 保持加载器工具(例如
search_tools)处于活动状态,并将可搜索工具保持为非活动状态。 - 在加载器执行期间,调用
pi.setActiveTools([...currentTools, ...matchingTools]). The change must be additive: do not remove currently active tools in the same call. - Pi 在加载器的工具结果上记录添加了哪些工具。
- 在下一个模型响应之前,Pi 使用原生延迟加载(如果支持)或常规活动工具列表来公开添加的定义。
你无需返回特定于提供商的工具引用,也无需将加载器标记为特殊的搜索工具。活动工具集的更改就是信号。传递给 pi.setActiveTools() 的名称必须已经注册;未知名称将被忽略。
具有原生延迟加载的模型
- Anthropic
- 模型: Sonnet、Opus、Fable 版本 4.5 或更高(不包括 Haiku)
- 原生表示: 延迟定义使用
defer_loading;加载点使用tool_reference内容。
- OpenAI
- 模型:
gpt-5.4及更新的系列 - 原生表示: Pi 在加载点添加已完成的客户端
tool_search_call和tool_search_output项。
- 模型:
对于已验证的自定义模型或代理,可以通过 compat.supportsToolReferences: true 为 anthropic-messages启用原生处理,或通过 compat.supportsToolSearch: true 为 openai-responses 和 openai-codex-responses启用。除非端点和模型接受相应的原生协议,否则请保持这些选项禁用。
回退行为
对于所有其他模型和提供商,动态激活仍然有效:Pi 会在下一次请求时正常发送完整的当前活动工具列表。模型可以调用新激活的工具,但添加它们的定义可能会使提供商缓存的提示前缀失效。
当活动集合不是纯粹增量时(例如用一组工具替换另一组工具),Pi 也会使用这种安全的回退方式。因此,工具移除可以正常工作,但不会使用延迟加载。
为了获得最佳的缓存行为,请在整个会话期间保持加载器工具处于活动状态,并添加工具而不是替换活动集合。另请注意,使用 promptSnippet 或 promptGuidelines 激活工具会重建系统提示;即使提供商支持延迟模式,该系统提示的更改也可能使前缀失效。延迟加载的工具通常应依赖其工具 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", () => {
// Keep searchable tools registered but initially inactive. Preserve built-ins
// and tools owned by other extensions, and keep the loader itself active.
const initialTools = pi.getActiveTools().filter(
(name) => !SEARCHABLE_TOOL_NAMES.has(name),
);
pi.setActiveTools([...new Set([...initialTools, "search_tools"])]);
});
}当 search_tools 添加匹配项时,模型会在紧随其后的请求中收到该定义。在支持原生功能的模型上,该定义会锚定在搜索结果之后,而不会更改初始的工具模式前缀。在其他模型上,它会出现在同一后续请求的正常工具列表中。
自定义 UI
扩展可以通过 ctx.ui 方法与用户交互,并自定义消息/工具的渲染方式。
有关自定义组件,请参阅 tui.md ,其中包含以下内容的复制粘贴模式:
- 选择对话框(SelectList)
- 带取消功能的异步操作(BorderedLoader)
- 设置开关(SettingsList)
- 状态指示器(setStatus)
- 流式传输期间的工作消息、可见性和指示器(
setWorkingMessage,setWorkingVisible,setWorkingIndicator) - 编辑器上方/下方的小部件(setWidget)
- 构建在内置斜杠/路径补全之上的自动补全提供程序(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 获取完整示例。
小部件、状态和页脚
// 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(...).
自动补全提供程序
使用 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 议题,并在本地过滤以实现快速 #... 补全。它需要 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)- 调用以关闭组件并返回值
参见 tui.md 获取完整的组件 API。
叠加模式(实验性)
传递 { 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
}
}
);一个聚焦的可见叠加层可以在临时非叠加自定义 UI 关闭后重新获取输入。如果你有意让另一个组件在叠加层保持可见时保持输入,请调用 handle.unfocus({ target })。传递 { target: null } 释放叠加层而不聚焦另一个组件。
参见 tui.md 获取完整的 OverlayOptions 和 OverlayHandle API,以及 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(tui, theme, keybindings)
);
});
}关键点:
- 扩展
CustomEditor(而不是基础Editor)以获取应用键绑定(退出、ctrl+d、模型切换) - 对于未处理的按键,调用
super.handleInput(data)用于你不处理的键 - Factory 接收
tui,theme,以及keybindings来自应用 - 使用
ctx.ui.getEditorComponent()在setEditorComponent()之前包装之前配置的自定义编辑器 - 传递
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 模式 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", // Matches registerMessageRenderer
content: "Status update",
display: true, // Show in TUI
details: { ... }, // Available in renderer
});发送。对于不应发送给 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 对象。参见 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)对于自定义工具渲染器中的语法高亮:
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);错误处理
- 扩展错误被记录,智能体继续运行
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 特定功能( ctx.mode === "tui" 、组件工厂、终端输入)之前使用custom()。在 TUI 和 RPC 模式下均可工作的对话框和通知方法之前使用 ctx.hasUI 在对话框和通知方法之前,这些方法在 TUI 和 RPC 模式下均可工作。
示例参考
所有示例位于 示例/扩展/.
| 示例 | 描述 | 关键 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 | 覆盖内置读取工具 | registerTool (与内置同名) |
| 命令 | ||
pirate.ts | 每回合修改系统提示词 | registerCommand, before_agent_start |
summarize.ts | 对话摘要命令 | registerCommand, ui.custom |
handoff.ts | 跨提供商模型切换 | registerCommand, ui.editor, ui.custom |
qna.ts | 自定义界面的问答 | 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"),信任界面,所需的信任结果 |
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暂存 | 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 |
| 界面组件 | ||
status-line.ts | 页脚状态指示器 | setStatus,会话事件 |
working-indicator.ts | 自定义流式工作指示器 | setWorkingIndicator, registerCommand |
github-issue-autocomplete.ts | 添加 #1234 通过预加载最近打开的问题,在内置自动补全之上提供问题补全 gh issue list | 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 | 带超时的对话框 | ui.confirm 带超时/信号 |
mac-system-theme.ts | 自动切换主题 | setTheme, exec |
| 复杂扩展 | ||
plan-mode/ | 完整的计划模式实现 | 所有事件类型, registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools |
preset.ts | 可保存的预设(模型、工具、思考) | registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry |
tools.ts | 开关工具的界面 | registerCommand, setActiveTools, SettingsList,会话事件 |
| 远程与沙箱 | ||
ssh.ts | SSH远程执行 | registerFlag, on("user_bash"), on("before_agent_start"),工具操作 |
interactive-shell.ts | 持久化shell会话 | on("user_bash") |
sandbox/ | 沙箱化工具执行 | 工具操作 |
gondolin/ | 将内置工具和 ! 命令路由到Gondolin微虚拟机 | 工具操作,内置工具覆盖, 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集成 | registerProvider 使用 OAuth |
| 消息与通信 | ||
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 |