第十章:扩展系统 -- Extension API
"好的框架不是让你做更多事,而是让你用更少的代码做更多的事。"
10.1 扩展系统概览
Pi 的扩展系统是它的核心竞争力。通过扩展,你可以:
| 能力 | 说明 |
|---|---|
| 注册自定义工具 | 让 LLM 调用你的工具 |
| 注册斜杠命令 | /my-command |
| 注册快捷键 | Ctrl+Shift+X |
| 注册自定义标志 | --my-flag |
| 监听生命周期事件 | 在会话开始、工具调用等时刻执行逻辑 |
| 修改 UI | 添加 Widget、Header、Footer |
| 注册自定义 Provider | 添加新的 LLM 提供商 |
| 注册消息渲染器 | 自定义消息的显示方式 |
📌 源码定位 ─────────────────────────────────────
packages/coding-agent/src/core/extensions/types.ts-- 扩展 API 类型定义(1550 行)packages/coding-agent/src/core/extensions/runner.ts-- 扩展运行时packages/coding-agent/src/core/extensions/loader.ts-- 扩展加载器packages/coding-agent/examples/extensions/-- 50+ 示例扩展─────────────────────────────────────────────────
10.2 扩展的基本结构
一个扩展就是一个导出默认函数的 TypeScript 文件:
// my-extension.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function myExtension(pi: ExtensionAPI) {
// 在这里注册工具、命令、事件监听器等
}⚡ Java 对照 ───────────────────────────────────── 这就是 Java SPI(Service Provider Interface)的函数式版本。Java 中你需要:
- 定义接口
interface Extension { void init(Context ctx); } - 实现类
class MyExtension implements Extension { ... } - 注册文件
META-INF/services/Extension
TypeScript 中只需要一个导出函数。简洁得多。 ─────────────────────────────────────────────────
10.3 ExtensionAPI 接口
ExtensionAPI 是扩展与 Pi 交互的唯一入口。它的方法可以分为几类:
事件订阅
interface ExtensionAPI {
// 24 种事件类型的重载签名
on(event: "session_start", handler: (e: SessionStartEvent, ctx: ExtensionContext) => void): void;
on(event: "agent_start", handler: (e: AgentStartEvent, ctx: ExtensionContext) => void): void;
on(event: "tool_call", handler: (e: ToolCallEvent, ctx: ExtensionContext) => void): void;
on(event: "turn_end", handler: (e: TurnEndEvent, ctx: ExtensionContext) => void): void;
// ... 20+ 种事件
}工具注册
interface ExtensionAPI {
registerTool<TParams extends TSchema, TDetails>(
tool: ToolDefinition<TParams, TDetails, any>
): void;
}命令注册
interface ExtensionAPI {
registerCommand(
name: string,
options: {
description?: string;
getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
}
): void;
}UI 操作
interface ExtensionAPI {
// 通过 ExtensionContext.ui 访问
// select, confirm, input, notify, setStatus, setWidget, ...
}消息发送
interface ExtensionAPI {
sendMessage<T>(message: CustomMessage<T>, options?: SendMessageOptions): void;
sendUserMessage(content: string, options?: SendMessageOptions): void;
}10.4 ExtensionContext:事件处理器的上下文
每个事件处理器都接收一个 ExtensionContext,它提供了运行时信息和操作能力:
interface ExtensionContext {
ui: ExtensionUIContext; // UI 操作接口
hasUI: boolean; // 是否在 TUI 模式下
cwd: string; // 当前工作目录
sessionManager: ReadonlySessionManager; // 会话管理器(只读)
modelRegistry: ModelRegistry; // 模型注册表
model: Model<any> | undefined; // 当前模型
isIdle: () => boolean; // Agent 是否空闲
signal: AbortSignal | undefined; // 取消信号
abort: () => void; // 请求取消
shutdown: () => void; // 关闭会话
getContextUsage: () => ContextUsage; // 上下文使用情况
compact: (options?) => void; // 触发压缩
getSystemPrompt: () => string; // 获取当前系统提示词
}★ Insight ─────────────────────────────────────ExtensionContext 是一个只读的运行时快照。 它让你"看到"当前状态,但不能直接修改 Agent 的内部状态。修改需要通过 ExtensionAPI 的方法(如 registerTool、sendMessage)。这种设计防止了扩展意外破坏 Agent 的内部一致性。 ─────────────────────────────────────────────────
10.5 实战:写一个自定义工具扩展
让我们实现一个"代码统计"工具,它统计指定文件的行数、函数数等:
// .pi/extensions/code-stats.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "fs/promises";
const codeStatsSchema = Type.Object({
path: Type.String({ description: "Path to the source file to analyze" }),
});
export default function codeStatsExtension(pi: ExtensionAPI) {
pi.registerTool({
name: "code_stats",
label: "Code Stats",
description: "Analyze a source file and return statistics (lines, functions, classes)",
promptSnippet: "Use code_stats to get quick metrics about a source file.",
promptGuidelines: [
"Use code_stats before making changes to understand the file's complexity.",
],
parameters: codeStatsSchema,
async execute(toolCallId, params) {
const content = await fs.readFile(params.path, "utf-8");
const lines = content.split("\n");
const stats = {
totalLines: lines.length,
blankLines: lines.filter(l => l.trim() === "").length,
commentLines: lines.filter(l => l.trim().startsWith("//") || l.trim().startsWith("/*")).length,
functionCount: (content.match(/function\s+\w+/g) || []).length,
classCount: (content.match(/class\s+\w+/g) || []).length,
importCount: (content.match(/^import\s/gm) || []).length,
};
return {
content: [{
type: "text",
text: [
`File: ${params.path}`,
`Total lines: ${stats.totalLines}`,
`Blank lines: ${stats.blankLines}`,
`Comment lines: ${stats.commentLines}`,
`Functions: ${stats.functionCount}`,
`Classes: ${stats.classCount}`,
`Imports: ${stats.importCount}`,
].join("\n"),
}],
details: stats,
};
},
});
}★ Insight ─────────────────────────────────────promptSnippet 和 promptGuidelines 是给 LLM 看的。 它们会被注入到系统提示词中,让 LLM 知道什么时候应该使用这个工具。promptSnippet 是简短描述,promptGuidelines 是使用建议。这和 Java 中 Javadoc 的 @see 标签类似——帮助使用者(这里是 LLM)理解工具的用途。 ─────────────────────────────────────────────────
10.6 实战:写一个事件监听扩展
这个扩展会记录每次工具调用的耗时:
// .pi/extensions/tool-timer.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function toolTimerExtension(pi: ExtensionAPI) {
const timings = new Map<string, number>(); // toolCallId → startTime
pi.on("tool_execution_start", (event, ctx) => {
timings.set(event.toolCallId, Date.now());
});
pi.on("tool_execution_end", (event, ctx) => {
const startTime = timings.get(event.toolCallId);
if (startTime) {
const duration = Date.now() - startTime;
timings.delete(event.toolCallId);
ctx.ui.notify(
`[Timer] ${event.toolName}: ${duration}ms`,
"info"
);
}
});
// 会话结束时输出统计
pi.on("session_shutdown", (event, ctx) => {
console.log("[Timer] Session ended. Remaining timings:", timings.size);
});
}10.7 实战:写一个斜杠命令扩展
// .pi/extensions/context-info.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function contextInfoExtension(pi: ExtensionAPI) {
pi.registerCommand("context", {
description: "Show current context usage",
handler: async (args, ctx) => {
const usage = ctx.getContextUsage();
if (!usage) {
ctx.ui.notify("No context usage available", "warning");
return;
}
const lines = [
`Tokens: ${usage.tokens?.toLocaleString() ?? "unknown"}`,
`Context Window: ${usage.contextWindow.toLocaleString()}`,
`Usage: ${usage.percent?.toFixed(1) ?? "unknown"}%`,
];
if (args.trim() === "--compact") {
ctx.compact();
ctx.ui.notify("Compaction triggered", "info");
} else {
ctx.ui.notify(lines.join("\n"), "info");
}
},
});
}10.8 自定义 Provider 扩展
扩展还可以注册新的 LLM 提供商:
// .pi/extensions/custom-provider.ts
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function customProviderExtension(pi: ExtensionAPI) {
pi.registerProvider("my-local-llm", {
name: "My Local LLM",
baseUrl: "http://localhost:11434",
api: "openai-completions",
models: [
{
id: "my-model",
name: "My Custom Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
maxTokens: 4096,
},
],
});
}★ Insight ─────────────────────────────────────这让 Pi 可以连接任何 OpenAI 兼容的 API。 很多本地 LLM(如 Ollama、LM Studio、vLLM)都暴露了 OpenAI 兼容的 API。通过注册自定义 Provider,你可以用这些本地模型运行 Pi,不需要云端 API key。 ─────────────────────────────────────────────────
10.9 扩展的加载和生命周期
Pi 启动
│
▼
加载扩展(从 ~/.pi/agent/extensions/ 和 .pi/extensions/)
│
├── 扫描目录,找到 .ts 文件
├── 用 jiti(TypeScript 运行时加载器)导入
├── 调用 default export 函数,传入 ExtensionAPI
│ │
│ ├── pi.on("session_start", ...) → 注册事件处理器
│ ├── pi.registerTool(...) → 注册工具
│ ├── pi.registerCommand(...) → 注册命令
│ └── pi.registerProvider(...) → 注册 Provider
│
▼
会话开始
│
├── emit("session_start") → 调用注册的处理器
├── emit("before_agent_start")
├── emit("agent_start")
├── [Agent Loop 运行]
│ ├── emit("turn_start")
│ ├── emit("tool_call") → beforeToolCall 拦截
│ ├── emit("tool_result") → afterToolCall 拦截
│ └── emit("turn_end")
├── emit("agent_end")
└── emit("session_shutdown")⚡ Java 对照 ───────────────────────────────────── 扩展的生命周期和 Java Servlet 的生命周期类似:
- 加载 →
init() - 请求处理 →
service() - 销毁 →
destroy()
或者 Spring Bean 的生命周期:
@PostConstruct→ 使用中 →@PreDestroy─────────────────────────────────────────────────
10.10 事件类型速查表
| 事件 | 触发时机 | 典型用途 |
|---|---|---|
session_start | 会话创建/恢复 | 初始化状态、注册动态工具 |
session_shutdown | 会话关闭 | 清理资源、保存状态 |
before_agent_start | Agent 开始处理前 | 修改系统提示词 |
agent_start | Agent 开始 | 记录开始时间 |
agent_end | Agent 结束 | 记录结束时间、统计 |
turn_start | 一轮 LLM 调用开始 | 显示进度指示器 |
turn_end | 一轮结束 | 清理进度指示器 |
tool_call | 工具调用前 | 审计日志、权限检查 |
tool_result | 工具执行后 | 结果过滤、日志记录 |
input | 用户输入 | 自定义输入处理 |
context | 上下文构建时 | 注入额外上下文 |
model_select | 模型切换 | 记录模型使用情况 |
10.11 本章小结
Extension API 的核心设计:
┌─────────────────────────────────────────────────────────┐
│ ExtensionAPI │
│ │
│ ┌────────────┐ ┌────────────┐ ┌───────────────────┐ │
│ │ on() │ │ register │ │ sendMessage() │ │
│ │ 事件订阅 │ │ Tool/ │ │ appendEntry() │ │
│ │ │ │ Command/ │ │ setSessionName() │ │
│ │ │ │ Provider │ │ │ │
│ └────────────┘ └────────────┘ └───────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐│
│ │ ExtensionContext ││
│ │ ui (操作界面) │ model (当前模型) │ signal (取消信号) ││
│ └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘关键设计决策:
- 单一入口:
ExtensionAPI是唯一的交互接口 - 事件驱动:所有交互通过事件订阅
- 类型安全:24 种事件类型都有精确的类型定义
- 隔离性:扩展不能直接修改 Agent 内部状态
- 动态加载:用 jiti 运行时加载 TypeScript,无需预编译