Pi 的配置、擴充、平台設定和 API 參考。

Extensions

pi 可以建立擴充。可以讓它為你的使用情境產生一個擴充。

Extensions 是用於擴充 pi 行為的 TypeScript 模組。它們可以訂閱生命週期事件、註冊可由 LLM 呼叫的自訂工具、新增指令等。

/reload 的放置位置: 將擴充放入 ~/.pi/agent/extensions/(全域)或 .pi/extensions/(專案本機)以進行自動探索。僅在快速測試時使用 pi -e ./path.ts。自動探索位置中的 Extensions 可以透過 /reload 熱重新載入。

關鍵能力:

  • 自訂工具 - 註冊 LLM 可以透過 pi.registerTool() 呼叫的工具
  • 事件攔截 - 阻止或修改工具呼叫、注入上下文、自訂壓縮
  • 使用者互動 - 透過 ctx.ui 提示使用者(選擇、確認、輸入、通知)
  • 自訂 UI 元件 - 完整的 TUI 元件,透過 ctx.ui.custom() 進行鍵盤輸入以實作複雜的互動
  • 自訂指令 - 透過 pi.registerCommand() 註冊諸如 /mycommand 之類的指令
  • 工作階段持久性 - 儲存透過 pi.appendEntry() 重新啟動後仍然存在的狀態
  • 自訂渲染 - 控制工具呼叫/結果和訊息在 TUI 中的顯示方式

使用情境範例:

  • 權限門(在 rm -rfsudo 等指令前確認)
  • Git 檢查點(每個對話輪次執行 stash,在分支上恢復)
  • 路徑保護(阻止寫入 .envnode_modules/
  • 自訂壓縮(按自己的規則總結對話)
  • 對話摘要(參見 summarize.ts 範例)
  • 互動式工具(問題、嚮導、自訂對話框)
  • 有狀態工具(待辦事項清單、連接池)
  • 外部整合(檔案監看器、webhooks、CI 觸發器)
  • 等待時玩遊戲(參見 snake.ts 範例)

可在 examples/extensions/ 查看可執行的實作。

目錄

快速入門

建立 ~/.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

擴充位置

安全性: Extensions 以完整的系統權限執行,可以執行任意程式碼。只安裝來自可信來源的擴充。

Extensions 會從受信任的位置自動探索。專案本機 .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

可用匯入

Package 用途
@earendil-works/pi-coding-agent 擴充類型(ExtensionAPIExtensionContext、事件)
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 package,執行階段依賴必須位於 dependencies 中。Package 安裝預設使用生產安裝(npm install --omit=dev),因此 devDependencies 在執行階段不可用;設定了 npmCommand 時,git package 會使用普通的 install 以相容包裝器。

還提供 Node.js 內建函式(node:fsnode:path 等)。

編寫擴充

擴充匯出一個接收 ExtensionAPI 的預設工廠函式。工廠可以是同步的或async 的:

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", { ... });
}

Extensions 透過 jiti 載入,因此 TypeScript 無需編譯即可工作。

如果工廠傳回 Promise,pi 會在繼續啟動前等待它。這意味著async 初始化會在 session_startresources_discover,以及透過 pi.registerProvider() 排隊的 Provider 註冊重新整理前完成。

async工廠函式

使用async工廠進行一次性啟動工作,例如取得遠端設定或動態探索可用模型。

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

長期資源和停用

擴充 factory 可能在從不啟動工作階段的呼叫中執行。不要從工廠啟動背景資源,例如程序、套接字、檔案監看程式或計時器。

推遲背景資源啟動,直到 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 package 的擴充:

~/.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

啟動事件

project_trust

在 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 預設是詢問、信任還是拒絕。

資源事件

resources_discover

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"],
  };
});

工作階段事件

請參閱 Session Format 瞭解工作階段儲存內部結構和 SessionManager API。

session_start

當工作階段啟動、載入或重新載入時觸發。

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");
});

session_info_changed

透過 /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");
});

session_before_switch

在開始新工作階段 (/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 中重新建立任何記憶體中狀態。

session_before_fork

透過 /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 導航上觸發。有關樹導航概念,請參閱Sessions

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_shutdown

在啟動的工作階段執行階段被拆除之前觸發。使用它來清理從 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.
});

Agent 事件

before_agent_start

在使用者commit Prompt 後、Agent 循環之前觸發。可以注入訊息和/或修改 system prompt。

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 用於建置 system prompt 的同一份結構化資料。借助它可以檢查 Pi 已載入的內容:自訂 Prompt、指南、工具片段、context files、Skills,而無需重新探索資源或重新解析標誌。當擴充需要對 system prompt 做深入且有上下文的更改,並尊重使用者提供的設定時,請使用它。

在目前處理程式內部,before_agent_startevent.systemPromptctx.getSystemPrompt() 都會反映目前鏈式 system prompt。後續 before_agent_start 處理程式仍然可以繼續修改它。

agent_start / agent_end / agent_settled

