PiPi
快速开始
使用 Pi
  • 简体中文
  • English
快速开始
使用 Pi
  • 简体中文
  • English
  • 从这里开始

    • 快速开始
    • 使用 Pi
    • 提供商
    • 安全
    • 容器化
    • 设置
    • 键位绑定
    • 会话
    • 压缩与分支总结
  • 自定义

    • 扩展
    • 技能
    • 提示词模板
    • 主题
    • Pi 包
    • 自定义模型
    • 自定义提供商
  • 参考

    • 会话文件格式
  • 编程式使用

    • SDK
    • RPC 模式
    • JSON 事件流模式
    • TUI 组件
  • 平台设置

    • Windows 设置
    • Termux(Android)设置
    • tmux 设置
    • 终端设置
    • Shell 别名
  • 开发

    • 开发

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/ 获取可运行实现。

目录

  • 快速开始
  • 扩展位置
  • 可用导入
  • 编写扩展
    • 扩展风格
  • 事件
    • 生命周期概览
    • 资源事件
    • 会话事件
    • Agent 事件
    • 模型事件
    • 工具事件
  • ExtensionContext
  • ExtensionCommandContext
  • ExtensionAPI 方法
  • 状态管理
  • 自定义工具
  • 自定义 UI
  • 错误处理
  • 模式行为
  • 示例参考

快速开始

创建 ~/.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 工具函数(用于 Google 兼容枚举的 StringEnum)
@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 中可用。

长生命周期资源与关闭

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

将后台资源启动推迟到 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_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                                            │
                                                           │
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 扩展会拥有该决策;第一个 yes/no 决策会胜出,并抑制内置信任提示。使用 remember: true 可持久保存 yes/no 决策;否则它只适用于当前进程。返回 "undecided" 可让后续处理器或内置信任流程决定。提示前检查 ctx.hasUI。如果没有处理器返回 yes/no,则正常信任解析继续:先应用已保存的 trust.json 决策,然后由 defaultProjectTrust 控制 Pi 是询问、信任还是默认拒绝。

资源事件

resources_discover

在 session_start 后触发,让扩展贡献额外的 skill、prompt 和 theme 路径。 启动路径使用 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 Format。

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

成功 fork 或 clone 后,Pi 会为旧扩展实例发出 session_shutdown,为新会话重新加载并重新绑定扩展,然后以 reason: "fork" 和 previousSessionFile 发出 session_start。 在 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,
    },
  }
})

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: '...', 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

在用户提交提示后、Agent 循环前触发。可以注入消息和/或修改系统提示。

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

每个用户提示触发一次。

pi.on('agent_start', async (_event, ctx) => {})

pi.on('agent_end', async (event, ctx) => {
  // event.messages - messages from this prompt
})

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 调用前触发。以非破坏方式修改消息。关于消息类型,请参见 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_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 响应后、消费其流式 body 之前触发。处理器按扩展加载顺序运行。

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'])
  }
})

Header 可用性取决于提供商和传输层。抽象 HTTP 响应的提供商可能不会暴露 header。

模型事件

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 会等待之前发出的 Agent 事件通过 AgentSession 完成排空。这意味着 ctx.sessionManager 已同步到当前 assistant 工具调用消息。

在默认并行工具执行模式中,同一 assistant 消息中的兄弟工具调用会依次预检,然后并发执行。tool_call 不能保证在 ctx.sessionManager 中看到同一 assistant 消息中兄弟工具的结果。

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

行为保证:

  • 对 event.input 的修改会影响实际工具执行
  • 后续 tool_call 处理器会看到前面处理器做出的修改
  • 修改后不会重新验证
  • tool_call 的返回值仅通过 { block: true, reason?: string } 控制阻止
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' }
    }
  }

  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);省略的字段保持当前值

在处理器内部的嵌套异步工作中使用 ctx.signal。这允许 Esc 取消由扩展启动的模型调用、fetch() 和其他支持 abort 的操作。

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

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

用户 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 和 template 展开之前。该事件看到的是原始输入文本,因此 /skill:foo 和 /template 尚未展开。

