跳转到主要内容

11 扩展

讲解 pi 扩展的开发:事件订阅、自定义工具与命令、UI 交互和会话控制。

pi 自己也能创建扩展。可以让它为你的使用场景构建一个。

扩展是用于扩展 pi 行为的 TypeScript 模块。它们可以订阅生命周期事件、注册可由 LLM 调用的自定义工具、添加命令等。

/reload 的存放位置: 若要被自动发现,请将扩展放在 ~/.pi/agent/extensions/(全局)或 .pi/extensions/(项目本地)。pi -e ./path.ts 仅用于快速测试。位于自动发现位置的扩展可以通过 /reload 热重载。

核心能力:

  • 自定义工具 - 通过 pi.registerTool() 注册可由 LLM 调用的工具
  • 事件拦截 - 阻止或修改工具调用、注入上下文、自定义上下文压缩
  • 用户交互 - 通过 ctx.ui 提示用户(select、confirm、input、notify)
  • 自定义 UI 组件 - 通过 ctx.ui.custom() 使用带键盘输入的完整 TUI 组件,应对复杂交互
  • 自定义命令 - 通过 pi.registerCommand() 注册 /mycommand 之类的命令
  • 会话持久化 - 通过 pi.appendEntry() 存储可在重启后保留的状态
  • 自定义渲染 - 控制工具调用/结果和消息在 TUI 中的呈现方式

典型用例:

  • 权限门禁(在 rm -rf、sudo 等操作前确认)
  • Git 检查点(每轮 stash,在分支上恢复)
  • 路径保护(阻止写入 .env、node_modules/)
  • 自定义上下文压缩(按你的方式总结对话)
  • 对话摘要(参见 summarize.ts 示例)
  • 交互式工具(提问、向导、自定义对话框)
  • 有状态工具(待办列表、连接池)
  • 外部集成(文件监视器、webhook、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

扩展位置

安全提示: 扩展以你的完整系统权限运行,可以执行任意代码。只从你信任的来源安装。

扩展会从受信任的位置自动发现。项目本地的 .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。

可用的导入

包用途
@earendil-works/pi-coding-agent扩展类型(ExtensionAPI、ExtensionContext、事件)
typebox工具参数的 schema 定义
@earendil-works/pi-aiAI 工具(StringEnum,用于 Google 兼容的枚举)
@earendil-works/pi-tui用于自定义渲染的 TUI 组件

npm 依赖也可以使用。在扩展旁边(或其上级目录中)添加一个 package.json,运行 npm install,来自 node_modules/ 的导入就会被自动解析。

对于通过 pi install 安装的分布式 pi 包(npm 或 git),运行时依赖必须位于 dependencies 中。包安装默认使用生产安装(npm install --omit=dev),因此 devDependencies 在运行时不可用;配置了 npmCommand 时,git 包会使用普通的 install,以兼容各类包装器。

Node.js 内置模块(node:fs、node: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", { ... });
}

扩展通过 jiti 加载,因此 TypeScript 无需编译即可使用。

如果工厂函数返回 Promise,pi 会在继续启动之前等待它完成。也就是说,异步初始化会在 session_start 之前、resources_discover 之前完成,也会先于通过 pi.registerProvider() 排队的提供商注册被刷新。

异步工厂函数

如果需要一次性的启动工作,例如获取远程配置或动态发现可用模型,可以使用异步工厂函数。

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 列出。

长生命周期资源与关闭

扩展工厂可能在从不启动会话的调用中运行。不要在工厂函数中启动后台资源,例如进程、socket、文件监视器或定时器。

把后台资源的启动推迟到 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 (success)
  └─► session_compact_failed (failure or abort)

/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 扩展即拥有决定权;第一个 yes/no 决定会胜出并抑制内置的信任提示。使用 remember: true 可持久化 yes/no 决定;否则它只对当前进程生效。返回 "undecided" 则交给后续处理器或内置信任流程决定。弹出提示前先检查 ctx.hasUI。如果没有处理器返回 yes/no,则继续正常的信任解析:先应用已保存的 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"],
  };
});

会话事件

会话存储内部结构和 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,为新会话重新加载并重新绑定扩展,然后发出带有 reason: "new" | "resume" 和 previousSessionFile 的 session_start。 清理工作放在 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,为新会话重新加载并重新绑定扩展,然后发出带有 reason: "fork" 和 previousSessionFile 的 session_start。 清理工作放在 session_shutdown 中完成,然后在 session_start 中重建任何内存状态。

session_before_compact / session_compact / session_compact_failed

在上下文压缩时触发。详情参见 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)
});

pi.on("session_compact_failed", async (event, ctx) => {
  // event.reason - "manual" (/compact), "threshold", or "overflow"
  // event.errorMessage - present for non-abort failures
  // event.aborted - true for cancelled/aborted compactions
  // event.willRetry - whether the aborted turn would have retried after compaction
  // event.fromExtension - whether extension-provided compaction content was being used
});