當底層 Agent run 開始時,agent_start 會觸發。agent_end 在 run 結束時觸發,但 Pi 仍可能自動重試、自動壓縮並重試,或繼續處理排隊的後續訊息。對於需要確認 Pi 不會繼續自動執行的狀態整合,請使用 agent_settled

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_startmessage_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 在 preflight 階段按 assistant source order 發出
  • tool_execution_update 事件可能會跨工具交錯
  • 每個工具完成後,tool_execution_end 按工具完成順序發出
  • 最終的 toolResult 訊息事件仍會稍後按 assistant source order 發出
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
});

context

在每次 LLM 呼叫之前觸發。可以非破壞性地修改訊息。訊息類型請參見 Session Format

pi.on("context", async (event, ctx) => {
  // event.messages - deep copy, safe to modify
  const filtered = event.messages.filter(m => !shouldPrune(m));
  return { messages: filtered };
});

before_provider_headers

組裝傳出 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;
});

每個 Provider 請求執行一次;重試重用相同的標頭而不是重新觸發Hook。

before_provider_request

在建置 Provider 特定的 payload 之後、傳送請求之前觸發。處理程式按擴充載入順序執行。傳回 undefined 會保持 payload 不變。傳回任何其他值都會替換傳給後續處理程式和實際請求的 payload。

該Hook可以重寫 Provider 層級的系統指令,也可以完全刪除它們。這些 payload 層級的更改不會反映在 ctx.getSystemPrompt() 中;後者報告的是 Pi 的系統提示字串,而不是最終序列化後的 Provider payload。

pi.on("before_provider_request", (event, ctx) => {
  console.log(JSON.stringify(event.payload, null, 2));

  // Optional: replace payload
  // return { ...event.payload, temperature: 0 };
});

這主要用於除錯 Provider 序列化和快取行為。

after_provider_response

在收到 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"]);
  }
});

標頭可用狀態取決於 Provider 和傳輸方式。有些 Provider 抽象可能不會暴露 HTTP 回應標頭。

模型事件

model_select

當模型透過 /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 元素(狀態欄、頁尾)或在活動模型更改時執行特定於模型的初始化。

thinking_level_select

當思考等級發生變化時觸發。該事件僅用於通知;處理程式傳回值會被忽略。

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()、模型更改或內建 thinking level 控件更改目前 thinking level 時,使用此事件更新擴充 UI。

工具事件

tool_call

tool_execution_start 之後、工具執行之前觸發。可以阻止。 使用 isToolCallEventType 縮小類型並取得類型化輸入。

tool_call 執行之前,pi 會等待先前發出的 Agent 事件透過 AgentSession 完成 drain。這意味著 ctx.sessionManager 會更新到目前的 assistant tool-calling 訊息。

在預設的並行工具執行模式下,來自同一個 assistant 訊息的同級工具呼叫會按順序進行 preflight,然後併發執行。tool_call 不保證能在 ctx.sessionManager 中看到同一個 assistant 訊息里的同級工具結果。

event.input 是可變的。在執行之前對其進行適當修改以修補工具參數。

行為保證:

  • event.input 的突變會影響實際的工具執行
  • 後來的 tool_call 處理程式看到了早期處理程式所做的突變
  • 突變後不會進行重新驗證
  • tool_call 傳回值透過 { block: true, reason?: string, terminate?: boolean } 控制阻塞
  • terminate 僅適用於阻塞呼叫;僅當批次中的每個最終結果都終止時,Agent 才會提前停止
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_result

在工具執行完成後、tool_execution_end 和最終工具結果訊息事件發出之前觸發。可以修改結果。

在並行工具模式下,tool_resulttool_execution_end 可能會按工具完成順序交錯,而最終的 toolResult 訊息事件仍會稍後按 assistant source order 發出。

tool_result 處理程式鏈式中間件:

  • 處理程式按擴充載入順序執行
  • 每個處理程式都會看到前一個處理程式更改後的最新結果
  • 處理程式可以傳回部分patch(contentdetailsisErrorusage);省略的欄位保留其目前值

使用 ctx.signal 進行處理程式內的巢狀 async工作。這允許 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 事件

user_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 尚未展開。