处理顺序:

  1. 先检查扩展命令(/cmd)——如果找到,运行处理器并跳过 input 事件
  2. 触发 input 事件——可拦截、转换或处理
  3. 如果未处理:skill 命令(/skill:name)展开为 skill 内容
  4. 如果未处理:prompt template(/template)展开为 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 - 修改 text/images,然后继续展开
  • handled - 完全跳过 Agent(第一个返回此值的处理器胜出)

转换会在多个处理器之间串联。有关支持 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)以及即发即忘方法(notify、setStatus、setWidget、setTitle、setEditorText),这些方法在 TUI 和 RPC 模式中都可工作。在 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 和条目类型见 Session Format。

对于 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

访问模型和 API key。

ctx.signal

当前 Agent abort signal;当没有活跃 Agent 轮次时为 undefined。

将它用于由扩展处理器启动的可 abort 嵌套工作,例如:

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

ctx.signal 通常在活动轮次事件期间定义,例如 tool_call、tool_result、message_update 和 turn_end。 在空闲或非轮次上下文中通常为 undefined,例如会话事件、扩展命令和 Pi 空闲时触发的快捷键。

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

控制流辅助函数。

ctx.shutdown()

请求 Pi 优雅关闭。

  • 交互模式: 延迟到 Agent 变为空闲后执行(处理完所有已排队的 steering 和 follow-up 消息之后)。
  • RPC 模式: 延迟到下一个空闲状态(完成当前命令响应后,等待下一个命令时)。
  • Print 模式: 无操作。进程会在所有提示处理完成后自动退出。

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

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

ctx.getContextUsage()

返回当前模型的上下文使用量。可用时使用最后一次 assistant usage,否则估算尾随消息的 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()

等待 Agent 完成流式输出:

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:要记录到新会话 header 中的父会话文件
  • setup:在 withSession 运行前修改新会话的 SessionManager
  • withSession:针对新的替换会话上下文运行切换后工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换生命周期和陷阱。

ctx.fork(entryId, options?)

从特定条目 fork,创建新会话文件:

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"(默认)在所选 user 消息之前 fork,并将该提示恢复到编辑器
  • position:"at" 复制经过所选条目的活动路径,不恢复编辑器文本
  • withSession:针对新的替换会话上下文运行切换后工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换生命周期和陷阱。

ctx.navigateTree(targetId, options?)

导航到会话树中的不同位置:

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
}

选项:

  • withSession:针对新的替换会话上下文运行切换后工作。不要使用捕获的旧 pi / 命令 ctx;参见 会话替换生命周期和陷阱。

若要发现可用会话,请使用静态 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, and themes',
  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() 之后的代码不得假设旧的内存扩展状态仍然有效
  • 处理器返回后,未来的命令/事件/工具调用使用新的扩展版本

为了行为可预测,应将 reload 视为该处理器的终点(await ctx.reload(); return;)。

