為什麼需要為 Pi Agent 寫擴充?

如果你已經用過 Pi Coding Agent,就會發現它是一個「小而美」的終端 AI 程式助手——核心只做一件事:把 LLM 的能力串進終端工作流。但真正讓它變得「專屬」的,是它的擴充系統(Extensions)

Pi 的擴充是 TypeScript 模組,可以訂閱生命週期事件、註冊 LLM 可呼叫的自訂工具、新增斜線命令,甚至自訂 TUI 元件。官方生態裡已經有 pi-hosts(23 pts)、Parallel Pi agents(8 pts)等社群專案,但繁體中文深度技術內容仍然稀缺。

如果你還沒裝 Pi,建議先看這篇入門:2026 年 5 大開源 AI 程式代理橫評,裡面有 Pi 的定位對比。本文則專注擴充開發,帶你從零寫出 4 個實戰擴充。

Pi Agent 架構解析

核心元件

Pi 的架構可以用一句話概括:最小核心 + 可擴充邊界

┌─────────────────────────────────────────────┐
│  Pi 核心核心                                 │
│  ├─ LLM Provider 層(Anthropic/OpenAI/本地) │
│  ├─ Session 管理(會話、壓縮、分支)          │
│  ├─ 內建工具(bash、read、write、search…)    │
│  └─ TUI 渲染(Ink/React)                    │
└─────────────────────────────────────────────┘
        ▲            ▲            ▲
        │            │            │
   Extensions     Skills      Packages
  (TypeScript)   (Markdown)   (npm/git)
  • Extensions:TypeScript 模組,執行時載入,能攔截事件、註冊工具、新增命令。
  • Skills:Markdown 檔案,按需載入到上下文,定義工作流和提示詞。
  • Packages:透過 npm 或 git 分發的擴充集合。

外掛系統原理

Pi 的擴充載入基於 jiti,無需編譯即可直接執行 TypeScript。擴充的入口是一個預設匯出的工廠函式,接收 ExtensionAPI 實體:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  // 在這裡訂閱事件、註冊工具、新增命令
}

擴充可以放在兩個位置被自動發現:

