Skip to content

第十章:扩展系统 -- 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 文件:

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 中你需要:

  1. 定义接口 interface Extension { void init(Context ctx); }
  2. 实现类 class MyExtension implements Extension { ... }
  3. 注册文件 META-INF/services/Extension

TypeScript 中只需要一个导出函数。简洁得多。 ─────────────────────────────────────────────────

10.3 ExtensionAPI 接口

ExtensionAPI 是扩展与 Pi 交互的唯一入口。它的方法可以分为几类:

事件订阅

typescript
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+ 种事件
}

工具注册

typescript
interface ExtensionAPI {
    registerTool<TParams extends TSchema, TDetails>(
        tool: ToolDefinition<TParams, TDetails, any>
    ): void;
}

命令注册

typescript
interface ExtensionAPI {
    registerCommand(
        name: string,
        options: {
            description?: string;
            getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
            handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
        }
    ): void;
}

UI 操作

typescript
interface ExtensionAPI {
    // 通过 ExtensionContext.ui 访问
    // select, confirm, input, notify, setStatus, setWidget, ...
}

消息发送

typescript
interface ExtensionAPI {
    sendMessage&lt;T&gt;(message: CustomMessage&lt;T&gt;, options?: SendMessageOptions): void;
    sendUserMessage(content: string, options?: SendMessageOptions): void;
}

10.4 ExtensionContext:事件处理器的上下文

每个事件处理器都接收一个 ExtensionContext,它提供了运行时信息和操作能力:

typescript
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 的方法(如 registerToolsendMessage)。这种设计防止了扩展意外破坏 Agent 的内部一致性。 ─────────────────────────────────────────────────

10.5 实战:写一个自定义工具扩展

让我们实现一个"代码统计"工具,它统计指定文件的行数、函数数等:

typescript
// .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 ─────────────────────────────────────promptSnippetpromptGuidelines 是给 LLM 看的。 它们会被注入到系统提示词中,让 LLM 知道什么时候应该使用这个工具。promptSnippet 是简短描述,promptGuidelines 是使用建议。这和 Java 中 Javadoc 的 @see 标签类似——帮助使用者(这里是 LLM)理解工具的用途。 ─────────────────────────────────────────────────

10.6 实战:写一个事件监听扩展

这个扩展会记录每次工具调用的耗时:

typescript
// .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 实战:写一个斜杠命令扩展

typescript
// .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 提供商:

typescript
// .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_startAgent 开始处理前修改系统提示词
agent_startAgent 开始记录开始时间
agent_endAgent 结束记录结束时间、统计
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 (取消信号) ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘

关键设计决策

  1. 单一入口ExtensionAPI 是唯一的交互接口
  2. 事件驱动:所有交互通过事件订阅
  3. 类型安全:24 种事件类型都有精确的类型定义
  4. 隔离性:扩展不能直接修改 Agent 内部状态
  5. 动态加载:用 jiti 运行时加载 TypeScript,无需预编译

第九章:上下文压缩 | 第十一章:实战 -- 从零构建你的 Agent →

基于 MIT 许可证发布