工具使用 ExtensionContext 运行,因此无法直接调用 ctx.reload()。请使用命令作为重载入口点,然后暴露一个工具,将该命令排队为 follow-up 用户消息。

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, and themes',
    handler: async (_args, ctx) => {
      await ctx.reload()
      return
    },
  })

  pi.registerTool({
    name: 'reload_runtime',
    label: 'Reload Runtime',
    description: 'Reload extensions, skills, prompts, and themes',
    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 部分,不带工具名前缀。每条 guideline 必须明确命名其所指工具——避免写 “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-only 内容,请使用 pi.appendEntry() 并配合 pi.registerEntryRenderer()。

pi.sendMessage({
  customType: "my-extension",
  content: "Message text",
  display: true,
  details: { ... },
}, {
  triggerTurn: true,
  deliverAs: "steer",
});

选项:

  • deliverAs - 投递模式:
    • "steer"(默认)- 流式输出期间排队消息。在当前 assistant 轮次完成工具调用后、下一次 LLM 调用前投递。
    • "followUp" - 等待 Agent 完成。仅在 Agent 不再有工具调用时投递。
    • "nextTurn" - 排队到下一次用户提示。不打断也不触发任何内容。
  • triggerTurn: true - 如果 Agent 空闲,则立即触发 LLM 响应。仅适用于 "steer" 和 "followUp" 模式(对 "nextTurn" 忽略)。

pi.sendUserMessage(content, options?)

向 Agent 发送用户消息。与发送自定义消息的 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' })

选项:

  • deliverAs - 当 Agent 正在流式输出时必填:
    • "steer" - 将消息排队,等待当前 assistant 轮次完成工具调用后投递
    • "followUp" - 等待 Agent 完成所有工具

非流式输出时,消息会立即发送并触发新轮次。流式输出时若未提供 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 调用的斜杠命令。包括扩展命令、prompt template 和 skill 命令。 该列表与 RPC get_commands 排序一致:先扩展,再 template,最后 skill。

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

设置当前模型。如果该模型没有可用 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)

获取或设置思考级别。级别会被限制在模型能力范围内(非 reasoning 模型始终使用 "off")。变化会发出 thinking_level_select。

const current = pi.getThinkingLevel() // "off" | "minimal" | "low" | "medium" | "high" | "xhigh"
pi.setThinkingLevel('high')

pi.events

用于扩展之间通信的共享事件总线:

pi.events.on("my:event", (data) => { ... });
pi.events.emit("my:event", { ... });

pi.registerProvider(name, config)

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

在扩展工厂函数期间调用会被排队,并在 runner 初始化后应用。之后调用——例如在用户设置流程后的命令处理器中——会立即生效,无需 /reload。

如果需要从远程端点发现模型,优先使用异步扩展工厂,而不是把 fetch 推迟到 session_start。Pi 会在启动继续前等待工厂完成,因此注册的模型会立即可用,包括在 pi --list-models 中。

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

// 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) {
      // Refresh logic
      return credentials;
    },
    getApiKey(credentials) {
      return credentials.access;
    }
  }
});

配置选项:

  • name - 在 /login 等 UI 中显示的提供商名称。
  • baseUrl - API 端点 URL。定义模型时必需。
  • apiKey - API key 字面量、环境变量插值($ENV_VAR 或 ${ENV_VAR}),或以 !command 开头。定义模型时必需(除非提供 oauth)。$$ 转义 $,$! 转义字面量 ! 而不触发命令执行。
  • api - API 类型:"anthropic-messages"、"openai-completions"、"openai-responses" 等。
  • headers - 请求中包含的自定义 header。
  • authHeader - 如果为 true,则自动添加 Authorization: Bearer header。
  • models - 模型定义数组。如果提供,会替换该提供商的所有现有模型。模型定义可以设置 baseUrl 来覆盖该模型的提供商端点。
  • oauth - 支持 /login 的 OAuth 提供商配置。提供后,该提供商会出现在登录菜单中。
  • streamSimple - 用于非标准 API 的自定义流式实现。

高级主题见 custom-provider.md:自定义流式 API、OAuth 详情、模型定义参考。

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

自定义工具

通过 pi.registerTool() 注册 LLM 可调用的工具。工具会出现在系统提示中,并且可以有自定义渲染。

使用 promptSnippet 在默认系统提示的 Available tools 部分中加入简短的一行条目。如果省略,自定义工具不会出现在该部分。

使用 promptGuidelines 向默认系统提示的 Guidelines 部分添加工具特定项目符号。这些项目符号仅在工具处于活动状态时包含(例如在 pi.setActiveTools([...]) 之后)。

重要: promptGuidelines 项目符号会以扁平方式追加到 Guidelines 部分,不带工具名前缀或分组。每条 guideline 必须明确命名其所指工具——避免写 “Use this tool when...”,因为 LLM 无法判断 “this” 指哪个工具。应写 “Use my_tool when...” 。

注意:有些模型会错误地在工具路径参数中包含 @ 前缀。内置工具会在解析路径前剥离开头的 @。如果你的自定义工具接受路径,也应规范化开头的 @。