處理順序:

  1. 首先檢查擴充指令 (/cmd) - 如果找到,則執行處理程式並跳過輸入事件
  2. input 事件觸發 - 可以攔截、轉換或處理
  3. 如果不處理:技能指令(/skill:name)擴充為技能內容
  4. 如果不處理:Prompt Templates (/template)擴充到模板內容
  5. Agent 處理開始(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 - 完全跳過 Agent(第一個傳回該狀態的處理程式獲勝)

跨處理程式轉換鏈。請參閱 input-transform.tsinput-transform-streaming.ts 瞭解 streamingBehavior 感知路由。

擴充上下文

所有處理程式都會收到 ctx: ExtensionContext

ctx.ui

使用者互動的 UI 方法。有關完整詳細資訊,請參閱Custom UI

ctx.mode

目前執行模式:"tui""rpc""json""print"。使用 ctx.mode === "tui" 保護僅限終端機的功能,例如 custom()、元件工廠、終端機輸入和直接 TUI 渲染。

ctx.hasUI

在 TUI 和 RPC 模式下為 true。在 print 模式(-p)和 JSON Schema下為 false。使用它來保護同時適用於 TUI 和 RPC 模式的對話框方法(selectconfirminputeditor)和 fire-and-forget 方法(notifysetStatussetWidgetsetTitlesetEditorText)。在 RPC 模式下,一些 TUI 專用方法是 no-op 或傳回預設值(見 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

對工作階段狀態的唯讀存取。請參閱 Session Format 瞭解完整的 SessionManager API 和條目類型。

對於 tool_call,該狀態會在處理程式執行之前同步到目前 assistant 訊息。在並行工具執行模式下,它仍不保證包含同一個 assistant 訊息里的同級工具結果。

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 ID

ctx.modelRegistry / ctx.model / ctx.thinkingLevel / ctx.scopedModels

存取模型、Provider 和已解析的身分驗證。ctx.modelRegistry.getProvider(id) 傳回有效的 pi-ai Provider,而 getProviderAuth(id) 會解析其目前 API Key、headers、base URL 和 Provider 範圍環境,且無需載入模型。ctx.model 是活動模型,ctx.thinkingLevel 是目前有效的 thinking level。

ctx.scopedModels 是目前工作階段範圍內模型的唯讀清單,與 /scoped-models 指令顯示的集合相同。它在工作階段開始時根據 --models CLI 標誌和 enabledModels 設定解析(透過 provider/modelId 上的 minimatch 或裸 modelId 比對可用目錄)。未設定範圍時它為空,表示每個可用模型都可用。每個條目都是 { model, thinkingLevel? },其中 thinkingLevel 僅當模式固定該值時才設定(例如 anthropic/*:high)。使用它填充模型選擇器,可以鏡像內建模型選擇器,而不是透過 ctx.modelRegistry.getAvailable() 枚舉整個目錄。

ctx.signal

目前 Agent abort signal;當沒有 Agent 輪次處於活動狀態時為 undefined

將它用於由擴充處理程式啟動、需要感知中止信號的巢狀工作,例如:

  • fetch(..., { signal: ctx.signal })
  • 接受 signal 的模型呼叫
  • 接受 AbortSignal 的檔案或程序助理

ctx.signal 通常在活動輪次事件期間定義,例如 tool_calltool_resultmessage_updateturn_end。 在空閒或非輪次上下文中,例如工作階段事件、擴充指令,以及 pi 空閒時觸發的快速鍵,它通常為 undefined

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()

控制流程助理。當 Pi 正在處理 Agent run、自動重試、自動壓縮重試或排隊延續時,ctx.isIdle()false

ctx.shutdown()

請求正常停用 pi。

  • 互動模式: 推遲到 Agent 變為空閒(處理完所有排隊的中途引導和後續訊息後)。
  • **RPC 模式:**推遲到下一個空閒狀態(完成目前指令回應後,等待下一個指令時)。
  • 列印模式: 無操作。處理完所有提示後,該過程將自動退出。

在退出之前向所有擴充發出 session_shutdown 事件。可用於所有上下文(事件處理程式、工具、指令、快速鍵)。

pi.on("tool_call", (event, ctx) => {
  if (isFatal(event.input)) {
    ctx.shutdown();
  }
});

ctx.getContextUsage()

傳回活動模型的目前上下文使用情況。優先使用最近一次助理 usage(如果可用),然後估算尾部訊息的 token。

const usage = ctx.getContextUsage();
if (usage && usage.tokens > 100_000) {
  // ...
}

ctx.compact()

觸發壓縮而不等待完成。使用 onCompleteonError 進行後續操作。

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_startevent.systemPromptOptions 具有相同形狀和可變性:自訂 Prompt、活動工具、工具片段、Prompt 指南、追加的系統 Prompt 文字、cwd、已載入的 context files 和已載入的 Skills。它可能包含完整的 context file 內容,因此應將其視為敏感的擴充本機資料,並避免透過指令清單、記錄或自動完成元資料公開。

這會報告目前的基礎 Prompt 輸入。它不包括每輪 before_agent_start 鏈式 system prompt 更改、後續 context 事件中的訊息變更,或 before_provider_request 中的 payload 重寫。

ctx.waitForIdle()

等待 Agent 完全 settled,包括自動重試、自動壓縮重試和排隊延續:

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:在 withSession 執行之前改變新工作階段的 SessionManager
  • withSession:針對新的替換工作階段上下文執行切換後工作。不要使用捕獲的舊 pi/指令 ctx;見Session replacement lifecycle and footguns

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/指令 ctx;見Session replacement lifecycle and footguns

ctx.navigateTree(targetId, options?)

導航到 session tree 中的不同點:

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
}

選項:

要探索可用工作階段,請使用靜態 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,它使用綁定到替換工作階段的 async sendMessage()sendUserMessage() helper擴充 ExtensionCommandContext

生命週期和易踩坑點:

  • withSession 只會在舊工作階段已發出 session_shutdown、舊執行階段已拆除、替換工作階段已重新綁定,並且新擴充實例已收到 session_start 後執行。
  • 回調仍然在原始閉包中執行,而不是在新的擴充實例中執行。這意味著你的舊擴充實例可能已經在 withSession 啟動之前執行了停用清理。
  • 捕獲的舊 pi / 舊指令 ctx 中的工作階段綁定物件,在替換後已經過期,繼續使用會拋錯。涉及工作階段綁定的工作只能使用傳給 withSessionctx
  • 之前提取的原始物件仍需自行負責。例如,如果在替換之前捕獲 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_startreason: "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)

訂閱事件。事件類型和傳回值見 Events

pi.registerTool(definition)

註冊一個可由 LLM 呼叫的自訂工具。完整說明見 Custom Tools

pi.registerTool() 在擴充載入期間和啟動後都有效。可以在 session_start、指令處理常式或其他事件處理常式中呼叫它。新工具會在同一工作階段中立即重新整理,因此會出現在 pi.getAllTools() 中,並且無需 /reload 即可由 LLM 呼叫。

使用 pi.setActiveTools() 在執行階段啟用或停用工具(包括動態新增的工具)。

使用 promptSnippet 將自訂工具選擇到 Available tools 中的單行條目中,並使用 promptGuidelines 在工具處於活動狀態時將特定於工具的項目符號附加到預設的 Guidelines 部分。

重要提示: promptGuidelines 的項目符號會平鋪追加到 Guidelines 部分,沒有工具名稱前綴。每條指南都必須寫明引用的是哪個工具,避免寫“Use this tool when...”,因為 LLM 無法判斷“this”指的是哪個工具。應改寫為“Use my_tool when...”。

完整範例請參見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" - 等待 Agent 完成。僅當 Agent 不再有工具呼叫時才傳送。
    • "nextTurn" - 排隊等待下一個使用者提示。不會中斷或觸發任何事情。
  • triggerTurn: true - 如果 Agent 空閒,立即觸發 LLM 回應。僅適用於 "steer""followUp" 模式("nextTurn" 忽略)。

pi.sendUserMessage(content, options?)

向 Agent 傳送使用者訊息。與傳送自訂訊息的 sendMessage() 不同,它傳送一條真正的使用者訊息,看起來就像由使用者鍵入。總是觸發一個 turn。

// 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 - Agent 串流傳輸時需要:
    • "steer" - 在目前助理輪次完成執行其工具呼叫後將訊息排隊等待傳遞
    • "followUp" - 等待 Agent 完成所有工具
  • expandPromptTemplates - 分派擴充功能命令,並展開 Skill 命令和 Prompt 範本。預設為 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 取得可呼叫的 slash commands。包括擴充指令、Prompt Templates 和技能指令。 該清單符合 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 上下文。參見Custom UI

pi.registerMarkdownTransformer(transformer)

為普通使用者文字、助理文字和 thinking block 中的 Markdown 註冊一個transformer。Transformer 會按擴充載入順序執行,每個 transformer 接收前一個 transformer 傳回的 Markdown。鏈路完成後,Pi 會用內建渲染器渲染轉換後的內容。

transformer接收 Markdown 字串和上下文:

  • messageType"user""assistant""assistant-thinking"
  • isStreamingtrue 用於部分助理更新; false 使用者、最終確定的助理和恢復的訊息
  • availableWidth — 可用於轉換後的 Markdown 內容的精確終端機列

傳回轉換後的 Markdown:

pi.registerMarkdownTransformer((markdown, { messageType, isStreaming }) => {
  if (isStreaming || messageType === "assistant-thinking") return markdown;
  return markdown.replaceAll("-->", "→");
});

如果 transformer 拋出異常,Pi 會保留到目前為止產生的 Markdown,並繼續處理下一個 transformer。這個 Hook 僅用於顯示:原始訊息在工作階段和模型上下文中保持不變。它會在新的使用者訊息、助理串流更新、恢復的工作階段訊息以及終端機寬度變化時執行,因此 transformer 應保持同步且開銷低。

pi.registerEntryRenderer(customType, renderer)

使用你的 customType 為自訂條目註冊自訂 TUI 渲染器。自訂條目是使用 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 flag。

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.killed

pi.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-only

pi.getAllTools() 傳回 namedescriptionparameterspromptGuidelinessourceInfo

典型 sourceInfo.source 值:

  • builtin 用於內建工具
  • sdk 對於透過 createAgentSession({ customTools }) 傳遞的工具
  • 由擴充註冊的工具的擴充源元資料

pi.setModel(model)

設定目前模型。如果模型沒有可用的 API Key,則傳回 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)

動態註冊或覆蓋模型 Provider。適合proxy、自訂端點或團隊範圍的模型設定。

一旦執行程式初始化,擴充 factory函式期間進行的呼叫就會排隊並應用。此後進行的呼叫(例如,從使用者設定流程後的指令處理程式進行的呼叫)立即生效,無需 /reload

動態 Provider 可以實作 refreshModels。Pi 會在模型重新整理期間呼叫它,透過 Provider 同步發佈傳回的清單,並傳入正規化的憑證、已儲存目錄、網路和信號上下文。擴充可以透過帶 generation 檢查的 context.publish({ persist: entry }) 決定是否持久化目錄元資料;像 llama.cpp 這樣的即時伺服器可以傳回模型而不持久化這些模型。

context.signal 始終是具體的 signal,Provider 回調必須將它傳給阻塞 I/O。公共 ModelRuntime.refresh()ModelRegistry.refresh() 呼叫接受選用 signal;省略時不設超時,擴充和應用自行選擇截止時間。即使 Provider 忽略 signal,取消也會讓呼叫方停止等待,但仍需要 Provider 配合才能停止底層工作。

需要原生 Provider 身分驗證、過濾、重新整理或串流行為的 Extensions,可以從 @earendil-works/pi-ai 註冊完整的 Provider。該 Provider 會成為組合基礎,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,包括原生 authgetModelsrefreshModelsfilterModelsstreamstreamSimple 行為。

舊設定選項:

  • name - UI 中Provider的顯示名稱,例如 /login
  • baseUrl - API 端點 URL。定義模型時需要。
  • apiKey - API Key literal、環境變數插值($ENV_VAR${ENV_VAR})或前導 !command。定義模型時必需(除非提供了 oauth)。$ 會轉義 $$! 會轉義 literal ! 而不觸發指令執行。
  • api - API 類型:"anthropic-messages""openai-completions""openai-responses" 等。
  • headers - 要包含在請求中的自訂標頭。
  • authHeader - 如果為 true,則自動新增 Authorization: Bearer header。
  • models - 模型定義陣列。如果提供,則替換該 Provider 的所有現有模型。模型定義可以設定 baseUrl 來覆寫該模型的 Provider 端點。
  • refreshModels - async動態探索回調。它傳回的模型會替換擴充提供的模型。context.stored 包含持久化的 Provider 快照;僅當更新後的目錄資料應該持久化時,才使用帶 generation 檢查的 context.publish({ persist: entry })。使用 persist: null 可以移除該快照。
  • oauth - 支援 /login 的 OAuth Provider 設定。提供後,該 Provider 會出現在登錄菜單中。
  • streamSimple - 非標準 API 的自訂流實作。

請參閱 custom-provider.md 瞭解高級主題:自訂串流傳輸 API、OAuth 詳細資訊、模型定義參考。

pi.unregisterProvider(name)

刪除先前註冊的 Provider 及其模型。被 Provider覆蓋的內建模型將被恢復。如果 Provider 未註冊,則無效。

registerProvider 一樣,這在初始載入階段後呼叫時立即生效,因此不需要 /reload

pi.registerCommand("my-setup-teardown", {
  description: "Remove the custom proxy provider",
  handler: async (_args, _ctx) => {
    pi.unregisterProvider("my-proxy");
  },
});

狀態管理

具有狀態的 Extensions 應將其儲存在工具結果 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
      };
    },
  });
}

自訂工具

註冊 LLM 可以透過 pi.registerTool() 呼叫的工具。工具會出現在系統提示中,並且可以自訂渲染。

在預設系統提示的 Available tools 部分中,promptSnippet 用作簡短的一行說明。如果省略,自訂工具將不會包含在該部分中。

使用 promptGuidelines 將工具特定的清單項新增到預設系統提示的 Guidelines 部分。這些清單項只會在工具處於活動狀態時包含(例如,在 pi.setActiveTools([...]) 之後)。

重要提示: promptGuidelines 的項目符號會平鋪追加到 Guidelines 部分,沒有工具名稱前綴或分組。每條指南都必須寫明引用的是哪個工具,避免寫“Use this tool when...”,因為 LLM 無法判斷“this”指的是哪個工具。應改寫為“Use my_tool when...”。

注意:有些模型會在工具路徑參數前加上 @ 前綴。內建工具會在解析路徑之前去除前導 @。如果自訂工具接受路徑,也應正規化前導 @

如果你的自訂工具會改變檔案,請使用 withFileMutationQueue(),以便它參與與內建 editwrite 相同的每個檔案佇列。這很重要,因為預設情況下工具呼叫是並行執行的。如果沒有佇列,兩個工具可以讀取相同的舊檔案內容,計算不同的更新,然後最後寫入的內容覆蓋另一個。

失敗案例範例:你的自訂工具編輯 foo.ts,而內建 edit 也在同一個助理輪次次中更改 foo.ts。如果你的工具不參與佇列,則兩者都可以讀取原始 foo.ts,應用單獨的更改,並且其中一個更改會丟失。

將真實的目標檔案路徑傳遞給 withFileMutationQueue(),而不是原始使用者參數。首先將其解析為相對於 ctx.cwd 或工具工作目錄的絕對路徑。對於現有檔案,helper透過 realpath() 進行正規化,因此同一檔案的符號連結別名共享一個佇列。對於新檔案,它會fallback 到已解析的絕對路徑,因為 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 拋出錯誤。無論傳回物件中包含哪些屬性,傳回值都不會設定錯誤標誌。

提前終止:execute() 傳回 terminate: true,以提示在目前工具批次之後應跳過自動後續 LLM 呼叫。僅當該批次中的每個最終工具結果都終止時,此操作才會生效。有關 Agent 以最終結構化輸出工具呼叫結束的最小範例,請參閱 examples/extensions/structured-output.ts

// 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: {} };
}

重要提示: 使用 @earendil-works/pi-ai 中的 StringEnum 作為字串枚舉。 Type.Union/Type.Literal 不適用於 Google 的 API。

參數準備: prepareArguments(args) 是選用的。如果定義,它會在模式驗證之前和 execute() 之前執行。當 pi 恢復其儲存的工具呼叫參數不再與目前模式比對的舊工作階段時,使用它來模仿舊的接受的輸入形狀。傳回你想要針對 parameters 進行驗證的物件。保持公共架構嚴格。不要僅僅為了保持舊的恢復工作階段正常工作而將已棄用的相容性欄位新增到 parameters

範例:舊工作階段可能包含具有頂級 oldTextnewTextedit 工具呼叫,而目前架構僅接受 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: {},
    };
  },
});

覆寫內建工具

Extensions 可以透過註冊同名工具來覆寫內建工具(readbasheditwritegrepfindls)。發生這種情況時,互動模式會顯示警告。

# 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

有關使用記錄記錄和存取控制覆寫 read 的完整範例,請參閱 examples/extensions/tool-override.ts

渲染: 內建渲染器繼承按 slot 解析。執行覆寫和渲染覆寫是獨立的。如果你的覆寫省略 renderCall,則使用內建 renderCall。如果你的覆寫省略 renderResult,則使用內建 renderResult。如果覆寫兩者都省略,則會自動使用內建渲染器(語法醒目提示、diff 等)。這樣就可以封裝用於記錄記錄或存取控制的內建工具,而不必重新實作 UI。

提示元資料: promptSnippetpromptGuidelines 不是從內建工具繼承的。如果你的覆寫應保留這些提示說明,請在覆寫上明確定義它們。

你的實作必須與確切的結果形狀比對,包括 details 類型。 UI 和工作階段邏輯依賴這些形狀來進行渲染和狀態追蹤。

內建工具實作:

遠端執行

內建工具支援可插拔操作以委託給遠端系統(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);
  },
});

操作介面: ReadOperationsWriteOperationsEditOperationsBashOperationsLsOperationsGrepOperationsFindOperations

對於 user_bash,擴充可以透過 createLocalBashOperations() 重用 pi 的本機 shell 後端,而不是重新實作本機程序產生、shell 解析和程序樹終止。

bash 工具還支援 spawn hook,用於在執行前調整指令、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_IDPI_SESSION_FILEPI_PROVIDERPI_MODELPI_REASONING_LEVEL 將目前工作階段公開給指令。注入發生在 spawnHook 之前,因此Hook在 env 中接收這些值,並在如上所述傳播現有環境時保留它們。設定 exposeSessionEnvironment: false 停用它們:

const bashTool = createBashTool(cwd, {
  exposeSessionEnvironment: false,
});

有關變數語義,請參閱Bash tool session environment。有關帶有 --ssh 標誌的完整 SSH 範例,請參閱 examples/extensions/ssh.ts

輸出截斷

工具必須截斷其輸出以避免壓垮 LLM 上下文。大輸出可能會導致:

  • 上下文溢出錯誤(提示太長)
  • 壓縮失敗
  • 模型性能下降

內建限制為 50KB(約 10k 代幣)和 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();
  });
}

自訂渲染

工具可以提供 renderCallrenderResult 用於自訂 TUI 顯示。請參閱 tui.md 瞭解完整元件 API,以及 tool-execution.ts 瞭解工具行的組成方式。

預設情況下,工具輸出包裝在處理填充和背景的 Box 中。定義的 renderCallrenderResult 必須傳回 Component。如果未定義槽渲染器,則 tool-execution.ts 使用該槽的後備渲染。

當工具應該渲染自己的 shell 而不是使用預設的 Box 時,設定 renderShell: "self"。這適合需要完全控制分幀或背景行為的工具,例如在工具穩定後必須保持視覺穩定的大型預覽。

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);
  },
});

renderCallrenderResult 各自接收一個 context 物件,其中:

  • args - 目前工具呼叫參數
  • state - 在 renderCallrenderResult 之間共享的行本機狀態
  • lastComponent - 該插槽之前傳回的元件(如果有)
  • invalidate() - 請求重新渲染此工具行
  • toolCallId, cwd, executionStarted, argsComplete, isPartial, expanded, showImages, isError

使用 context.state 來實作跨槽共享狀態。當你想要跨渲染重用和改變同一元件時,請在傳回的元件實例上保留插槽本機快取。

渲染呼叫

呈現工具呼叫或標頭:

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(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:

  • 程式開發 Agent ID 使用 app.* 命名空間,例如 app.tools.expandapp.editor.externalapp.session.rename
  • 共享 TUI id 使用 tui.* 命名空間,例如 tui.select.confirmtui.select.canceltui.input.tab

有關鍵綁定 ID 和預設值的詳盡清單,請參閱 keybindings.mdkeybindings.json 使用相同的命名空間 id。

自訂編輯器和 ctx.ui.custom() 元件會接收注入的 keybindings: KeybindingsManager 參數。它們應該直接使用注入的管理器,而不是呼叫 getKeybindings()setKeybindings()

最佳實踐

  • 使用 Text 和填充 (0, 0)。預設的 Box 處理填充。
  • 使用 \n 表示多行內容。
  • 處理 isPartial 以取得串流傳輸進度。
  • 支援 expanded 以便隨需查看詳細資訊。
  • 保持預設視圖緊湊。
  • renderResult 中讀取 context.args,而不是將參數複製到 context.state
  • 僅對必須在呼叫和結果槽之間共享的資料使用 context.state
  • 當相同的元件實例可以就地更新時,重用 context.lastComponent
  • 僅當預設 boxed shell 造成妨礙時才使用 renderShell: "self"。在 self-shell 模式下,該工具負責自己的分幀、填充和背景。

後備渲染

如果槽渲染器未定義或拋出:

  • renderCall:顯示工具名稱
  • renderResult:顯示來自 content 的原始文字

動態工具載入

擴充可以註冊許多工具,同時只讓一小部分初始工具保持活動狀態。之後,工具可以在執行期間使用 pi.setActiveTools() 新增更多工具。Pi 會檢測純追加變更,在該工具結果上記錄新可用的工具名稱,並在下一次模型請求前應用更新後的活動集。

這適用於所有模型。具備原生延遲載入支援的模型會保留穩定的 Prompt 前綴,並在工具結果位置載入新定義。其他模型使用下面描述的 fallback。

生命週期是:

  1. 將每個工具註冊到 pi.registerTool(),以便它出現在 pi.getAllTools() 中。
  2. 保持載入工具(例如 search_tools)處於活動狀態,並使可搜尋工具處於非活動狀態。
  3. 在載入器執行期間,呼叫 pi.setActiveTools([...currentTools, ...matchingTools])。更改必須是追加式的:不要在同一次呼叫中刪除目前活動工具。
  4. Pi 記錄載入器的工具結果上新增了哪些工具。
  5. 在下一個模型回應之前,Pi 會透過原生延遲載入(如果支援)暴露新增定義,否則使用正常的活動工具清單。

不需要傳回 Provider 特定的工具引用,也不需要把載入器標記為特殊搜尋工具。活動工具集的變更本身就是信號。傳遞給 pi.setActiveTools() 的名稱必須已經註冊;未知名稱會被忽略。

支援原生延遲載入的模型

  • Anthropic
    • 模型: Sonnet、Opus、Fable 版本 4.5 或更高版本(不含 Haiku)
    • 原生表示: 延遲定義使用 defer_loading;載入點使用 tool_reference 內容。
  • OpenAI
    • 模型: gpt-5.4 及更新系列
    • 原生表示: Pi 會在載入點新增已完成的客戶端 tool_search_calltool_search_output 專案。

對於經過驗證的自訂模型或 Provider,可以使用 anthropic-messagescompat.supportsToolReferences: true,或 openai-responsesopenai-codex-responsescompat.supportsToolSearch: true 啟用原生處理。除非端點和模型接受相應的原生協議,否則應保持停用。

fallback 行為

對於所有其他模型和 Provider,動態激活仍然有效:Pi 通常會在下一次請求中傳送完整的目前活動工具清單。模型可以呼叫新激活的工具,但新增這些定義可能會使 Provider 的快取 Prompt 前綴失效。

當活動集不是純追加式時(例如用一組工具替換另一組工具),Pi 也會使用這種安全 fallback。因此,移除工具仍然可用,但不會使用延遲載入。

為了獲得最佳快取行為,請在整個工作階段中讓載入器工具保持活動狀態,並透過新增工具而不是替換活動集來擴充工具集合。還需注意,使用 promptSnippetpromptGuidelines 激活工具會重建 system prompt;即使 Provider 支援延遲模式,system prompt 變化也可能使前綴失效。延遲載入的工具通常應依賴自身的工具 description,並省略僅在活動時使用的 Prompt 元資料。

搜尋工具範例

以下擴充註冊了兩個可搜尋工具,將它們從初始活動集中刪除,並僅保留 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 新增比對項時,模型會在緊隨其後的請求中收到該定義。在原生支援的模型上,定義會錨定在搜尋結果之後,而不改變初始 tool-schema 前綴。在其他模型上,它會在同一個後續請求中出現在正常工具清單裡。

自訂使用者介面

Extensions 可以透過 ctx.ui 方法與使用者互動並自訂訊息/工具的呈現方式。

對於自訂元件,請參閱 tui.md,它具有以下複製貼上模式:

  • 選擇對話框(SelectList)
  • 帶取消的async操作 (BorderedLoader)
  • 設定切換(設定清單)
  • 狀態指示器(setStatus)
  • 串流傳輸期間的工作訊息、可見性和指示器(setWorkingMessagesetWorkingVisiblesetWorkingIndicator
  • 編輯器上方/下方的小部件 (setWidget)
  • 自動完成 Provider 位於內建斜槓/路徑完成之上 (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() 傳回 undefined
  • confirm() 傳回 false
  • input() 傳回 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(...)

自動完成 Provider

使用 ctx.ui.addAutocompleteProvider() 可以在內建斜槓指令和路徑 Provider 之上疊加自訂自動完成邏輯。為自訂自然觸發器設定 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 } 可以釋放浮層輸入,而不聚焦另一個元件。

有關完整的 OverlayOptionsOverlayHandle API 和 overlay-qa-tests.ts 範例,請參閱 tui.md

自訂編輯器

將主輸入編輯器替換為自訂實作(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)以獲得應用按鍵綁定(escape 中止、ctrl+d、模型切換)
  • 對未處理的按鍵呼叫 super.handleInput(data)
  • Factory 從應用接收 tuithemekeybindings
  • setEditorComponent() 之前使用 ctx.ui.getEditorComponent() 包裝之前設定的自訂編輯器
  • 傳入 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);

錯誤處理

  • 擴充錯誤會被記錄,Agent 繼續執行
  • 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 方法為 no-op
Print (-p) "print" false Extensions 會執行,但不能提示使用者

在使用 TUI 專用功能(custom()、元件工廠、終端機輸入)前檢查 ctx.mode === "tui"。在使用同時適用於 TUI 和 RPC 模式的對話框和通知方法前檢查 ctx.hasUI

範例參考

所有範例都在examples/extensions/中。

範例 描述 關鍵 API
工具
hello.ts 最少的工具註冊 registerTool
question.ts 與使用者互動的工具 registerTool, ui.select
questionnaire.ts 多步驟嚮導工具 registerTool, ui.custom
todo.ts 帶持久化的有狀態工具 registerTool, appendEntry, renderResult, session events
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 跨 Provider 模型切換 registerCommand, ui.editor, ui.custom
qna.ts 帶有自訂 UI 的問答 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"), trust UI, required trust result
protected-paths.ts 阻止寫入特定路徑 on("tool_call")
confirm-destructive.ts 確認工作階段更改 on("session_before_switch"), on("session_before_fork")
dirty-repo-guard.ts 對 dirty git repo 發出警告 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 檢查 payload 和 Provider 回應 headers on("before_provider_request"), on("after_provider_response")
system-prompt-header.ts 顯示 system prompt 資訊 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 每個 turn 執行 Git stash on("turn_start"), on("session_before_fork"), exec
git-merge-and-resolve.ts 取得、合併和解決衝突 on("agent_end"), exec, sendUserMessage
auto-commit-on-exit.ts 停用時commit on("session_shutdown"), exec
使用者介面元件
status-line.ts 頁尾狀態指示器 setStatus, session events
working-indicator.ts 自訂串流工作指示器 setWorkingIndicator, registerCommand
github-issue-autocomplete.ts 預載入 gh issue list 中最近開啟的 issue,在內建自動完成之上新增 #1234 issue 補全 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 Overlay 元件 ui.custom with overlay options
overlay-qa-tests.ts 綜合 Overlay 測試 ui.custom, all overlay options
notify.ts 簡單的通知 ui.notify
timed-confirm.ts 帶超時的對話框 ui.confirm with timeout/signal
mac-system-theme.ts 自動切換主題 setTheme, exec
複雜 Extensions
plan-mode/ 完整 plan mode 實作 All event types, registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools
preset.ts 可儲存的預設(模型、工具、thinking level) registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry
tools.ts 開啟/停用 UI 工具 registerCommandsetActiveToolsSettingsList、工作階段事件
遠端和沙箱
ssh.ts SSH 遠端執行 registerFlag, on("user_bash"), on("before_agent_start"), tool operations
interactive-shell.ts 持久 shell 工作階段 on("user_bash")
sandbox/ 沙盒工具執行 工具操作
gondolin/ 將內建工具和 ! 指令路由至 Gondolin 微型虛擬機 工具操作、內建工具覆蓋、on("user_bash")
subagent/ 產生子 Agent registerTool, exec
遊戲
snake.ts 貪吃蛇遊戲 registerCommandui.custom、鍵盤處理
space-invaders.ts 太空侵略者遊戲 registerCommand, ui.custom
doom-overlay/ Overlay 中的 Doom ui.custom with overlay
Providers
custom-provider-anthropic/ 自訂 Anthropic proxy registerProvider
custom-provider-gitlab-duo/ GitLab Duo 整合 registerProvider 與 OAuth
訊息與通訊
message-renderer.ts 自訂訊息渲染 registerMessageRenderer, sendMessage
entry-renderer.ts TUI-only 自訂條目渲染 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 command、cwd 和 env createBashTool, spawnHook
with-deps/ 帶 npm 依賴的擴充 Package structure with package.json