位置 作用域
~/.pi/agent/extensions/*.ts 全域(所有專案)
.pi/extensions/*.ts 專案本地(需信任專案)

API 介面說明

ExtensionAPI 提供三類能力:

  1. 事件訂閱pi.on("event_name", handler) — 涵蓋從 session_starttool_call 的完整生命週期。
  2. 工具註冊pi.registerTool({ name, description, parameters, execute }) — 讓 LLM 能呼叫你的自訂函式。
  3. 命令註冊pi.registerCommand("name", { handler }) — 新增 /name 斜線命令。

開發環境建置

前置條件

  • Node.js 18+(推薦 20 LTS)
  • npm 或 pnpm
  • Pi Coding Agent 已安裝:npm install -g --ignore-scripts @earendil-works/pi-coding-agent

建立擴充專案

# 建立全域擴充目錄
mkdir -p ~/.pi/agent/extensions/my-first-ext
cd ~/.pi/agent/extensions/my-first-ext

# 初始化 package.json
npm init -y

# 安裝型別定義
npm install @earendil-works/pi-coding-agent typebox

# 建立入口檔案
touch index.ts

目錄結構

my-first-ext/
├── package.json
├── node_modules/
└── src/
    └── index.ts        # 入口

package.json 需要宣告擴充入口:

{
  "name": "my-first-ext",
  "dependencies": {
    "@earendil-works/pi-coding-agent": "^1.0.0",
    "typebox": "^0.34.0"
  },
  "pi": {
    "extensions": ["./src/index.ts"]
  }
}

第一個擴充:Hello World

基礎版:通知 + 命令

// src/index.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  // 會話啟動時彈出通知
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Hello World 擴充已載入!", "info");
  });

  // 註冊 /hello 命令
  pi.registerCommand("hello", {
    description: "向 Pi 打個招呼",
    handler: async (args, ctx) => {
      const name = args?.trim() || "World";
      ctx.ui.notify(`Hello, ${name}! 👋`, "info");
    },
  });
}

測試執行

# 方式 1:自動發現(放在 ~/.pi/agent/extensions/ 下)
pi

# 方式 2:明確指定(臨時測試)
pi -e ./src/index.ts

進入 Pi 後輸入 /hello Kevin,你應該看到通知:Hello, Kevin! 👋

進階:攔截危險命令

pi.on("tool_call", async (event, ctx) => {
  if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
    const ok = await ctx.ui.confirm("⚠️ 危險操作", "確認執行 rm -rf?");
    if (!ok) return { block: true, reason: "使用者取消" };
  }
});

這個擴充展示了 tool_call 事件的攔截能力——你可以在 LLM 執行工具前做權限檢查。

實戰專案一:自訂程式碼模板產生器

需求

團隊有固定的程式碼模板(React 元件、Express 路由、Python 腳本等),每次新建檔案都要複製貼上。我們寫一個擴充,讓 LLM 能呼叫 generate_template 工具自動產生。

實作

// src/template-generator.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";

const TEMPLATES: Record<string, string> = {
  "react-component": `import React from 'react';

interface {{Name}}Props {
  title: string;
}

export const {{Name}}: React.FC<{{Name}}Props> = ({ title }) => {
  return <div className="{{name}}">{title}</div>;
};
`,
  "express-route": `import { Router, Request, Response } from 'express';

const router = Router();

router.get('/', (req: Request, res: Response) => {
  res.json({ message: '{{Name}} endpoint' });
});

export default router;
`,
  "python-script": `#!/usr/bin/env python3
"""{{Name}} module."""

import argparse
import sys


def main(args: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="{{Name}}")
    parser.add_argument("--name", default="World")
    parsed = parser.parse_args(args)
    print(f"Hello, {parsed.name}!")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
`,
};

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "generate_template",
    label: "模板產生器",
    description: "根據模板名和元件名產生程式碼檔案。可選模板:react-component, express-route, python-script",
    parameters: Type.Object({
      template: Type.String({ description: "模板名稱" }),
      name: Type.String({ description: "元件/模組名(PascalCase)" }),
      outputPath: Type.String({ description: "輸出檔案路徑" }),
    }),
    async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
      const tpl = TEMPLATES[params.template];
      if (!tpl) {
        return {
          content: [{ type: "text", text: `❌ 未知模板: ${params.template}` }],
          details: {},
        };
      }

      const pascalName = params.name.replace(/(^|[-_])(\w)/g, (_, __, c) => c.toUpperCase());
      const kebabName = pascalName.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
      const code = tpl
        .replace(/\{\{Name\}\}/g, pascalName)
        .replace(/\{\{name\}\}/g, kebabName);

      const outPath = path.resolve(params.outputPath);
      fs.mkdirSync(path.dirname(outPath), { recursive: true });
      fs.writeFileSync(outPath, code, "utf-8");

      return {
        content: [{ type: "text", text: `✅ 已產生 ${outPath}\n\n${code}` }],
        details: { path: outPath, template: params.template },
      };
    },
  });
}

使用方式

在 Pi 中直接說:

用 react-component 模板產生一個 UserProfile 元件,放到 src/components/UserProfile.tsx

Pi 會呼叫 generate_template 工具,自動完成變數替換和檔案寫入。

實戰專案二:Git 工作流自動化

需求

每次提交前自動跑 lint、產生 commit message、推送後通知團隊。我們用擴充攔截 bash 工具,在偵測到 git commit 時注入前置檢查。

實作

// src/git-workflow.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { execSync } from "node:child_process";

export default function (pi: ExtensionAPI) {
  // 攔截 git commit,前置 lint
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") return;
    const cmd: string = event.input.command || "";

    if (cmd.startsWith("git commit")) {
      ctx.ui.setStatus("git-workflow", "🔍 執行 pre-commit 檢查...");

      try {
        execSync("npm run lint --if-present", { stdio: "pipe" });
        execSync("npm run test --if-present", { stdio: "pipe" });
        ctx.ui.setStatus("git-workflow", "✅ 檢查通過,繼續提交");
      } catch (err) {
        ctx.ui.notify("❌ Lint/Test 失敗,已阻止提交", "error");
        return { block: true, reason: "pre-commit checks failed" };
      }
    }

    // 攔截 git push,發送通知
    if (cmd.startsWith("git push")) {
      ctx.ui.setStatus("git-workflow", "📤 推送中...");
      // 可以在這裡呼叫 webhook 通知團隊
    }
  });

  // 註冊 /git-summary 命令
  pi.registerCommand("git-summary", {
    description: "顯示目前分支的提交摘要",
    handler: async (_args, ctx) => {
      try {
        const log = execSync(
          "git log --oneline -10 --format='%h %s (%an, %ar)'",
          { encoding: "utf-8" }
        );
        ctx.ui.notify(`最近 10 條提交:\n${log}`, "info");
      } catch {
        ctx.ui.notify("❌ 無法讀取 git 歷史", "error");
      }
    },
  });
}

效果

  • 當 Pi 嘗試執行 git commit 時,擴充自動跑 lint 和測試,失敗則阻止提交。
  • /git-summary 命令快速檢視最近提交。

實戰專案三:程式碼品質檢查整合

需求

讓 LLM 在寫程式碼時自動呼叫外部品質檢查工具(如 eslintpylintsemgrep),並在結果中附带修復建議。

實作

// src/quality-checker.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { execSync } from "node:child_process";

type Linter = {
  cmd: string;
  pattern: RegExp;
};

const LINTERS: Linter[] = [
  { cmd: "npx eslint --format json", pattern: /\.(ts|tsx|js|jsx)$/ },
  { cmd: "pylint --output-format=json", pattern: /\.py$/ },
  { cmd: "semgrep --json", pattern: /.*/ },
];

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "check_code_quality",
    label: "程式碼品質檢查",
    description: "對指定檔案執行 lint/靜態分析,回傳問題列表和修復建議",
    parameters: Type.Object({
      filePath: Type.String({ description: "要檢查的檔案路徑" }),
      linter: Type.Optional(
        Type.String({ description: "指定 linter(eslint/pylint/semgrep),留空自動偵測" })
      ),
    }),
    async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
      const file = params.filePath;
      let linter = LINTERS.find((l) => l.pattern.test(file));

      if (params.linter) {
        linter = LINTERS.find((l) => l.cmd.startsWith(params.linter!));
      }

      if (!linter) {
        return {
          content: [{ type: "text", text: "⚠️ 未找到符合的 linter" }],
          details: {},
        };
      }

      ctx.ui.setStatus("quality", `🔍 執行 ${linter.cmd.split(" ")[0]}...`);

      try {
        const output = execSync(`${linter.cmd} ${file}`, {
          encoding: "utf-8",
          stdio: ["pipe", "pipe", "pipe"],
        });
        return {
          content: [{ type: "text", text: `✅ 無問題\n${output}` }],
          details: { file, linter: linter.cmd },
        };
      } catch (err: any) {
        // linter 通常以非零退出碼回傳問題
        const output = err.stdout || err.stderr || "";
        return {
          content: [{ type: "text", text: `⚠️ 發現問題:\n${output.slice(0, 3000)}` }],
          details: { file, linter: linter.cmd, hasIssues: true },
        };
      }
    },
  });
}