session_before_tree / session_tree

在 /tree 导航时触发。树状导航的概念参见 会话。

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

智能体事件

before_agent_start

在用户提交提示词之后、智能体循环开始之前触发。可以注入消息和/或修改系统提示词。

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 构建系统提示词所用的同一份结构化数据。这样你无需重新发现资源或重新解析标志,就能查看 pi 加载了什么——自定义提示词、指南、工具摘要、上下文文件、技能。当你的扩展需要在尊重用户配置的前提下对系统提示词做深入而知情的修改时,可以使用它。

在 before_agent_start 内部,event.systemPrompt 和 ctx.getSystemPrompt() 都反映截至当前处理器的链式系统提示词。后面的 before_agent_start 处理器仍然可以再次修改它。

agent_start / agent_end / agent_settled

agent_start 在低层智能体运行开始时触发。agent_end 在该运行结束时触发,但 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.
});

ui_prompt_start / ui_prompt_end

针对阻塞式用户界面提示的通知型生命周期事件。它们在 ctx.ui.select()、ctx.ui.confirm()、ctx.ui.input()、ctx.ui.editor() 和 ctx.ui.custom() 周围触发,让宿主/状态集成可以报告「正在等待用户」,而不只是「运行中」。

嵌套或重叠的提示会被合并为一个外层等待区间。处理器以尽力而为的方式调用,不会在提示显示或关闭之前被等待。

pi.on("ui_prompt_start", async (event, ctx) => {
  // event.reason === "ui_prompt"
  // event.kind: "select" | "confirm" | "input" | "editor" | "custom"
  // event.title: prompt title when available
});

pi.on("ui_prompt_end", async (event, ctx) => {
  // Pi is no longer waiting on that UI prompt span.
});

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_start 和 message_end 针对 user、assistant 和 toolResult 消息触发。
  • message_update 针对 assistant 流式更新触发。
  • 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 在预检阶段按 assistant 源顺序发出
  • tool_execution_update 事件可能在多个工具之间交错
  • tool_execution_end 在每个工具定稿之后按工具完成顺序发出
  • 最终的 toolResult 消息事件稍后仍按 assistant 源顺序发出
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 调用之前触发。以非破坏性的方式修改消息。消息类型参见 会话格式。

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

每次提供商请求运行一次;重试会复用相同的头,而不会重新触发该钩子。

before_provider_request

在构建完提供商特定的 payload 之后、请求发出之前触发。处理器按扩展加载顺序运行。返回 undefined 保持 payload 不变;返回任何其他值都会为后续处理器和实际请求替换 payload。

这个钩子可以改写提供商层面的系统指令,或将其完全移除。这些 payload 层面的修改不会反映在 ctx.getSystemPrompt() 中,后者报告的是 pi 的系统提示词字符串,而不是最终序列化的提供商 payload。

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

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

这主要用于调试提供商的序列化和缓存行为。

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

头的可用性取决于提供商和传输方式。对 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()、模型变化或内置思考级别控制改变了活动思考级别时,用它来更新扩展 UI。

工具事件

tool_call

在 tool_execution_start 之后、工具执行之前触发。可以阻止。 使用 isToolCallEventType 收窄类型并获得有类型的输入。

在 tool_call 运行之前,pi 会等待先前发出的智能体事件在 AgentSession 中排空完毕。这意味着 ctx.sessionManager 已同步到当前的 assistant 工具调用消息。

在默认的并行工具执行模式下,同一条 assistant 消息中的兄弟工具调用会先按顺序预检,再并发执行。tool_call 不保证能在 ctx.sessionManager 中看到同一 assistant 消息的兄弟工具结果。

event.input 是可变的。就地修改它即可在执行前修补工具参数。

行为保证:

  • 对 event.input 的修改会影响实际的工具执行
  • 后面的 tool_call 处理器能看到前面处理器所做的修改
  • 修改之后不会重新校验
  • tool_call 的返回值通过 { block: true, reason?: string, terminate?: boolean } 控制阻止
  • terminate 只对被阻止的调用生效;只有当批次中每个已定稿的结果都是终止性的,智能体才会提前停止
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_result 和 tool_execution_end 可能按工具完成顺序交错,而最终的 toolResult 消息事件稍后仍按 assistant 源顺序发出。

tool_result 处理器像中间件一样链式执行:

  • 处理器按扩展加载顺序运行
  • 每个处理器看到的是前一个处理器修改之后的最新结果
  • 处理器可以返回部分补丁(content、details、isError 或 usage);省略的字段保持当前值

在处理器内部做嵌套异步工作时使用 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 } };
});

输入事件

input

在收到用户输入时触发,此时扩展命令已检查完毕,但技能和模板尚未展开。事件看到的是原始输入文本,因此 /skill:foo 和 /template 还没有被展开。