如果你的自定义工具会修改文件,请使用 withFileMutationQueue(),使其参与与内置 edit 和 write 相同的按文件队列。这很重要,因为工具调用默认并行运行。如果没有队列,两个工具可能读取同一个旧文件内容、计算不同更新,然后最后落盘的写入覆盖另一个。

失败案例示例:你的自定义工具编辑 foo.ts,而内置 edit 也在同一个 assistant 轮次中更改 foo.ts。如果你的工具不参与队列,两者都可能读取原始 foo.ts、分别应用更改,然后其中一个更改丢失。

将真实目标文件路径传给 withFileMutationQueue(),不要传原始用户参数。先将其解析为绝对路径,相对于 ctx.cwd 或你的工具工作目录。对于现有文件,辅助函数会通过 realpath() 规范化,因此同一文件的符号链接别名共享一个队列。对于新文件,因为尚无内容可 realpath(),它会回退到解析后的绝对路径。

将目标路径上的整个修改窗口纳入队列。这包括 read-modify-write 逻辑,而不仅仅是最终写入。

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

表示错误: 要将工具执行标记为失败(在结果上设置 isError: true 并报告给 LLM),请从 execute 抛出错误。返回值永远不会设置错误标志,不管返回对象中包含什么属性。

提前终止: 从 execute() 返回 terminate: true,表示当前工具批次后应跳过自动 follow-up LLM 调用。只有当该批次中每个最终工具结果也都返回 terminating 时才会生效。有关最终结构化输出工具调用后 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) 是可选的。如果定义,它会在 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、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 和会话逻辑依赖这些形状来渲染和跟踪状态。

内置工具实现:

  • read.ts - ReadToolDetails
  • bash.ts - BashToolDetails
  • edit.ts
  • write.ts
  • grep.ts - GrepToolDetails
  • find.ts - FindToolDetails
  • ls.ts - LsToolDetails

远程执行

内置工具支持可插拔 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)
  },
})

Operations 接口: ReadOperations、WriteOperations、EditOperations、BashOperations、LsOperations、GrepOperations、FindOperations

对于 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' },
  }),
})

完整 SSH 示例见 examples/extensions/ssh.ts,其中包含 --ssh 标志。

输出截断

工具必须截断其输出,以避免压垮 LLM 上下文。大型输出可能导致:

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

内置限制是 50KB(约 10k tokens)和 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。

默认情况下,工具输出会被包装在处理 padding 和 background 的 Box 中。已定义的 renderCall 或 renderResult 必须返回 Component。如果某个槽位渲染器未定义,tool-execution.ts 会对该槽位使用 fallback 渲染。

当工具应渲染自己的 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)
  },
})

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

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

使用 context.state 存储跨槽位共享状态。当你想跨渲染复用并修改同一个组件时,将槽位本地缓存保存在返回的组件实例上。

renderCall

渲染工具调用或 header:

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:

  • Coding-agent 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。它们应直接使用注入的 manager,而不是调用 getKeybindings() 或 setKeybindings()。

最佳实践

  • 使用带 padding (0, 0) 的 Text。默认 Box 会处理 padding。
  • 使用 \n 表示多行内容。
  • 处理 isPartial 以支持流式进度。
  • 支持 expanded 以按需显示详情。
  • 保持默认视图简洁。
  • 在 renderResult 中读取 context.args,而不是把 args 复制到 context.state。
  • 仅将 context.state 用于必须在 call 和 result 槽位之间共享的数据。
  • 当同一个组件实例可以原地更新时,复用 context.lastComponent。
  • 仅在默认 boxed shell 妨碍需求时使用 renderShell: "self"。在 self-shell 模式中,工具负责自己的框架、padding 和背景。

Fallback

如果某个槽位渲染器未定义或抛错:

  • renderCall:显示工具名称
  • renderResult:显示来自 content 的原始文本

自定义 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