使用方式

檢查一下 src/auth/login.ts 的程式碼品質

Pi 會呼叫 check_code_quality,回傳 lint 結果,然後自動給出修復建議。

實戰專案四:自訂搜尋工具整合

需求

讓 Pi 能搜尋團隊內部知識庫(比如 Notion、Confluence、或本地 Markdown 檔案)。這裡以本地 Markdown 知識庫為例。

實作

// src/knowledge-search.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import * as fs from "node:fs";
import * as path from "node:path";

const KB_ROOT = process.env.KB_ROOT || "~/knowledge-base";

interface SearchResult {
  file: string;
  line: number;
  context: string;
}

function searchMarkdown(query: string, root: string): SearchResult[] {
  const results: SearchResult[] = [];
  const terms = query.toLowerCase().split(/\s+/);

  function walk(dir: string) {
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        walk(full);
      } else if (entry.name.endsWith(".md")) {
        const content = fs.readFileSync(full, "utf-8");
        const lines = content.split("\n");
        for (let i = 0; i < lines.length; i++) {
          const lower = lines[i].toLowerCase();
          if (terms.every((t) => lower.includes(t))) {
            results.push({
              file: path.relative(root, full),
              line: i + 1,
              context: lines.slice(Math.max(0, i - 1), i + 2).join("\n"),
            });
          }
        }
      }
    }
  }

  walk(path.resolve(root));
  return results.slice(0, 10);
}

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "search_knowledge_base",
    label: "知識庫搜尋",
    description: "在本地 Markdown 知識庫中搜尋相關內容",
    parameters: Type.Object({
      query: Type.String({ description: "搜尋關鍵詞" }),
      kbRoot: Type.Optional(Type.String({ description: "知識庫根目錄,預設 ~/knowledge-base" })),
    }),
    async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
      const root = params.kbRoot || KB_ROOT;
      const resolved = path.resolve(root.replace("~/", process.env.HOME + "/"));

      if (!fs.existsSync(resolved)) {
        return {
          content: [{ type: "text", text: `❌ 知識庫目錄不存在: ${resolved}` }],
          details: {},
        };
      }

      const results = searchMarkdown(params.query, resolved);
      if (results.length === 0) {
        return {
          content: [{ type: "text", text: "🔍 未找到相關內容" }],
          details: { query: params.query },
        };
      }

      const formatted = results
        .map((r) => `📄 ${r.file}:${r.line}\n${r.context}`)
        .join("\n\n");

      return {
        content: [{ type: "text", text: `找到 ${results.length} 處符合:\n\n${formatted}` }],
        details: { query: params.query, count: results.length },
      };
    },
  });
}

