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 的默认工厂函数。工厂可以是同步的或异步的:

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 会在继续启动前等待它。这意味着异步初始化会在 session_startresources_discover,以及通过 pi.registerProvider() 排队的 Provider 注册刷新前完成。

异步工厂函数

使用异步工厂进行一次性启动工作,例如获取远程配置或动态发现可用模型。

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

长期资源和关闭

扩展工厂可能在从不启动会话的调用中运行。不要从工厂启动后台资源,例如进程、套接字、文件观察程序或计时器。

推迟后台资源启动,直到 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 包的扩展:

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

在用户提交 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 请求运行一次;重试重用相同的标头而不是重新触发钩子。

before_provider_request

在构建 Provider 特定的 payload 之后、发送请求之前触发。处理程序按扩展加载顺序运行。返回 undefined 会保持 payload 不变。返回任何其他值都会替换传给后续处理程序和实际请求的 payload。

该钩子可以重写 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 处理程序链式中间件:

  • 处理程序按扩展加载顺序运行
  • 每个处理程序都会看到前一个处理程序更改后的最新结果
  • 处理程序可以返回部分补丁(contentdetailsisErrorusage);省略的字段保留其当前值

使用 ctx.signal 进行处理程序内的嵌套异步工作。这允许 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 模式下为 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,它使用绑定到替换会话的异步 sendMessage()sendUserMessage() 帮助程序扩展 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 返回的 Markdown。链路完成后,Pi 会用内置渲染器渲染转换后的内容。

转换器接收 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。适合代理、自定义端点或团队范围的模型配置。

一旦运行程序初始化,扩展工厂函数期间进行的调用就会排队并应用。此后进行的调用(例如,从用户设置流程后的命令处理程序进行的调用)立即生效,无需 /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 字面量、环境变量插值($ENV_VAR${ENV_VAR})或前导 !command。定义模型时必需(除非提供了 oauth)。$ 会转义 $$! 会转义字面量 ! 而不触发命令执行。
  • api - API 类型:"anthropic-messages""openai-completions""openai-responses" 等。
  • headers - 要包含在请求中的自定义标头。
  • authHeader - 如果为 true,则自动添加 Authorization: Bearer header。
  • models - 模型定义数组。如果提供,则替换该 Provider 的所有现有模型。模型定义可以设置 baseUrl 来覆盖该模型的 Provider 端点。
  • refreshModels - 异步动态发现回调。它返回的模型会替换扩展提供的模型。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 或工具工作目录的绝对路径。对于现有文件,帮助器通过 realpath() 进行规范化,因此同一文件的符号链接别名共享一个队列。对于新文件,它会回退到已解析的绝对路径,因为 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 之前,因此钩子在 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 启用原生处理。除非端点和模型接受相应的原生协议,否则应保持禁用。

回退行为

对于所有其他模型和 Provider,动态激活仍然有效:Pi 通常会在下一次请求中发送完整的当前活动工具列表。模型可以调用新激活的工具,但添加这些定义可能会使 Provider 的缓存 Prompt 前缀失效。

当活动集不是纯追加式时(例如用一组工具替换另一组工具),Pi 也会使用这种安全回退。因此,移除工具仍然可用,但不会使用延迟加载。

为了获得最佳缓存行为,请在整个会话中让加载器工具保持活动状态,并通过添加工具而不是替换活动集来扩展工具集合。还需注意,使用 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)
  • 带取消的异步操作 (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 关闭时提交 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