Skip to content

第 10 章 · Extensions 入门:TypeScript 扩展 API

本章目标:理解扩展能做什么、放在哪里、如何加载与热重载,掌握扩展的生命周期事件全景,并完整走读一个最小扩展示例。

10.1 扩展是 pi 的"超能力层"

如果说技能是"给模型看的说明书",扩展就是"给 pi 本体动的手术"。扩展是 TypeScript 模块,可以:

  • 注册自定义工具——pi.registerTool(),让 LLM 能调用你的函数;
  • 拦截事件——阻止或修改工具调用(如 rm -rf 前弹窗确认)、注入上下文、自定义压缩;
  • 用户交互——通过 ctx.ui 弹出 select/confirm/input/notify;
  • 自定义 UI 组件——ctx.ui.custom() 构建带键盘交互的 TUI 组件;
  • 自定义命令——pi.registerCommand() 注册 /mycommand
  • 会话持久化——pi.appendEntry() 存储跨重启的状态。

官方列举的典型用例:危险命令权限门、git 自动 checkpoint、保护 .env 不被写入、按自己方式做压缩、文件监听/webhook 集成……甚至有人写了贪吃蛇在等待时玩。pi 刻意不内置子代理和计划模式,就是要靠这个生态来长。

10.2 放在哪里、怎么加载

text
~/.pi/agent/extensions/*.ts          ← 全局自动发现
~/.pi/agent/extensions/*/index.ts    ← 全局(子目录形式)
.pi/extensions/*.ts                  ← 项目级(受 Project Trust 门控)
.pi/extensions/*/index.ts            ← 项目级(子目录形式)

补充通道:settings.json 的 extensions 数组可指定本地路径/目录;packages 数组可加载 npm/git 分发的包;CLI 用 -e ./path.ts 临时挂载。

开发期用 -e,稳定后进自动发现目录

官方建议:只有放进自动发现位置的扩展才能配合 /reload 热重载;pi -e ./x.ts 只适合快速试验。

bash
# 快速测试一个扩展
pi -e ./my-extension.ts
# 放入自动发现位置后,改代码后在交互模式里:
/reload

权限即用户权限

扩展以你的完整系统权限运行、可执行任意代码——项目级扩展之所以被 Project Trust 门控就是这个原因。只安装你信任的来源。

10.3 最小扩展示例走读

下面这个官方风格的示例覆盖了四类核心能力,逐段拆解:

typescript
// ~/.pi/agent/extensions/my-extension.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
  // ① 订阅生命周期事件:会话启动时通知
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });

  // ② 拦截工具调用:危险 bash 命令先问过用户
  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" };
    }
  });

  // ③ 注册自定义工具:LLM 可直接调用的 greet
  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: {},
      };
    },
  });

  // ④ 注册自定义命令 /hello
  pi.registerCommand("hello", {
    description: "Say hello",
    handler: async (args, ctx) => {
      ctx.ui.notify(`Hello ${args || "world"}!`, "info");
    },
  });
}

四个关键点:

  1. 默认导出一个工厂函数,参数是 ExtensionAPI;工厂可以是异步的,pi 会等它完成再继续启动;
  2. 工具参数 schema 用 typeboxType.Object(...) 描述,description 字段会成为模型理解参数的依据;
  3. tool_call 处理器返回 { block: true, reason } 即可拦截本次工具执行;
  4. 扩展经 jiti 加载,TypeScript 直接写、无需编译步骤。

依赖管理:在扩展旁放 package.jsonnpm install 后,node_modules 里的包自动可用;Node 内置模块(node:fs 等)天然可用。

10.4 生命周期事件全景

官方文档给出的启动到响应的完整事件流(节选主干):

text
pi 启动
 ├─ project_trust          (仅用户/全局/CLI 扩展收到,早于项目资源加载)
 ├─ session_start { reason: "startup" }
 └─ resources_discover
用户发送提示
 ├─ input                  (可拦截/改写/接管输入)
 ├─ before_agent_start     (可注入消息、修改 system prompt)
 ├─ agent_start
 │   ┌─ turn 循环(LLM 调工具则重复)─────────┐
 │   ├─ turn_start → context(可改消息列表)
 │   ├─ tool_execution_start
 │   ├─ tool_call           (可 block)
 │   ├─ tool_result         (可修改结果)
 │   └─ turn_end
 ├─ agent_end
 └─ agent_settled          (无重试/压缩/后续消息时)
/new 或 /resume 切换会话
 └─ session_before_switch → session_shutdown → session_start{reason}

两个对初学者最重要的实践规则:

  • 不要在工厂函数里启动后台资源(进程、socket、定时器)——工厂可能在没有会话的调用中运行;把这类初始化推迟到 session_start,并在 session_shutdown 里幂等地清理;
  • project_trust 早于项目资源:需要感知信任状态的逻辑应挂在 user/global 扩展上。

异步工厂的一个真实用途——启动时从本地服务拉取模型列表并注册 provider:

typescript
export default async function (pi: ExtensionAPI) {
  const res = await fetch("http://localhost:1234/v1/models");
  const payload = await res.json();
  pi.registerProvider("local-openai", {
    baseUrl: "http://localhost:1234/v1",
    apiKey: "$LOCAL_OPENAI_API_KEY",
    api: "openai-completions",
    models: payload.data.map((m) => ({
      id: m.id,
      name: m.name ?? m.id,
      reasoning: false,
      input: ["text"],
      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
      contextWindow: m.context_window ?? 128000,
      maxTokens: m.max_tokens ?? 4096,
    })),
  });
}

调试方法:ctx.ui.notify() 打点最简单;类型定义可直接查看安装目录 node_modules/@earendil-works/pi-coding-agent/dist/

本章小结

  • 扩展 = 默认导出工厂函数的 TypeScript 模块,jiti 直载免编译;
  • 自动发现位置:全局 ~/.pi/agent/extensions/、项目 .pi/extensions/(后者需信任),开发期用 -e + /reload
  • 四大能力:事件订阅(pi.on)、工具注册(registerTool + typebox)、命令注册(registerCommand)、UI 交互(ctx.ui);
  • tool_call 返回 {block:true, reason} 可拦截危险操作;
  • 后台资源延迟到 session_start 再启动,session_shutdown 幂等清理。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 扩展文件的正确导出形式是什么?

2. 想在 LLM 调用 bash 执行 rm -rf 前弹出确认框,应该订阅哪个事件?

3. 关于扩展的加载与热重载,正确的是?

4. 扩展需要在会话期间维护一个文件监听器,正确的做法是?

🛠️ 动手实践

  1. 实现本章的 my-extension.ts,验证 /hello 命令、greet 工具与 rm -rf 拦截三条路径都工作。
  2. 写一个 tool_result 事件的处理器,把所有 bash 输出末尾追加一行 [inspected by ext],观察对模型的影响。
  3. 故意在工厂函数里 setTimeout 一个无限循环打印,再用规范写法(挪到 session_start + session_shutdown 清理)对比两种行为的差异。

完成练习后,进入下一章:Extensions 进阶