使用方式

設定環境變數 KB_ROOT=~/my-docs,然後在 Pi 中:

搜一下知識庫裡關於 API 認證的內容

Pi 會呼叫 search_knowledge_base,回傳符合的檔案片段。

進階主題

多 Agent 協作

Pi 支援透過擴充實作多 Agent 模式。核心思路是:主 Agent 接收任務,拆分後委託給子 Agent(透過 bash 工具啟動新的 pi 程序,使用 --resume 或 session 檔案共享上下文)。

pi.registerTool({
  name: "delegate_to_specialist",
  label: "委託專家",
  description: "將子任務委託給專門的 Agent 處理",
  parameters: Type.Object({
    task: Type.String({ description: "子任務描述" }),
    specialist: Type.String({ description: "專家類型:frontend/backend/test" }),
  }),
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
    const sessionFile = `/tmp/pi-${params.specialist}-${Date.now()}.json`;
    const prompt = encodeURIComponent(params.task);

    ctx.ui.setStatus("delegate", `🤖 委託 ${params.specialist} 處理...`);

    try {
      const output = execSync(
        `pi --session ${sessionFile} --print "${params.task}"`,
        { encoding: "utf-8", timeout: 120_000 }
      );
      return {
        content: [{ type: "text", text: output }],
        details: { specialist: params.specialist, session: sessionFile },
      };
    } catch (err: any) {
      return {
        content: [{ type: "text", text: `❌ 委託失敗: ${err.message}` }],
        details: {},
      };
    }
  },
});