处理顺序:

  1. 先检查扩展命令(/cmd)- 如果找到,则运行其处理器并跳过 input 事件
  2. 触发 input 事件 - 可以拦截、转换或处理
  3. 若未被处理:技能命令(/skill:name)展开为技能内容
  4. 若未被处理:提示词模板(/template)展开为模板内容
  5. 开始智能体处理(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 - 修改 text/images,然后继续展开
  • handled - 完全跳过智能体(第一个返回此值的处理器获胜)

转换会在多个处理器之间链式生效。感知 streamingBehavior 的路由参见 input-transform.ts 和 input-transform-streaming.ts。

ExtensionContext

所有处理器都会收到 ctx: ExtensionContext。

ctx.ui

用于用户交互的 UI 方法。完整细节参见 自定义 UI。

ctx.mode

当前运行模式:"tui"、"rpc"、"json" 或 "print"。用 ctx.mode === "tui" 来保护仅限终端的功能,例如 custom()、组件工厂、终端输入和直接 TUI 渲染。

ctx.hasUI

在 TUI 和 RPC 模式下为 true;在 print 模式(-p)和 JSON 模式下为 false。用它来保护对话框方法(select、confirm、input、editor),以及同时适用于 TUI 和 RPC 模式的即发即忘方法(notify、setStatus、setWidget、setTitle、setEditorText)。在 RPC 模式下,某些 TUI 特有的方法是无操作或返回默认值(参见 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

对会话状态的只读访问。完整的 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

访问模型、提供商和已解析的认证信息。ctx.modelRegistry.getProvider(id) 返回生效的 pi-ai 提供商,而 getProviderAuth(id) 会解析其当前 API 密钥、请求头、base URL 和提供商级环境变量,且无需加载模型。ctx.model 是活动模型,ctx.thinkingLevel 是其当前生效的思考级别。

ctx.scopedModels 是限定到当前会话的只读模型列表——与 /scoped-models 命令显示的是同一个集合。它在会话启动时由 --models CLI 标志和 enabledModels 设置解析得出(用 minimatch 按 provider/modelId 或裸 modelId 与可用目录匹配)。未配置限定时它为空,表示所有可用模型都可以使用。每个条目是 { model, thinkingLevel? },其中 thinkingLevel 只有在某个模式固定了它时才会被设置(例如 anthropic/*:high)。用它来填充一个与内置选择器一致的模型选择器,而不是通过 ctx.modelRegistry.getAvailable() 枚举整个目录。

流式模型调用

需要 reasoning 这类提供商中立选项时,使用 ctx.modelRegistry.streamSimple(model, context, options);需要 API 特定选项时,使用 stream()。两者都使用已配置的提供商并解析认证,包括通过 pi.registerProvider() 注册的提供商。请使用它们,而不是 pi-ai/compat 的流式函数,后者看不到扩展的提供商注册。

两者都返回 AssistantMessageEventStream。迭代它以获得响应事件,并等待 .result() 获得最终消息。初始化失败会产生错误事件和错误结果。

ctx.signal

当前的智能体中止信号;没有处于活动状态的智能体轮次时为 undefined。

为扩展处理器发起的可中止嵌套工作使用它,例如:

  • fetch(..., { signal: ctx.signal })
  • 接受 signal 的模型调用
  • 接受 AbortSignal 的文件或进程辅助函数

ctx.signal 通常在活动轮次事件(如 tool_call、tool_result、message_update 和 turn_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 正在处理智能体运行、自动重试、自动上下文压缩重试或排队延续时,ctx.isIdle() 为 false。

ctx.shutdown()

请求 pi 优雅关闭。

  • 交互模式: 推迟到智能体空闲之后(处理完所有排队的引导和后续消息)。
  • RPC 模式: 推迟到下一个空闲状态(完成当前命令响应、等待下一条命令时)。
  • print 模式: 无操作。处理完所有提示词后进程会自动退出。

退出前会向所有扩展发出 session_shutdown 事件。在所有上下文中都可用(事件处理器、工具、命令、快捷键)。

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

ctx.getContextUsage()

返回活动模型的当前上下文使用量。可用时使用最后一次 assistant 使用量,否则估算尾部消息的 token 数。

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

ctx.compact()

触发上下文压缩,但不等待完成。用 onComplete 和 onError 执行后续动作。

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 的 payload 改写。
  • 如果在你之后加载的扩展晚于你运行,它们仍然可以改变最终发送的内容。
pi.on("before_agent_start", (event, ctx) => {
  const prompt = ctx.getSystemPrompt();
  console.log(`System prompt length: ${prompt.length}`);
});

ExtensionCommandContext

命令处理器接收 ExtensionCommandContext,它在 ExtensionContext 的基础上扩展了会话控制方法。这些方法只在命令中可用,因为从事件处理器调用它们可能导致死锁。

ctx.getSystemPromptOptions()

返回 pi 当前用来构建系统提示词的基础输入。

const options = ctx.getSystemPromptOptions();
const contextPaths = options.contextFiles?.map((file) => file.path) ?? [];

它与 before_agent_start 的 event.systemPromptOptions 形状和可变性相同:自定义提示词、活动工具、工具摘要、提示词指南、追加的系统提示词文本、cwd、已加载的上下文文件和已加载的技能。它可能包含完整的上下文文件内容,因此请将其视为敏感的扩展本地数据,避免通过命令列表、日志或自动补全元数据将其暴露。

它报告的是当前的基础提示词输入。它不包含每轮 before_agent_start 的链式系统提示词修改、后续 context 事件的消息修改,也不包含 before_provider_request 的 payload 改写。

ctx.waitForIdle()

等待智能体完全安定,包括自动重试、自动上下文压缩重试和排队延续:

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;参见 会话替换的生命周期与易错点。

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;参见 会话替换的生命周期与易错点。

ctx.navigateTree(targetId, options?)

导航到会话树中的另一个节点。当智能体响应、手动或自动上下文压缩、或另一次树状导航处于活动状态时会拒绝,即使传入了 summarize: false 也是如此。这类冲突会让活动分支保持不变,并直接拒绝 promise,而不是返回 { cancelled: true }。请等待活动操作完成(例如在命令处理器中 await ctx.waitForIdle())后重试:

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,它在 ExtensionCommandContext 的基础上扩展了绑定到替换会话的异步 sendMessage() 和 sendUserMessage() 辅助方法。

生命周期与易错点:

  • withSession 只在旧会话发出 session_shutdown、旧运行时被销毁、替换会话被重新绑定、且新扩展实例已收到 session_start 之后才会运行。
  • 回调仍在原始闭包中执行,而不是在新扩展实例内部。这意味着你的旧扩展实例可能早在 withSession 开始之前就完成了关闭清理。
  • 捕获的旧 pi / 旧命令 ctx 中绑定会话的对象在替换之后已失效,使用时会抛出异常。绑定会话的工作只使用传给 withSession 的 ctx。
  • 之前提取出的原始对象仍由你负责。例如,如果在替换前捕获了 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
  • 然后重载资源,并发出带 reason: "reload" 的 session_start,以及带 reason "reload" 的 resources_discover
  • 当前正在运行的命令处理器仍继续在旧的调用帧中执行
  • 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)

订阅事件。事件类型与返回值见事件。

pi.registerTool(definition)

注册一个可由 LLM 调用的自定义工具。完整说明见自定义工具。

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" - 等待智能体完成。仅当智能体不再有工具调用时才投递。
    • "nextTurn" - 排队到下一条用户提示。不会中断或触发任何内容。
  • triggerTurn: true - 若智能体处于空闲状态,则立即触发一次 LLM 响应。仅适用于 "steer" 和 "followUp" 模式("nextTurn" 时忽略)。

pi.sendUserMessage(content, options?)

向智能体发送一条用户消息。与发送自定义消息的 sendMessage() 不同,它发送的是一条真实的用户消息,看起来就像用户亲手输入的一样。始终会触发一个回合。

// 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 - 智能体正在流式输出时必填:
    • "steer" - 将消息排队,在当前助手回合执行完其工具调用之后投递
    • "followUp" - 等待智能体完成所有工具
  • expandPromptTemplates - 分发扩展命令,并展开技能命令和提示词模板。默认为 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 调用的斜杠命令,包括扩展命令、提示词模板和技能命令。 列表顺序与 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 上下文。见自定义 UI。

pi.registerMarkdownTransformer(transformer)

为普通用户文本、助手文本和思考块中的 Markdown 注册一个转换器。转换器按扩展加载顺序运行,每个转换器都会接收上一个转换器返回的 Markdown。整条链结束后,pi 会用内置渲染器渲染转换后的内容。

转换器接收 Markdown 字符串和一个包含以下字段的上下文:

  • messageType — "user"、"assistant" 或 "assistant-thinking"
  • isStreaming — 助手部分更新时为 true;用户消息、已完成的助手消息和恢复的消息为 false
  • availableWidth — 转换后的 Markdown 内容可用的精确终端列数

返回转换后的 Markdown:

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

如果某个转换器抛出异常,pi 会保留到目前为止生成的 Markdown,并继续执行下一个转换器。这个钩子只影响显示:会话和模型上下文中的原始消息保持不变。它会在新用户消息、助手流式更新、恢复的会话消息以及终端宽度变化时运行,因此转换器应保持同步且开销低。

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 标志。

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() 返回 name、description、parameters、promptGuidelines 和 sourceInfo。

典型的 sourceInfo.source 值:

  • builtin 表示内置工具
  • sdk 表示通过 createAgentSession({ customTools }) 传入的工具
  • 扩展注册的工具则使用该扩展的来源元数据

pi.setModel(model)

设置当前会话的模型。该更改会记录在会话历史中,恢复该会话时会还原,但它不会改变新会话所使用的已配置的 defaultProvider 或 defaultModel。如果未为该模型的提供商配置身份验证,则返回 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 事件。

pi.setThinkingLevel() 更改当前会话的思考级别。该更改会记录在会话历史中,恢复该会话时会还原,但它不会改变新会话所使用的已配置默认值。

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)

动态注册或覆盖一个模型提供商。适用于代理、自定义端点或团队统一的模型配置。

在扩展工厂函数中进行的调用会排队,待运行器(runner)初始化后一次性应用。之后再进行的调用——例如在用户设置流程之后的命令处理器中——会立即生效,无需 /reload。

动态提供商可以实现 refreshModels。pi 会在模型刷新期间调用它,通过该提供商同步发布返回的列表,并传入规范的凭证/已存储目录/网络/信号上下文。是否通过经过世代检查(generation-checked)的 context.publish({ persist: entry }) 持久化目录元数据,由扩展自行决定;llama.cpp 这类实时服务器可以直接返回模型而不做持久化。

context.signal 始终是一个具体的 signal,提供商回调必须把它传递给阻塞式 I/O。公开的 ModelRuntime.refresh() 和 ModelRegistry.refresh() 调用接受可选的 signal,省略时不设时限;扩展和应用程序自行选择自己的截止时间。即使提供商忽略 signal,取消也会让调用方停止等待,但要停止底层工作仍需要它的配合。

需要原生提供商身份验证、过滤、刷新或流式行为的扩展,可以从 @earendil-works/pi-ai 注册一个完整的 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,包括原生的 auth、getModels、refreshModels、filterModels、stream 和 streamSimple 行为。

旧版配置选项:

  • name - 提供商在 /login 等 UI 中的显示名称。
  • baseUrl - API 端点 URL。定义模型时必填。
  • apiKey - API 密钥字面量、环境变量插值($ENV_VAR 或 ${ENV_VAR}),或以 !command 开头的命令调用。定义模型时必填(除非提供了 oauth)。$$ 转义 $,$! 转义字面量 ! 而不会触发命令执行。
  • api - API 类型:"anthropic-messages"、"openai-completions"、"openai-responses" 等。
  • headers - 请求中附带的自定义请求头。
  • authHeader - 若为 true,自动添加 Authorization: Bearer 请求头。
  • models - 模型定义数组。若提供,会替换该提供商的全部现有模型。模型定义可以设置 baseUrl,为该模型覆盖提供商端点。
  • refreshModels - 异步动态发现回调。其返回的模型会替换扩展提供的模型。context.stored 包含持久化的提供商快照;仅当更新后的目录数据应当持久化时,才使用经过世代检查的 context.publish({ persist: entry })。使用 persist: null 可删除该快照。
  • oauth - 用于支持 /login 的 OAuth 提供商配置。提供后,该提供商会出现在登录菜单中。
  • streamSimple - 面向非标准 API 的自定义流式实现。

高级主题(自定义流式 API、OAuth 细节、模型定义参考)见 custom-provider.md。

pi.unregisterProvider(name)

移除先前注册的提供商及其模型。被该提供商覆盖的内置模型会恢复原状。如果该提供商未被注册,则不会有任何效果。

与 registerProvider 一样,在初始加载阶段之后调用会立即生效,因此不需要 /reload。

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

状态管理

有状态的扩展应把状态存储在工具结果的 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() 调用的工具。这些工具会出现在系统提示词中,并且可以自定义渲染。

使用 promptSnippet 可以在默认系统提示词的 Available tools 小节中生成一条简短的单行条目。如果省略,自定义工具不会出现在该小节中。

使用 promptGuidelines 向默认系统提示词的 Guidelines 小节添加针对特定工具的条目。这些条目只在工具处于激活状态时(例如在 pi.setActiveTools([...]) 之后)才会被包含。

重要: promptGuidelines 条目会平铺地追加到 Guidelines 小节,不带工具名前缀,也不分组。每条准则都必须指明它所针对的工具——避免写成 “Use this tool when…",因为 LLM 无法判断 “this” 指的是哪个工具。应写成 “Use my_tool when…"。

注意:有些模型不太聪明,会在工具的路径参数中带上 @ 前缀。内置工具在解析路径之前会剥掉开头的 @。如果你的自定义工具接受路径,也要对开头的 @ 做同样的规范化。

如果你的自定义工具会修改文件,请使用 withFileMutationQueue(),让它与内置的 edit 和 write 参与同一个按文件划分的队列。这一点很重要,因为默认情况下工具调用是并行运行的。没有这个队列,两个工具可能读到同一份旧文件内容,计算出不同的更新,而后落盘的那个写入会覆盖另一个。

失败场景举例:你的自定义工具编辑 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 调用。只有当该批次中每个已完成的工具结果都是终止性的,这才会生效。最小示例见 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) 是可选的。如果定义了,它会在 schema 校验之前、execute() 之前运行。当 pi 恢复一个旧会话、其中存储的工具调用参数与当前 schema 不再匹配时,可以用它来模拟旧的接受输入形状。返回你希望按 parameters 校验的对象。保持公开 schema 严格,不要仅为让旧会话能恢复而向 parameters 添加已废弃的兼容字段。

例如:一个旧会话可能包含带顶层 oldText 和 newText 的 edit 工具调用,而当前 schema 只接受 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: {},
    };
  },
});

覆盖内置工具

扩展可以通过注册同名工具来覆盖内置工具(read、bash、powershell、edit、write、grep、find、ls)。发生这种情况时,交互模式会显示警告。

# 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

完整示例见 examples/extensions/tool-override.ts,它为 read 加上了日志和访问控制。

渲染: 内置渲染器的继承按槽位解析。执行覆盖与渲染覆盖是相互独立的。如果你的覆盖省略了 renderCall,就使用内置的 renderCall;如果省略了 renderResult,就使用内置的 renderResult;如果两者都省略,会自动使用内置渲染器(语法高亮、diff 等)。这样你就可以在不重新实现 UI 的情况下,为日志或访问控制包装内置工具。

提示词元数据: promptSnippet 和 promptGuidelines 不会从内置工具继承。如果你的覆盖需要保留这些提示词指令,请在覆盖中显式定义。

你的实现必须与精确的结果形状匹配,包括 details 类型。UI 和会话逻辑依赖这些形状进行渲染和状态跟踪。

内置工具实现:

远程执行

内置工具支持以可插拔的操作(operations)委托给远程系统(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);
  },
});

操作接口: ReadOperations、WriteOperations、EditOperations、BashOperations、PowerShellOperations、LsOperations、GrepOperations、FindOperations

对 user_bash 而言,扩展可以通过 createLocalBashOperations() 复用 pi 的本地 shell 后端,而无需重新实现本地进程生成、shell 解析和进程树终止。

bash 和 powershell 工具还支持一个 spawn 钩子,用于在执行前调整命令、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() 和 createPowerShellTool() 会通过 PI_SESSION_ID、PI_SESSION_FILE、PI_PROVIDER、PI_MODEL 和 PI_REASONING_LEVEL 把当前会话暴露给命令。注入发生在 spawnHook 之前,因此钩子会在 env 中收到这些值,并像上面那样在展开现有环境时保留它们。设置 exposeSessionEnvironment: false 可以禁用:

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

变量语义见 Shell 工具会话环境。带 --ssh 标志的完整 SSH 示例见 examples/extensions/ssh.ts。

输出截断

工具必须截断自己的输出,避免撑爆 LLM 上下文。过大的输出可能导致:

  • 上下文溢出错误(提示词过长)
  • 上下文压缩失败
  • 模型性能下降

内置限制为 50KB(约 10k token)和 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();
  });
}

自定义渲染

工具可以提供 renderCall 和 renderResult 来实现自定义 TUI 显示。完整组件 API 见 tui.md,工具行的组合方式见 tool-execution.ts。

默认情况下,工具输出被包裹在一个负责内边距和背景的 Box 中。已定义的 renderCall 或 renderResult 必须返回一个 Component。如果某个槽位没有定义渲染器,tool-execution.ts 会为该槽位使用回退渲染。

当工具需要渲染自己的外壳而不是使用默认 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);
  },
});

renderCall 和 renderResult 各接收一个 context 对象,包含:

  • args - 当前工具调用的参数
  • state - 在 renderCall 和 renderResult 之间共享的行局部状态
  • lastComponent - 该槽位先前返回的组件(如果有)
  • invalidate() - 请求重新渲染此工具行
  • toolCallId、cwd、executionStarted、argsComplete、isPartial、expanded、showImages、isError

需要跨槽位共享的状态请使用 context.state。当你想在多次渲染之间复用并原地修改同一组件时,把槽位局部缓存放在返回的组件实例上。

renderCall

渲染工具调用或头部:

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

渲染工具结果或输出:

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:

  • 编码智能体的 id 使用 app.* 命名空间,例如 app.tools.expand、app.editor.external、app.session.rename
  • 共享 TUI 的 id 使用 tui.* 命名空间,例如 tui.select.confirm、tui.select.cancel、tui.input.tab

快捷键 id 和默认值的完整列表见 keybindings.md。keybindings.json 使用的就是这些带命名空间的 id。

自定义编辑器和 ctx.ui.custom() 组件会收到 keybindings: KeybindingsManager 作为注入参数。它们应直接使用该注入的管理器,而不是调用 getKeybindings() 或 setKeybindings()。

最佳实践

  • 使用带 (0, 0) 内边距的 Text,默认 Box 负责内边距。
  • 多行内容使用 \n。
  • 处理 isPartial 以展示流式进度。
  • 支持 expanded 以按需展开详情。
  • 保持默认视图紧凑。
  • 在 renderResult 中读取 context.args,而不是把参数复制进 context.state。
  • context.state 只用于必须跨调用槽位和结果槽位共享的数据。
  • 当同一组件实例可以原地更新时,复用 context.lastComponent。
  • 仅当默认的带框外壳碍事时才使用 renderShell: "self"。在自外壳模式下,工具要自己负责边框、内边距和背景。

回退

如果某个槽位的渲染器未定义或抛出异常:

  • renderCall:显示工具名
  • renderResult:显示 content 中的原始文本

动态加载工具

扩展可以注册很多工具,但只让一小部分初始激活。之后工具可以在执行期间通过 pi.setActiveTools() 添加更多工具。pi 会检测纯增量变更,把新可用的工具名记录到该工具结果上,并在下一次模型请求之前应用更新后的激活集合。

这适用于所有模型。原生支持延迟加载的模型会保持稳定的提示词前缀,并在工具结果的位置加载新定义。其他模型使用下文描述的回退机制。

生命周期如下:

  1. 用 pi.registerTool() 注册每一个工具,使其出现在 pi.getAllTools() 中。
  2. 保持加载器工具(例如 search_tools)激活,让可搜索工具保持未激活。
  3. 在加载器执行期间,调用 pi.setActiveTools([...currentTools, ...matchingTools])。变更必须是增量的:不要在同一次调用中移除当前激活的工具。
  4. pi 在加载器的工具结果上记录添加了哪些工具。
  5. 在下一次模型响应之前,pi 会公开新增的定义——在支持时使用原生延迟加载,否则使用正常的激活工具列表。

你不需要返回提供商特定的工具引用,也不需要把加载器标记为特殊的搜索工具。激活工具集合的变更本身就是信号。传给 pi.setActiveTools() 的名称必须已经注册;未知名称会被忽略。

原生支持延迟加载的模型

  • Anthropic
    • 模型: Sonnet、Opus、Fable 4.5 或更新版本(不含 Haiku)
    • 原生表示: 延迟定义使用 defer_loading;加载点使用 tool_reference 内容。
  • Fireworks Messages API
    • 原生表示: 延迟定义使用 defer_loading;加载点使用 tool_reference 内容。
    • 加载器名称: 要获得前缀延迟效果,加载器需命名为 ToolSearch 或 tool_search。其他加载器名称也能工作,但 Fireworks 会把已加载的 schema 包含在初始工具前缀中,从而失去缓存优势。
    • 这不改变 API 路由:Fireworks GLM 模型和 Kimi K3 使用 Chat Completions,而不是 Messages。
  • OpenAI
    • 模型: gpt-5.4 及更新的家族
    • 原生表示: pi 在加载点添加已完成的客户端 tool_search_call 和 tool_search_output 条目。

对于经过验证的自定义模型或代理,可以为 anthropic-messages 启用 compat.supportsToolReferences: true,或为 openai-responses 和 openai-codex-responses 启用 compat.supportsToolSearch: true,以启用原生处理。除非端点和模型支持相应的原生协议,否则请保持禁用。

回退行为

对于所有其他模型和提供商,动态激活仍然有效:pi 会在下一次请求中正常发送完整的当前激活工具列表。模型可以调用新激活的工具,但添加它们的定义可能使提供商缓存的提示词前缀失效。

当激活集合不是纯增量时(例如用一组工具替换另一组),pi 也会使用这种安全回退。因此工具移除也能工作,但不使用延迟加载。

为了获得最佳缓存行为,请让加载器工具在整个会话期间保持激活,并以添加工具为主,而不是替换激活集合。还要注意,激活一个带 promptSnippet 或 promptGuidelines 的工具会重建系统提示词;即使提供商支持延迟 schema,这种系统提示词变更也可能使前缀失效。延迟加载的工具通常应依赖其工具 description,并省略仅激活时生效的提示词元数据。

搜索工具示例

下面的扩展注册两个可搜索工具,把它们从初始激活集合中移除,只保留 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 添加了匹配项时,模型会在紧随其后的那次请求中收到相应定义。在支持原生能力的模型上,定义锚定在搜索结果之后,初始工具 schema 前缀保持不变。在其他模型上,它出现在同一次后续请求的正常工具列表中。

自定义 UI

扩展可以通过 ctx.ui 的方法与用户交互,并自定义消息/工具的渲染方式。

自定义组件请参见 tui.md,其中提供了可直接复制的模式:

  • 选择对话框(SelectList)
  • 可取消的异步操作(BorderedLoader)
  • 设置开关(SettingsList)
  • 状态指示器(setStatus)
  • 流式输出期间的工作消息、可见性与指示器(setWorkingMessage、setWorkingVisible、setWorkingIndicator)
  • 编辑器上方/下方的小部件(setWidget)
  • 在内置斜杠/路径补全之上叠加的自动补全提供器(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(...)。

自动补全提供器

使用 ctx.ui.addAutocompleteProvider() 在内置斜杠命令和路径提供器之上叠加自定义自动补全逻辑。为 $ 这类自定义触发字符设置 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 issue,并在本地过滤以实现快速的 #... 补全。它需要 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) - 调用以关闭组件并返回值

完整组件 API 见 tui.md。

覆盖层模式(实验性)

传入 { 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 } 会在不聚焦其他组件的情况下释放覆盖层。

完整的 OverlayOptions 和 OverlayHandle API 见 tui.md,示例见 overlay-qa-tests.ts。

自定义编辑器

用自定义实现替换主输入编辑器(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)
  • 自定义编辑器默认保留独立的工作行。若想改用内置的编辑器边框加载动画,请将 { embedWorkingStatus: true } 作为 CustomEditor 构造函数的第四个参数传入
  • 工厂函数从应用接收 tui、theme 和 keybindings
  • 在 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);

错误处理

  • 扩展错误会被记录日志,智能体继续运行
  • tool_call 错误会阻止该工具(故障安全)
  • 工具 execute 的错误必须通过抛出来发出信号;抛出的错误会被捕获,并以 isError: true 报告给 LLM,执行继续

模式行为

模式ctx.modectx.hasUI说明
交互"tui"true完整 TUI 与终端渲染
RPC(--mode rpc)"rpc"true对话框与通知通过 JSON 协议传递;custom() 返回 undefined。见 rpc.md
JSON(--mode json)"json"false事件流输出到 stdout;UI 方法为空操作
打印(-p)"print"false扩展会运行,但无法提示用户

在使用 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, 会话事件
dynamic-tools.ts启动后及命令执行期间注册工具registerTool, session_start, registerCommand
structured-output.ts带 terminate: true 的最终结构化输出工具registerTool, 终止性工具结果
truncated-tool.ts输出截断示例registerTool, truncateHead
tool-override.ts覆盖内置 read 工具registerTool(与内置工具同名)
命令
pirate.ts按回合修改系统提示词registerCommand, before_agent_start
summarize.ts会话摘要命令registerCommand, ui.custom
handoff.ts跨提供商模型交接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"), 信任 UI, 必需的信任结果
protected-paths.ts阻止向特定路径写入on("tool_call")
confirm-destructive.ts确认破坏性的会话变更on("session_before_switch"), on("session_before_fork")
dirty-repo-guard.ts在 git 仓库有未提交更改时发出警告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检查载荷与提供商响应头on("before_provider_request"), on("after_provider_response")
system-prompt-header.ts显示系统提示词信息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按回合执行 git stashon("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
UI 组件
status-line.ts页脚状态指示器setStatus, 会话事件
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.tsVim 风格模态编辑器setEditorComponent, CustomEditor
rainbow-editor.ts自定义编辑器样式setEditorComponent
widget-placement.ts编辑器上方/下方的小部件setWidget
overlay-test.ts覆盖层组件ui.custom 及 overlay 选项
overlay-qa-tests.ts全面的覆盖层测试ui.custom, 所有 overlay 选项
notify.ts简单通知ui.notify
timed-confirm.ts带超时的对话框ui.confirm 及 timeout/signal
mac-system-theme.ts自动切换主题setTheme, exec
复杂扩展
plan-mode/完整的计划模式实现所有事件类型, registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools
preset.ts可保存的预设(模型、工具、思考)registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry
tools.ts工具开关 UIregisterCommand, setActiveTools, SettingsList, 会话事件
远程与沙箱
ssh.tsSSH 远程执行registerFlag, on("user_bash"), on("before_agent_start"), 工具操作
interactive-shell.ts持久 shell 会话on("user_bash")
sandbox/沙箱化的工具执行工具操作
gondolin/将内置工具和 ! 命令路由进 Gondolin 微型 VM工具操作, 内置工具覆盖, on("user_bash")
subagent/生成子智能体registerTool, exec
游戏
snake.ts贪吃蛇游戏registerCommand, ui.custom, 键盘处理
space-invaders.tsSpace Invaders 游戏registerCommand, ui.custom
doom-overlay/覆盖层中的 Doomui.custom 及 overlay 选项
提供商
custom-provider-anthropic/自定义 Anthropic 代理registerProvider
custom-provider-gitlab-duo/GitLab Duo 集成带 OAuth 的 registerProvider
消息与通信
message-renderer.ts自定义消息渲染registerMessageRenderer, sendMessage
entry-renderer.ts仅 TUI 的自定义条目渲染registerEntryRenderer, appendEntry
event-bus.ts扩展间事件pi.events
会话元数据
session-name.ts为选择器命名会话setSessionName, getSessionName
bookmark.ts为 /tree 标记条目书签setLabel
其他
inline-bash.ts在工具调用中内联 bashon("tool_call")
bash-spawn-hook.ts执行前调整 bash 命令、cwd 和 envcreateBashTool, spawnHook
with-deps/带 npm 依赖的扩展含 package.json 的包结构