自定义工作指示器 frame 会原样渲染。如果想要颜色,请自行将颜色添加到 frame 字符串中,例如使用 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 预加载最新 open GitHub issue,并在本地过滤以实现快速 #... 补全。它需要 GitHub CLI(gh)和一个 GitHub 仓库 checkout。

自定义组件

对于复杂 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 - App 键绑定管理器(用于检查快捷键)
  • 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(theme, keybindings))
  })
}

要点:

  • 扩展 CustomEditor(而不是基础 Editor)以获得 App 键绑定(escape 中止、ctrl+d、模型切换)
  • 对你不处理的按键调用 super.handleInput(data)
  • 工厂从 App 接收 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 Pattern 7。

消息和条目渲染

为带有你的 customType 的消息注册自定义渲染器。对于应参与 LLM 上下文的内容,请使用消息渲染器:

import { Text } from '@earendil-works/pi-tui'

pi.registerMessageRenderer('my-extension', (message, options, theme) => {
  const { expanded } = 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, 0, 0)
})

消息通过 pi.sendMessage() 发送:

pi.sendMessage({
  customType: "my-extension",  // Matches registerMessageRenderer
  content: "Status update",
  display: true,               // Show in TUI
  details: { ... },            // Available in renderer
});

对于不应发送给 LLM 的 TUI-only 内容,请改为渲染自定义条目:

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 错误会阻止工具(fail-safe)
  • 工具 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 方法为空操作
Print(-p)"print"false扩展会运行,但不能提示用户

在使用 TUI 特定功能(custom()、组件工厂、终端输入)前,请使用 ctx.mode === "tui" 判断。使用 ctx.hasUI 保护在 TUI 和 RPC 模式都能工作的对话框与通知方法。

示例参考

所有示例都在 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, terminating tool results
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"), 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脏 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检查 payload 和提供商响应 headeron("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.tsFetch、merge 并解决冲突on("agent_end"), exec, sendUserMessage
auto-commit-on-exit.ts关闭时提交on("session_shutdown"), exec
UI 组件
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替换启动 headeron("session_start"), setHeader
modal-editor.tsVim 风格模态编辑器setEditorComponent, CustomEditor
rainbow-editor.ts自定义编辑器样式setEditorComponent
widget-placement.ts编辑器上方/下方小部件setWidget
overlay-test.ts覆盖层组件ui.custom with overlay options
overlay-qa-tests.ts全面覆盖层测试ui.custom, all overlay options
notify.ts简单通知ui.notify
timed-confirm.ts带超时的对话框ui.confirm with timeout/signal
mac-system-theme.ts自动切换主题setTheme, exec
复杂扩展
plan-mode/完整计划模式实现All event types, registerCommand, registerShortcut, registerFlag, setStatus, setWidget, sendMessage, setActiveTools
preset.ts可保存的预设(模型、工具、思考)registerCommand, registerShortcut, registerFlag, setModel, setActiveTools, setThinkingLevel, appendEntry
tools.tsUI 中切换工具开关registerCommand, setActiveTools, SettingsList, session events
远程与沙箱
ssh.tsSSH 远程执行registerFlag, on("user_bash"), on("before_agent_start"), tool operations
interactive-shell.ts持久 shell 会话on("user_bash")
sandbox/沙箱化工具执行Tool operations
gondolin/将内置工具和 ! 命令路由到 Gondolin micro-VMTool operations, built-in tool overrides, on("user_bash")
subagent/生成子 AgentregisterTool, exec
游戏
snake.ts贪吃蛇游戏registerCommand, ui.custom, keyboard handling
space-invaders.ts太空侵略者游戏registerCommand, ui.custom
doom-overlay/覆盖层中的 Doomui.custom with overlay
提供商
custom-provider-anthropic/自定义 Anthropic 代理registerProvider
custom-provider-gitlab-duo/GitLab Duo 集成registerProvider with OAuth
消息与通信
message-renderer.ts自定义消息渲染registerMessageRenderer, sendMessage
entry-renderer.tsTUI-only 自定义条目渲染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 structure with package.json
最近更新:: 2026/7/6 09:32
Contributors: seepine
Next
技能