效能最佳化

  • 延遲載入:不要在工廠函式裡啟動背景程序,推遲到 session_start 或首次工具呼叫。
  • 快取結果:對頻繁呼叫的工具(如 lint),用 Map 快取檔案雜湊和結果,避免重複執行。
  • 串流更新:用 onUpdate 回呼即時回報進度,避免 UI 卡死。
// 快取範例
const cache = new Map<string, string>();

async execute(_toolCallId, params) {
  const hash = crypto.createHash("md5").update(fs.readFileSync(params.filePath)).digest("hex");
  const key = `${params.filePath}:${hash}`;
  if (cache.has(key)) return { content: [{ type: "text", text: cache.get(key)! }], details: { cached: true } };
  // ... 執行檢查 ...
  cache.set(key, result);
  return { content: [{ type: "text", text: result }], details: {} };
}

發佈和分享擴充

Pi 擴充可以透過 npm 或 git 儲存庫分發為 pi packages

// package.json
{
  "name": "pi-ext-my-tools",
  "version": "1.0.0",
  "pi": {
    "extensions": ["./src/index.ts"]
  }
}

使用者安裝:

# 從 npm
pi install npm:pi-ext-my-tools@1.0.0

# 從 git
pi install git:github.com/user/repo@v1

也可以在 pi.dev/packages 發佈到官方套件目錄。

社群優秀擴充推薦

擴充 功能 連結
pi-hosts 管理 /etc/hosts,快速切換環境 GitHub
Parallel Pi agents 多 Agent 並行執行任務 GitHub
pi-extensions (narumiruna) 自動化、規劃、瀏覽器控制、Git 工作流集合 GitHub
pi-extensions-skill (Dwsy) 漸進式擴充開發學習指南 GitHub
pi-extension-builder (LobeHub) 擴充鷹架產生器 LobeHub

常見問題 FAQ

Pi Agent 擴充和 Skills 有什麼區別?

Extensions 是 TypeScript 模組,執行時載入,能攔截事件、註冊工具、新增命令,擁有完整的系統權限。Skills 是 Markdown 檔案,按需載入到 LLM 上下文,定義工作流和提示詞,不能直接執行程式碼。簡單說:Extension 是「程式碼級擴充」,Skill 是「提示詞級擴充」。

擴充開發需要會 TypeScript 嗎?

需要基礎 TypeScript 能力。Pi 用 jiti 直接載入 .ts 檔案,無需編譯步驟。如果你只會 JavaScript,也能寫——把檔案後綴改成 .js 即可,型別定義是可選的。

如何除錯擴充?

pi -e ./my-ext.ts 啟動,在程式碼裡加 console.log,輸出會顯示在 Pi 的 TUI 底部。也可以用 ctx.ui.notify() 彈出通知。複雜邏輯建議寫單元測試,用 Node.js 直接跑。

擴充會影響 Pi 的啟動速度嗎?

同步載入的擴充會阻塞啟動。建議把耗時操作(網路請求、檔案掃描)推遲到 session_start 事件或首次工具呼叫時執行。工廠函式裡只做 pi.on()pi.registerTool() 這類輕量註冊。

可以在擴充裡呼叫外部 API 嗎?

可以。擴充執行在 Node.js 環境,能用 fetchhttp、任何 npm 套件。記得在 package.jsondependencies 裡宣告相依性。

總結

Pi Agent 的擴充系統給了開發者「改造 AI 助手」的完整能力。從攔截危險命令到註冊自訂工具,從 Git 自動化到知識庫搜尋,TypeScript + 事件驅動的組合讓擴充既靈活又可控。

本文的 4 個實戰專案覆蓋了最常見的場景:模板產生、Git 工作流、程式碼品質、知識搜尋。你可以直接拿去用,也可以作為模板改造出更多擴充。

下一步建議:

  1. 讀官方 Extensions 文件
  2. examples/extensions/ 裡的範例
  3. pi.dev/packages 找靈感,或發佈你的第一個擴充