跳转到主要内容

书籍

站点书籍汇总:以整本书形式组织的技术文档与译作。

书籍栏目收录以整本书形式组织的文档。每本书拥有独立的目录、章节编号与侧边栏。

收录书目

Pi 用户手册

pi 终端编码智能体的完整中文手册:安装、提供商配置、扩展与技能、SDK 与各平台设置。

Pi 是一个极简的终端编码智能体框架。它的核心保持小巧,通过 TypeScript 扩展、技能、提示词模板、主题和 pi 包来扩展能力。

本手册翻译自 pi 官方文档。

快速开始

使用 npm 安装 Pi:

npm install -g --ignore-scripts @earendil-works/pi-coding-agent

--ignore-scripts 会在安装过程中禁用依赖的生命周期脚本。pi 在常规 npm 安装下不需要执行安装脚本。

在 Linux 或 macOS 上,也可以使用安装脚本:

curl -fsSL https://pi.dev/install.sh | sh

卸载 pi 本体时(无论当初用 curl 还是 npm 安装),使用 npm:

npm uninstall -g @earendil-works/pi-coding-agent

如果使用 pnpm、Yarn 或 Bun 安装,请使用对应的全局卸载命令:pnpm remove -g @earendil-works/pi-coding-agent、yarn global remove @earendil-works/pi-coding-agent 或 bun uninstall -g @earendil-works/pi-coding-agent。

然后在项目目录中运行:

pi

订阅制提供商通过 /login 完成认证,或在启动 pi 前设置 API 密钥(例如 ANTHROPIC_API_KEY)。

完整的首次运行流程参见快速开始。

目录

1 快速开始

从安装、登录认证到完成第一个 pi 会话的完整入门流程。

本页带你从安装一路走到完成第一个实用的 pi 会话。

安装

Pi 以 npm 包的形式分发:

npm install -g --ignore-scripts @earendil-works/pi-coding-agent

--ignore-scripts 会在安装过程中禁用依赖的生命周期脚本。在常规 npm 安装下,Pi 不需要安装脚本。

卸载

使用安装 pi 时所用的包管理器来卸载。curl 安装器底层使用全局 npm,因此 curl 和 npm 安装的版本都用 npm 卸载:

# curl installer or npm install -g
npm uninstall -g @earendil-works/pi-coding-agent

# pnpm
pnpm remove -g @earendil-works/pi-coding-agent

# Yarn
yarn global remove @earendil-works/pi-coding-agent

# Bun
bun uninstall -g @earendil-works/pi-coding-agent

卸载 pi 后,设置、凭据、会话以及已安装的 pi 包会保留在 ~/.pi/agent/ 中。

然后在想让 pi 处理的项目目录中启动它:

cd /path/to/project
pi

认证

Pi 可以通过 /login 使用订阅型提供商,也可以通过环境变量或认证文件使用 API 密钥型提供商。

方式一:订阅登录

启动 pi 并运行:

/login

然后选择一个提供商。内置的订阅登录包括 Claude Pro/Max、ChatGPT Plus/Pro(Codex)以及 GitHub Copilot。

方式二:API 密钥

在启动 pi 之前设置 API 密钥:

export ANTHROPIC_API_KEY=sk-ant-...
pi

也可以运行 /login 并选择一个 API 密钥型提供商,把密钥存入 ~/.pi/agent/auth.json。

所有受支持的提供商、环境变量以及云端提供商的配置,请参阅提供商。

第一个会话

pi 启动后,输入一个请求并按回车:

Summarize this repository and tell me how to run its checks.

默认情况下,pi 会为模型提供四个工具:

  • read - 读取文件
  • write - 创建或覆盖文件
  • edit - 修补文件
  • bash - 运行 shell 命令

更多内置只读工具(grep、find、ls)可通过工具选项启用。Pi 在你当前的工作目录中运行,并且可以修改其中的文件。如果想要方便地回滚,请使用 git 或其他检查点工作流。

为 pi 提供项目指令

Pi 启动时会加载上下文文件。添加一个 AGENTS.md 文件,告诉它在这个项目中如何工作:

# Project Instructions

- Run `npm run check` after code changes.
- Do not run production migrations locally.
- Keep responses concise.

Pi 会加载:

  • ~/.pi/agent/AGENTS.md:全局指令
  • 当前目录及父目录中的 AGENTS.md 或 CLAUDE.md

如果某个目录中存在 AGENTS.override.md,Pi 会加载它来取代该目录中的 AGENTS.md 或 CLAUDE.md。

修改上下文文件后,请重启 pi 或运行 /reload。

常用操作

引用文件

在编辑器中输入 @ 可以模糊搜索文件,也可以在命令行上直接传入文件:

pi @README.md "Summarize this"
pi @src/app.ts @src/app.test.ts "Review these together"

图片或文本可以用 Ctrl+V 粘贴(Windows 上是 Alt+V);图片也可以直接拖入支持的终端。

运行 shell 命令

在交互模式下:

!npm run lint

命令输出会发送给模型。使用 !!command 可以运行命令而不把输出加入模型上下文。

切换模型

使用 /model 或 Ctrl+L 为当前会话选择模型。在模型选择器中按 Ctrl+S,可以把高亮的模型保存为启动默认值。使用 /thinking 为当前会话选择思考级别;在该选择器中按 Ctrl+S 可保存启动默认的思考级别。使用 Shift+Tab 可以循环切换思考级别。使用 Ctrl+P / Shift+Ctrl+P 可以在限定范围(scoped)的模型之间循环切换。

稍后继续

会话会自动保存:

pi -c                  # Continue most recent session
pi -r                  # Browse previous sessions
pi --name "my task"    # Set session display name at startup
pi --session <path|id> # Open a specific session

在 pi 内部,使用 /resume、/new、/tree、/fork 和 /clone 管理会话。

非交互模式

一次性提示可以这样运行:

pi -p "Summarize this codebase"
cat README.md | pi -p "Summarize this text"
pi -p @screenshot.png "What's in this image?"

使用 --mode json 输出 JSON 事件流,或使用 --mode rpc 进行进程集成。

后续步骤

  • 使用 Pi——交互模式、slash 命令、会话、上下文文件与 CLI 参考。
  • 提供商——认证与模型配置。
  • 设置——全局与项目配置。
  • 快捷键——快捷键与自定义。
  • Pi 包——安装共享的扩展、技能、提示词模板和主题。

平台说明:Windows、Termux、tmux、终端配置、Shell 别名。

2 使用 Pi

pi 的日常使用细节:交互模式、斜杠命令、会话管理、CLI 参考与设计原则。

本页面收录了不适合放在快速开始页面中的日常使用细节。

交互模式

交互模式

界面分为四个主要区域:

  • 启动头部——快捷方式、已加载的上下文文件、提示词模板、技能和扩展
  • 消息区——用户消息、助手回复、工具调用、工具结果、通知、错误和扩展 UI
  • 编辑器——你输入内容的地方;边框颜色指示当前的思考级别
  • 底部栏——工作目录、会话名称、token/缓存用量、费用、上下文用量和当前模型。总计包含助手回复、工具报告的用量以及摘要生成。

编辑器可以被内置 UI(如 /settings)或自定义扩展 UI 临时替换。

编辑器功能

功能用法
文件引用输入 @ 对项目文件进行模糊搜索
路径补全按 Tab 补全路径
多行输入Shift+Enter,Windows Terminal 上为 Ctrl+Enter
复制回复在 /tree 中 Ctrl+X 复制选中的消息;否则复制上一条助手消息;当 fullscreenCopyOnSelect 禁用时,复制全屏模式下活动的文本选区
图片使用 Ctrl+V 粘贴(Windows 上为 Alt+V),或拖拽到终端中
Shell 命令!command 运行命令并将输出发送给模型
隐藏 Shell 命令!!command 运行命令但不将输出发送给模型
外部编辑器Ctrl+G 打开 externalEditor、$VISUAL、$EDITOR、Windows 上的记事本或其他平台上的 nano

所有快捷键及自定义方式参见快捷键。

斜杠命令

在编辑器中输入 / 打开命令补全。扩展可以注册自定义命令,技能以 /skill:name 的形式提供,提示词模板通过 /templatename 展开。

命令说明
/login、/logout管理 OAuth 或 API 密钥凭证
/llama下载、加载和卸载 llama.cpp 路由器模型
/model切换模型;在选择器中按 Ctrl+S 保存为启动默认值
/thinking切换思考级别;在选择器中按 Ctrl+S 保存为启动默认值
/scoped-models启用/禁用用于 Ctrl+P 循环切换的模型
/settings主题、消息送达、传输方式等偏好设置
/resume从之前的会话中选择
/new开始新会话
/name <name>设置会话显示名称
/session显示会话文件、ID、消息、token 和费用
/tree跳转到会话中的任意位置并从那里继续
/trust保存项目信任决定,供未来会话使用
/fork从之前的用户消息创建新会话
/clone将当前活动分支复制为一个新会话
/compact [prompt]手动压缩上下文,可选附带自定义指令
/copy将上一条助手消息复制到剪贴板
/export [file]将会话导出为 HTML 或 JSONL
/import <file>从 JSONL 文件导入并恢复会话
/share上传为私有 GitHub gist,并生成可分享的 HTML 链接
/reload重新加载快捷键、扩展、技能、提示词、主题和上下文文件
/hotkeys显示所有键盘快捷键
/changelog显示版本历史
/quit退出 pi

消息队列

你可以在智能体仍在工作时提交消息:

  • Enter 将一条转向消息加入队列,在当前助手回合执行完其工具调用后送达。
  • Alt+Enter 将一条后续消息加入队列,在智能体完成所有工作后送达。
  • Escape 中止操作,并将排队的消息恢复到编辑器中。
  • Alt+Up 将排队的消息取回编辑器。

在 Windows Terminal 中,Alt+Enter 默认是全屏快捷键。如果希望 pi 接收到该快捷键,请按终端设置中的说明重新映射。

可在设置中通过 steeringMode 和 followUpMode 配置送达方式。

会话

会话会自动保存到 ~/.pi/agent/sessions/,按工作目录组织。

pi -c                  # Continue most recent session
pi -r                  # Browse and select a session
pi --no-session        # Ephemeral mode; do not save
pi --name "my task"    # Set session display name at startup
pi --session <path|id> # Use a specific session file or session ID
pi --fork <path|id>    # Fork a session into a new session file

常用的会话命令:

  • /session 显示当前会话文件和 ID。
  • /tree 导航会话文件内的会话树,并可以总结被放弃的分支。
  • /fork 从更早的用户消息创建新会话。
  • /clone 将当前活动分支复制到一个新的会话文件。
  • /compact 总结较旧的消息以释放上下文。

详情参见会话和上下文压缩。

上下文文件

Pi 启动时会从以下位置加载 AGENTS.md 或 CLAUDE.md:

  • ~/.pi/agent/AGENTS.md 用于全局指令
  • 从当前工作目录向上遍历的父目录
  • 当前目录

如果某个目录包含 AGENTS.override.md,Pi 会改为加载它,而不再加载该目录中的 AGENTS.md 或 CLAUDE.md。来自其他目录的上下文文件仍照常分层叠加。

可以用上下文文件记录项目约定、命令、安全规则和偏好。使用 --no-context-files 或 -nc 禁用加载。

系统提示词文件

替换默认系统提示词:

  • 项目级:.pi/SYSTEM.md
  • 全局:~/.pi/agent/SYSTEM.md

在上述任一位置放置 APPEND_SYSTEM.md,可以在默认提示词后追加内容而不替换它。

项目信任

在交互式启动时,如果项目文件夹包含项目本地设置、资源或项目级 .agents/skills,且在 ~/.pi/agent/trust.json 中没有针对该文件夹或其父文件夹的已保存决定,pi 会先询问是否信任该项目。信任项目后,pi 才能加载 .pi/settings.json 和 .pi 资源、安装缺失的项目包,并执行项目扩展。

在做出信任决定之前,pi 只加载上下文文件、用户/全局扩展以及 CLI -e 扩展,以便它们能够处理 project_trust 事件。项目本地扩展、项目包管理的扩展和项目设置只有在项目被信任后才会加载。当切换到来自其他 cwd、且信任尚未在当前进程中确定的会话时,同样适用这一划分。

非交互模式(-p、--mode json 和 --mode rpc)不显示信任提示。在没有适用的已保存信任决定时,它们使用全局设置中的 defaultProjectTrust:ask(默认)和 never 会忽略这些项目资源,而 always 则信任它们。传入 --approve/-a 或 --no-approve/-na 可为单次运行覆盖项目信任。

如果没有扩展或已保存决定适用,则由 defaultProjectTrust 控制回退行为。在 ~/.pi/agent/settings.json 中将其设为 "ask"、"always" 或 "never",或通过 /settings 修改。

pi config 和包管理命令使用相同的项目信任流程,但 pi update 从不弹出提示。传入 --approve 可为单条命令信任项目本地设置,传入 --no-approve 则忽略它们。

在交互模式下使用 /trust 可以为未来会话保存项目信任决定,包括对直接父文件夹的信任。它只写入 ~/.pi/agent/trust.json;当前会话不会重新加载,因此需重启 pi 才能生效。

导出与分享会话

使用 /export [file] 将会话写入 HTML 文件。

使用 /share 上传一个私有 GitHub gist,并附带可分享的 HTML 链接。

如果你将 pi 用于开源工作,并希望发布会话以供模型、提示词、工具和评估研究使用,请参见 badlogic/pi-share-hf。它可以将会话发布到 Hugging Face 数据集。

CLI 参考

pi [options] [--] [@files...] [messages...]

包管理命令

pi install <source> [-l]     # Install package, -l for project-local
pi remove <source> [-l]      # Remove package
pi uninstall <source> [-l]   # Alias for remove
pi update [source|self|pi]   # Update pi only, or one package source
pi update --all              # Update pi and packages; reconcile pinned git refs
pi update --extensions       # Update packages only; reconcile pinned git refs
pi update --models           # Refresh model catalogs only
pi update --self             # Update pi only
pi update --extension <src>  # Update one package
pi list                      # List installed packages
pi config                    # Enable/disable package resources

这些命令用于管理 pi 包,pi update 还可以更新 pi CLI 本体。要卸载 pi 本体,请参见快速开始。pi config 和项目包命令接受 --approve/--no-approve,用于为单条命令信任或忽略项目本地设置。pi update 从不因项目信任而弹出提示。

包来源与安全说明参见 Pi 包。

模式

标志说明
default交互模式
-p、--print打印回复后退出
--mode json以 JSON 行输出所有事件;参见 JSON 模式
--mode rpc通过 stdin/stdout 的 RPC 模式;参见 RPC 模式
--export <in> [out]将会话导出为 HTML

在打印模式下,pi 还会读取管道传入的 stdin 并将其合并到初始提示词中:

cat README.md | pi -p "Summarize this text"

模型选项

选项说明
--provider <name>提供商,例如 anthropic、openai 或 google
--model <pattern>模型模式或 ID;支持 provider/id 以及可选的 :<thinking>
--api-key <key>API 密钥,覆盖环境变量
--thinking <level>off、minimal、low、medium、high、xhigh、max
--models <patterns>用于 Ctrl+P 循环切换的逗号分隔模式
--list-models [search]列出可用模型

会话选项

选项说明
-c、--continue继续最近的会话
-r、--resume浏览并选择一个会话
--session <path|id>使用指定的会话文件或部分 UUID
--fork <path|id>将会话文件或部分 UUID 分叉为一个新会话
--session-dir <dir>自定义会话存储目录
--no-session临时模式;不保存
--name <name>、-n <name>启动时设置会话显示名称

工具选项

选项说明
--tools <list>、-t <list>以白名单方式指定可用的内置、扩展和自定义工具
--exclude-tools <list>、-xt <list>禁用指定的内置、扩展和自定义工具
--no-builtin-tools、-nbt禁用内置工具,但保留扩展/自定义工具
--no-tools、-nt禁用所有工具

内置工具包括:read、bash、powershell(Windows)、edit、write、grep、find、ls。

资源选项

选项说明
-e、--extension <source>从路径、npm 或 git 加载扩展;可重复使用
--no-extensions禁用扩展发现
--skill <path>加载技能;可重复使用
--no-skills禁用技能发现
--prompt-template <path>加载提示词模板;可重复使用
--no-prompt-templates禁用提示词模板发现
--theme <path>加载主题;可重复使用
--no-themes禁用主题发现
--no-context-files、-nc禁用 AGENTS.md 和 CLAUDE.md 发现

将 --no-* 与显式标志组合使用,可以忽略设置、精确加载所需资源。示例:

pi --no-extensions -e ./my-extension.ts

其他选项

选项说明
--system-prompt <text>替换默认提示词;上下文文件和技能仍会追加
--append-system-prompt <text>在系统提示词后追加内容
--tui-mode <mode>TUI 模式:regular(默认)或实验性 fullscreen
--use-theme <name[/name]>为本次运行设置初始交互主题,不修改设置
--verbose强制显示详细启动信息
-a、--approve本次运行信任项目本地文件
-na、--no-approve本次运行忽略项目本地文件
--停止解析选项;其余参数作为提示词或 @file 输入
-h、--help显示帮助
-v、--version显示版本

在 fullscreen 模式下,对话记录在终端视口内滚动,而排队消息、工作状态、扩展小部件、编辑器和底部栏固定在底部。鼠标/触控板输入会滚动指针所在区域;键盘视口操作始终可用。行内图片在支持 Kitty 图形协议的终端(包括 Kitty 和 Ghostty)中可以正常显示。在 iTerm2 中,它们会渲染为文本占位符,因为 iTerm2 的行内图片协议无法在应用接管滚动期间删除或裁剪已放置的图片。在 regular 模式下,pi 使用主屏幕和终端自带的回滚缓冲区,iTerm2 中的行内图片可继续正常渲染。各终端的专属设置与变通方案参见终端设置。

在 /settings 中设置 TUI 模式,可以立即在 regular 和 fullscreen 之间切换,并为未来会话选择默认值。全屏退出输出控制退出全屏时是打印最终对话记录,还是恢复之前的屏幕并只打印会话恢复提示。

文件参数

为文件名加上 @ 前缀可将其包含在消息中:

pi @prompt.md "Answer this"
pi -p @screenshot.png "What's in this image?"
pi @code.ts @test.ts "Review these files"

示例

# Interactive with initial prompt
pi "List all .ts files in src/"

# Non-interactive
pi -p "Summarize this codebase"

# Prompt beginning with a dash
pi -p -- "- Summarize these points"

# Non-interactive with piped stdin
cat README.md | pi -p "Summarize this text"

# Named one-shot session
pi --name "release audit" -p "Audit this repository"

# Different model
pi --provider openai --model gpt-4o "Help me refactor"

# Model with provider prefix
pi --model openai/gpt-4o "Help me refactor"

# Model with thinking level shorthand
pi --model sonnet:high "Solve this complex problem"

# Limit model cycling
pi --models "claude-*,gpt-4o"

# Read-only mode
pi --tools read,grep,find,ls -p "Review the code"

# Disable one extension or built-in tool while keeping the rest available
pi --exclude-tools ask_question

设计原则

Pi 保持核心小巧,将特定工作流的行为推送到扩展、技能、提示词模板和包中。

它有意不内置 MCP、子智能体、权限弹窗、计划模式、待办事项或后台 bash。你可以将这些工作流构建或安装为扩展或包,也可以使用容器和 tmux 等外部工具。

完整的设计理由请阅读博客文章。

3 提供商

配置 pi 的模型提供商:订阅认证、API 密钥、云端与自定义提供商。

Pi 通过 OAuth 支持订阅制提供商,通过环境变量或认证文件支持 API 密钥提供商。内置模型目录随 pi 一起发布;已配置的提供商可以刷新更新的目录,并将其缓存到 ~/.pi/agent/models-store.json 以供离线使用。

目录

订阅

在交互模式下使用 /login,然后选择一个提供商:

  • ChatGPT Plus/Pro(Codex)
  • Claude Pro/Max
  • GitHub Copilot
  • xAI(Grok/X 订阅)
  • OpenRouter(通过 OAuth 铸造的 API 密钥,从 OpenRouter 余额计费)
  • Radius

使用 /logout 清除凭证。令牌存储在 ~/.pi/agent/auth.json 中,过期时自动刷新。OpenRouter 则会铸造一个由用户控制的 API 密钥,它不会自动过期。

OpenAI Codex

  • 需要 ChatGPT Plus 或 Pro 订阅
  • 由 OpenAI 官方背书:Codex for OSS

Claude Pro/Max

Anthropic 订阅认证适用于 Claude Pro/Max 账户。第三方工具的使用会消耗额外用量,并按 token 计费,而不计入 Claude 套餐限额。

GitHub Copilot

  • 在 github.com 上直接按 Enter,或输入你的 GitHub Enterprise Server 域名
  • 如果出现 “model not supported”,请在 VS Code 中启用该模型:Copilot Chat → 模型选择器 → 选择模型 → “Enable”

xAI(Grok/X 订阅)

  • 运行 /login xai,然后选择 Use a subscription
  • XAI_API_KEY 仍可通过 Use an API key 使用

OpenRouter

  • 运行 /login openrouter,然后选择 Sign in with OpenRouter,打开 OpenRouter PKCE 授权流程
  • 授权会创建一个由用户控制的 OpenRouter API 密钥,从你的 OpenRouter 余额中扣费
  • 在远程/无头机器上(例如通过 SSH 连接),浏览器无法访问本地回环回调;此时请将最终重定向 URL(或授权码)粘贴到登录提示中
  • OPENROUTER_API_KEY 仍可通过 Use an API key 使用

Radius

Radius 是一个动态 pi-messages 网关。/login radius 会将 OAuth 令牌存储到 auth.json;网关目录会独立刷新并缓存到 models-store.json。自定义 Radius 网关可以在 models.json 中通过 "oauth": "radius" 和网关 baseUrl 声明。

API 密钥

环境变量或认证文件

在交互模式下使用 /login 并选择一个提供商,即可将 API 密钥存入 auth.json;也可以通过环境变量设置凭证:

export ANTHROPIC_API_KEY=sk-ant-...
pi
提供商环境变量auth.json 键
AnthropicANTHROPIC_API_KEYanthropic
Ant LingANT_LING_API_KEYant-ling
Azure OpenAI ResponsesAZURE_OPENAI_API_KEYazure-openai-responses
OpenAIOPENAI_API_KEYopenai
DeepSeekDEEPSEEK_API_KEYdeepseek
NVIDIA NIMNVIDIA_API_KEYnvidia
Google GeminiGEMINI_API_KEYgoogle
Amazon BedrockAWS_BEARER_TOKEN_BEDROCKamazon-bedrock
MistralMISTRAL_API_KEYmistral
GroqGROQ_API_KEYgroq
CerebrasCEREBRAS_API_KEYcerebras
Cloudflare AI GatewayCLOUDFLARE_API_KEY(另需 CLOUDFLARE_ACCOUNT_ID、CLOUDFLARE_GATEWAY_ID)cloudflare-ai-gateway
Cloudflare Workers AICLOUDFLARE_API_KEY(另需 CLOUDFLARE_ACCOUNT_ID)cloudflare-workers-ai
xAIXAI_API_KEYxai
OpenRouterOPENROUTER_API_KEYopenrouter
Vercel AI GatewayAI_GATEWAY_API_KEYvercel-ai-gateway
ZAI Coding Plan (Global)ZAI_API_KEYzai
ZAI Coding Plan (China)ZAI_CODING_CN_API_KEYzai-coding-cn
OpenCode ZenOPENCODE_API_KEYopencode
OpenCode GoOPENCODE_API_KEYopencode-go
RadiusRADIUS_API_KEYradius
Hugging FaceHF_TOKENhuggingface
FireworksFIREWORKS_API_KEYfireworks
Together AITOGETHER_API_KEYtogether
BasetenBASETEN_API_KEYbaseten
Kimi For CodingKIMI_API_KEYkimi-coding
MiniMaxMINIMAX_API_KEYminimax
MiniMax (China)MINIMAX_CN_API_KEYminimax-cn
Qwen Token Plan (existing catalog)QWEN_TOKEN_PLAN_API_KEYqwen-token-plan
Qwen Token Plan (Individual)QWEN_TOKEN_PLAN_API_KEYqwen-token-plan-individual
Qwen Token Plan (China)QWEN_TOKEN_PLAN_CN_API_KEYqwen-token-plan-cn
Xiaomi MiMoXIAOMI_API_KEYxiaomi
Xiaomi MiMo Token Plan (China)XIAOMI_TOKEN_PLAN_CN_API_KEYxiaomi-token-plan-cn
Xiaomi MiMo Token Plan (Amsterdam)XIAOMI_TOKEN_PLAN_AMS_API_KEYxiaomi-token-plan-ams
Xiaomi MiMo Token Plan (Singapore)XIAOMI_TOKEN_PLAN_SGP_API_KEYxiaomi-token-plan-sgp

环境变量与 auth.json 键的对照参见 packages/ai/src/env-api-keys.ts 中的 const envMap。

认证文件

将凭证存储在 ~/.pi/agent/auth.json:

{
  "anthropic": { "type": "api_key", "key": "sk-ant-..." },
  "ant-ling": { "type": "api_key", "key": "..." },
  "openai": { "type": "api_key", "key": "sk-..." },
  "deepseek": { "type": "api_key", "key": "sk-..." },
  "nvidia": { "type": "api_key", "key": "nvapi-..." },
  "google": { "type": "api_key", "key": "..." },
  "opencode": { "type": "api_key", "key": "..." },
  "opencode-go": { "type": "api_key", "key": "..." },
  "together": { "type": "api_key", "key": "..." },
  "qwen-token-plan":  { "type": "api_key", "key": "sk-sp-..." },
  "qwen-token-plan-individual": { "type": "api_key", "key": "sk-sp-..." },
  "qwen-token-plan-cn": { "type": "api_key", "key": "sk-sp-..." },
  "xiaomi": { "type": "api_key", "key": "..." },
  "xiaomi-token-plan-cn":  { "type": "api_key", "key": "..." },
  "xiaomi-token-plan-ams": { "type": "api_key", "key": "..." },
  "xiaomi-token-plan-sgp": { "type": "api_key", "key": "..." }
}

qwen-token-plan-individual 使用与 qwen-token-plan 相同的国际端点和 QWEN_TOKEN_PLAN_API_KEY,但会将选择器限制为个人(Individual)订阅文档所列的模型。已有提供商出于向后兼容保留了更宽的目录。使用 auth.json 时,请将凭证存储在你所选的提供商名下;环境变量则由两个国际版提供商共用。

该文件以 0600 权限创建(仅用户可读写)。认证文件中的凭证优先于环境变量。

API 密钥凭证还可以包含提供商作用域的环境值。在解析凭证密钥、提供商/模型请求头以及提供商配置(如 Cloudflare 账户 ID、Azure OpenAI 设置、Vertex 项目/位置、Bedrock 设置、PI_CACHE_RETENTION 和 HTTP_PROXY/HTTPS_PROXY)时,这些值优先于进程环境变量。

{
  "cloudflare-ai-gateway": {
    "type": "api_key",
    "key": "$CLOUDFLARE_API_KEY",
    "env": {
      "CLOUDFLARE_API_KEY": "...",
      "CLOUDFLARE_ACCOUNT_ID": "account-id",
      "CLOUDFLARE_GATEWAY_ID": "gateway-id"
    }
  }
}

当希望 pi 使用与项目 shell 环境不同的提供商设置时,可以使用这种方式。

密钥解析

key 字段支持命令执行、环境变量插值和字面量:

  • Shell 命令: 以 "!command" 开头时,整个值会作为命令执行,并使用其 stdout(在进程生命周期内缓存)
    { "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" }
    { "type": "api_key", "key": "!op read 'op://vault/item/credential'" }
  • 环境变量插值: "$ENV_VAR" 或 "${ENV_VAR}" 使用指定变量的值。插值也可以在更长的字面量中使用。
    { "type": "api_key", "key": "$MY_ANTHROPIC_KEY" }
    { "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" }
    $FOO_BAR 指向变量 FOO_BAR;如果 BAR 是字面文本,请使用 ${FOO}_BAR。环境变量缺失时该值视为未解析。
  • 转义: "$$" 输出字面量 "$";"$!" 输出字面量 "!" 而不触发命令执行。
    { "type": "api_key", "key": "$$literal-dollar-prefix" }
    { "type": "api_key", "key": "$!literal-bang-prefix" }
  • 字面量: 直接使用。纯大写字符串(如 MY_API_KEY)是字面量;要引用环境变量请使用 $MY_API_KEY。
    { "type": "api_key", "key": "sk-ant-..." }
    { "type": "api_key", "key": "public" }

OAuth 凭证在 /login 之后也会存储在这里,并自动管理。

云端提供商

Azure OpenAI

export AZURE_OPENAI_API_KEY=...
export AZURE_OPENAI_BASE_URL=https://your-resource.ai.azure.com
# also supported: https://your-resource.cognitiveservices.azure.com
# also supported: https://your-resource.openai.azure.com
# root endpoints are auto-normalized to /openai/v1
# or use resource name instead of base URL
export AZURE_OPENAI_RESOURCE_NAME=your-resource

# Optional
export AZURE_OPENAI_API_VERSION=2024-02-01
export AZURE_OPENAI_DEPLOYMENT_NAME_MAP=gpt-4=my-gpt4,gpt-4o=my-gpt4o

Amazon Bedrock

使用 /login amazon-bedrock 存储 Bedrock API 密钥,或配置以下任一 AWS 环境凭证来源:

# Option 1: AWS Profile
export AWS_PROFILE=your-profile

# Option 2: IAM Keys
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=...

# Option 3: Bearer Token
export AWS_BEARER_TOKEN_BEDROCK=...

# Optional region (defaults to us-east-1)
export AWS_REGION=us-west-2

还支持 ECS 任务角色(AWS_CONTAINER_CREDENTIALS_*)和 IRSA(AWS_WEB_IDENTITY_TOKEN_FILE)。

pi --provider amazon-bedrock --model us.anthropic.claude-sonnet-4-20250514-v1:0

对于 ID 中包含可识别模型名称的 Claude 模型(基础模型和系统定义的推理配置文件),提示词缓存会自动启用。对于应用推理配置文件(其 ARN 不包含模型名称),请设置 AWS_BEDROCK_FORCE_CACHE=1 以启用缓存点:

export AWS_BEDROCK_FORCE_CACHE=1
pi --provider amazon-bedrock --model arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc123

如果你要连接 Bedrock API 代理,可以使用以下环境变量:

# Set the URL for the Bedrock proxy (standard AWS SDK env var)
export AWS_ENDPOINT_URL_BEDROCK_RUNTIME=https://my.corp.proxy/bedrock

# Set if your proxy does not require authentication
export AWS_BEDROCK_SKIP_AUTH=1

# Set if your proxy only supports HTTP/1.1
export AWS_BEDROCK_FORCE_HTTP1=1

Cloudflare AI Gateway

CLOUDFLARE_API_KEY 可以通过 /login 设置。账户 ID 和网关 slug 可以设置为环境变量,也可以放在 auth.json 中 API 密钥凭证的 env 对象里。

export CLOUDFLARE_API_KEY=...           # or use /login
export CLOUDFLARE_ACCOUNT_ID=...
export CLOUDFLARE_GATEWAY_ID=...        # create at dash.cloudflare.com → AI → AI Gateway
pi --provider cloudflare-ai-gateway --model "claude-sonnet-4-5"

通过 Cloudflare AI Gateway 路由到 OpenAI、Anthropic 和 Workers AI。Workers AI 使用 Unified API(/compat)和带前缀的模型 ID(workers-ai/@cf/...)。OpenAI 使用 OpenAI 透传路由(/openai)及原生 OpenAI 模型 ID(如 gpt-5.1)。Anthropic 使用 Anthropic 透传路由(/anthropic)及原生 Anthropic 模型 ID(如 claude-sonnet-4-5)。

AI Gateway 认证将 CLOUDFLARE_API_KEY 用作 cf-aig-authorization。上游认证可以是以下之一:

模式请求认证上游认证
Workers AI仅 Cloudflare 令牌Cloudflare 原生
统一计费仅 Cloudflare 令牌由 Cloudflare 处理上游认证并扣除额度
存储式 BYOK仅 Cloudflare 令牌Cloudflare 注入存储在 AI Gateway 控制台中的提供商密钥
行内 BYOKCloudflare 令牌加上游 Authorization 请求头由请求提供上游提供商密钥

对于普通的 pi 用法,优先选择统一计费或存储式 BYOK。行内 BYOK 需要为 Cloudflare AI Gateway 提供商配置额外的上游 Authorization 请求头,例如通过 models.json 的提供商/模型覆盖来实现。

Cloudflare Workers AI

CLOUDFLARE_API_KEY 可以通过 /login 设置。CLOUDFLARE_ACCOUNT_ID 可以设置为环境变量,也可以放在 auth.json 中 API 密钥凭证的 env 对象里。

export CLOUDFLARE_API_KEY=...           # or use /login
export CLOUDFLARE_ACCOUNT_ID=...
pi --provider cloudflare-workers-ai --model "@cf/moonshotai/kimi-k2.6"

Pi 会自动设置 x-session-affinity,以享受前缀缓存折扣。

Google Vertex AI

使用应用默认凭证(Application Default Credentials):

gcloud auth application-default login
export GOOGLE_CLOUD_PROJECT=your-project
export GOOGLE_CLOUD_LOCATION=us-central1

或者将 GOOGLE_APPLICATION_CREDENTIALS 指向服务账号密钥文件。

llama.cpp

Pi 支持 llama.cpp 路由服务器。使用 /login llama.cpp 进行配置,用 /llama 管理已加载的模型,并用 /model 选择一个已加载的模型。

服务器搭建、模型目录布局、环境变量和命令用法参见 llama.cpp。

自定义提供商

通过 models.json: 添加 Ollama、LM Studio、vLLM,或任何使用受支持 API(OpenAI Completions、OpenAI Responses、Anthropic Messages、Google Generative AI)的提供商。参见 models.md。

通过扩展: 对于需要自定义 API 实现或 OAuth 流程的提供商,可以创建扩展。参见 custom-provider.md 和 examples/extensions/custom-provider-gitlab-duo。

解析顺序

解析某个提供商的凭证时,顺序如下:

  1. CLI --api-key 标志
  2. auth.json 条目(API 密钥或 OAuth 令牌)
  3. 环境变量
  4. 来自 models.json 的自定义提供商密钥

4 llama.cpp

让 pi 通过 llama.cpp 路由服务器加载和切换多个本地 GGUF 模型。

Pi 支持 llama.cpp 路由服务器。路由器会发现多个 GGUF 模型,并按需加载或卸载它们。

请使用支持路由器功能的较新版本 llama.cpp。可以按照构建说明自行构建,或为你的平台安装预编译版本。

启动路由器

启动 llama-server 时不要带 --model 或 -m。如果传入模型,则会进入单模型模式而非路由器模式。

llama-server \
  --models-dir ~/models \
  --no-models-autoload \
  --jinja \
  --host 127.0.0.1 \
  --port 8080 \
  -ngl 999 \
  -c 32768

重要选项:

  • --models-dir ~/models 用于发现本地 GGUF 文件。
  • --no-models-autoload 让模型加载必须通过 /llama 显式进行。
  • --jinja 启用兼容的聊天模板和工具调用。
  • -ngl 999 将尽可能多的层卸载到 GPU。
  • -c 32768 为每个已加载的模型设置上下文窗口。如果省略,则使用模型的原生上下文,这可能会显著增加内存占用。

单文件模型可以直接放在模型目录中。多模态和多分片模型请放在单独的子目录中:

~/models/
├── llama-3.2-1b-Q4_K_M.gguf
├── gemma-3-4b-it-Q4_K_M/
│   ├── gemma-3-4b-it-Q4_K_M.gguf
│   └── mmproj-F16.gguf
└── large-model-Q4_K_M/
    ├── large-model-Q4_K_M-00001-of-00003.gguf
    ├── large-model-Q4_K_M-00002-of-00003.gguf
    └── large-model-Q4_K_M-00003-of-00003.gguf

手动添加文件后请重启路由器。如需为每个模型设置上下文大小等选项,可以使用 llama.cpp 模型预设。

配置 Pi

启动 Pi 并配置提供商:

/login llama.cpp

输入路由器 URL 和可选的 API 密钥。默认 URL 为 http://127.0.0.1:8080。

如果启动路由器时使用了 --no-models-autoload,/login llama.cpp 只会保存连接信息。运行 /llama 加载模型,再运行 /model 为当前会话选择已加载的模型。

也可以通过环境变量配置相同的值,无需 /login:

export LLAMA_BASE_URL=http://127.0.0.1:8080
export LLAMA_API_KEY=optional-secret
pi

如果服务器使用了 API 密钥,请在启动 llama-server 时传入相同的 --api-key 值。保持 --host 127.0.0.1 可将访问限制在本地。

管理模型

运行:

/llama
  • 选择一个未加载的模型即可加载它。
  • 选择一个已加载的模型即可卸载它。
  • 选择 Download model…,搜索 Hugging Face,然后选择仓库和量化版本。直接输入精确的 owner/repository[:quant] 值也可以。
  • 在加载或下载过程中按 Escape 可确认取消。

Hugging Face 搜索在设置了 HF_TOKEN 时会使用它,然后依次检查 $HF_TOKEN_PATH、$HF_HOME/token、$XDG_CACHE_HOME/huggingface/token 和 ~/.cache/huggingface/token。未认证时也可以搜索,但会受到更低的速率限制。Pi 在下载受限仓库前会发出警告并链接到其访问页面。下载由 llama.cpp 服务器执行,因此当所选仓库需要访问权限时,该服务器进程也必须持有 HF_TOKEN。

如果已有其他模型处于加载状态,Pi 会询问是先卸载它们还是保持加载。Pi 不会静默卸载模型,也绝不会删除模型文件。路由器可能被其他客户端共享,因此 /llama 始终显示路由器的当前状态。

只有已加载的模型才会出现在 /model 中。加载模型后,运行 /model 为当前 Pi 会话选择它。

如果路由器连接断开,/llama 会显示 Retry 和 Close。重试会重新连接并刷新模型状态,但不会重放被中断的操作。

故障排除

检查路由器是否可达:

curl http://127.0.0.1:8080/health
curl http://127.0.0.1:8080/models
  • /llama 中没有模型: 检查 --models-dir 和目录布局,并重启路由器。
  • /model 中缺少模型: 先用 /llama 加载它。
  • 加载失败或内存占用过高: 降低 -c 或卸载其他模型。
  • 服务器未处于路由器模式: 启动时不要带 --model、-m 或 -hf。

5 安全

pi 的本地信任模型、项目信任机制、沙箱边界与安全问题报告方式。

Pi 是一个本地编码智能体。它以启动它的用户账户的权限运行,并把该用户可写的文件视为处于同一个本地信任边界之内。

项目信任

项目信任决定 pi 是否加载项目级设置、资源、包和扩展。它不是沙箱,也不会在你开始于某个目录中工作之后限制模型让工具执行的操作。

当 pi 从当前工作目录发现以下任意一项时,就会认为该项目包含需要信任的资源:

  • .pi/settings.json
  • .pi/extensions、.pi/skills、.pi/prompts 或 .pi/themes
  • .pi/SYSTEM.md 或 .pi/APPEND_SYSTEM.md
  • 当前目录或祖先目录中的项目级 .agents/skills

一个空的 .pi 目录不算作需要信任的项目资源。

当一个交互式会话在包含需要信任的资源、且当前目录或父目录没有已保存决定的项目中启动时,pi 会遵循全局设置中的 defaultProjectTrust。默认值为 "ask",即在有可用 UI 时询问是否信任该项目。已保存的决定按规范化目录存储在 ~/.pi/agent/trust.json 中;当前路径或父路径上距离最近的已保存决定,会先于全局默认值生效。

信任一个项目后,pi 就可以加载需要信任的项目资源,包括:

  • .pi/settings.json
  • .pi 资源,例如扩展、技能、提示词模板、主题和系统提示词文件
  • 通过项目设置配置的缺失项目包
  • 项目级扩展和由项目包管理的扩展

拒绝信任则会跳过受保护的资源。AGENTS.override.md、AGENTS.md 和 CLAUDE.md 等上下文文件无论项目信任与否都会加载,除非上下文加载被禁用。在信任状态确定之前,pi 只加载上下文文件、用户/全局扩展以及 CLI -e 扩展。用户/全局扩展和 CLI 扩展可以处理 project_trust 事件;第一个返回是/否决定的扩展拥有该决定权。

非交互模式(-p、--mode json 和 --mode rpc)不会显示信任提示。在没有适用的已保存信任决定时,defaultProjectTrust: "ask" 和 "never" 会忽略这类资源,而 "always" 会信任它们。使用 --approve/-a 或 --no-approve/-na 可以为单次运行覆盖项目信任。

没有内置沙箱

Pi 不包含内置沙箱。内置工具可以读取文件、写入文件、编辑文件,并以 pi 进程自身的权限运行 shell 命令。扩展是以相同权限运行的 TypeScript 模块。包安装、shell 命令、语言服务器、测试命令和其他开发工具都表现为普通的本地进程。

这是有意为之。Pi 的设计目标是在本地源码树上操作、调用项目工具链,并与用户现有的开发环境集成。一个进程内的部分沙箱很容易被误解为安全边界,但它仍然要依赖宿主机的 shell、文件系统、包管理器、凭据和扩展代码。真正的隔离必须来自操作系统或虚拟化/容器边界。

项目信任只是一道输入加载防线。它可以防止仓库在你批准之前悄悄更改 pi 的设置或扩展。它无法让不受信任的代码、不受信任的提示词或不受信任的模型输出变得安全。来自仓库文件、注释、文档、上下文文件或构建输出的提示词注入,属于本地智能体预期之中且无法避免的风险,pi 无法可靠地防范。

运行不受信任或无人监督的工作

对于不受信任的仓库、你不打算密切监督的生成代码,或无人值守的自动化,请在受控环境中运行 pi。使用容器、虚拟机、微虚拟机、远程沙箱或受策略控制的沙箱,并且只提供任务所需的文件和凭据。

常见模式记录在容器化一章中:

  • 将整个 pi 进程放入容器/沙箱中运行
  • 在宿主机上运行 pi,同时把内置工具的执行路由到 Gondolin 微虚拟机
  • 只挂载智能体应该访问的工作区路径
  • 避免挂载宿主机的 ~/.pi/agent,除非容器需要访问宿主机的会话、设置和凭据
  • 只传入必需的最少 API 密钥,或使用短期凭据
  • 在任务不需要网络时限制网络访问
  • 在把结果复制回受信任系统之前,先审查 diff 和输出

如果你以读写方式绑定挂载宿主机工作区,容器或虚拟机内部的写入仍然可以修改宿主机文件。当你需要更强的保护、防止意外写入时,请使用只读挂载,或者以复制文件的方式把内容移入和移出沙箱。

报告安全问题

要报告安全问题,请遵循仓库的安全策略。不要为安全敏感的报告创建公开 issue。

预期中的本地智能体行为、缺少内置沙箱、来自不受信任内容的提示词注入,以及用户安装的扩展或技能的行为,通常不在安全边界范围之内,除非报告演示了真实的权限边界绕过,或表明 pi 授予了本地用户原本并不拥有的访问权限。

6 容器化

介绍以容器、微虚拟机或沙箱隔离运行 pi 的四种模式与配置要点。

Pi 默认以全部权限运行,但在某些情况下,你会希望更好地控制 Pi 可以写入哪些目录、拥有哪些访问权限。

总体上有两种选择:

  1. 将整个 pi 进程放在隔离环境中运行,或者
  2. 在宿主机上运行 pi,把工具执行路由到隔离环境中。

选择模式

模式隔离内容适用场景备注
Gondolin 扩展内置工具和 ! 命令本地微虚拟机隔离,同时把认证保留在宿主机上见 examples/extensions/gondolin/。
普通 Docker整个 pi 进程在本地容器中简单的本地隔离提供商 API 密钥会进入容器。
OpenShell整个 pi 进程在受策略控制的沙箱中本地或远程的托管沙箱需要 OpenShell 网关
Docker Sandboxes整个 pi 进程在托管沙箱中本地隔离,提供商密钥保留在宿主机上需要 Docker Sandboxes(sbx)。

扩展在 pi 进程所在的位置运行。如果你在宿主机上运行 pi 并使用工具路由扩展,其他自定义扩展工具仍会在宿主机上运行,除非它们也把自己的操作委托出去。

Gondolin

Gondolin 是一个本地 Linux 微虚拟机。 当你想让 pi 留在宿主机上、而所有内置工具都路由到虚拟机内执行时,可以使用示例扩展。

安装:

cp -R packages/coding-agent/examples/extensions/gondolin ~/.pi/agent/extensions/gondolin
cd ~/.pi/agent/extensions/gondolin
npm install --ignore-scripts

在要挂载的项目中运行:

cd /path/to/project
pi -e ~/.pi/agent/extensions/gondolin

该扩展会把宿主机当前工作目录挂载到虚拟机内的 /workspace,并接管 read、write、edit、bash、grep、find 和 ls。 用户的 ! 命令同样会被路由到虚拟机中。 /workspace 下的文件改动会直接写回宿主机。

环境要求:@earendil-works/gondolin 需要 Node.js >= 23.6.0,另外需要 QEMU(须通过你的包管理器安装)。

普通 Docker

当你想要最简单的本地容器边界时,可以把整个 pi 进程放进 Docker 中运行。

Dockerfile.pi:

FROM node:24-bookworm-slim

RUN apt-get update \
  && apt-get install -y --no-install-recommends bash ca-certificates git ripgrep \
  && rm -rf /var/lib/apt/lists/*
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent

WORKDIR /workspace
ENTRYPOINT ["pi"]

构建并运行:

docker build -t pi-sandbox -f Dockerfile.pi .

docker run --rm -it \
  -e ANTHROPIC_API_KEY \
  -v "$PWD:/workspace" \
  -v pi-agent-home:/root/.pi/agent \
  pi-sandbox

-v "$PWD:/workspace" 会把你当前所在的目录挂载到容器内的 /workspace,这样 Docker 内部对 /workspace 的读写会直接影响宿主机文件,效果与 Gondolin 示例相同。

如果希望容器内有独立的设置和会话,可以为 /root/.pi/agent 使用命名卷。挂载宿主机的 ~/.pi/agent 会把宿主机的认证和会话文件暴露给容器。

OpenShell

当你需要带有文件系统、进程、网络、凭据和推理控制的策略型沙箱时,可以使用 NVIDIA OpenShell。 OpenShell 可以通过由 Docker、Podman 或虚拟机运行时支撑的本地网关运行沙箱,也可以通过远程 Kubernetes 网关运行。

每个沙箱都需要一个活跃的网关。 在创建沙箱之前,先注册并选择一个:

openshell gateway add <gateway-url> --name <name>
openshell gateway select <name>

在 OpenShell 沙箱内启动 pi:

openshell sandbox create --name pi-sandbox --from pi -- pi

在这种模式下,整个 pi 进程都在沙箱内运行。 内置工具、! 命令和扩展工具都在 OpenShell 边界内执行。

如果网关是远程的,项目文件不会从宿主机绑定挂载,也就是说沙箱内的写入不会反映到你的机器上。 请在沙箱内克隆仓库,或使用 OpenShell 的文件传输命令:

openshell sandbox upload pi-sandbox ./repo /workspace
openshell sandbox download pi-sandbox /workspace/repo ./repo-out

OpenShell 提供商可以让原始的模型 API 密钥留在沙箱之外。 配置推理路由之后,沙箱内的代码可以调用 https://inference.local,由网关在上游注入配置好的提供商凭据。 如果你希望模型流量走这条路线,可以将 Pi 配置为使用对应的 OpenAI 兼容或 Anthropic 兼容端点。

Docker Sandboxes

Docker Sandboxes 是 Docker 提供的托管沙箱运行时,会把整个 pi 进程放进沙箱中运行。 它是没有内置沙箱所指的那种容器边界之一。

与上面的普通 Docker 模式不同,提供商凭据不会传入容器。 沙箱收到的是一个哨兵值(sentinel),由 sbx 代理在出口处向 api.anthropic.com 替换为真实凭据。 凭据在创建时接线,因此在创建沙箱之前,要先把凭据存放在宿主机上。

对于 Claude Pro/Max 订阅,先在一台装有 Claude Code 的机器上运行 claude setup-token,然后把结果存储到宿主机上。 如果已经绑定了 anthropic secret,请先移除它:否则代理会在 Bearer token 之外再附加一个 x-api-key 头,Anthropic 会拒绝该请求。 sbx secret set-custom 从 stdin 读取 token,因此不会留在 shell 历史记录中。

sbx secret rm anthropic

sbx secret set-custom \
  --host api.anthropic.com \
  --env ANTHROPIC_OAUTH_TOKEN \
  --placeholder 'sk-ant-oat01-{rand}'

沙箱拿到的是 OAuth 形式的占位符而非真实 token,由代理在出口到该主机时替换;ANTHROPIC_OAUTH_TOKEN 是 pi 本身就会读取并优先于 API 密钥使用的变量,因此无需额外的 pi 配置。

如果是 API 密钥,则改用 sbx secret set anthropic 存储。该套件(kit)以相同的方式接线:作为一个由代理在出口处替换的哨兵值。

凭据存放好之后,在要挂载的项目中启动 pi:

sbx run --kit "docker.io/sbx/pi-kit:latest" pi

该套件已把 pi 预置到镜像中,因此沙箱启动时无需安装任何东西,当前目录就是沙箱工作区。

不要在沙箱内认证:在里面运行 /login 会把真实 token 写入容器,破坏代理模型。

脚本化使用的方式相同:

sbx exec <sandbox-name> -- pi -p "list the failing tests"

完整的凭据矩阵、故障排除和版本固定,请参阅套件文档。

7 设置

pi 的 JSON 设置文件:全局与项目设置、模型、界面、压缩、重试等全部选项说明。

Pi 使用 JSON 设置文件,项目设置会覆盖全局设置。

位置作用域
~/.pi/agent/settings.json全局(所有项目)
.pi/settings.json项目(当前目录)

可以直接编辑,也可以使用 /settings 调整常用选项。要以交互方式保存启动时的默认模型,请使用 /model 并在所需模型上按 Ctrl+S。要保存启动时的思考级别,请使用 /thinking 并按 Ctrl+S。

项目信任

在交互式启动时,如果项目文件夹包含项目本地设置、资源或项目级 .agents/skills,且在 ~/.pi/agent/trust.json 中没有针对该文件夹或其父文件夹的已保存决定,pi 会先询问是否信任该项目。信任项目后,pi 才能加载 .pi/settings.json 和 .pi 资源、安装缺失的项目包,并执行项目扩展。

非交互模式(-p、--mode json 和 --mode rpc)不显示信任提示。在没有适用的已保存信任决定时,它们使用全局设置中的 defaultProjectTrust:ask(默认)和 never 会忽略这些项目资源,而 always 则信任它们。传入 --approve/-a 或 --no-approve/-na 可为单次运行覆盖项目信任。

如果没有扩展或已保存决定适用,则由 defaultProjectTrust 控制回退行为。在 ~/.pi/agent/settings.json 中将其设为 "ask"、"always" 或 "never",或通过 /settings 修改。

pi config 和包管理命令使用相同的项目信任流程,但 pi update 从不弹出提示。传入 --approve 可为单条命令信任项目本地设置,传入 --no-approve 则忽略它们。

在交互模式下使用 /trust 可以为未来会话保存项目信任决定,包括对直接父文件夹的信任。它只写入 ~/.pi/agent/trust.json;当前会话不会重新加载,因此需重启 pi 才能生效。

全部设置

模型与思考

设置类型默认值说明
defaultProvider字符串-启动提供商(如 "anthropic"、"openai";通过 /model 中按 Ctrl+S 保存,或手动编辑)
defaultModel字符串-启动模型 ID(通过 /model 中按 Ctrl+S 保存,或手动编辑)
defaultThinkingLevel字符串-启动思考级别(通过 /thinking 中按 Ctrl+S 保存,或手动编辑):"off"、"minimal"、"low"、"medium"、"high"、"xhigh"、"max"
modelThinkingLevels对象-以 "provider/modelId" 为键的每模型启动思考级别;可在 /settings → Default thinking level per model 中配置,或手动编辑
hideThinkingBlock布尔值false在输出中隐藏思考块
showCacheMissNotices布尔值false为显著的提示词缓存未命中、上下文压缩或分支摘要用量,以及提供商恢复诊断(如被丢弃的 Anthropic 思考块)显示对话记录通知
thinkingBudgets对象-每个思考级别的自定义 token 预算。Anthropic、Google 和 Bedrock 原生使用这些预算。兼容 OpenAI 的模型在设置了 compat.thinkingTokenBudget(或 supportsThinkingTokenBudget)时使用它们。

thinkingBudgets

{
  "thinkingBudgets": {
    "minimal": 1024,
    "low": 4096,
    "medium": 10240,
    "high": 32768
  }
}

UI 与显示

设置类型默认值说明
theme字符串"dark"主题名称("dark"、"light" 或自定义)
externalEditor字符串$VISUAL,然后 $EDITOR,再然后 Windows 上的记事本或其他平台上的 nanoCtrl+G 外部编辑器命令;优先于环境变量
quietStartup布尔值false隐藏启动头部
defaultProjectTrust字符串"ask"回退的项目信任行为:"ask"、"always" 或 "never"。仅限全局设置
collapseChangelog布尔值false更新后显示精简版更新日志
enableInstallTelemetry布尔值true发送匿名安装/更新 ping 以及所选提供商的归因请求头。它不控制更新检查
enableAnalytics布尔值false选择加入的分析数据共享。目前仅在实验性首次设置(PI_EXPERIMENTAL=1)期间询问
trackingId字符串-分析跟踪标识符,在开启 enableAnalytics 时生成
doubleEscapeAction字符串"tree"连按两次 Escape 的动作:"tree"、"fork" 或 "none"
treeFilterMode字符串"default"/tree 的默认过滤器:"default"、"no-tools"、"user-only"、"labeled-only"、"all"
editorPaddingX数字0输入编辑器的水平内边距(0-3)
outputPad数字1用户消息、助手消息和思考内容的水平内边距(0 或 1)
autocompleteMaxVisible数字5自动补全下拉菜单的最大可见项数(3-20)
showHardwareCursor布尔值false在 TUI 为输入法支持定位光标时显示终端光标
tuiMode字符串"regular"交互式 TUI 模式:"regular" 或实验性 "fullscreen"。在 /settings 中更改后立即生效;--tui-mode 会在启动时覆盖该设置
fullscreenExitOutput字符串"transcript"全屏退出输出:"transcript" 打印最终对话记录和恢复提示,"resume-hint" 则恢复之前的屏幕并只打印恢复提示。在常规 TUI 模式下无效
fullscreenScrollbar字符串"auto"全屏对话记录滚动条:"auto" 在滚动期间或指针悬停在最右侧轨道列时临时显示,"always" 保留该列并始终显示,"hidden" 将其隐藏。在常规 TUI 模式下无效
fullscreenCopyOnSelect布尔值true在全屏模式下自动复制选中的文本。禁用时,选区保持高亮,由 Ctrl+X 复制活动选区

对于 VS Code,请加上 --wait,这样编辑器退出后 pi 会继续运行:

{
  "externalEditor": "code --wait"
}

遥测与更新检查

enableInstallTelemetry 控制发送到 https://pi.dev/api/report-install 的匿名安装/更新 ping,以及 OpenRouter、NVIDIA NIM 和 Cloudflare 提供商请求中的 Pi 归因请求头。关闭后会同时禁用两者。它不会禁用更新检查;Pi 仍可能获取 https://pi.dev/api/latest-version 以查询最新版本。

设置 PI_SKIP_VERSION_CHECK=1 可禁用 Pi 版本更新检查。使用 --offline 或 PI_OFFLINE=1 可禁用本文所述的所有启动网络操作,包括更新检查、包更新检查和安装/更新遥测。

网络

设置类型默认值说明
httpProxy字符串-HTTP 代理 URL,应用为 HTTP_PROXY 和 HTTPS_PROXY。仅限全局设置。
{
  "httpProxy": "http://127.0.0.1:7890"
}

警告

设置类型默认值说明
warnings.anthropicExtraUsage布尔值true当 Anthropic 订阅认证可能使用付费的额外用量时显示警告
{
  "warnings": {
    "anthropicExtraUsage": false
  }
}

上下文压缩

设置类型默认值说明
compaction.enabled布尔值true启用自动压缩
compaction.reserveTokens数字16384为 LLM 回复保留的 token
compaction.keepRecentTokens数字20000保留的近期 token(不被摘要)
compaction.modelOverrides对象-以精确 "provider/modelId" 为键的每模型 reserveTokens 和 keepRecentTokens 覆盖
{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}

每模型压缩覆盖

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000,
    "modelOverrides": {
      "some-provider/big-model": {
        "reserveTokens": 400000
      },
      "local/small-model": {
        "reserveTokens": 2048,
        "keepRecentTokens": 4096
      }
    }
  }
}

键必须精确匹配区分大小写的 provider/modelId 值,而不是名称或 glob 模式。模型 ID 可能包含斜杠(例如 openrouter/anthropic/claude-sonnet-4)。

每个 token 设置独立解析:匹配的模型覆盖 → 普通 compaction 设置 → 内置默认值。在上面的例子中,some-provider/big-model 仍保留普通的 20000 近期 token。token 值必须是非负的安全整数。匹配的模型覆盖中存在无效值时会在读取时报错;只有省略的字段才会回退到普通设置。模型覆盖条目必须是对象。普通 token 设置存在无效值时同样会在读取时报错,即使当前模型有有效的覆盖。只有省略的普通值才使用内置默认值。零是允许的,但 reserveTokens: 0 会失去回复余量,并将摘要输出预算也设为零。

全局和项目设置在模型查找之前递归合并。项目可以只覆盖某个模型的某一个字段,而不替换它的其他字段或其他模型。全局的模型专属值优先于项目范围的回退值;要更改它,需在项目中覆盖同一模型条目。

enabled 不区分模型。当前模型的 token 设置适用于手动压缩、自动阈值检查(包括助手回合之间)以及溢出恢复。切换模型会在下一次检查或压缩时生效。覆盖请在 JSON 中配置;/settings 只保留普通的自动压缩开关。

触发和摘要行为参见 compaction.md。

分支摘要

设置类型默认值说明
branchSummary.reserveTokens数字16384选择分支历史时保留的 token;输出上限为 4096 token
branchSummary.skipPrompt布尔值false在 /tree 导航时跳过 “Summarize branch?” 提示(默认不做摘要)

重试

设置类型默认值说明
retry.enabled布尔值true在瞬时错误上启用自动的智能体级重试
retry.maxRetries数字3智能体级重试的最大次数
retry.baseDelayMs数字2000智能体级指数退避的基础延迟(2 秒、4 秒、8 秒)
retry.maxAgentDelayMs数字60000智能体级重试的最大延迟(60 秒)
retry.provider.timeoutMs数字SDK 默认值提供商/SDK 请求超时(毫秒)
retry.provider.maxRetries数字0提供商/SDK 重试次数
retry.provider.maxRetryDelayMs数字60000失败前允许的服务器请求延迟上限(60 秒)

智能体级重试使用以 retry.maxAgentDelayMs 为上限的指数退避,因此即使长时间故障,较长的重试过程也能保持响应。

当提供商请求的重试延迟超过 retry.provider.maxRetryDelayMs 时,请求会立即失败并给出有说明的错误,而不是静默等待。将其设为 0 可禁用该限制。

除非确实需要提供商级重试,否则请将 retry.provider.maxRetries 保持为 0。将其设为大于 0 可能让 SDK/提供商重试在 Pi 察觉之前处理超出用量限额的错误,某些情况下这会阻塞智能体,直到提供商配额重置。

{
  "retry": {
    "enabled": true,
    "maxRetries": 3,
    "baseDelayMs": 2000,
    "maxAgentDelayMs": 60000,
    "provider": {
      "timeoutMs": 3600000,
      "maxRetries": 0,
      "maxRetryDelayMs": 60000
    }
  }
}

消息送达

设置类型默认值说明
steeringMode字符串"one-at-a-time"转向消息的发送方式:"all" 或 "one-at-a-time"
followUpMode字符串"one-at-a-time"后续消息的发送方式:"all" 或 "one-at-a-time"
transport字符串"auto"支持多种传输方式的提供商的首选传输:"sse"、"websocket"、"websocket-cached" 或 "auto"
httpIdleTimeoutMs数字300000HTTP 头部/正文空闲超时(毫秒),同样适用于有显式流空闲超时的提供商。设为 0 可禁用。
websocketConnectTimeoutMs数字15000支持 WebSocket 传输的提供商的 WebSocket 连接/打开握手超时(毫秒)。设为 0 可禁用。

终端与图片

设置类型默认值说明
terminal.showImages布尔值true在终端中显示图片(如果支持)
terminal.imageWidthCells数字60终端中行内图片的首选宽度(以单元格计)
terminal.clearOnShrink布尔值false内容收缩时清除空行(可能导致闪烁)
terminal.hyperlinks布尔值或 "auto""auto"覆盖 OSC 8 超链接支持(高级,仅 JSON)
terminal.images字符串或布尔值"auto"以 "kitty"、"iterm2"、false 或 "auto" 覆盖图片协议支持(高级,仅 JSON)
terminal.trueColor布尔值或 "auto""auto"覆盖真彩色支持(高级,仅 JSON)
images.autoResize布尔值true将图片缩放到最大 2000x2000。适用于 @file 附件、read 以及工具返回的图片
images.blockImages布尔值false阻止所有图片发送给 LLM

Shell

设置类型默认值说明
shellPath字符串-自定义 shell 路径(例如 Windows 上的 Cygwin);支持开头的 ~ 表示主目录
shellCommandPrefix字符串-每条 bash 命令的前缀(例如 "shopt -s expand_aliases")
npmCommand字符串数组-npm 包查找/安装操作使用的命令 argv(例如 ["mise", "exec", "node@20", "--", "npm"])

JSON 中的 Windows 路径必须使用正斜杠或转义的反斜杠:

{
  "shellPath": "C:/Program Files/Git/bin/bash.exe"
}
{
  "shellPath": "C:\\Program Files\\Git\\bin\\bash.exe"
}
{
  "npmCommand": ["mise", "exec", "node@20", "--", "npm"]
}

npmCommand 用于所有 npm 包管理器操作,包括安装、卸载以及 git 包内部的依赖安装。用户范围的 npm 包安装在 ~/.pi/agent/npm/ 下;项目范围的 npm 包安装在 .pi/npm/ 下。请按进程的实际启动方式使用 argv 形式的条目。配置了 npmCommand 后,git 包的依赖安装会使用纯 install,以避免包装器或替代包管理器中的 npm 专属标志。

工具

设置类型默认值说明
defaultTools字符串数组-启动时启用的内置工具。省略时,Pi 使用其标准默认值

defaultTools 选择启动时启用的内置工具。扩展和 SDK 自定义工具仍然保持启用。可用的内置工具有 read、bash、powershell、edit、write、grep、find 和 ls:

{
  "defaultTools": ["bash", "edit", "write"]
}

在 Windows 上,请选择 powershell 而不是 bash,或者两者都包含:

{
  "defaultTools": ["read", "powershell", "edit", "write"]
}

空数组表示启动时不启用任何内置工具,同时保留扩展和 SDK 自定义工具。--tools 会以针对所有工具的严格白名单取代这一行为,--no-tools 禁用所有工具,--no-builtin-tools 禁用内置默认工具。--exclude-tools 会在结果列表上再做过滤。项目的 defaultTools 数组会替换全局数组。

会话

设置类型默认值说明
sessionDir字符串-会话文件的存储目录。接受绝对或相对路径,也支持 ~。
{ "sessionDir": ".pi/sessions" }

当多个来源指定会话目录时,优先级依次为 --session-dir、PI_CODING_AGENT_SESSION_DIR、settings.json 中的 sessionDir。

模型循环

设置类型默认值说明
enabledModels字符串数组-用于 Ctrl+P 循环切换的模型模式(格式与 --models CLI 标志相同)
{
  "enabledModels": ["claude-*", "gpt-4o", "gemini-2*"]
}

Markdown

设置类型默认值说明
markdown.codeBlockIndent字符串" "代码块的缩进
markdown.mermaid字符串"streaming"Mermaid 渲染模式:"off"、"final" 或 "streaming"

资源

这些设置定义从哪里加载扩展、技能、提示词和主题。

~/.pi/agent/settings.json 中的路径相对于 ~/.pi/agent 解析。.pi/settings.json 中的路径相对于 .pi 解析。支持绝对路径和 ~。

设置类型默认值说明
packages数组[]从中加载资源的 npm/git 包
extensions字符串数组[]本地扩展文件路径或目录
skills字符串数组[]本地技能文件路径或目录
prompts字符串数组[]本地提示词模板路径或目录
themes字符串数组[]本地主题文件路径或目录
enableSkillCommands布尔值true将技能注册为 /skill:name 命令

数组支持 glob 模式和排除项。使用 !pattern 排除。使用 +path 强制包含某个精确路径,使用 -path 强制排除某个精确路径。

packages

字符串形式会加载包中的所有资源:

{
  "packages": ["pi-skills", "@org/my-extension"]
}

对象形式可以过滤要加载的资源:

{
  "packages": [
    {
      "source": "pi-skills",
      "skills": ["brave-search", "transcribe"],
      "extensions": []
    }
  ]
}

包管理详情参见 packages.md。

示例

{
  "defaultProvider": "anthropic",
  "defaultModel": "claude-sonnet-4-20250514",
  "defaultThinkingLevel": "medium",
  "modelThinkingLevels": {
    "anthropic/claude-sonnet-4-20250514": "high"
  },
  "theme": "dark",
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  },
  "retry": {
    "enabled": true,
    "maxRetries": 3
  },
  "enabledModels": ["claude-*", "gpt-4o"],
  "warnings": {
    "anthropicExtraUsage": true
  },
  "packages": ["pi-skills"]
}

项目覆盖

项目设置(.pi/settings.json)会覆盖全局设置。嵌套对象会被合并:

// ~/.pi/agent/settings.json (global)
{
  "theme": "dark",
  "compaction": { "enabled": true, "reserveTokens": 16384 }
}

// .pi/settings.json (project)
{
  "compaction": { "reserveTokens": 8192 }
}

// Result
{
  "theme": "dark",
  "compaction": { "enabled": true, "reserveTokens": 8192 }
}

8 快捷键

自定义 pi 的全部键盘快捷键:按键格式、动作清单与 Emacs/Vim 配置示例。

所有键盘快捷键都可以通过 ~/.pi/agent/keybindings.json 自定义。每个动作可以绑定一个或多个按键。

该配置文件使用与 pi 内部以及扩展作者在 keyHint() 和注入的 keybindings 管理器中相同的带命名空间快捷键 ID。

使用 cursorUp 或 expandTools 等旧版无命名空间 ID 的配置会在启动时自动迁移为带命名空间的 ID。

编辑 keybindings.json 后,在 pi 中运行 /reload 即可应用更改,无需重启会话。

按键格式

格式为 modifier+key,其中修饰键为 ctrl、shift、alt、super(可组合),按键包括:

  • 字母: a-z
  • 数字: 0-9
  • 特殊键: escape、esc、enter、return、tab、space、backspace、delete、insert、clear、home、end、pageUp、pageDown、up、down、left、right
  • 功能键: f1-f12
  • 符号: `、-、=、[、]、\、;、'、,、.、/、!、@、#、$、%、^、&、*、(、)、_、+、|、~、{、}、:、<、>、?

修饰键组合:ctrl+shift+x、alt+ctrl+x、ctrl+shift+alt+x、super+k、ctrl+super+k、ctrl+1 等。

super 绑定要求终端能够单独上报该修饰键,通常需要 Kitty 键盘协议。在不支持的终端中可能无法工作。

全部动作

TUI 编辑器光标移动

快捷键 ID默认值说明
tui.editor.cursorUpup光标上移,到达顶部后浏览更早的历史
tui.editor.cursorDowndown光标下移,到达底部后浏览更新的历史
tui.editor.historyPrevious(无)选择上一条提示词历史
tui.editor.historyNext(无)选择下一条提示词历史
tui.editor.cursorLeftleft、ctrl+b光标左移
tui.editor.cursorRightright、ctrl+f光标右移
tui.editor.cursorWordLeftalt+left、ctrl+left、alt+b光标左移一个单词
tui.editor.cursorWordRightalt+right、ctrl+right、alt+f光标右移一个单词
tui.editor.cursorLineStarthome、ctrl+home、ctrl+a移动到行首
tui.editor.cursorLineEndend、ctrl+end、ctrl+e移动到行尾
tui.editor.jumpForwardctrl+]向前跳转到指定字符
tui.editor.jumpBackwardctrl+alt+]向后跳转到指定字符
tui.editor.pageUppageUp、ctrl+pageUp向上翻页
tui.editor.pageDownpageDown、ctrl+pageDown向下翻页

专用的历史动作始终切换历史条目,与多行提示词中的光标位置无关。当主编辑器获得焦点时,显式的历史绑定优先于应用动作,因此将 tui.editor.historyPrevious 绑定到 ctrl+p 只会在该上下文中覆盖模型循环切换,而不会改变选择器中的 Ctrl+P。

TUI 编辑器删除

快捷键 ID默认值说明
tui.editor.deleteCharBackwardbackspace向后删除一个字符
tui.editor.deleteCharForwarddelete、ctrl+d向前删除一个字符
tui.editor.deleteWordBackwardctrl+w、alt+backspace向后删除一个单词
tui.editor.deleteWordForwardalt+d、alt+delete向前删除一个单词
tui.editor.deleteToLineStartctrl+u删除到行首
tui.editor.deleteToLineEndctrl+k删除到行尾

TUI 输入

快捷键 ID默认值说明
tui.input.newLineshift+enter、ctrl+j插入新行
tui.input.submitenter提交输入
tui.input.tabtabTab / 自动补全

TUI 剪切环

快捷键 ID默认值说明
tui.editor.yankctrl+y粘贴最近删除的文本
tui.editor.yankPopalt+y在粘贴后循环切换已删除的文本
tui.editor.undoctrl+-(Windows 上为 ctrl+z;WSL 上为 alt+z)撤销上次编辑

TUI 剪贴板与选择

快捷键 ID默认值说明
tui.input.copyctrl+c复制选区
tui.select.upup选区上移
tui.select.downdown选区下移
tui.select.pageUppageUp列表中向上翻页
tui.select.pageDownpageDown列表中向下翻页
tui.select.confirmenter确认选择
tui.select.cancelescape、ctrl+c取消选择

TUI 全屏视口

这些动作在交互模式使用 --tui-mode fullscreen 时生效,作用于主对话记录滚动区域。双指触控板和鼠标滚轮输入会滚动指针所在区域;当指针位于固定的编辑器/状态栏/底部栏停靠区上时,则回退为滚动对话记录。点击 OSC 8 超链接会用默认处理器打开它。按住主鼠标键拖动会选择文本并复制到剪贴板;在对话记录的上、下边缘按住不动时会自动滚动到屏幕外的内容。对话记录向上滚动时,其底部行会显示可点击的 “Jump to latest message” 标签,展示 tui.altScreen.bottom 快捷键。各终端专属的鼠标与触控行为参见终端设置。

全屏对话记录绑定优先于编辑器绑定。因此在全屏模式下,默认的无修饰导航键控制对话记录,而它们的 ctrl 变体继续控制编辑器。在非全屏模式下,两种变体都控制编辑器。

对话记录搜索面板会显示已配置的上一个/下一个快捷键以及可点击的箭头控件。再次按下 tui.altScreen.search,或使用 tui.altScreen.searchClose,即可关闭它。

按键默认模式全屏模式
home、end编辑器对话记录
ctrl+home、ctrl+end编辑器编辑器
pageUp、pageDown编辑器对话记录
ctrl+pageUp、ctrl+pageDown编辑器编辑器

这一路由仍可通过常规的动作绑定进行配置。例如,"tui.altScreen.pageUp": "ctrl+pageUp" 会让全屏模式下的 pageUp 控制编辑器、ctrl+pageUp 控制对话记录。绑定 tui.altScreen.halfPageUp 和 tui.altScreen.halfPageDown 可以按半页步进,绑定 tui.altScreen.lineUp 和 tui.altScreen.lineDown 可以按单行步进。设置 "tui.altScreen.pageUp": [] 会完全禁用该对话记录快捷键。用户绑定会替换该动作的默认值。

快捷键 ID默认值说明
tui.altScreen.pageUppageUp将对话记录向上滚动一页
tui.altScreen.pageDownpageDown将对话记录向下滚动一页
tui.altScreen.halfPageUp(无)将对话记录向上滚动半页
tui.altScreen.halfPageDown(无)将对话记录向下滚动半页
tui.altScreen.lineUp(无)将对话记录向上滚动一行
tui.altScreen.lineDown(无)将对话记录向下滚动一行
tui.altScreen.previousPromptctrl+shift+up、ctrl+up(Windows 和 WSL 上仅为 ctrl+up)跳转到上一个标记消息
tui.altScreen.nextPromptctrl+shift+down、ctrl+down(Windows 和 WSL 上仅为 ctrl+down)跳转到下一个标记消息
tui.altScreen.searchctrl+shift+f(Windows 和 WSL 上为 ctrl+f)搜索渲染后的对话记录
tui.altScreen.searchNextenter、ctrl+g搜索时选择下一个匹配项
tui.altScreen.searchPreviousshift+enter、ctrl+shift+g搜索时选择上一个匹配项
tui.altScreen.searchCloseescape关闭对话记录搜索
tui.altScreen.tophome滚动到对话记录开头
tui.altScreen.bottomend滚动到对话记录末尾并跟随新输出

应用

快捷键 ID默认值说明
app.interruptescape取消 / 中止
app.clearctrl+c清空编辑器(第一次)/ 退出(第二次)
app.exitctrl+d退出(编辑器为空时)
app.suspendctrl+z(Windows 上无)挂起到后台
app.editor.externalctrl+g在外部编辑器中打开(externalEditor、$VISUAL、$EDITOR、Windows 上的记事本或其他平台上的 nano)
app.clipboard.pasteImagectrl+v(Windows 和 WSL 上为 alt+v)从剪贴板粘贴图片或文本

会话

快捷键 ID默认值说明
app.session.new(无)开始新会话(/new)
app.session.tree(无)打开会话树导航器(/tree)
app.session.fork(无)分叉当前会话(/fork)
app.session.resume(无)打开会话恢复选择器(/resume)
app.session.togglePathctrl+p切换路径显示
app.session.toggleSortctrl+s切换排序方式
app.session.toggleNamedFilterctrl+n切换"仅已命名"过滤器
app.session.renamectrl+r重命名会话
app.session.deletectrl+d删除会话
app.session.deleteNoninvasivectrl+backspace查询为空时删除会话

模型与思考

快捷键 ID默认值说明
app.model.selectctrl+l打开模型选择器
app.model.cycleForwardctrl+p循环切换到下一个模型
app.model.cycleBackwardshift+ctrl+p(Windows 和 WSL 上为 alt+p)循环切换到上一个模型
app.models.savectrl+s将选定的默认模型或范围模型配置保存到设置
app.thinking.cycleshift+tab循环切换思考级别
app.thinking.savectrl+s将当前思考级别保存到设置
app.thinking.togglectrl+t折叠或展开思考块

显示与消息队列

快捷键 ID默认值说明
app.tools.expandctrl+o折叠或展开工具输出
app.message.copyctrl+x在 /tree 中复制选中的消息;否则复制上一条助手消息;当 fullscreenCopyOnSelect 禁用时,复制全屏模式下活动的文本选区
app.message.followUpalt+enter(Windows 和 WSL 上为 ctrl+q)将后续消息加入队列
app.message.dequeuealt+up(Windows 和 WSL 上为 alt+q)将排队消息恢复到编辑器

树状导航

快捷键 ID默认值说明
app.tree.foldOrUpctrl+left、alt+left折叠当前分支段,或跳转到上一段起点
app.tree.unfoldOrDownctrl+right、alt+right展开当前分支段,或跳转到下一段起点或分支末尾
app.tree.editLabelshift+l编辑所选树节点的标签
app.tree.toggleLabelTimestampshift+t切换树中标签时间戳的显示
app.tree.filter.defaultctrl+d将树过滤器设为默认视图
app.tree.filter.noToolsctrl+t切换隐藏工具结果的树过滤器
app.tree.filter.userOnlyctrl+u切换仅显示用户消息的树过滤器
app.tree.filter.labeledOnlyctrl+l切换仅显示已标记条目的树过滤器
app.tree.filter.allctrl+a切换显示所有条目的树过滤器
app.tree.filter.cycleForwardctrl+o向前循环切换树过滤器
app.tree.filter.cycleBackwardshift+ctrl+o向后循环切换树过滤器

作用域模型选择器

用于作用域模型选择器内部(通过 /scoped-models 打开)。

快捷键 ID默认值说明
app.models.enableAllctrl+a启用所有模型(或当前搜索匹配的所有模型)
app.models.clearAllctrl+x清除所有模型(或当前搜索匹配的所有模型)
app.models.toggleProviderctrl+p切换当前提供商的所有模型
app.models.reorderUpalt+up在循环顺序中将所选模型上移
app.models.reorderDownalt+down在循环顺序中将所选模型下移

自定义配置

创建 ~/.pi/agent/keybindings.json:

{
  "tui.editor.historyPrevious": "ctrl+p",
  "tui.editor.historyNext": "ctrl+n",
  "tui.editor.deleteWordBackward": ["ctrl+w", "alt+backspace"]
}

每个动作可以绑定单个按键或一个按键数组。用户配置会覆盖默认值。

在原生 Windows 上,app.suspend 没有默认绑定,因为 Windows 终端不支持 Unix 作业控制。如果你手动绑定它,pi 会显示一条状态消息而不是挂起进程。在 WSL 中,正常的 Linux ctrl+z/fg 行为仍然适用。

Emacs 示例

{
  "tui.editor.historyPrevious": "ctrl+p",
  "tui.editor.historyNext": "ctrl+n",
  "tui.editor.cursorLeft": ["left", "ctrl+b"],
  "tui.editor.cursorRight": ["right", "ctrl+f"],
  "tui.editor.cursorWordLeft": ["alt+left", "alt+b"],
  "tui.editor.cursorWordRight": ["alt+right", "alt+f"],
  "tui.editor.deleteCharForward": ["delete", "ctrl+d"],
  "tui.editor.deleteCharBackward": ["backspace", "ctrl+h"],
  "tui.input.newLine": ["shift+enter", "ctrl+j"]
}

Vim 示例

{
  "tui.editor.cursorUp": ["up", "alt+k"],
  "tui.editor.cursorDown": ["down", "alt+j"],
  "tui.editor.cursorLeft": ["left", "alt+h"],
  "tui.editor.cursorRight": ["right", "alt+l"],
  "tui.editor.cursorWordLeft": ["alt+left", "alt+b"],
  "tui.editor.cursorWordRight": ["alt+right", "alt+w"]
}

9 会话

会话的自动保存、恢复、命名、树状分支结构与常用管理命令。

Pi 会把对话保存为会话,让你可以继续之前的工作、从更早的回合分叉,或者回顾之前走过的路径。

会话存储

会话自动保存到 ~/.pi/agent/sessions/,按工作目录组织。每个会话都是一个具有树状结构的 JSONL 文件。

pi -c                  # Continue most recent session
pi -r                  # Browse and select from past sessions
pi --no-session        # Ephemeral mode; do not save
pi --name "my task"    # Set session display name at startup
pi --session <path|id> # Use a specific session file or partial session ID
pi --fork <path|id>    # Fork a session file or partial session ID into a new session

在交互模式下使用 /session 可以查看当前会话文件、会话 ID、消息数、token 数和费用。

关于 JSONL 文件格式和 SessionManager API,请参阅会话格式。

会话命令

命令说明
/resume浏览并选择之前的会话
/new开始一个新会话
/name <name>设置当前会话的显示名称
/session显示会话信息
/tree在当前会话树中导航
/fork从之前的某条用户消息创建新会话
/clone把当前活动分支复制为一个新会话
/compact [prompt]汇总较早的上下文;见上下文压缩
/export [file]将会话导出为 HTML
/share上传为私有 GitHub gist,并生成可分享的 HTML 链接

恢复和删除会话

/resume 会打开当前项目的交互式会话选择器。pi -r 则在启动时打开同一个选择器。

在选择器中你可以:

  • 输入内容进行搜索
  • 用 Ctrl+P 切换路径显示
  • 用 Ctrl+S 切换排序方式
  • 用 Ctrl+N 只显示命名会话
  • 用 Ctrl+R 重命名
  • 用 Ctrl+D 删除,然后确认

可用时,pi 会使用 trash CLI 执行删除,而不是永久移除文件。

命名会话

使用 /name <name> 设置人类可读的会话名称:

/name Refactor auth module

也可以在启动时用 --name 或 -n 设置名称:

pi --name "Refactor auth module"
pi --name "CI audit" -p "Review this build failure"

命名会话在 /resume 和 pi -r 中更容易找到。

使用 /tree 进行分支

会话以树的形式存储。每个条目都有 id 和 parentId,当前位置就是活动叶子。/tree 让你跳到之前的任意节点并从那里继续,而不必创建新文件。

树状视图

示例结构:

├─ user: "Hello, can you help..."
│  └─ assistant: "Of course! I can..."
│     ├─ user: "Let's try approach A..."
│     │  └─ assistant: "For approach A..."
│     │     └─ user: "That worked..."  ← active
│     └─ user: "Actually, approach B..."
│        └─ assistant: "For approach B..."

树状视图操作

按键操作
↑/↓在可见条目间移动
←/→向上/向下翻页
Ctrl+←/Ctrl+→ 或 Alt+←/Alt+→折叠/展开,或在分支段之间跳转
Shift+L为选中条目设置或清除标签
Shift+T切换标签时间戳
Enter选中条目
Escape/Ctrl+C取消
Ctrl+O循环切换过滤模式

过滤模式包括:default、no-tools、user-only、labeled-only 和 all。可在设置中通过 treeFilterMode 配置默认值。

选择行为

选中一条用户消息或自定义消息时:

  1. 把叶子移动到所选消息的父节点。
  2. 把所选消息的文本放入编辑器。
  3. 让你编辑并重新提交,从而创建一个新分支。

选中助手、工具、压缩或其他非用户条目时:

  1. 把叶子移动到该条目。
  2. 编辑器保持为空。
  3. 让你从该位置继续。

选中根用户消息会把叶子重置为空对话,并把原始提示词放入编辑器。

/tree、/fork 与 /clone

功能/tree/fork/clone
输出同一个会话文件新会话文件新会话文件
视图完整树用户消息选择器当前活动分支
典型用途在原位探索不同方案从较早的提示词开启新会话在继续之前复制当前工作
摘要可选的分支摘要无无

当你想把不同方案保留在同一个会话中时,使用 /tree。当你想要一个独立的会话文件时,使用 /fork 或 /clone。

分支摘要

当 /tree 从一个分支切换到另一个分支时,pi 可以对被放弃的分支生成摘要,并把该摘要附加到新位置。这样既保留了你所离开路径上的重要上下文,又不必重放整个分支。

出现提示时,从以下选项中选择:

  1. 不生成摘要
  2. 使用默认提示词生成摘要
  3. 使用自定义侧重点指令生成摘要

关于分支汇总的内部机制和扩展钩子,请参阅上下文压缩。

会话格式

会话文件为 JSONL 格式,包含消息条目、模型变更、思考级别变更、标签、压缩记录、分支摘要和扩展条目。

关于解析器、扩展、SDK 用法和完整的 SessionManager API,请参阅会话格式。

10 上下文压缩与分支摘要

pi 如何在上下文超限时自动压缩并总结被放弃的分支,以及通过扩展自定义摘要。

LLM 的上下文窗口是有限的。当对话变得过长时,Pi 会使用上下文压缩来总结较早的内容,同时保留近期的工作。本页面同时介绍自动压缩和分支摘要。

源码文件(pi):

如需项目中的 TypeScript 定义,请查看 node_modules/@earendil-works/pi-coding-agent/dist/。

概述

Pi 有两种摘要机制:

机制触发条件目的
上下文压缩上下文超过阈值,或 /compact总结旧消息以释放上下文
分支摘要/tree 导航切换分支时保留上下文

两者使用相同的结构化摘要格式,并累积跟踪文件操作。压缩和分支摘要请求使用全新的路由会话 ID,并且在提供商支持的情况下禁用提示词缓存写入,因为这些一次性提示词不太可能被复用。

上下文压缩

触发时机

自动压缩在以下条件满足时触发:

contextTokens > contextWindow - reserveTokens

默认情况下,reserveTokens 为 16384 token(可在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置)。这为 LLM 的回复留出空间。

在多回合的智能体运行过程中,Pi 会在工具完成且结果被追加之后、开始下一条助手回复之前检查该阈值。如果超过阈值,Pi 会在同一次智能体运行内完成压缩,并带着摘要和保留的消息继续。当已完成的工具批次结束了本次运行、且没有排队消息需要再次回复时,Pi 会跳过这次回合间检查。Pi 还会在新的用户提示词之前以及底层智能体运行结束后检查该阈值。

也可以使用 /compact [instructions] 手动触发,可选的指令用于聚焦摘要内容。

工作原理

  1. 确定切割点:从最新消息向前回溯,累积 token 估算值,直到达到 keepRecentTokens(默认 20k,可在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置)
  2. 提取消息:收集从上一次保留边界(或会话起点)到切割点之间的消息
  3. 生成摘要:调用 LLM 以结构化格式进行总结,存在前一份摘要时将其作为迭代上下文传入
  4. 追加条目:保存包含摘要和 firstKeptEntryId 的 CompactionEntry
  5. 重建上下文:会话为下一次请求重建上下文,使用摘要 + 从 firstKeptEntryId 起的消息
Before compaction:

  entry:  0     1     2     3      4     5     6      7      8     9
        ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┘
                └────────┬───────┘ └──────────────┬──────────────┘
               messagesToSummarize            kept messages
                                   ↑
                          firstKeptEntryId (entry 4)

After compaction (new entry appended):

  entry:  0     1     2     3      4     5     6      7      8     9     10
        ┌─────┬─────┬─────┬──────┬─────┬─────┬──────┬──────┬─────┬─────┬─────┐
        │ hdr │ usr │ ass │ tool │ usr │ ass │ tool │ tool │ ass │ tool│ cmp │
        └─────┴─────┴─────┴──────┴─────┴─────┴──────┴──────┴─────┴─────┴─────┘
               └──────────┬──────┘ └──────────────────────┬───────────────────┘
                 not sent to LLM                    sent to LLM
                                                         ↑
                                              starts from firstKeptEntryId

What the LLM sees:

  ┌────────┬─────────┬─────┬─────┬──────┬──────┬─────┬──────┐
  │ system │ summary │ usr │ ass │ tool │ tool │ ass │ tool │
  └────────┴─────────┴─────┴─────┴──────┴──────┴─────┴──────┘
       ↑         ↑      └─────────────────┬────────────────┘
    prompt   from cmp          messages from firstKeptEntryId

在多次压缩中,被摘要的范围从上一次压缩的保留边界(firstKeptEntryId)开始,而不是从压缩条目本身开始;如果该保留条目在路径中找不到,则回退到上一次压缩之后的那条条目。这样,在上一次压缩中幸存的消息也会被纳入下一次摘要过程,从而得到保留。Pi 还会在写入新的 CompactionEntry 之前,根据重建后的会话上下文重新计算 tokensBefore,使 token 计数反映被替换的实际压缩前上下文。

拆分回合

“回合"从一条用户消息开始,包含直到下一条用户消息之前的所有助手回复和工具调用。通常,压缩在回合边界处切割。

当单个回合超过 keepRecentTokens 时,切割点会落在回合中间的某条助手消息上。这就是"拆分回合”:

Split turn (one huge turn exceeds budget):

  entry:  0     1     2      3     4      5      6     7      8
        ┌─────┬─────┬─────┬──────┬─────┬──────┬──────┬─────┬──────┐
        │ hdr │ usr │ ass │ tool │ ass │ tool │ tool │ ass │ tool │
        └─────┴─────┴─────┴──────┴─────┴──────┴──────┴─────┴──────┘
                ↑                                     ↑
         turnStartIndex = 1                  firstKeptEntryId = 7
                │                                     │
                └──── turnPrefixMessages (1-6) ───────┘
                                                      └── kept (7-8)

  isSplitTurn = true
  messagesToSummarize = []  (no complete turns before)
  turnPrefixMessages = [usr, ass, tool, ass, tool, tool]

对于拆分回合,Pi 会生成两份摘要并合并它们:

  1. 历史摘要:之前的上下文(如果有)
  2. 回合前缀摘要:拆分回合的前半部分

切割点规则

有效的切割点是:

  • 用户消息
  • 助手消息
  • BashExecution 消息
  • 自定义消息(custom_message、branch_summary)

绝不在工具结果处切割(它们必须与对应的工具调用待在一起)。

CompactionEntry 结构

定义于 session-manager.ts:

interface CompactionEntry<T = unknown> {
  type: "compaction";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  firstKeptEntryId: string;
  tokensBefore: number;
  usage?: Usage;       // LLM usage that generated the summary
  fromHook?: boolean;  // true if provided by extension (legacy field name)
  details?: T;         // implementation-specific data
}

// Default compaction uses this for details (from compaction.ts):
interface CompactionDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

扩展可以在 details 中存储任何可 JSON 序列化的数据。默认压缩会跟踪文件操作,但自定义的扩展实现可以使用自己的结构。生成和扩展提供的摘要会在可用时存储其 LLM usage,使会话总计包含摘要工作。

实现参见 prepareCompaction() 和 compact()。如需直接以编程方式生成摘要,generateSummary() 返回摘要文本,generateSummaryWithUsage() 返回 { text, usage }。

分支摘要

触发时机

当你使用 /tree 导航到另一个分支时,Pi 会提议对你正在离开的工作进行摘要。这会把被离开分支的上下文注入新分支。

工作原理

  1. 寻找共同祖先:旧位置与新位置共享的最深节点
  2. 收集条目:从旧叶子回溯到共同祖先
  3. 在预算内准备:包含不超过 token 预算的消息(优先最新)
  4. 生成摘要:以结构化格式调用 LLM
  5. 追加条目:在导航位置保存 BranchSummaryEntry
Tree before navigation:

         ┌─ B ─ C ─ D (old leaf, being abandoned)
    A ───┤
         └─ E ─ F (target)

Common ancestor: A
Entries to summarize: B, C, D

After navigation with summary:

         ┌─ B ─ C ─ D
    A ───┤
         └─ E ─ F ─ [summary of B,C,D] (new leaf)

累积文件跟踪

压缩和分支摘要都会累积跟踪文件。生成摘要时,pi 会从以下来源提取文件操作:

  • 正在被摘要的消息中的工具调用
  • 之前的压缩或分支摘要 details(如果有)

这意味着文件跟踪会在多次压缩或嵌套的分支摘要之间累积,保留已读取和已修改文件的完整历史。

BranchSummaryEntry 结构

定义于 session-manager.ts:

interface BranchSummaryEntry<T = unknown> {
  type: "branch_summary";
  id: string;
  parentId: string;
  timestamp: number;
  summary: string;
  fromId: string;      // Entry we navigated from
  usage?: Usage;       // LLM usage that generated the summary
  fromHook?: boolean;  // true if provided by extension (legacy field name)
  details?: T;         // implementation-specific data
}

// Default branch summarization uses this for details (from branch-summarization.ts):
interface BranchSummaryDetails {
  readFiles: string[];
  modifiedFiles: string[];
}

与压缩相同,扩展可以在 details 中存储自定义数据。

实现参见 collectEntriesForBranchSummary()、prepareBranchEntries() 和 generateBranchSummary()。

摘要格式

压缩和分支摘要使用相同的结构化格式:

## Goal
[What the user is trying to accomplish]

## Constraints & Preferences
- [Requirements mentioned by user]

## Progress
### Done
- [x] [Completed tasks]

### In Progress
- [ ] [Current work]

### Blocked
- [Issues, if any]

## Key Decisions
- **[Decision]**: [Rationale]

## Next Steps
1. [What should happen next]

## Critical Context
- [Data needed to continue]

<read-files>
path/to/file1.ts
path/to/file2.ts
</read-files>

<modified-files>
path/to/changed.ts
</modified-files>

消息序列化

在摘要之前,消息会通过 serializeConversation() 序列化为文本:

[User]: What they said
[Assistant thinking]: Internal reasoning
[Assistant]: Response text
[Assistant tool calls]: read(path="foo.ts"); edit(path="bar.ts", ...)
[Tool result]: Output from tool

这样可以避免模型将其视为需要继续的对话。

序列化时,工具结果会被截断到 2000 个字符。超出该限制的内容会替换为一个标记,指明被截断的字符数。这样可以让摘要请求保持在合理的 token 预算内,因为工具结果(尤其是来自 read 和 bash 的)通常是上下文大小的最大来源。

通过扩展自定义摘要

扩展可以拦截并自定义压缩和分支摘要。事件类型定义参见 extensions/types.ts。

session_before_compact

在自动压缩或 /compact 之前触发。可以取消操作或提供自定义摘要。参见类型文件中的 SessionBeforeCompactEvent 和 CompactionPreparation。

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation, branchEntries, customInstructions, reason, willRetry, signal } = event;

  // preparation.messagesToSummarize - messages to summarize
  // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
  // preparation.previousSummary - previous compaction summary
  // preparation.fileOps - extracted file operations
  // preparation.tokensBefore - context tokens before compaction
  // preparation.firstKeptEntryId - where kept messages start
  // preparation.settings - effective settings after applying model overrides

  // branchEntries - all entries on current branch (for custom state)
  // reason - "manual" (/compact), "threshold", or "overflow"
  // willRetry - whether the aborted turn is retried after compaction (overflow recovery)
  // signal - AbortSignal (pass to LLM calls)

  // Cancel:
  return { cancel: true };

  // Custom summary:
  return {
    compaction: {
      summary: "Your summary...",
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      // usage: summaryResponse.usage, // Optional; included in session totals
      details: { /* custom data */ },
    }
  };
});

将消息转换为文本

要用你自己的模型生成摘要,请使用 serializeConversation 将消息转换为文本:

import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";

pi.on("session_before_compact", async (event, ctx) => {
  const { preparation } = event;
  
  // Convert AgentMessage[] to Message[], then serialize to text
  const conversationText = serializeConversation(
    convertToLlm(preparation.messagesToSummarize)
  );
  // Returns:
  // [User]: message text
  // [Assistant thinking]: thinking content
  // [Assistant]: response text
  // [Assistant tool calls]: read(path="..."); bash(command="...")
  // [Tool result]: output text

  // Now send to your model for summarization
  const { summary, usage } = await myModel.summarize(conversationText);
  
  return {
    compaction: {
      summary,
      firstKeptEntryId: preparation.firstKeptEntryId,
      tokensBefore: preparation.tokensBefore,
      usage,
    }
  };
});

使用不同模型的完整示例参见 custom-compaction.ts。

session_compact_failed

在手动或自动压缩失败或中止时触发。对于需要将 session_before_compact 尝试与最终结果配对的遥测扩展来说非常有用。

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

session_before_tree

在 /tree 导航之前触发。无论用户是否选择摘要都会触发。可以取消导航或提供自定义摘要。

pi.on("session_before_tree", async (event, ctx) => {
  const { preparation, signal } = event;

  // preparation.targetId - where we're navigating to
  // preparation.oldLeafId - current position (being abandoned)
  // preparation.commonAncestorId - shared ancestor
  // preparation.entriesToSummarize - entries that would be summarized
  // preparation.userWantsSummary - whether user chose to summarize

  // Cancel navigation entirely:
  return { cancel: true };

  // Provide custom summary (only used if userWantsSummary is true):
  if (preparation.userWantsSummary) {
    return {
      summary: {
        summary: "Your summary...",
        // usage: summaryResponse.usage, // Optional; included in session totals
        details: { /* custom data */ },
      }
    };
  }
});

参见类型文件中的 SessionBeforeTreeEvent 和 TreePreparation。

设置

在 ~/.pi/agent/settings.json 或 <project-dir>/.pi/settings.json 中配置压缩:

{
  "compaction": {
    "enabled": true,
    "reserveTokens": 16384,
    "keepRecentTokens": 20000
  }
}
设置默认值说明
enabledtrue启用自动压缩
reserveTokens16384为 LLM 回复保留的 token
keepRecentTokens20000保留的近期 token(不被摘要)

使用 "enabled": false 禁用自动压缩。你仍可以使用 /compact 手动压缩。

每模型覆盖

使用 compaction.modelOverrides 为不同模型调整 token 预算:

{
  "compaction": {
    "reserveTokens": 16384,
    "keepRecentTokens": 20000,
    "modelOverrides": {
      "some-provider/big-model": {
        "reserveTokens": 400000
      }
    }
  }
}

对于上下文窗口为 1M 的模型,这个覆盖会在超过 600K token 时触发压缩,并保留普通的 20000 近期 token。其他模型仍使用普通的 16384 token 保留量。reserveTokens 还会影响摘要输出上限(以模型的最大输出 token 为上限);它并不只是一个触发阈值。

键必须是精确、区分大小写的 provider/modelId 值,包括模型 ID 中的任何斜杠。每个 reserveTokens 和 keepRecentTokens 值都独立回退:从模型覆盖到普通设置,再到内置默认值。值必须是非负的安全整数。匹配的模型覆盖中存在无效值时会在读取时报错;只有省略的字段才回退到普通设置。模型覆盖条目必须是对象。普通 token 设置存在无效值时同样会在读取时报错,即使当前模型有有效的覆盖。只有省略的普通值才使用内置默认值。enabled 始终是全局的,不区分模型。

这些解析后的值用于手动压缩、所有自动阈值检查、溢出恢复,以及扩展可见的 preparation.settings。切换模型会影响后续的检查和压缩,但不会更改普通设置。已在进行中的压缩使用为该操作捕获的模型和设置。分支摘要设置不受影响。

覆盖在全局和项目设置中都可以使用。两个文件在查找之前递归合并,因此全局的模型专属值优先于项目范围的回退值;项目必须覆盖该模型条目才能更改它。详情参见 settings.md。

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 的包结构

12 技能

技能的加载位置、工作原理、SKILL.md 结构、frontmatter 字段与校验规则。

pi 可以创建技能。可以让它为你的用例构建一个。

技能是智能体按需加载的自包含能力包。一个技能为特定任务提供专门的工作流、安装说明、辅助脚本和参考文档。

Pi 实现了 Agent Skills 标准,对大多数违规情况只给出警告但保持宽容。Pi 允许技能名称与其父目录不同,尽管标准不允许;对于在多个智能体框架之间共享的技能目录来说,那条规则并不理想。

目录

位置

安全提示: 技能可以指示模型执行任何操作,还可能包含会被模型调用的可执行代码。使用前请审查技能内容。

Pi 从以下位置加载技能:

  • 全局:
    • ~/.pi/agent/skills/
    • ~/.agents/skills/
  • 项目(仅在项目受信任之后):
    • .pi/skills/
    • cwd 及祖先目录中的 .agents/skills/(向上直到 git 仓库根目录;不在仓库中时则到文件系统根目录)
  • 包:skills/ 目录或 package.json 中的 pi.skills 条目
  • 设置:skills 数组,元素为文件或目录
  • CLI:--skill <path>(可重复,即使使用 --no-skills 也会叠加加载)

发现规则:

  • 在 ~/.pi/agent/skills/ 和 .pi/skills/ 中,根目录下的 .md 文件只要带有合法的技能 frontmatter 且 description 非空,就会被识别为独立技能
  • 在所有技能位置中,包含 SKILL.md 的目录会被递归发现
  • 在 ~/.agents/skills/ 和项目级 .agents/skills/ 中,根目录下的 .md 文件会被忽略,但分组文件夹中嵌套的 .md 文件在声明了技能 frontmatter 后会被发现
  • 除 SKILL.md 外,其他看起来不像技能的根级 Markdown 文件会被静默忽略

使用 --no-skills 可以禁用发现(显式指定的 --skill 路径仍会加载)。

使用来自其他智能体框架的技能

要使用来自 Claude Code 或 OpenAI Codex 的技能,把它们的目录加入设置:

{
  "skills": [
    "~/.claude/skills",
    "~/.codex/skills"
  ]
}

对于项目级的 Claude Code 技能,在 .pi/settings.json 中添加:

{
  "skills": ["../.claude/skills"]
}

技能的工作原理

  1. 启动时,pi 扫描技能位置并提取名称和描述
  2. 系统提示词按照规范以 XML 格式包含可用技能
  3. 当任务匹配时,智能体使用 read(在 read 不可用时使用 bash)加载完整的 SKILL.md(模型并不总会这样做;可以用提示词或 /skill:name 强制加载)
  4. 智能体遵循其中的指令,使用相对路径引用脚本和资源

这就是渐进式披露:上下文中始终只有描述,完整指令按需加载。

技能命令

技能会注册为 /skill:name 命令:

/skill:brave-search           # Load and execute the skill
/skill:pdf-tools extract      # Load skill with arguments

命令之后的参数会以 User: <args> 的形式附加到技能内容后面。

在交互模式下通过 /settings,或在 settings.json 中切换技能命令:

{
  "enableSkillCommands": true
}

技能结构

一个技能就是一个包含 SKILL.md 文件的目录,其余内容完全自由。

my-skill/
├── SKILL.md              # Required: frontmatter + instructions
├── scripts/              # Helper scripts
│   └── process.sh
├── references/           # Detailed docs loaded on-demand
│   └── api-reference.md
└── assets/
    └── template.json

SKILL.md 格式

---
name: my-skill
description: What this skill does and when to use it. Be specific.
---

# My Skill

## Setup

Run once before first use:
```bash
cd /path/to/skill && npm install
```

## Usage

```bash
./scripts/process.sh <input>
```

使用技能目录中的相对路径:

详情请参阅参考指南。

Frontmatter

依据 Agent Skills 规范:

字段必填说明
name是最多 64 个字符。仅限小写 a-z、0-9 和连字符。与标准不同,Pi 不要求它与父目录同名,因为该标准要求对共享技能目录而言并不理想。
description是最多 1024 个字符。说明技能做什么以及何时使用。
license否许可证名称,或指向包内许可证文件的引用。
compatibility否最多 500 个字符。环境要求。
metadata否任意键值映射。
allowed-tools否以空格分隔的预批准工具列表(实验性)。
disable-model-invocation否为 true 时,技能不会出现在系统提示词中。用户必须使用 /skill:name。

名称规则

  • 1-64 个字符
  • 仅限小写字母、数字和连字符
  • 不能以连字符开头或结尾
  • 不能有连续的连字符

Pi 不要求名称与父目录一致。Agent Skills 标准有此要求,但对被多个工具共享的技能目录来说,这条要求并不理想。

有效:pdf-processing、data-analysis、code-review 无效:PDF-Processing、-pdf、pdf--processing

描述的最佳实践

描述决定了智能体何时加载技能,请写得具体。

好的示例:

description: Extracts text and tables from PDF files, fills PDF forms, and merges multiple PDFs. Use when working with PDF documents.

差的示例:

description: Helps with PDFs.

校验

Pi 会按照 Agent Skills 标准校验技能。大多数问题只产生警告,技能仍会加载:

  • 名称超过 64 个字符或包含非法字符
  • 名称以连字符开头/结尾,或存在连续连字符
  • 描述超过 1024 个字符

未知的 frontmatter 字段会被忽略。

声明为技能但缺少描述的不会被加载。格式错误的 SKILL.md 文件以及没有描述的 SKILL.md 文件会产生警告且不会被加载。其他不带合法技能 frontmatter 的 Markdown 文件会被忽略。

名称冲突(不同位置出现同名)会产生警告,并保留最先找到的技能。

示例

brave-search/
├── SKILL.md
├── search.js
└── content.js

SKILL.md:

---
name: brave-search
description: Web search and content extraction via Brave Search API. Use for searching documentation, facts, or any web content.
---

# Brave Search

## Setup

```bash
cd /path/to/brave-search && npm install
```

## Search

```bash
./search.js "query"              # Basic search
./search.js "query" --content    # Include page content
```

## Extract Page Content

```bash
./content.js https://example.com
```

技能仓库

  • Anthropic Skills——文档处理(docx、pdf、pptx、xlsx)、Web 开发
  • Pi Skills——Web 搜索、浏览器自动化、Google API、语音转写

13 提示词模板

用 Markdown 片段定义可通过 /name 调用的提示词模板,支持参数与默认值。

pi 可以创建提示词模板。让它为你的工作流构建一个吧。

提示词模板是可以展开为完整提示词的 Markdown 片段。在编辑器中输入 /name 即可调用模板,其中 name 是不含 .md 扩展名的文件名。

存放位置

Pi 从以下位置加载提示词模板:

  • 全局:~/.pi/agent/prompts/*.md
  • 项目:.pi/prompts/*.md(仅在项目受信任后生效)
  • 包:prompts/ 目录或 package.json 中的 pi.prompts 条目
  • 设置:prompts 数组,包含文件或目录
  • CLI:--prompt-template <path>(可重复指定)

使用 --no-prompt-templates 可禁用模板自动发现。

格式

---
description: Review staged git changes
---
Review the staged changes (`git diff --cached`). Focus on:
- Bugs and logic errors
- Security issues
- Error handling gaps
  • 文件名即为命令名。review.md 对应 /review。
  • description 是可选的。如果缺失,则使用第一个非空行。
  • argument-hint 也是可选的。设置后,该提示会显示在自动补全下拉列表中描述的前面。

参数提示

在 frontmatter 中使用 argument-hint 可以在自动补全中显示预期的参数。必选参数使用 <angle brackets> 表示,可选参数使用 [square brackets] 表示:

---
description: Review PRs from URLs with structured issue and code analysis
argument-hint: "<PR-URL>"
---

在自动补全下拉列表中的渲染效果如下:

→ pr   <PR-URL>       — Review PRs from URLs with structured issue and code analysis
  is   <issue>        — Analyze GitHub issues (bugs or feature requests)
  wr   [instructions] — Finish the current task end-to-end
  cl   — Audit changelog entries before release

用法

在编辑器中输入 / 加模板名。自动补全会显示可用模板及其描述。

/review                           # Expands review.md
/component Button                 # Expands with argument
/component Button "click handler" # Multiple arguments

参数

模板支持位置参数、默认值和简单的切片操作:

  • $1、$2……位置参数
  • $@ 或 $ARGUMENTS 表示所有参数拼接后的内容
  • ${1:-default} 在第 1 个参数存在且非空时使用该参数,否则使用 default
  • ${@:-default} 或 ${ARGUMENTS:-default} 在参数存在且非空时使用全部参数,否则使用 default
  • ${@:N} 表示从第 N 个位置开始的参数(从 1 计数)
  • ${@:N:L} 表示从第 N 个开始的 L 个参数

示例:

---
description: Create a component
---
Create a React component named $1 with features: $@

默认值对可选参数很有用:

Summarize the current state in ${1:-7} bullet points.

用法:/component Button "onClick handler" "disabled support"

加载规则

  • 在 prompts/ 目录中发现模板的过程是非递归的。
  • 如果要把模板放在子目录中,请通过 prompts 设置或包清单显式添加。

14 主题

用 JSON 文件定义 pi TUI 的配色:主题格式、颜色令牌、取值方式与创建技巧。

pi 可以创建主题。让它为你的环境构建一个吧。

主题是定义 TUI 颜色的 JSON 文件。

目录

加载位置

Pi 从以下位置加载主题:

  • 内置:dark、light
  • 全局:~/.pi/agent/themes/*.json
  • 项目:.pi/themes/*.json(仅在项目被信任后)
  • 包:themes/ 目录或 package.json 中的 pi.themes 条目
  • 设置:包含文件或目录的 themes 数组
  • CLI:--theme <path>(可重复使用)

使用 --no-themes 禁用发现。

选择主题

通过 /settings 或在 settings.json 中选择主题:

{
  "theme": "my-theme"
}

首次运行时,pi 会检测你的终端背景,并默认使用 dark 或 light。

初始主题

以某个主题启动交互式运行,而不更改已保存的设置:

pi --use-theme light

若要跟随终端外观,请使用 lightTheme/darkTheme 语法:

pi --use-theme light/dark

CLI 传入的值是本次运行的初始主题。之后在 /settings 中选择其他主题会立即生效,并照常保存。

创建自定义主题

  1. 创建主题文件:
mkdir -p ~/.pi/agent/themes
vim ~/.pi/agent/themes/my-theme.json
  1. 定义包含所有必需颜色的主题(参见颜色令牌):
{
  "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
  "name": "my-theme",
  "vars": {
    "primary": "#00aaff",
    "secondary": 242
  },
  "colors": {
    "accent": "primary",
    "border": "primary",
    "borderAccent": "#00ffff",
    "borderMuted": "secondary",
    "success": "#00ff00",
    "error": "#ff0000",
    "warning": "#ffff00",
    "muted": "secondary",
    "dim": 240,
    "text": "",
    "thinkingText": "secondary",
    "selectedBg": "#2d2d30",
    "scrollbarTrack": "secondary",
    "scrollbarThumb": "",
    "searchMatchBg": "#2d2d30",
    "searchMatchText": "",
    "userMessageBg": "#2d2d30",
    "userMessageText": "",
    "customMessageBg": "#2d2d30",
    "customMessageText": "",
    "customMessageLabel": "primary",
    "toolPendingBg": "#1e1e2e",
    "toolSuccessBg": "#1e2e1e",
    "toolErrorBg": "#2e1e1e",
    "toolTitle": "primary",
    "toolOutput": "",
    "mdHeading": "#ffaa00",
    "mdLink": "primary",
    "mdLinkUrl": "secondary",
    "mdCode": "#00ffff",
    "mdCodeBlock": "",
    "mdCodeBlockBorder": "secondary",
    "mdQuote": "secondary",
    "mdQuoteBorder": "secondary",
    "mdHr": "secondary",
    "mdListBullet": "#00ffff",
    "toolDiffAdded": "#00ff00",
    "toolDiffRemoved": "#ff0000",
    "toolDiffContext": "secondary",
    "syntaxComment": "secondary",
    "syntaxKeyword": "primary",
    "syntaxFunction": "#00aaff",
    "syntaxVariable": "#ffaa00",
    "syntaxString": "#00ff00",
    "syntaxNumber": "#ff00ff",
    "syntaxType": "#00aaff",
    "syntaxOperator": "primary",
    "syntaxPunctuation": "secondary",
    "thinkingOff": "secondary",
    "thinkingMinimal": "primary",
    "thinkingLow": "#00aaff",
    "thinkingMedium": "#00ffff",
    "thinkingHigh": "#ff00ff",
    "thinkingXhigh": "#ff0000",
    "thinkingMax": "#ff0088",
    "bashMode": "#ffaa00"
  }
}
  1. 通过 /settings 选择主题。

热重载: 当你编辑当前启用的自定义主题文件时,pi 会自动重新加载它,以获得即时的视觉反馈。

主题格式

{
  "$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
  "name": "my-theme",
  "vars": {
    "blue": "#0066cc",
    "gray": 242
  },
  "colors": {
    "accent": "blue",
    "muted": "gray",
    "text": "",
    ...
  }
}
  • name 是必需的,必须唯一,且不能包含 /。
  • vars 是可选的。在这里定义可复用的颜色,然后在 colors 中引用它们。
  • colors 必须定义全部 53 个必需令牌。thinkingMax 和两个搜索高亮令牌是可选的,使用下面列出的回退值。

$schema 字段可以启用编辑器自动补全和验证。

颜色令牌

每个主题都必须定义全部 53 个必需的颜色令牌。可选令牌用于保持与现有主题的兼容:thinkingMax 回退到 thinkingXhigh,searchMatchBg 回退到 selectedBg,searchMatchText 回退到 text。其他搜索匹配项在 searchMatchBg 背景上使用带下划线的 searchMatchText;当前匹配项则反转该前景/背景对并使用粗体文本。

核心 UI(13 色)

令牌用途
accent主强调色(徽标、选中项、光标)
border普通边框
borderAccent高亮边框
borderMuted弱化边框(编辑器)
success成功状态
error错误状态
warning警告状态
muted次要文本
dim三级文本
text默认文本(通常为 "")
thinkingText思考块文本
scrollbarTrack全屏滚动条轨道前景色
scrollbarThumb全屏滚动条滑块前景色,普通和展开状态共用

背景与内容(11 个必需,2 个可选)

令牌用途
selectedBg选中行背景
searchMatchBg对话记录搜索匹配背景及当前匹配文本;可选,回退到 selectedBg
searchMatchText对话记录搜索匹配文本及当前匹配背景;可选,回退到 text
userMessageBg用户消息背景
userMessageText用户消息文本
customMessageBg扩展消息背景
customMessageText扩展消息文本
customMessageLabel扩展消息标签
toolPendingBg工具框(等待中)
toolSuccessBg工具框(成功)
toolErrorBg工具框(错误)
toolTitle工具标题
toolOutput工具输出文本

Markdown(10 色)

令牌用途
mdHeading标题
mdLink链接文本
mdLinkUrl链接 URL
mdCode行内代码
mdCodeBlock代码块内容
mdCodeBlockBorder代码块围栏
mdQuote引用块文本
mdQuoteBorder引用块边框
mdHr水平分割线
mdListBullet列表符号

工具 Diff(3 色)

令牌用途
toolDiffAdded新增行
toolDiffRemoved删除行
toolDiffContext上下文行

语法高亮(9 色)

令牌用途
syntaxComment注释
syntaxKeyword关键字
syntaxFunction函数名
syntaxVariable变量
syntaxString字符串
syntaxNumber数字
syntaxType类型
syntaxOperator运算符
syntaxPunctuation标点符号

思考级别边框(6 个必需,1 个可选)

指示思考级别的编辑器边框颜色(视觉层级从弱到强):

令牌用途
thinkingOff关闭思考
thinkingMinimal极少思考
thinkingLow低思考
thinkingMedium中等思考
thinkingHigh高思考
thinkingXhigh超高思考
thinkingMax最高思考;可选,回退到 thinkingXhigh

Bash 模式(1 色)

令牌用途
bashModebash 模式下的编辑器边框(! 前缀)

HTML 导出(可选)

export 部分控制 /export HTML 输出的颜色。如果省略,颜色将从 userMessageBg 派生。

{
  "export": {
    "pageBg": "#18181e",
    "cardBg": "#1e1e24",
    "infoBg": "#3c3728"
  }
}

颜色取值

支持四种格式:

格式示例说明
十六进制"#ff0000"6 位十六进制 RGB
256 色39xterm 256 色调色板索引(0-255)
变量"primary"对 vars 条目的引用
默认""终端默认颜色

256 色调色板

  • 0-15:基本 ANSI 颜色(因终端而异)
  • 16-231:6×6×6 RGB 立方体(16 + 36×R + 6×G + B,其中 R、G、B 为 0-5)
  • 232-255:灰度渐变

终端兼容性

Pi 使用 24 位 RGB 颜色。大多数现代终端都支持(iTerm2、Kitty、WezTerm、Windows Terminal、VS Code)。对于只支持 256 色的旧终端,pi 会回退到最接近的近似值。

检查真彩色支持:

echo $COLORTERM  # Should output "truecolor" or "24bit"

技巧

深色终端: 使用明亮、饱和度高、对比更强的颜色。

浅色终端: 使用更暗、更柔和、对比更低的颜色。

颜色和谐: 从一个基础调色板(Nord、Gruvbox、Tokyo Night)开始,在 vars 中定义它,并保持一致的引用。

测试: 用不同的消息类型、工具状态、Markdown 内容和长换行文本检查你的主题。

VS Code: 将 terminal.integrated.minimumContrastRatio 设为 1 以获得准确的颜色。

示例

参见内置主题:

15 Pi 包

pi 包的安装管理、三种来源、创建方法、目录结构、依赖与过滤配置。

pi 可以帮你创建 pi 包。可以让它把你的扩展、技能、提示词模板或主题打包。

Pi 包把扩展、技能、提示词模板和主题打包在一起,让你可以通过 npm 或 git 分享它们。一个包可以在 package.json 的 pi 键下声明资源,也可以使用约定目录。

目录

安装与管理

安全提示: Pi 包以完整的系统权限运行。扩展会执行任意代码,技能可以指示模型执行任何操作,包括运行可执行文件。安装第三方包之前,请先审查其源代码。

pi install npm:@foo/bar@1.0.0
pi install git:github.com/user/repo@v1
pi install https://github.com/user/repo  # raw URLs work too
pi install /absolute/path/to/package
pi install ./relative/path/to/package

pi remove npm:@foo/bar
pi list                     # show installed packages from settings
pi update                   # update pi only
pi update --all             # update pi, update packages, and reconcile pinned git refs
pi update --extensions      # update packages and reconcile pinned git refs only
pi update --models          # refresh model catalogs only
pi update --self            # update pi only
pi update --self --force    # reinstall pi even if current
pi update npm:@foo/bar      # update one package
pi update --extension npm:@foo/bar

这些命令管理 pi 包,其中 pi update 还能更新 pi CLI 本身。对于实验性的安装器托管安装,pi update 会把确切的已检出版本安装到一个分阶段的、由锁文件支撑的发布中,并且只有在验证通过后才激活它;更新失败时当前发布保持不变。托管安装不支持 --force;要修复请重新运行安装器。要卸载 pi 本身,请参阅快速开始。

默认情况下,install 和 remove 写入用户设置(~/.pi/agent/settings.json)。使用 -l 可以改为写入项目设置(.pi/settings.json)。项目设置可以与团队共享;项目受信任之后,pi 会在启动时自动安装缺失的包。

要临时试用一个包而不正式安装,可以使用 --extension 或 -e。这只会在本次运行中安装到临时目录:

pi -e npm:@foo/bar
pi -e git:github.com/user/repo

包来源

Pi 在设置和 pi install 中接受三种来源类型。

npm

npm:@scope/pkg@1.2.3
npm:pkg
  • 带版本号的规格会被固定,包更新(pi update --extensions、pi update --all)会跳过它们。
  • 用户级安装位于 ~/.pi/agent/npm/ 之下。
  • 项目级安装位于 .pi/npm/ 之下。
  • 在 settings.json 中设置 npmCommand,可以把 npm 包查找和安装操作固定到某个特定的包装命令,例如 mise 或 asdf。

示例:

{
  "npmCommand": ["mise", "exec", "node@20", "--", "npm"]
}

git

git:github.com/user/repo@v1
git:git@github.com:user/repo@v1
https://github.com/user/repo@v1
ssh://git@github.com/user/repo@v1
  • 不带 git: 前缀时,只接受带协议的 URL(https://、http://、ssh://、git://)。
  • 带 git: 前缀时,接受简写格式,包括 github.com/user/repo 和 git@github.com:user/repo。
  • HTTPS 和 SSH URL 都受支持。
  • SSH URL 会自动使用你配置的 SSH 密钥(遵循 ~/.ssh/config)。
  • 在非交互运行(例如 CI)中,可以设置 GIT_TERMINAL_PROMPT=0 来禁用凭据提示,并设置 GIT_SSH_COMMAND(例如 ssh -o BatchMode=yes -o ConnectTimeout=5)以便快速失败。
  • ref 是固定的 tag 或 commit。pi update --extensions 和 pi update --all 不会把它们移动到更新的 ref,但会把已有克隆对账到配置的 ref。
  • 使用 pi install git:host/user/repo@new-ref 可以更新设置,并把已有包移动到新的固定 ref。
  • 克隆到 ~/.pi/agent/git/<host>/<path>(全局)或 .pi/git/<host>/<path>(项目)。
  • 当对账改变了检出内容时,pi 会重置并清理克隆,然后在存在 package.json 时运行 npm install。

SSH 示例:

# git@host:path shorthand (requires git: prefix)
pi install git:git@github.com:user/repo

# ssh:// protocol format
pi install ssh://git@github.com/user/repo

# With version ref
pi install git:git@github.com:user/repo@v1.0.0

本地路径

/absolute/path/to/package
./relative/path/to/package

本地路径指向磁盘上的文件或目录,会被加入设置而不做复制。相对路径相对于其所在的设置文件解析。如果路径是文件,则作为单个扩展加载;如果是目录,pi 会按照包规则加载其中的资源。

创建 Pi 包

在 package.json 中添加 pi 清单,或使用约定目录。加上 pi-package 关键字便于被发现。

{
  "name": "my-package",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions"],
    "skills": ["./skills"],
    "prompts": ["./prompts"],
    "themes": ["./themes"]
  }
}

路径相对于包根目录。数组支持 glob 模式和 ! 排除项。清单中的正向 glob 会按字典序发现可见路径。以点开头的路径需要直接列出。如果某个 glob 需要穿越符号链接继续匹配,请直接列出该符号链接指向的资源根目录。

包展示页会展示带有 pi-package 标签的包。添加 video 或 image 字段可以显示预览:

{
  "name": "my-package",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions"],
    "video": "https://example.com/demo.mp4",
    "image": "https://example.com/screenshot.png"
  }
}
  • video:仅限 MP4。桌面端悬停时自动播放,点击后打开全屏播放器。
  • image:PNG、JPEG、GIF 或 WebP。以静态预览的形式展示。

两者同时设置时,video 优先。

包结构

约定目录

如果没有 pi 清单,pi 会从这些目录自动发现资源:

  • extensions/ 加载 .ts 和 .js 文件
  • skills/ 递归查找 SKILL.md 文件夹,并把顶层的 .md 文件作为技能加载
  • prompts/ 加载 .md 文件
  • themes/ 加载 .json 文件

依赖

第三方运行时依赖应放在 package.json 的 dependencies 中。不注册扩展、技能、提示词模板或主题的依赖也属于 dependencies。当 pi 从 npm 或 git 安装一个包时,会运行 npm install,因此这些依赖会被自动安装。

Pi 为扩展和技能打包了核心包。如果你导入了其中任何一个,请把它们列在 peerDependencies 中并使用 "*" 范围,而且不要打包它们:@earendil-works/pi-ai、@earendil-works/pi-agent-core、@earendil-works/pi-coding-agent、@earendil-works/pi-tui、typebox。

其他 pi 包必须打包进你的 tarball。把它们加入 dependencies 和 bundledDependencies,然后通过 node_modules/ 路径引用它们的资源。Pi 加载包时使用各自独立的模块根,因此分开安装的包不会冲突,也不会共享模块。

示例:

{
  "dependencies": {
    "shitty-extensions": "^1.0.1"
  },
  "bundledDependencies": ["shitty-extensions"],
  "pi": {
    "extensions": ["extensions", "node_modules/shitty-extensions/extensions"],
    "skills": ["skills", "node_modules/shitty-extensions/skills"]
  }
}

包过滤

在设置中使用对象形式,可以过滤一个包加载的内容:

{
  "packages": [
    "npm:simple-pkg",
    {
      "source": "npm:my-package",
      "extensions": ["extensions/*.ts", "!extensions/legacy.ts"],
      "skills": [],
      "prompts": ["prompts/review.md"],
      "themes": ["+themes/legacy.json"]
    }
  ]
}

+path 和 -path 是相对于包根目录的精确路径。

  • 省略某个键表示加载该类型的全部资源。
  • 使用 [] 表示不加载该类型的任何资源。
  • !pattern 排除匹配项。
  • +path 强制包含某个精确路径。
  • -path 强制排除某个精确路径。
  • 过滤器叠加在清单之上,只会收窄已经允许的范围。

启用与禁用资源

使用 pi config 可以启用或禁用来自已安装包和本地目录的扩展、技能、提示词模板和主题。pi config 默认从全局设置(~/.pi/agent/settings.json)开始;按 Tab 可以在全局与项目级模式之间切换。使用 pi config -l 可以从项目覆盖(.pi/settings.json)开始,继承自全局的资源会以暗淡方式显示。

作用域与去重

包可以同时出现在全局和项目设置中。如果同一个包同时出现在两处,项目条目优先,除非项目条目设置了 autoload: false,此时它作为对全局条目的增量应用。包的同一性由以下方式确定:

  • npm:包名
  • git:不带 ref 的仓库 URL
  • 本地路径:解析后的绝对路径

16 自定义模型

通过 ~/.pi/agent/models.json 添加自定义提供商与模型,配置兼容性与逐模型覆盖。

通过 ~/.pi/agent/models.json 添加自定义提供商与模型(Ollama、vLLM、LM Studio、代理)。

目录

最小示例

对于本地模型(Ollama、LM Studio、vLLM),每个模型只需提供 id:

{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "models": [
        { "id": "llama3.1:8b" },
        { "id": "qwen2.5-coder:7b" }
      ]
    }
  }
}

apiKey 的值只是一个占位符,因为 Ollama 会忽略它。pi 仍要求模型在通过认证之后才会出现在 /model 中,因此不带密钥的本地服务器应保留一个虚拟值,用 /login 为该提供商保存一个密钥,或在选择模型时传入 --api-key。

一些 OpenAI 兼容服务器无法理解推理模型所用的 developer 角色。对于这类提供商,将 compat.supportsDeveloperRole 设为 false,pi 便会改用 system 消息来发送系统提示词。如果服务器同样不支持 reasoning_effort,请将 compat.supportsReasoningEffort 也设为 false。

compat 可以设在提供商级别,作用于所有模型;也可以设在模型级别,为特定模型做覆盖。这常见于 Ollama、vLLM、SGLang 以及类似的 OpenAI 兼容服务器。

{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "compat": {
        "supportsDeveloperRole": false,
        "supportsReasoningEffort": false
      },
      "models": [
        {
          "id": "gpt-oss:20b",
          "reasoning": true
        }
      ]
    }
  }
}

完整示例

在需要具体数值时覆盖默认值:

{
  "providers": {
    "ollama": {
      "baseUrl": "http://localhost:11434/v1",
      "api": "openai-completions",
      "apiKey": "ollama",
      "models": [
        {
          "id": "llama3.1:8b",
          "name": "Llama 3.1 8B (Local)",
          "reasoning": false,
          "input": ["text"],
          "contextWindow": 128000,
          "maxTokens": 32000,
          "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }
        }
      ]
    }
  }
}

每次打开 /model 时该文件都会重新加载。可以在会话中随时编辑,无需重启。

Google AI Studio 示例

配合 baseUrl 使用 google-generative-ai,即可添加来自 Google AI Studio 的模型,包括自定义的 Gemma 4 条目:

{
  "providers": {
    "my-google": {
      "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
      "api": "google-generative-ai",
      "apiKey": "$GEMINI_API_KEY",
      "models": [
        {
          "id": "gemma-4-31b-it",
          "name": "Gemma 4 31B",
          "input": ["text", "image"],
          "contextWindow": 262144,
          "reasoning": true
        }
      ]
    }
  }
}

为 google-generative-ai API 类型添加自定义模型时,必须提供 baseUrl。

支持的 API

API说明
openai-completionsOpenAI Chat Completions(兼容性最好)
openai-responsesOpenAI Responses API
anthropic-messagesAnthropic Messages API
google-generative-aiGoogle Generative AI

在提供商级别设置 api(作为所有模型的默认值),或在模型级别设置(按模型覆盖)。

提供商配置

字段说明
baseUrlAPI 端点 URL
apiAPI 类型(见上文)
apiKey可选的 API 密钥配置(取值解析见下文)。当认证由 /login/auth.json 或 CLI 的 --api-key 提供时,可将其省略。
oauth动态 OAuth 提供商类型。目前支持 "radius";需要网关的 baseUrl。
headers自定义请求头(取值解析见下文)
authHeader设为 true 时自动添加 Authorization: Bearer <apiKey>
models模型配置数组
modelOverrides针对该提供商的内置模型或扩展注册模型进行逐模型覆盖

对于带 models 的提供商,非内置的提供商配置需要在提供商级别或模型级别提供 baseUrl 和 api 值。加载该文件本身并不要求 apiKey:当认证通过 /login/auth.json、CLI --api-key 或提供商 apiKey 配置好之后,模型即可使用。如果未配置任何认证,模型仍会加载,但在 /model 和 --list-models 中不可用。

取值解析

apiKey 与 headers 字段支持命令执行、环境变量插值和字面量:

  • Shell 命令: 值以 "!command" 开头时,整个值会作为命令执行,并使用其 stdout
    "apiKey": "!security find-generic-password -ws 'anthropic'"
    "apiKey": "!op read 'op://vault/item/credential'"
  • 环境变量插值: "$ENV_VAR" 或 "${ENV_VAR}" 使用指定变量的值。插值也可以在更长的字面量内部使用。
    "apiKey": "$MY_API_KEY"
    "apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}"
    $FOO_BAR 指向变量 FOO_BAR;如果 BAR 是字面文本,应使用 ${FOO}_BAR。环境变量缺失时,该值视为未解析。
  • 转义: "$$" 输出字面量 "$";"$!" 输出字面量 "!",且不会触发命令执行。
    "apiKey": "$$literal-dollar-prefix"
    "apiKey": "$!literal-bang-prefix"
  • 字面量: 直接使用原值。MY_API_KEY 这类纯大写字符串就是字面量;要引用环境变量请写 $MY_API_KEY。
    "apiKey": "sk-..."

对于 models.json,shell 命令在请求时解析。pi 有意不为任意命令应用内置的 TTL、过期复用或恢复逻辑——不同的命令需要不同的缓存与失败策略,pi 无法推断出正确的做法。

如果你的命令较慢、开销大、受速率限制,或希望在偶发失败时沿用先前的值,请把它包装进你自己的脚本或命令中,实现你想要的缓存或 TTL 行为。

/model 的可用性检查只依据已配置的认证信息,不会执行 shell 命令。

自定义请求头

{
  "providers": {
    "custom-proxy": {
      "baseUrl": "https://proxy.example.com/v1",
      "apiKey": "$MY_API_KEY",
      "api": "anthropic-messages",
      "headers": {
        "x-portkey-api-key": "$PORTKEY_API_KEY",
        "x-secret": "!op read 'op://vault/item/secret'"
      },
      "models": [...]
    }
  }
}

模型配置

字段必填默认值说明
id是—模型标识符(传给 API)
name否id人类可读的模型标签。用于模型匹配(--model 的模式),并作为次要模型详情文本显示。
api否提供商的 api为该模型覆盖提供商的 API
reasoning否false支持扩展思考
thinkingLevelMap否省略将 pi 的思考级别映射为提供商取值,并标记不支持的级别(见下文)
input否["text"]输入类型:["text"] 或 ["text", "image"]
contextWindow否128000上下文窗口大小(token 数)
maxTokens否16384最大输出 token 数
samplingParams否省略逐字合并进每个请求体的采样参数(见下文)
cost否全为零每百万 token 费率,可选按请求整体生效的输入定价分层
compat否提供商的 compat提供商兼容性覆盖。两者都设置时会与提供商级别的 compat 合并。

费用分层提供一套完整的替代费率;当总输入用量(input + cacheRead + cacheWrite)超过 inputTokensAbove 时,对整个请求生效。多个分层同时命中时,以阈值最高的为准。

{
  "cost": {
    "input": 5,
    "output": 30,
    "cacheRead": 0.5,
    "cacheWrite": 6.25,
    "tiers": [
      {
        "inputTokensAbove": 272000,
        "input": 10,
        "output": 45,
        "cacheRead": 1,
        "cacheWrite": 12.5
      }
    ]
  }
}

当前行为:

  • /model、--list-models 和交互式页脚均按模型 id 显示条目。
  • 配置的 name 用于模型匹配和次要模型详情文本,不会替换页脚/状态栏中的模型 id。

采样参数

samplingParams 是一个自由格式的对象,会逐字合并进该模型的每个请求体,并且合并在 pi 自身设置的字段之后,因此它的键优先。可用它发送 pi 未建模的采样参数——包括服务器特有的参数,比如 llama.cpp 的 min_p 或 vLLM 的 top_k:

{
  "id": "deepseek-v4-flash",
  "samplingParams": {
    "temperature": 1.0,
    "top_p": 0.95,
    "top_k": 0,
    "min_p": 0.0
  }
}

只有 OpenAI 兼容 API 会应用它(openai-completions、openai-responses、azure-openai-responses);其他 API 会忽略它。其中的键会覆盖 pi 的具名请求字段(例如这里的 temperature 键优先于请求级别的 temperature),因此建议将它作为模型采样参数的唯一事实来源。在 modelOverrides 中,samplingParams 会按键与基础模型的值合并。

固定的思考 token 上限也可以放在这里,但它不会跟随 thinkingBudgets 变化,也不会为回答预留空间。这类需求请优先使用 compat.thinkingTokenBudgetField(或其别名 supportsThinkingTokenBudget)。

思考级别映射

在模型上使用 thinkingLevelMap 来描述该模型特有的思考控制。键是 pi 的思考级别:off、minimal、low、medium、high、xhigh、max。映射可以留有空档,例如某模型可以提供 high 和 max,而不提供 xhigh。

取值有三态:

取值含义
省略high 及以下的标准级别使用提供商的默认映射;扩展的 xhigh 和 max 级别视为不支持
string该级别受支持,此值会发送给提供商
null该级别不受支持,会被隐藏/跳过/钳制掉

仅支持 off、high 和 max 推理的模型示例:

{
  "id": "deepseek-v4-pro",
  "reasoning": true,
  "thinkingLevelMap": {
    "minimal": null,
    "low": null,
    "medium": null,
    "high": "high",
    "xhigh": null,
    "max": "max"
  }
}

思考无法被禁用的模型示例:

{
  "id": "always-thinking-model",
  "reasoning": true,
  "thinkingLevelMap": {
    "off": null
  }
}

迁移:旧配置中使用的 compat.reasoningEffortMap 应迁移到模型级别的 thinkingLevelMap。对不应出现在 UI 中的级别使用 null。

覆盖内置提供商

将内置提供商的请求路由到代理,而无需重新定义模型:

{
  "providers": {
    "anthropic": {
      "baseUrl": "https://my-proxy.example.com/v1"
    }
  }
}

所有内置 Anthropic 模型仍然可用。已有的 OAuth 或 API 密钥认证继续有效。

要将自定义模型合并进内置提供商,请加上 models 数组:

{
  "providers": {
    "anthropic": {
      "baseUrl": "https://my-proxy.example.com/v1",
      "apiKey": "$ANTHROPIC_API_KEY",
      "api": "anthropic-messages",
      "models": [...]
    }
  }
}

合并语义:

  • 保留内置模型。
  • 自定义模型按 id 在该提供商内执行 upsert。
  • 如果自定义模型的 id 与某个内置模型相同,该自定义模型会替换对应的内置模型。
  • 如果自定义模型的 id 是新的,会与内置模型并列添加。

按模型覆盖

使用 modelOverrides 定制内置模型以及匹配的扩展注册模型,而无需替换该提供商的完整模型列表。

{
  "providers": {
    "openrouter": {
      "modelOverrides": {
        "anthropic/claude-sonnet-4": {
          "name": "Claude Sonnet 4 (Bedrock Route)",
          "compat": {
            "openRouterRouting": {
              "only": ["amazon-bedrock"]
            }
          }
        }
      }
    }
  }
}

modelOverrides 支持按模型设置以下字段:name、reasoning、thinkingLevelMap、input、cost(部分)、contextWindow、maxTokens、samplingParams(按键合并)、headers、compat。

OpenAI 直连的 GPT-5.6 Sol、Terra 和 Luna 默认使用 272000 上下文窗口,以便请求保持在 OpenAI 的短上下文定价档内。要启用 OpenAI 的 1.05M 上下文窗口,请为你使用的每个模型调大该值:

{
  "providers": {
    "openai": {
      "modelOverrides": {
        "gpt-5.6-sol": {
          "contextWindow": 1050000
        }
      }
    }
  }
}

该覆盖会保留内置的定价元数据。总输入 token 超过 272K 的请求,将按 GPT-5.6 的长上下文费率对整个请求计费。需要时可对 gpt-5.6-terra 或 gpt-5.6-luna 应用相同的覆盖。

行为说明:

  • modelOverrides 应用于内置提供商模型以及匹配的扩展注册提供商模型。
  • 未知的模型 ID 会被忽略。
  • 可以将提供商级别的 baseUrl/headers 与 modelOverrides 组合使用。
  • 覆盖 name 只影响模型匹配和次要详情文本;页脚和主模型列表仍显示模型 id。
  • 如果提供商还定义了 models,自定义模型会在内置覆盖之后合并。id 相同的自定义模型会替换已被覆盖的内置模型条目。

Anthropic Messages 兼容性

对于使用 api: "anthropic-messages" 的提供商或代理,可用 compat 控制 Anthropic 特有的请求兼容性。

默认情况下,pi 会为每个工具发送 eager_input_streaming: true。如果代理或 Anthropic 兼容后端拒绝该字段,请将 supportsEagerToolInputStreaming 设为 false。这样 pi 会省略 tools[].eager_input_streaming,并在带工具的请求中改用旧版 fine-grained-tool-streaming-2025-05-14 beta 头。

某些 Anthropic 模型需要自适应思考(thinking.type: "adaptive" 加 output_config.effort),而不是传统的基于预算的思考负载。内置模型会自动设置。对于路由到这些模型的自定义提供商或别名,请将 forceAdaptiveThinking 设为 true。

支持逐轮 effort 的 Claude 模型使用 supportsMidConvoEffort。启用后,pi 会持久化每个响应中提供商返回的 effort,在后续请求中重建仅含 effort 的系统消息,并发送带 prefix_mismatch_behavior: "drop_block" 的思考绑定控制,以避免过期的已签名思考前缀导致持续的 400 响应。请只对忠实实现 Anthropic Messages 传输且确切受支持的 Claude 模型启用它;不要为仅模仿 Messages 结构的 API 启用。

某些 Anthropic 兼容提供商会输出签名为空的思考块,并且期望在回放时原样传回。只应对这类提供商将 allowEmptySignature 设为 true;真正的 Anthropic 会拒绝空的思考签名。

内置 Anthropic 模型在模型元数据中启用了 supportsStrictTools。自定义的 Anthropic 兼容模型,在其端点接受严格 JSON schema 工具定义时,必须将它设为 true。

{
  "providers": {
    "anthropic-proxy": {
      "baseUrl": "https://proxy.example.com",
      "api": "anthropic-messages",
      "apiKey": "$ANTHROPIC_PROXY_KEY",
      "compat": {
        "supportsEagerToolInputStreaming": false,
        "supportsLongCacheRetention": true,
        "forceAdaptiveThinking": true,
        "allowEmptySignature": true
      },
      "models": [
        {
          "id": "claude-opus-4-7",
          "reasoning": true,
          "input": ["text", "image"]
        }
      ]
    }
  }
}
字段说明
supportsEagerToolInputStreaming提供商是否接受逐工具的 eager_input_streaming。默认:true。设为 false 可省略该字段,并在带工具的请求中使用旧版细粒度工具流式 beta 头。
supportsLongCacheRetention当缓存保留策略为 long 时,提供商是否接受 Anthropic 长缓存保留(cache_control.ttl: "1h")。默认:true。
sendSessionAffinityHeaders启用缓存时,是否根据会话 id 发送 x-session-affinity。默认:对已知提供商自动检测。
supportsCacheControlOnTools提供商是否接受工具定义上的 Anthropic 风格 cache_control 标记。默认:true。
forceAdaptiveThinking是否为该模型发送自适应思考(thinking.type: "adaptive" 加 output_config.effort)。内置自适应模型会自动设置。默认:false。
supportsMidConvoEffort确切的 Claude 模型传输是否支持逐轮 effort 系统消息与思考绑定控制。启用后,pi 会持久化原生 effort 级别并始终发送 drop_block。默认:false。
allowEmptySignature回放时是否将空思考签名保持为 signature: "",而不是把思考转换为文本。默认:false。
supportsStrictTools提供商是否接受严格 JSON schema 工具定义。默认:false;内置 Anthropic 模型在生成的元数据中启用了它。

OpenAI 兼容性

对于只部分兼容 OpenAI 的提供商,使用 compat 字段。

  • 提供商级别的 compat 为该提供商下的所有模型提供默认值。
  • 模型级别的 compat 为该模型覆盖提供商级别的值。
{
  "providers": {
    "local-llm": {
      "baseUrl": "http://localhost:8080/v1",
      "api": "openai-completions",
      "compat": {
        "supportsUsageInStreaming": false,
        "maxTokensField": "max_tokens"
      },
      "models": [...]
    }
  }
}
字段说明
supportsStore提供商支持 store 字段
supportsDeveloperRole使用 developer 还是 system 角色
supportsReasoningEffort是否支持 reasoning_effort 参数
supportsUsageInStreaming是否支持 stream_options: { include_usage: true }(默认:true)
supportsFinishReason流式响应是否包含 finish_reason。为 false 时,pi 会在流结束时推断 stop 或 toolUse。默认:true。
maxTokensField使用 max_completion_tokens 还是 max_tokens
requiresToolResultName在工具结果消息中包含 name
requiresAssistantAfterToolResult在工具结果之后的用户消息前插入一条 assistant 消息
requiresThinkingAsText将思考块转换为纯文本
requiresReasoningContentOnAssistantMessages启用推理时,在所有回放的 assistant 消息上包含空的 reasoning_content
thinkingFormat使用 reasoning_effort、openrouter、deepseek、together、baseten、zai、qwen、chat-template 或 qwen-chat-template 思考参数
chatTemplateKwargsthinkingFormat: "chat-template" 时的 chat_template_kwargs 取值;使用 { "$var": "thinking.enabled" }、{ "$var": "thinking.effort" } 或 { "$var": "thinking.budget" } 表示由 pi 控制的思考取值
chatTemplateArgsthinkingFormat: "baseten" 时的 chat_template_args 取值;使用 { "$var": "thinking.enabled" }、{ "$var": "thinking.effort" } 或 { "$var": "thinking.budget" } 表示由 pi 控制的思考取值
thinkingTokenBudgetField用于根据 thinkingBudgets 限制推理 token 的顶层请求字段,并会钳制以确保至少为回答保留 1024 个 token。"thinking_token_budget"(vLLM)、"thinking_budget"(Qwen/DashScope/SGLang)、"thinking_budget_tokens"(llama.cpp)。默认关闭;不会在生成的模型目录上设置。
supportsThinkingTokenBudgetthinkingTokenBudgetField: "thinking_token_budget"(vLLM)的别名。建议优先使用 thinkingTokenBudgetField。默认:false。
cacheControlFormat在系统提示词、最后一个工具定义以及最后一条 user、assistant 或工具结果文本内容上使用 Anthropic 风格的 cache_control 标记。目前仅支持 anthropic。
sendSessionAffinityHeaders对 openai-completions,启用缓存时根据会话 id 发送会话亲和头。默认:false。
sessionAffinityFormat对 openai-completions 和 openai-responses 的会话亲和头格式:openai 发送 session_id/x-client-request-id(completions 还会发送 x-session-affinity);openai-nosession 省略包含下划线的 session_id 头;openrouter 发送 x-session-id。不影响 prompt_cache_key 请求体参数。默认:自动检测。
supportsStrictMode提供商是否接受严格 JSON schema 的函数工具定义。默认值取决于 API;内置 OpenAI 模型带有显式的能力元数据。
supportsOpenAIGrammarToolsOpenAI 兼容 API 是否输出自定义 Lark/正则文法工具。为 false 时,文法约束的工具会退回为普通函数工具。默认:false;内置模型目录为 OpenAI、OpenAI Codex、Azure OpenAI、GitHub Copilot、opencode 和 Cloudflare AI Gateway 上的 GPT-5+ 模型启用了它。
deferredToolsMode使用提供商特有的延迟工具序列化。目前仅支持 "kimi",用于 Kimi 的 OpenAI 兼容 Chat Completions 格式。
supportsLongCacheRetention当缓存保留策略为 long 时,提供商是否接受长缓存保留:GPT-5.6+ Responses 模型使用 prompt_cache_options.ttl: "30m",较早的 OpenAI 模型使用 prompt_cache_retention: "24h",cacheControlFormat 为 anthropic 时使用 cache_control.ttl: "1h"。默认:true。
openRouterRoutingOpenRouter 提供商路由偏好。该对象会原样放入 OpenRouter API 请求的 provider 字段中发送。
vercelGatewayRoutingVercel AI Gateway 用于提供商选择的路由配置(only、order)

openrouter 使用 reasoning: { effort }。together 使用 reasoning: { enabled },并在启用 supportsReasoningEffort 时同时发送 reasoning_effort。qwen 使用顶层的 enable_thinking。对于需要 chat_template_kwargs.enable_thinking 和 preserve_thinking 的本地 Qwen 兼容服务器,使用 qwen-chat-template。对于需要可配置 chat_template_kwargs 的 vLLM/Hugging Face 聊天模板,使用 chat-template,例如 DeepSeek V3.x 模板可用 chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }。对于通过 chat_template_args 暴露开关控制并可选支持顶层 reasoning_effort 的提供商,将 thinkingFormat: "baseten" 与 chatTemplateArgs 搭配使用。

thinkingTokenBudgetField 与 thinkingFormat 相互独立。不要在生成的 Qwen 模型目录上启用它:这些模型已经发送 reasoning_effort,而 DashScope 会拒绝 thinking_budget 与 reasoning_effort 同时出现。

cacheControlFormat: "anthropic" 适用于通过文本内容和工具定义上的 cache_control 标记暴露 Anthropic 风格提示词缓存的 OpenAI 兼容提供商。

示例:

{
  "providers": {
    "openrouter": {
      "baseUrl": "https://openrouter.ai/api/v1",
      "apiKey": "$OPENROUTER_API_KEY",
      "api": "openai-completions",
      "models": [
        {
          "id": "openrouter/anthropic/claude-3.5-sonnet",
          "name": "OpenRouter Claude 3.5 Sonnet",
          "compat": {
            "openRouterRouting": {
              "allow_fallbacks": true,
              "require_parameters": false,
              "data_collection": "deny",
              "zdr": true,
              "enforce_distillable_text": false,
              "order": ["anthropic", "amazon-bedrock", "google-vertex"],
              "only": ["anthropic", "amazon-bedrock"],
              "ignore": ["gmicloud", "friendli"],
              "quantizations": ["fp16", "bf16"],
              "sort": {
                "by": "price",
                "partition": "model"
              },
              "max_price": {
                "prompt": 10,
                "completion": 20
              },
              "preferred_min_throughput": {
                "p50": 100,
                "p90": 50
              },
              "preferred_max_latency": {
                "p50": 1,
                "p90": 3,
                "p99": 5
              }
            }
          }
        }
      ]
    }
  }
}

Vercel AI Gateway 示例:

{
  "providers": {
    "vercel-ai-gateway": {
      "baseUrl": "https://ai-gateway.vercel.sh/v1",
      "apiKey": "$AI_GATEWAY_API_KEY",
      "api": "openai-completions",
      "models": [
        {
          "id": "moonshotai/kimi-k2.5",
          "name": "Kimi K2.5 (Fireworks via Vercel)",
          "reasoning": true,
          "input": ["text", "image"],
          "cost": { "input": 0.6, "output": 3, "cacheRead": 0, "cacheWrite": 0 },
          "contextWindow": 262144,
          "maxTokens": 262144,
          "compat": {
            "vercelGatewayRouting": {
              "only": ["fireworks", "novita"],
              "order": ["fireworks", "novita"]
            }
          }
        }
      ]
    }
  }
}

17 自定义提供商

用扩展的 pi.registerProvider() 注册自定义提供商、OAuth 登录与自定义流式 API。

扩展可以通过 pi.registerProvider() 注册自定义模型提供商。它可以实现:

  • 代理 —— 将请求路由到企业代理或 API 网关
  • 自定义端点 —— 使用自托管或私有的模型部署
  • OAuth/SSO —— 为企业级提供商添加认证流程
  • 自定义 API —— 为非标准 LLM API 实现流式传输

示例扩展

请参阅以下完整的提供商示例:

目录

快速参考

扩展既可以注册一个完整的 pi-ai Provider,也可以使用旧版的提供商配置形式。当需要自定义认证、过滤、刷新或流式行为时,建议使用完整的 Provider。pi 会将 models.json 覆盖叠加在已注册的原生提供商之上。

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

export default function (pi: ExtensionAPI) {
  pi.registerProvider(createProvider({
    id: "native-local",
    name: "Native Local",
    baseUrl: "http://localhost:8080/v1",
    auth: {
      apiKey: {
        name: "Local server API key",
        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()
  }));

  // Legacy provider-config form:
  // Override baseUrl for existing provider
  pi.registerProvider("anthropic", {
    baseUrl: "https://proxy.example.com"
  });

  // Register new provider with models
  pi.registerProvider("my-provider", {
    name: "My Provider",
    baseUrl: "https://api.example.com",
    apiKey: "$MY_API_KEY",
    api: "openai-completions",
    models: [
      {
        id: "my-model",
        name: "My Model",
        reasoning: false,
        input: ["text", "image"],
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
        contextWindow: 128000,
        maxTokens: 4096
      }
    ]
  });
}

扩展工厂函数也可以是 async 的。要实现动态模型发现,请在工厂函数中(而不是在 session_start 里)获取并注册模型。pi 会在继续启动之前等待工厂函数完成,因此该提供商在交互式启动期间以及 pi --list-models 中都可用。

覆盖已有提供商

最简单的用例:将已有提供商的请求重定向到代理。

// All Anthropic requests now go through your proxy
pi.registerProvider("anthropic", {
  baseUrl: "https://proxy.example.com"
});

// Add custom headers to OpenAI requests
pi.registerProvider("openai", {
  headers: {
    "X-Custom-Header": "value"
  }
});

// Both baseUrl and headers
pi.registerProvider("google", {
  baseUrl: "https://ai-gateway.corp.com/google",
  headers: {
    "X-Corp-Auth": "$CORP_AUTH_TOKEN"  // env var or literal
  }
});

当只提供 baseUrl 和/或 headers(不含 models)时,该提供商的所有既有模型都会保留,并使用新的端点。

注册新提供商

要添加一个全新的提供商,请在必要配置之外指定 models。

如果模型列表来自远程端点,请使用 async 扩展工厂函数:

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

export default async function (pi: ExtensionAPI) {
  const response = await fetch("http://localhost:1234/v1/models");
  const payload = (await response.json()) as {
    data: Array<{
      id: string;
      name?: string;
      context_window?: number;
      max_tokens?: number;
    }>;
  };

  pi.registerProvider("local-openai", {
    baseUrl: "http://localhost:1234/v1",
    apiKey: "$LOCAL_OPENAI_API_KEY",
    api: "openai-completions",
    models: payload.data.map((model) => ({
      id: model.id,
      name: model.name ?? model.id,
      reasoning: false,
      input: ["text"],
      cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
      contextWindow: model.context_window ?? 128000,
      maxTokens: model.max_tokens ?? 4096,
    })),
  });
}

这会在启动完成之前注册获取到的模型。

pi.registerProvider("my-llm", {
  baseUrl: "https://api.my-llm.com/v1",
  apiKey: "$MY_LLM_API_KEY",  // env var reference
  api: "openai-completions",  // which streaming API to use
  models: [
    {
      id: "my-llm-large",
      name: "My LLM Large",
      reasoning: true,        // supports extended thinking
      input: ["text", "image"],
      cost: {
        input: 3.0,           // $/million tokens
        output: 15.0,
        cacheRead: 0.3,
        cacheWrite: 3.75
      },
      contextWindow: 200000,
      maxTokens: 16384
    }
  ]
});

提供 models 时,它会替换该提供商的所有既有模型。

apiKey 与自定义请求头的取值使用与 models.json 相同的配置值语法:值以 !command 开头时将整个值作为命令执行;$ENV_VAR 和 ${ENV_VAR} 插值环境变量;$$ 输出字面量 $;$! 输出字面量 !。

注销提供商

使用 pi.unregisterProvider(name) 移除之前通过 pi.registerProvider(name, ...) 注册的提供商:

// Register
pi.registerProvider("my-llm", {
  baseUrl: "https://api.my-llm.com/v1",
  apiKey: "$MY_LLM_API_KEY",
  api: "openai-completions",
  models: [
    {
      id: "my-llm-large",
      name: "My LLM Large",
      reasoning: true,
      input: ["text", "image"],
      cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
      contextWindow: 200000,
      maxTokens: 16384
    }
  ]
});

// Later, remove it
pi.unregisterProvider("my-llm");

注销会移除该提供商的动态模型、API 密钥回退、OAuth 提供商注册以及自定义流处理器注册。之前被覆盖的内置模型或提供商行为都会恢复。

在扩展初始加载阶段之后发起的调用会立即生效,因此无需 /reload。

API 类型

api 字段决定使用哪个流式实现:

API适用对象
anthropic-messagesAnthropic Claude API 及其兼容实现
openai-completionsOpenAI Chat Completions API 及其兼容实现
openai-responsesOpenAI Responses API
azure-openai-responsesAzure OpenAI Responses API
openai-codex-responsesOpenAI Codex Responses API
mistral-conversationsMistral 原生 Chat Completions 流式
google-generative-aiGoogle Generative AI API
google-vertexGoogle Vertex AI API
bedrock-converse-streamAmazon Bedrock Converse API

大多数 OpenAI 兼容提供商都可以使用 openai-completions。模型特有的思考级别用模型级别的 thinkingLevelMap 设置,提供商的怪癖用 compat 处理。xhigh 和 max 级别需要显式启用,要求映射条目为非 null 值,且中间可能隔着不支持的空档:

models: [{
  id: "custom-model",
  // ...
  reasoning: true,
  thinkingLevelMap: {              // map pi levels to provider values; null hides unsupported levels
    minimal: null,
    low: null,
    medium: null,
    high: "default",
    xhigh: null,
    max: "max"
  },
  compat: {
    supportsDeveloperRole: false,   // use "system" instead of "developer"
    supportsReasoningEffort: true,
    maxTokensField: "max_tokens",   // instead of "max_completion_tokens"
    requiresToolResultName: true,   // tool results need name field
    thinkingFormat: "qwen",        // top-level enable_thinking: true
    cacheControlFormat: "anthropic" // Anthropic-style cache_control markers
  }
}]

OpenRouter 风格的 reasoning: { effort } 控制使用 openrouter。Together 风格的 reasoning: { enabled } 控制使用 together;配合 supportsReasoningEffort 时,它还会发送 reasoning_effort。读取 chat_template_kwargs.enable_thinking 且需要 preserve_thinking 的本地 Qwen 兼容服务器使用 qwen-chat-template。 对于通过在系统提示词、最后一个工具定义以及最后一条 user、assistant 或工具结果文本内容上添加 cache_control 来暴露 Anthropic 风格提示词缓存的 OpenAI 兼容提供商,使用 cacheControlFormat: "anthropic"。

对于使用 api: "anthropic-messages" 的 Anthropic 兼容提供商,若其上游模型需要自适应思考(thinking.type: "adaptive" 加 output_config.effort),请在相应的模型或提供商上设置 compat.forceAdaptiveThinking: true。内置的自适应 Claude 模型会自动设置。compat.allowEmptySignature: true 只应用于会输出空思考签名并期望回放时传回 signature: "" 的提供商。

迁移说明:Mistral 已从 openai-completions 迁移到 mistral-conversations。 原生 Mistral 模型请使用 mistral-conversations。 如果你有意让 Mistral 兼容/自定义端点走 openai-completions,请按需显式设置 compat 标志。

认证请求头

如果提供商期望 Authorization: Bearer <key> 但并不使用标准 API,请设置 authHeader: true:

pi.registerProvider("custom-api", {
  baseUrl: "https://api.example.com",
  apiKey: "$MY_API_KEY",
  authHeader: true,  // adds Authorization: Bearer header
  api: "openai-completions",
  models: [...]
});

密钥会按请求逐次解析。显式设置的请求 Authorization 头优先于生成的值。

OAuth 支持

添加可与 /login 集成的 OAuth/SSO 认证:

import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";

pi.registerProvider("corporate-ai", {
  baseUrl: "https://ai.corp.com/v1",
  api: "openai-responses",
  models: [...],
  oauth: {
    name: "Corporate AI (SSO)",

    async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
      const method = await callbacks.onSelect({
        message: "Select login method:",
        options: [
          { id: "browser", label: "Browser OAuth" },
          { id: "device", label: "Device code" }
        ]
      });
      if (!method) throw new Error("Login cancelled");

      let code: string;
      if (method === "device") {
        callbacks.onDeviceCode({
          userCode: "ABCD-1234",
          verificationUri: "https://sso.corp.com/device",
          intervalSeconds: 5,
          expiresInSeconds: 900
        });
        code = await pollDeviceCodeUntilComplete();
      } else {
        callbacks.onAuth({ url: "https://sso.corp.com/authorize?..." });
        code = await callbacks.onPrompt({ message: "Enter SSO code:" });
      }

      // Exchange for tokens (your implementation)
      const tokens = await exchangeCodeForTokens(code);

      return {
        refresh: tokens.refreshToken,
        access: tokens.accessToken,
        expires: Date.now() + tokens.expiresIn * 1000
      };
    },

    async refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials> {
      const tokens = await refreshAccessToken(credentials.refresh, signal);
      return {
        refresh: tokens.refreshToken ?? credentials.refresh,
        access: tokens.accessToken,
        expires: Date.now() + tokens.expiresIn * 1000
      };
    },

    getApiKey(credentials: OAuthCredentials): string {
      return credentials.access;
    }
  }
});

注册后,用户即可通过 /login corporate-ai 完成认证。

OAuthLoginCallbacks

callbacks 对象为提供商自己的登录流程提供与 UI 无关的交互方式:

interface OAuthLoginCallbacks {
  // Open URL in browser (for OAuth redirects)
  onAuth(params: { url: string }): void;

  // Show device code (for device authorization flow)
  onDeviceCode(params: {
    userCode: string;
    verificationUri: string;
    intervalSeconds?: number;
    expiresInSeconds?: number;
  }): void;

  // Show transient progress
  onProgress?(message: string): void;

  // Prompt user for input (for manual token entry)
  onPrompt(params: { message: string }): Promise<string>;

  // Show an interactive selector, e.g. to choose browser OAuth vs device code
  onSelect(params: {
    message: string;
    options: { id: string; label: string }[];
  }): Promise<string | undefined>;
}

OAuthCredentials

凭据会持久化到 ~/.pi/agent/auth.json:

interface OAuthCredentials {
  refresh: string;   // Refresh token (for refreshToken())
  access: string;    // Access token (returned by getApiKey())
  expires: number;   // Expiration timestamp in milliseconds
}

自定义流式 API

对于使用非标准 API 的提供商,可实现 streamSimple。编写自己的实现之前,请先研究现有的 API 实现:

参考实现:

流式模式

所有提供商都遵循相同的模式:

import {
  type AssistantMessage,
  type AssistantMessageEventStream,
  type Context,
  type Model,
  type SimpleStreamOptions,
  calculateCost,
  createAssistantMessageEventStream,
} from "@earendil-works/pi-ai";

function streamMyProvider(
  model: Model<any>,
  context: Context,
  options?: SimpleStreamOptions
): AssistantMessageEventStream {
  const stream = createAssistantMessageEventStream();

  (async () => {
    // Initialize output message
    const output: AssistantMessage = {
      role: "assistant",
      content: [],
      api: model.api,
      provider: model.provider,
      model: model.id,
      usage: {
        input: 0,
        output: 0,
        cacheRead: 0,
        cacheWrite: 0,
        totalTokens: 0,
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
      },
      stopReason: "pending",
      timestamp: Date.now(),
    };

    try {
      // Push start event
      stream.push({ type: "start", partial: output });

      // Make API request and process response...
      // Push content events as they arrive and set stopReason from the terminal event.
      if (output.stopReason === "pending") {
        throw new Error("Provider stream ended without a stop reason");
      }
      if (output.stopReason === "error" || output.stopReason === "aborted") {
        throw new Error(output.errorMessage || "An unknown error occurred");
      }

      // Push done event
      stream.push({
        type: "done",
        reason: output.stopReason,
        message: output
      });
      stream.end();
    } catch (error) {
      output.stopReason = options?.signal?.aborted ? "aborted" : "error";
      output.errorMessage = error instanceof Error ? error.message : String(error);
      stream.push({ type: "error", reason: output.stopReason, error: output });
      stream.end();
    }
  })();

  return stream;
}

事件类型

按以下顺序通过 stream.push() 推送事件:

  1. { type: "start", partial: output } - 流开始

  2. 内容事件(可重复,为每个块跟踪 contentIndex):

    • { type: "text_start", contentIndex, partial } - 文本块开始
    • { type: "text_delta", contentIndex, delta, partial } - 文本分片
    • { type: "text_end", contentIndex, content, partial } - 文本块结束
    • { type: "thinking_start", contentIndex, partial } - 思考开始
    • { type: "thinking_delta", contentIndex, delta, partial } - 思考分片
    • { type: "thinking_end", contentIndex, content, partial } - 思考结束
    • { type: "toolcall_start", contentIndex, partial } - 工具调用开始
    • { type: "toolcall_delta", contentIndex, delta, partial } - 工具调用 JSON 分片
    • { type: "toolcall_end", contentIndex, toolCall, partial } - 工具调用结束
  3. { type: "done", reason, message } 或 { type: "error", reason, error } - 流结束

每个事件的 partial 字段包含当前 AssistantMessage 的状态。收到数据时更新 output.content,然后把 output 作为 partial 传入。

内容块

内容块到达时将其添加到 output.content:

// Text block
output.content.push({ type: "text", text: "" });
stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });

// As text arrives
const block = output.content[contentIndex];
if (block.type === "text") {
  block.text += delta;
  stream.push({ type: "text_delta", contentIndex, delta, partial: output });
}

// When block completes
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });

工具调用

工具调用需要累积 JSON 并解析:

// Start tool call
output.content.push({
  type: "toolCall",
  id: toolCallId,
  name: toolName,
  arguments: {}
});
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });

// Accumulate JSON
let partialJson = "";
partialJson += jsonDelta;
try {
  block.arguments = JSON.parse(partialJson);
} catch {}
stream.push({ type: "toolcall_delta", contentIndex, delta: jsonDelta, partial: output });

// Complete
stream.push({
  type: "toolcall_end",
  contentIndex,
  toolCall: { type: "toolCall", id, name, arguments: block.arguments },
  partial: output
});

用量与费用

根据 API 响应更新用量并计算费用:

output.usage.input = response.usage.input_tokens;
output.usage.output = response.usage.output_tokens;
output.usage.cacheRead = response.usage.cache_read_tokens ?? 0;
output.usage.cacheWrite = response.usage.cache_write_tokens ?? 0;
output.usage.totalTokens = output.usage.input + output.usage.output +
                           output.usage.cacheRead + output.usage.cacheWrite;
calculateCost(model, output.usage);

上下文溢出错误

当请求超出模型的上下文窗口时,pi 可以通过对会话进行上下文压缩并重试来自动恢复。只有当 pi 把失败识别为溢出时,该恢复机制才会生效。

检测在定稿的 assistant 消息上进行:

如果你的提供商返回的溢出错误消息 pi 无法识别,请在注册该提供商的同一个扩展中规范化错误。使用 message_end 处理器改写 assistant 消息,使其 errorMessage 以 pi 能识别的短语开头。通用回退值 context_length_exceeded 是最安全的选择。

const MY_PROVIDER_OVERFLOW_PATTERN = /your provider's overflow phrase/i;

export default function (pi: ExtensionAPI) {
  pi.registerProvider("my-provider", { /* ... */ });

  pi.on("message_end", (event, ctx) => {
    const message = event.message;
    if (message.role !== "assistant") return;
    if (message.stopReason !== "error") return;
    if (
      message.provider !== "my-provider" &&
      ctx.model?.provider !== "my-provider"
    )
      return;

    const errorMessage = message.errorMessage ?? "";
    if (errorMessage.includes("context_length_exceeded")) return;
    if (!MY_PROVIDER_OVERFLOW_PATTERN.test(errorMessage)) return;

    return {
      message: {
        ...message,
        errorMessage: `context_length_exceeded: ${errorMessage}`,
      },
    };
  });
}

message_end 在 pi 为自动上下文压缩记录该 assistant 消息之前运行,因此 pi 检查的是改写后的 errorMessage。有了这一处理,pi 将:

  1. 从 errorMessage 检测溢出。
  2. 将失败的 assistant 消息从活跃上下文中丢弃。
  3. 执行上下文压缩。
  4. 重试该请求一次。

请谨慎地对改写加保护条件:

  • 将作用域限定在你的提供商(message.provider 与 ctx.model?.provider),避免影响其他提供商的无关错误。
  • 匹配提供商特有的模式,而不是 pi 的通用溢出模式。如果改写了限流或节流错误(rate limit、too many requests),会错误地触发上下文压缩,而不是走 pi 正常的退避重试路径。
  • 当 errorMessage 已包含 context_length_exceeded 时跳过,以保证处理器的幂等性。

注册

注册你的流式函数:

pi.registerProvider("my-provider", {
  baseUrl: "https://api.example.com",
  apiKey: "$MY_API_KEY",
  api: "my-custom-api",
  models: [...],
  streamSimple: streamMyProvider
});

测试你的实现

使用内置提供商所用的同一套测试来测试你的提供商。从 packages/ai/test/ 复制并改造以下测试文件:

测试用途
stream.test.ts基础流式、文本输出
tokens.test.tstoken 计数与用量
abort.test.tsAbortSignal 处理
empty.test.ts空/最小响应
context-overflow.test.ts上下文窗口限制
image-limits.test.ts图像输入处理
unicode-surrogate.test.tsUnicode 边界情况
tool-call-without-result.test.ts工具调用边界情况
image-tool-result.test.ts工具结果中的图像
total-tokens.test.ts总 token 计算
cross-provider-handoff.test.ts提供商之间的上下文交接

用你的提供商/模型组合运行测试以验证兼容性。

配置参考

interface ProviderConfig {
  /** Display name for the provider in UI such as /login. */
  name?: string;

  /** API endpoint URL. Required when defining models. */
  baseUrl?: string;

  /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */
  apiKey?: string;

  /** API type for streaming. Required at provider or model level when defining models. */
  api?: Api;

  /** Custom streaming implementation for non-standard APIs. */
  streamSimple?: (
    model: Model<Api>,
    context: Context,
    options?: SimpleStreamOptions
  ) => AssistantMessageEventStream;

  /** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */
  headers?: Record<string, string>;

  /** If true, adds Authorization: Bearer header with the resolved API key. */
  authHeader?: boolean;

  /** Models to register. If provided, replaces all existing models for this provider. */
  models?: ProviderModelConfig[];

  /** OAuth provider for /login support. */
  oauth?: {
    name: string;
    login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials>;
    refreshToken(credentials: OAuthCredentials, signal: AbortSignal): Promise<OAuthCredentials>;
    getApiKey(credentials: OAuthCredentials): string;
  };
}

模型定义参考

interface ProviderModelConfig {
  /** Model ID (e.g., "claude-sonnet-4-20250514"). */
  id: string;

  /** Display name (e.g., "Claude 4 Sonnet"). */
  name: string;

  /** API type override for this specific model. */
  api?: Api;

  /** API endpoint URL override for this specific model. */
  baseUrl?: string;

  /** Whether the model supports extended thinking. */
  reasoning: boolean;

  /** Maps pi thinking levels to provider/model-specific values; null marks a level unsupported. */
  thinkingLevelMap?: Partial<Record<"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max", string | null>>;

  /** Supported input types. */
  input: ("text" | "image")[];

  /** Cost per million tokens (for usage tracking). */
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
  };

  /** Maximum context window size in tokens. */
  contextWindow: number;

  /** Maximum output tokens. */
  maxTokens: number;

  /** Custom headers for this specific model. */
  headers?: Record<string, string>;

  /** Compatibility settings for the selected API. */
  compat?: {
    // openai-completions
    supportsStore?: boolean;
    supportsDeveloperRole?: boolean;
    supportsReasoningEffort?: boolean;
    supportsUsageInStreaming?: boolean;
    supportsFinishReason?: boolean;
    supportsStrictMode?: boolean;
    supportsOpenAIGrammarTools?: boolean; // openai-completions/openai-responses; false falls back to normal function tools
    maxTokensField?: "max_completion_tokens" | "max_tokens";
    requiresToolResultName?: boolean;
    requiresAssistantAfterToolResult?: boolean;
    requiresThinkingAsText?: boolean;
    requiresReasoningContentOnAssistantMessages?: boolean;
    thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "baseten" | "zai" | "qwen" | "chat-template" | "qwen-chat-template" | "string-thinking" | "ant-ling";
    chatTemplateKwargs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort" | "thinking.budget"; omitWhenOff?: boolean }>;
    chatTemplateArgs?: Record<string, string | number | boolean | null | { "$var": "thinking.enabled" | "thinking.effort" | "thinking.budget"; omitWhenOff?: boolean }>;
    thinkingTokenBudgetField?: "thinking_token_budget" | "thinking_budget" | "thinking_budget_tokens";
    supportsThinkingTokenBudget?: boolean;
    cacheControlFormat?: "anthropic";
    sessionAffinityFormat?: "openai" | "openai-nosession" | "openrouter";
    sendSessionAffinityHeaders?: boolean;

    // anthropic-messages
    supportsEagerToolInputStreaming?: boolean;
    supportsLongCacheRetention?: boolean;
    sendSessionAffinityHeaders?: boolean;
    supportsCacheControlOnTools?: boolean;
    forceAdaptiveThinking?: boolean;
    allowEmptySignature?: boolean;
    supportsStrictTools?: boolean;
  };
}

openrouter 发送 reasoning: { effort }。deepseek 发送 thinking: { type: "enabled" | "disabled" },启用时还会发送 reasoning_effort。together 发送 reasoning: { enabled },并在启用 supportsReasoningEffort 时同时发送 reasoning_effort。qwen 用于 DashScope 风格的顶层 enable_thinking。读取 chat_template_kwargs.enable_thinking 且需要 preserve_thinking 的本地 Qwen 兼容服务器使用 qwen-chat-template。chat-template 用于可配置的 chat_template_kwargs,例如由 vLLM 提供服务的 DeepSeek V3.x 可用 chatTemplateKwargs: { "thinking": { "$var": "thinking.enabled" } }。当提供商期望在 chat_template_args 下传递开关取值并可选支持顶层 reasoning_effort 时,将 thinkingFormat: "baseten" 与 chatTemplateArgs 搭配使用。 thinkingTokenBudgetField 将按级别钳制的思考预算作为顶层请求字段发送(vLLM 用 thinking_token_budget,Qwen/SGLang 用 thinking_budget,llama.cpp 用 thinking_budget_tokens)。supportsThinkingTokenBudget: true 是 vLLM 字段名的别名。不要在 DashScope 的 Qwen 模型上将它与 reasoning_effort 组合使用。 cacheControlFormat: "anthropic" 会将 Anthropic 风格的 cache_control 标记应用到系统提示词、最后一个工具定义以及最后一条 user、assistant 或工具结果文本内容上。

18 环境变量

pi 读取的配置变量、进程标记,以及注入 shell 工具的会话环境变量。

Pi 通过三种方式使用环境变量:

  • PI_OFFLINE 等变量用于配置 Pi 进程。
  • Pi 会设置进程标记,让子进程能够识别出启动它们的智能体是 Pi。
  • 由 LLM 可调用的 shell 工具执行的命令会收到描述当前会话的 PI_* 变量。

提供商 API 密钥变量在提供商中单独说明。

进程标记

CLI 和 RPC 入口会设置两个进程标记:

  • AI_AGENT=pi 是一个通用标记,让各类工具能够识别 Pi 是启动该进程的智能体。
  • PI_CODING_AGENT=true 是 Pi 专用的标记,让子进程检测到自己运行在 Pi 内部。

子进程会继承这两个标记。它们与会话无关,并且在通过 SDK 嵌入 Pi 时不会自动设置。

Shell 工具会话环境

由 bash 和 powershell 工具执行的命令会收到当前 Pi 会话的状态:

变量说明
PI_SESSION_ID当前会话 ID
PI_SESSION_FILE当前会话 JSONL 文件的绝对路径;临时会话未设置
PI_PROVIDER当前选定的模型提供商
PI_MODEL当前选定的模型 ID
PI_REASONING_LEVEL当前生效的推理级别:off、minimal、low、medium、high、xhigh 或 max

这些值在每条命令启动时解析。因此,切换模型或更改推理级别无需重启 Pi 即可影响下一条 shell 命令。PI_PROVIDER 和 PI_MODEL 标识的是所选的 Pi 模型,而不是路由器内部可能选择的其他上游模型。

当被问到当前运行的是哪个模型或提供商时,应检查这些变量,而不是从系统提示词中推断答案:

printf '%s/%s\n' "$PI_PROVIDER" "$PI_MODEL"
printf 'reasoning=%s session=%s\n' "$PI_REASONING_LEVEL" "$PI_SESSION_ID"

当会话是持久化会话时,可以直接查看会话文件:

if [ -n "$PI_SESSION_FILE" ]; then
  tail -n 1 "$PI_SESSION_FILE"
fi

这些变量会注入到 LLM 可调用的 bash 和 powershell 工具中,但不会注入到用户手动输入的 ! 或 !! 命令中。

自定义 Shell 工具

使用 createBashTool() 或 createPowerShellTool() 创建的工具在注册到 Pi 后,默认会暴露会话环境。注入发生在 spawnHook 之前,因此钩子会在 ctx.env 中收到这些变量:

const bashTool = createBashTool(cwd, {
  spawnHook: (ctx) => ({
    ...ctx,
    env: { ...ctx.env, CI: "1" },
  }),
});

可以独立于 spawn 钩子禁用会话元数据:

const powershellTool = createPowerShellTool(cwd, {
  exposeSessionEnvironment: false,
  spawnHook: (ctx) => ctx,
});

禁用后,Pi 会移除这些变量的继承值,避免嵌套的 Pi 进程暴露过时的父会话元数据。

Pi 进程配置

以下变量由 Pi 自身读取:

变量说明
PI_CODING_AGENT_DIR覆盖配置目录;默认为 ~/.pi/agent
PI_CODING_AGENT_SESSION_DIR覆盖会话存储;会被 --session-dir 覆盖
PI_PACKAGE_DIR覆盖包目录,适用于 Nix/Guix store 路径
PI_OFFLINE禁用启动时的网络操作,包括更新检查、包更新和安装/更新遥测
PI_SKIP_VERSION_CHECK禁用 pi.dev 的最新版本请求
PI_TELEMETRY覆盖安装/更新遥测和提供商归因标头:1/true/yes 或 0/false/no
PI_CACHE_RETENTION在支持的提供商上设为 long 可启用扩展提示词缓存
PI_SHARE_VIEWER_URL覆盖 /share 使用的基础 URL
PI_HARDWARE_CURSOR设为 1 显示硬件光标;参见终端设置
PI_HYPERLINKS以 1、0 或 auto 覆盖 OSC 8 超链接检测
PI_IMAGE_PROTOCOL以 kitty、iterm2、none 或 auto 覆盖内联图片检测
PI_TRUE_COLOR以 1、0 或 auto 覆盖真彩色检测
PI_TUI_ESC_TIMEOUT在单独按下 ESC 后等待多久才将其视为 Escape 键,单位为毫秒;SSH 下默认为 100,其他情况为 10。如果 Alt 键输入被误判为 Escape,请增大该值
VISUAL、EDITOR未设置 externalEditor 时使用的外部编辑器回退
HTTP_PROXY、HTTPS_PROXY为出站 HTTP 请求设置代理

ANTHROPIC_API_KEY、OPENAI_API_KEY 等提供商凭据以及云提供商配置详见提供商。

PI_SERVER_DIR 和 PI_SERVER_ID 仅适用于只随源码提供的实验性远程测试框架,不适用于分发的构建。

19 会话文件格式

会话 JSONL 文件的存储结构:条目类型、消息类型、树形结构与解析示例。

会话以 JSONL(JSON Lines)文件的形式存储。每一行都是一个带有 type 字段的 JSON 对象。会话条目通过 id/parentId 字段构成树形结构,从而无需创建新文件即可就地分叉。

文件位置

~/.pi/agent/sessions/--<path>--/<timestamp>_<session-id>.jsonl

默认情况下,<session-id> 是一个 UUID。调用者可以通过 SDK 或 --session-id 提供自定义 ID。对于 <path>,Pi 会去掉开头的路径分隔符,并将 /、\\ 和 : 替换为 -。

删除会话

删除 ~/.pi/agent/sessions/ 下对应的 .jsonl 文件即可移除会话。

Pi 还支持在 /resume 中交互式删除会话(选中会话后按 Ctrl+D,然后确认)。在可用时,pi 会使用 trash CLI 以避免永久删除。

会话版本

会话在头部包含一个版本字段:

  • 版本 1:线性条目序列(旧版,加载时自动迁移)
  • 版本 2:通过 id/parentId 连接的树形结构
  • 版本 3:将 hookMessage 角色重命名为 custom(扩展统一)

已有会话在加载时会自动迁移到当前版本(v3)。

源码文件

GitHub 上的源码(pi):

如需项目中的 TypeScript 定义,请查看 node_modules/@earendil-works/pi-coding-agent/dist/ 和 node_modules/@earendil-works/pi-ai/dist/。

消息类型

会话条目中包含 AgentMessage 对象。理解这些类型对于解析会话和编写扩展至关重要。

内容块

消息包含类型化内容块的数组:

interface TextContent {
  type: "text";
  text: string;
  textSignature?: string;
}

interface ImageContent {
  type: "image";
  data: string;      // base64 encoded
  mimeType: string;  // e.g., "image/jpeg", "image/png"
}

interface ThinkingContent {
  type: "thinking";
  thinking: string;
  thinkingSignature?: string;
  redacted?: boolean;
}

interface ToolCall {
  type: "toolCall";
  id: string;
  name: string;
  arguments: Record<string, any>;
  thoughtSignature?: string;
  namespace?: string;
}

基础消息类型(来自 pi-ai)

interface UserMessage {
  role: "user";
  content: string | (TextContent | ImageContent)[];
  timestamp: number;  // Unix ms
}

interface AssistantMessage {
  role: "assistant";
  content: (TextContent | ThinkingContent | ToolCall)[];
  api: string;
  provider: string;
  model: string;
  responseModel?: string;
  responseId?: string;
  providerThinkingLevel?: string;
  diagnostics?: AssistantMessageDiagnostic[];
  usage: Usage;
  stopReason: "pending" | "stop" | "length" | "toolUse" | "error" | "aborted" | "deferred";
  deferred?: DeferredHandle;
  errorMessage?: string;
  rawStopReason?: string;
  endTurn?: boolean;
  timestamp: number;
}

interface ToolResultMessage {
  role: "toolResult";
  toolCallId: string;
  toolName: string;
  content: (TextContent | ImageContent)[];
  details?: any;      // Tool-specific metadata
  usage?: Usage;      // Nested LLM work performed by the tool
  addedToolNames?: string[];
  isError: boolean;
  timestamp: number;
}

interface Usage {
  input: number;
  output: number;
  cacheRead: number;
  cacheWrite: number;
  cacheWrite1h?: number;
  reasoning?: number;
  totalTokens: number;
  cost: {
    input: number;
    output: number;
    cacheRead: number;
    cacheWrite: number;
    total: number;
  };
}

"pending" 保留给流式事件中的部分消息。在 Pi 持久化助手消息之前,终止事件会将其替换为完成原因,因此 "pending" 不应出现在会话 JSONL 中。"deferred" 是一种终止原因,表示提供商响应将在稍后完成;其 deferred 句柄包含取回该响应所需的提供商数据。

扩展消息类型(来自 pi-coding-agent)

interface BashExecutionMessage {
  role: "bashExecution";
  command: string;
  output: string;
  exitCode: number | undefined;
  cancelled: boolean;
  truncated: boolean;
  fullOutputPath?: string;
  excludeFromContext?: boolean;  // true for !! prefix commands
  timestamp: number;
}

interface CustomMessage {
  role: "custom";
  customType: string;            // Extension identifier
  content: string | (TextContent | ImageContent)[];
  display: boolean;              // Show in TUI
  details?: any;                 // Extension-specific metadata
  timestamp: number;
}

interface BranchSummaryMessage {
  role: "branchSummary";
  summary: string;
  fromId: string | null;         // Previous leaf whose abandoned path was summarized
  timestamp: number;
}

interface CompactionSummaryMessage {
  role: "compactionSummary";
  summary: string;
  tokensBefore: number;
  timestamp: number;
}

AgentMessage 联合类型

type AgentMessage =
  | UserMessage
  | AssistantMessage
  | ToolResultMessage
  | BashExecutionMessage
  | CustomMessage
  | BranchSummaryMessage
  | CompactionSummaryMessage;

条目基础

所有条目(SessionHeader 除外)都扩展自 SessionEntryBase:

interface SessionEntryBase {
  type: string;
  id: string;           // Usually an 8-char hex ID; may fall back to a full UUID
  parentId: string | null;  // Parent entry ID (null for a root entry)
  timestamp: string;    // ISO timestamp
}

条目类型

SessionHeader

文件的第一行。仅包含元数据,不属于树的一部分(没有 id/parentId)。

{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project"}

对于有父会话的会话(通过 /fork、/clone 或 newSession({ parentSession }) 创建):

{"type":"session","version":3,"id":"uuid","timestamp":"2024-12-03T14:00:00.000Z","cwd":"/path/to/project","parentSession":"/path/to/original/session.jsonl"}

SessionMessageEntry

对话中的一条消息。message 字段包含一个 AgentMessage。

{"type":"message","id":"a1b2c3d4","parentId":"prev1234","timestamp":"2024-12-03T14:00:01.000Z","message":{"role":"user","content":"Hello","timestamp":1733234401000}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"Hi!"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{...},"stopReason":"stop","timestamp":1733234402000}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2024-12-03T14:00:03.000Z","message":{"role":"toolResult","toolCallId":"call_123","toolName":"bash","content":[{"type":"text","text":"output"}],"isError":false,"timestamp":1733234403000}}

ModelChangeEntry

在用户于会话中途切换模型时发出。

{"type":"model_change","id":"d4e5f6g7","parentId":"c3d4e5f6","timestamp":"2024-12-03T14:05:00.000Z","provider":"openai","modelId":"gpt-4o"}

ThinkingLevelChangeEntry

在用户更改思考/推理级别时发出。

{"type":"thinking_level_change","id":"e5f6g7h8","parentId":"d4e5f6g7","timestamp":"2024-12-03T14:06:00.000Z","thinkingLevel":"high"}

CompactionEntry

在上下文被压缩时创建。存储较早消息的摘要。

{"type":"compaction","id":"f6g7h8i9","parentId":"e5f6g7h8","timestamp":"2024-12-03T14:10:00.000Z","summary":"User discussed X, Y, Z...","firstKeptEntryId":"c3d4e5f6","tokensBefore":50000}

firstKeptEntryId 是必需的。它标识压缩条目之前被保留的第一条条目。重建上下文时,Pi 会用压缩摘要替换较早的被摘要条目,并保留从该条目开始的范围。

可选字段:

  • usage:生成摘要的 LLM 用量;计入会话的 token 与费用总计
  • details:实现专属数据(默认为 { readFiles: string[], modifiedFiles: string[] },扩展可使用自定义数据)
  • fromHook:由扩展生成时为 true,由 pi 生成时为 false/undefined(旧版字段名)

BranchSummaryEntry

在通过 /tree 切换分支时创建,附带由 LLM 生成的、从被离开分支到共同祖先的摘要。捕获被放弃路径中的上下文。

{"type":"branch_summary","id":"g7h8i9j0","parentId":"a1b2c3d4","timestamp":"2024-12-03T14:15:00.000Z","fromId":"f6g7h8i9","summary":"Branch explored approach A..."}

parentId 是新分支延续自的条目。fromId 是被放弃路径被摘要的前一个叶子。

可选字段:

  • usage:生成摘要的 LLM 用量;计入会话的 token 与费用总计
  • details:文件跟踪数据(默认为 { readFiles: string[], modifiedFiles: string[] }),扩展可使用自定义数据
  • fromHook:由扩展生成时为 true,由 pi 生成时为 false/undefined(旧版字段名)

CustomEntry

扩展状态持久化。不参与 LLM 上下文。

{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}

使用 customType 在重新加载时识别你扩展的条目。交互模式可以通过 pi.registerEntryRenderer(customType, renderer) 渲染自定义条目,但它们仍然不参与 LLM 上下文。

CustomMessageEntry

扩展注入的、会参与 LLM 上下文的消息。

{"type":"custom_message","id":"i9j0k1l2","parentId":"h8i9j0k1","timestamp":"2024-12-03T14:25:00.000Z","customType":"my-extension","content":"Injected context...","display":true}

字段:

  • content:字符串或 (TextContent | ImageContent)[](与 UserMessage 相同)
  • display:true = 以独特样式显示在 TUI 中,false = 隐藏
  • details:可选的扩展专属元数据(不发送给 LLM)

LabelEntry

条目上的用户定义书签/标记。

{"type":"label","id":"j0k1l2m3","parentId":"i9j0k1l2","timestamp":"2024-12-03T14:30:00.000Z","targetId":"a1b2c3d4","label":"checkpoint-1"}

将 label 设为 undefined 即可清除标签。

SessionInfoEntry

会话元数据(例如用户定义的显示名称)。通过 /name、--name / -n 或扩展中的 pi.setSessionName() 设置。

{"type":"session_info","id":"k1l2m3n4","parentId":"j0k1l2m3","timestamp":"2024-12-03T14:35:00.000Z","name":"Refactor auth module"}

设置后,会话名称会在会话选择器(/resume)中显示,取代第一条消息。

树形结构

条目通常构成一棵树,但导航 API 可以创建多个根:

  • 根条目的 parentId 为 null;第一条条目最初就是根
  • 每个非根条目通过 parentId 指向其父条目
  • 分叉会从更早的条目创建新的子条目
  • “叶子"是树中的当前位置
  • 调用 resetLeaf() 或 branchWithSummary(null, ...) 可以让后续条目成为另一个根
[user msg] ─── [assistant] ─── [user msg] ─── [assistant] ─┬─ [user msg] ← current leaf
                                                            │
                                                            └─ [branch_summary] ─── [user msg] ← alternate branch

上下文构建

buildContextEntries() 从当前叶子走到根,生成活动条目列表,同时处理压缩:

  1. 收集路径上的所有条目
  2. 如果路径上有一个或多个 CompactionEntry,使用最新的那一个:
    • 首先包含该压缩条目
    • 包含从 firstKeptEntryId 到(但不包含)压缩条目之间的条目
    • 包含压缩条目之后的条目
  3. 在所选范围内保留非消息条目,以便交互模式渲染它们

buildSessionContext() 在该条目列表的基础上生成给 LLM 的消息列表:

  1. 从完整路径中提取当前模型和思考级别设置
  2. 将选中的条目转换为消息:
    • message -> 存储的 AgentMessage
    • compaction -> compactionSummary
    • branch_summary -> branchSummary
    • custom_message -> CustomMessage
    • custom -> 不生成上下文消息

压缩摘要会替换 firstKeptEntryId 之前的条目。保留的条目以及压缩之后的所有条目仍对 LLM 可用。

解析示例

import { readFileSync } from "fs";

const lines = readFileSync("session.jsonl", "utf8").trim().split("\n");

for (const line of lines) {
  const entry = JSON.parse(line);

  switch (entry.type) {
    case "session":
      console.log(`Session v${entry.version ?? 1}: ${entry.id}`);
      break;
    case "message":
      console.log(`[${entry.id}] ${entry.message.role}: ${JSON.stringify(entry.message.content)}`);
      break;
    case "compaction":
      console.log(`[${entry.id}] Compaction: ${entry.tokensBefore} tokens summarized`);
      break;
    case "branch_summary":
      console.log(`[${entry.id}] Branch from ${entry.fromId}`);
      break;
    case "custom":
      console.log(`[${entry.id}] Custom (${entry.customType}): ${JSON.stringify(entry.data)}`);
      break;
    case "custom_message":
      console.log(`[${entry.id}] Extension message (${entry.customType}): ${entry.content}`);
      break;
    case "label":
      console.log(`[${entry.id}] Label "${entry.label}" on ${entry.targetId}`);
      break;
    case "model_change":
      console.log(`[${entry.id}] Model: ${entry.provider}/${entry.modelId}`);
      break;
    case "thinking_level_change":
      console.log(`[${entry.id}] Thinking: ${entry.thinkingLevel}`);
      break;
  }
}

SessionManager API

以编程方式使用会话的关键方法。

静态创建方法

  • SessionManager.create(cwd, sessionDir?, options?) - 新建会话;options 可设置 id 和 parentSession
  • SessionManager.open(path, sessionDir?, cwdOverride?) - 打开已有的会话文件
  • SessionManager.continueRecent(cwd, sessionDir?) - 继续最近的会话或新建
  • SessionManager.inMemory(cwd?, options?, entries?) - 不持久化到文件,可选择从条目初始化
  • SessionManager.forkFrom(sourcePath, targetCwd, sessionDir?, options?) - 从另一个项目分叉会话

静态列表方法

  • SessionManager.list(cwd, sessionDir?, onProgress?) - 列出某个目录的会话
  • SessionManager.listAll(onProgress?) - 列出所有项目的所有会话
  • SessionManager.listAll(sessionDir?, onProgress?) - 列出自定义会话根目录下的会话

实例方法 - 会话管理

  • newSession(options?) - 开始新会话(options:{ id?: string, parentSession?: string })
  • setSessionFile(path) - 切换到另一个会话文件
  • createBranchedSession(leafId) - 将分支提取到新的会话文件

实例方法 - 追加(均返回条目 ID)

  • appendMessage(message) - 添加消息
  • appendThinkingLevelChange(level) - 记录思考级别变化
  • appendModelChange(provider, modelId) - 记录模型变化
  • appendCompaction(summary, firstKeptEntryId, tokensBefore, details?, fromHook?, usage?) - 添加压缩
  • appendCustomEntry(customType, data?) - 扩展状态(不进入上下文)
  • appendSessionInfo(name) - 设置会话显示名称
  • appendCustomMessageEntry(customType, content, display, details?) - 扩展消息(进入上下文)
  • appendLabelChange(targetId, label) - 设置/清除标签

实例方法 - 树形导航

  • getLeafId() - 当前位置
  • getLeafEntry() - 获取当前叶子条目
  • getEntry(id) - 按 ID 获取条目
  • getBranch(fromId?) - 从条目走到根
  • getTree() - 获取完整树形结构
  • getChildren(parentId) - 获取直接子条目
  • getLabel(id) - 获取条目标签
  • branch(entryId) - 将叶子移到更早的条目
  • resetLeaf() - 将叶子重置为 null(所有条目之前)
  • branchWithSummary(entryId, summary, details?, fromHook?, usage?) - 带上下文摘要的分叉;entryId 可为 null 以从根分叉

实例方法 - 上下文与信息

  • buildContextEntries() - 获取应用了压缩的活动分支条目
  • buildSessionContext() - 获取给 LLM 的消息、thinkingLevel 和模型
  • getEntries() - 所有条目(不含头部)
  • getHeader() - 会话头部元数据
  • getSessionName() - 从最新的 session_info 条目获取显示名称
  • getCwd() - 工作目录
  • getSessionDir() - 会话存储目录
  • getSessionId() - 会话 UUID
  • getSessionFile() - 会话文件路径(内存会话为 undefined)
  • isPersisted() - 会话是否已保存到磁盘

20 SDK

通过 SDK 以编程方式调用 pi 的智能体能力,将 pi 嵌入应用、构建自定义界面与自动化集成。

pi 可以帮你使用 SDK。可以请它为你的使用场景构建一个集成。

SDK 提供了对 pi 智能体能力的编程式访问。你可以用它把 pi 嵌入其他应用、构建自定义界面,或与自动化流程集成。

典型使用场景:

  • 构建自定义 UI(Web、桌面、移动端)
  • 将智能体能力集成到现有应用中
  • 借助智能体推理能力创建自动化流水线
  • 构建能派生子智能体的自定义工具
  • 以编程方式测试智能体行为

从最小化接入到完全控制的可运行示例,请参见 examples/sdk/。

快速开始

import { createAgentSession, ModelRuntime, SessionManager } from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  modelRuntime,
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("What files are in the current directory?");

安装

npm install @earendil-works/pi-coding-agent

SDK 已包含在主包中,无需单独安装。

核心概念

createAgentSession()

创建单个 AgentSession 的主工厂函数。

createAgentSession() 使用 ResourceLoader 来提供扩展、技能、提示词模板、主题和上下文文件。如果你没有提供,它会使用带标准发现机制的 DefaultResourceLoader。

import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

// Minimal: defaults with DefaultResourceLoader
const { session } = await createAgentSession();

// Custom: override specific options
const { session } = await createAgentSession({
  model: myModel,
  tools: ["read", "bash"],
  sessionManager: SessionManager.inMemory(),
});

AgentSession

会话负责管理智能体生命周期、消息历史、模型状态、上下文压缩和事件流。

interface AgentSession {
  // Send a prompt and wait for completion
  prompt(text: string, options?: PromptOptions): Promise<void>;

  // Queue messages during streaming
  steer(text: string): Promise<void>;
  followUp(text: string): Promise<void>;

  // Subscribe to events (returns unsubscribe function)
  subscribe(listener: (event: AgentSessionEvent) => void): () => void;

  // Session info
  sessionFile: string | undefined;
  sessionId: string;

  // Model control
  setModel(model: Model): Promise<void>;
  setThinkingLevel(level: ThinkingLevel): void;
  cycleModel(): Promise<ModelCycleResult | undefined>;
  cycleThinkingLevel(): ThinkingLevel | undefined;

  // State access
  agent: Agent;
  model: Model | undefined;
  thinkingLevel: ThinkingLevel;
  messages: AgentMessage[];
  isStreaming: boolean;

  // In-place tree navigation within the current session file
  navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>;

  // Compaction
  compact(customInstructions?: string): Promise<CompactionResult>;
  abortCompaction(): void;

  // Abort current operation
  abort(): Promise<void>;

  // Cleanup
  dispose(): void;
}

当智能体正在响应、手动或自动上下文压缩正在进行、或另一次树状导航处于活动状态时,session.navigateTree() 会直接拒绝,即使设置了 summarize: false 也不例外。对于这类冲突,它既不会把导航排入队列,也不会返回 { cancelled: true }。请等待当前操作完成(例如使用 await session.waitForIdle())后重试。被拒绝时,当前活动分支保持不变。

新建会话、恢复、分叉和导入等会话替换 API 位于 AgentSessionRuntime 上,而不是 AgentSession 上。

createAgentSessionRuntime() 与 AgentSessionRuntime

当你需要替换当前活动会话并重建与 cwd 绑定的运行时状态时,请使用运行时 API。内置的交互模式、print 模式和 RPC 模式使用的就是这一层。

createAgentSessionRuntime() 接受一个运行时工厂,以及初始的 cwd/会话目标。该工厂闭包捕获进程级固定输入,为实际生效的 cwd 重建与 cwd 绑定的服务,基于这些服务解析会话选项,并返回完整的运行时结果。

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({
      services,
      sessionManager,
      sessionStartEvent,
    })),
    services,
    diagnostics: services.diagnostics,
  };
};

const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

AgentSessionRuntime 负责在以下操作中替换活动运行时:

  • newSession()
  • switchSession()
  • fork()
  • 通过 fork(entryId, { position: "at" }) 实现的克隆流程
  • importFromJsonl()

重要行为:

  • 这些操作之后 runtime.session 会发生变化
  • 事件订阅绑定在具体的 AgentSession 上,替换后需要重新订阅
  • 如果使用了扩展,需要为新会话再次调用 runtime.session.bindExtensions(...)
  • 创建结果的诊断信息位于 runtime.diagnostics
  • 如果运行时创建或替换失败,方法会抛出异常,由调用方决定如何处理
let session = runtime.session;
let unsubscribe = session.subscribe(() => {});

await runtime.newSession();

unsubscribe();
session = runtime.session;
unsubscribe = session.subscribe(() => {});

提示与消息排队

PromptOptions 用于控制提示词模板展开、流式期间的排队行为,以及提示词预检(preflight)通知:

interface PromptOptions {
  expandPromptTemplates?: boolean;
  images?: ImageContent[];
  streamingBehavior?: "steer" | "followUp";
  source?: InputSource;
  preflightResult?: (success: boolean) => void;
}

preflightResult 在每次 prompt() 调用中只触发一次:

  • 提示词被接受、进入队列或被立即处理时为 true
  • 提示词预检在接受之前被拒绝时为 false

它在 prompt() 完成之前触发。prompt() 仍要等到整个被接受的运行(包括重试)结束后才会完成。接受之后的失败会通过正常的事件和消息流上报,而不会通过 preflightResult(false) 体现。

prompt() 方法负责处理提示词模板、扩展命令和消息发送:

// Basic prompt (when not streaming)
await session.prompt("What files are here?");

// With images
await session.prompt("What's in this image?", {
  images: [{ type: "image", source: { type: "base64", mediaType: "image/png", data: "..." } }]
});

// During streaming: must specify how to queue the message
await session.prompt("Stop and do this instead", { streamingBehavior: "steer" });
await session.prompt("After you're done, also check X", { streamingBehavior: "followUp" });

行为:

  • 扩展命令(例如 /mycommand):立即执行,流式期间也不例外。它们通过 pi.sendMessage() 自行管理 LLM 交互。
  • 基于文件的提示词模板(来自 .md 文件):在发送或排队之前展开为其内容。
  • 流式期间未指定 streamingBehavior:会抛出错误。请直接使用 steer() 或 followUp(),或者显式指定该选项。
  • preflightResult(true):表示提示词已被接受、进入队列或被立即处理。
  • preflightResult(false):表示预检在接受之前被拒绝。

要在流式期间显式排队:

// Queue a steering message for delivery after the current assistant turn finishes its tool calls
await session.steer("New instruction");

// Wait for agent to finish (delivered only when agent stops)
await session.followUp("After you're done, also do this");

steer() 和 followUp() 都会展开基于文件的提示词模板,但遇到扩展命令会报错(扩展命令无法排队)。

Agent 与 AgentState

Agent 类(来自 @earendil-works/pi-agent-core)负责核心的 LLM 交互,可通过 session.agent 访问。

// Access current state
const state = session.agent.state;

// state.messages: AgentMessage[] - conversation history
// state.model: Model - current model
// state.thinkingLevel: ThinkingLevel - current thinking level
// state.systemPrompt: string - system prompt
// state.tools: AgentTool[] - available tools
// state.streamingMessage?: AgentMessage - current partial assistant message
// state.errorMessage?: string - latest assistant error

// Replace messages (useful for branching or restoration)
session.agent.state.messages = messages; // copies the top-level array

// Replace tools
session.agent.state.tools = tools; // copies the top-level array

// Wait for agent to finish processing
await session.agent.waitForIdle();

事件

订阅事件以接收流式输出和生命周期通知。

session.subscribe((event) => {
  switch (event.type) {
    // Streaming text from assistant
    case "message_update":
      if (event.assistantMessageEvent.type === "text_delta") {
        process.stdout.write(event.assistantMessageEvent.delta);
      }
      if (event.assistantMessageEvent.type === "thinking_delta") {
        // Thinking output (if thinking enabled)
      }
      break;
    
    // Tool execution
    case "tool_execution_start":
      console.log(`Tool: ${event.toolName}`);
      break;
    case "tool_execution_update":
      // Streaming tool output
      break;
    case "tool_execution_end":
      console.log(`Result: ${event.isError ? "error" : "success"}`);
      break;
    
    // Message lifecycle
    case "message_start":
      // New message starting
      break;
    case "message_end":
      // Message complete
      break;
    
    // Agent lifecycle
    case "agent_start":
      // Agent started processing prompt
      break;
    case "agent_end":
      // Agent finished (event.messages contains new messages)
      break;
    
    // Turn lifecycle (one LLM response + tool calls)
    case "turn_start":
      break;
    case "turn_end":
      // event.message: assistant response
      // event.toolResults: tool results from this turn
      break;
    
    // Session events (queue, compaction, retry)
    case "queue_update":
      console.log(event.steering, event.followUp);
      break;
    case "compaction_start":
    case "compaction_end":
    case "auto_retry_start":
    case "auto_retry_end":
    case "summarization_retry_scheduled":
    case "summarization_retry_attempt_start":
    case "summarization_retry_finished":
      break;
  }
});

选项参考

目录

const { session } = await createAgentSession({
  // Working directory for DefaultResourceLoader discovery
  cwd: process.cwd(), // default
  
  // Global config directory
  agentDir: "~/.pi/agent", // default (expands ~)
});

DefaultResourceLoader 将 cwd 用于:

  • 项目扩展(.pi/extensions/)
  • 项目技能:
    • .pi/skills/
    • cwd 及其祖先目录中的 .agents/skills/(向上直到 git 仓库根目录;不在仓库中时则到文件系统根目录)
  • 项目提示词模板(.pi/prompts/)
  • 上下文文件(从 cwd 向上逐级查找 AGENTS.md)
  • 会话目录命名

DefaultResourceLoader 将 agentDir 用于:

  • 全局扩展(extensions/)
  • 全局技能:
    • agentDir 下的 skills/(例如 ~/.pi/agent/skills/)
    • ~/.agents/skills/
  • 全局提示词模板(prompts/)
  • 全局上下文文件(AGENTS.md)
  • 设置(settings.json)
  • 自定义模型(models.json)
  • 凭据(auth.json)
  • 会话(sessions/)

当你传入自定义 ResourceLoader 时,cwd 和 agentDir 不再控制资源发现,但它们仍会影响会话命名和工具路径解析。

模型

import { getModel } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create();

// create() restores cached catalogs but does not refresh them from pi.dev by default.
// Opt in to a create-time network refresh and bound how long it may take:
const refreshedRuntime = await ModelRuntime.create({
  allowModelNetwork: true,
  modelRefreshTimeoutMs: 15_000,
});

// Find specific built-in model (doesn't check if API key exists)
const opus = getModel("anthropic", "claude-opus-4-5");
if (!opus) throw new Error("Model not found");

// Find any model by provider/id, including custom models from models.json
// (doesn't check if API key exists)
const customModel = modelRuntime.getModel("my-provider", "my-model");

// Get only models that have valid authentication configured
const available = await modelRuntime.getAvailable();

const { session } = await createAgentSession({
  model: opus,
  thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh, max
  
  // Models for cycling (Ctrl+P in interactive mode)
  scopedModels: [
    { model: opus, thinkingLevel: "high" },
    { model: haiku, thinkingLevel: "off" },
  ],
  
  modelRuntime,
});

如果没有提供模型:

  1. 尝试从会话恢复(如果是继续已有会话)
  2. 使用设置中的默认值
  3. 回退到第一个可用模型

远程目录会持久化到本地,后续创建的运行时无需网络请求即可恢复它们。默认文件是 ~/.pi/agent/models-store.json;可设置 modelsStorePath 选择其他位置,或注入 modelsStore 来控制持久化。网络刷新有节流限制:除非强制,每个提供商每四小时最多刷新一次。要强制立即刷新,可调用 await modelRuntime.refresh({ allowNetwork: true, force: true, signal })。设置 PI_OFFLINE 会禁用模型网络访问。

要与 CLI 的模型解析行为保持一致,请使用导出的解析辅助函数:

import {
  resolveCliModel,
  resolveModelScopeWithDiagnostics,
} from "@earendil-works/pi-coding-agent";

const cliModel = resolveCliModel({
  cliModel: "anthropic/claude-opus-4-5:high",
  modelRuntime,
});
if (cliModel.error) throw new Error(cliModel.error);
if (cliModel.warning) console.warn(cliModel.warning);

const { scopedModels, diagnostics } = await resolveModelScopeWithDiagnostics(
  ["anthropic/*:high", "gpt-5"],
  modelRuntime,
);
for (const diagnostic of diagnostics) {
  console.warn(diagnostic.message);
}

resolveCliModel() 会使用所有已注册的模型,因此 --api-key 式的首次设置流程可以在存储的凭据存在之前就解析出模型。resolveModelScopeWithDiagnostics() 与 --models 和 enabledModels 的语义一致,但以返回值形式给出警告而不是直接打印。

参见 examples/sdk/02-custom-model.ts

API 密钥与 OAuth

认证解析优先级(由 ModelRuntime 处理):

  1. 运行时覆盖(通过 setRuntimeApiKey 设置,不持久化)
  2. auth.json 中存储的凭据(API 密钥或 OAuth 令牌)
  3. 环境变量(ANTHROPIC_API_KEY、OPENAI_API_KEY 等)
  4. 回退解析器(用于 models.json 中自定义提供商的密钥)
import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { createAgentSession, ModelRuntime } from "@earendil-works/pi-coding-agent";

// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const modelRuntime = await ModelRuntime.create();

// Provider-owned auth methods and current status
for (const provider of modelRuntime.getProviders()) {
  const status = await modelRuntime.checkAuth(provider.id);
  console.log(provider.name, provider.auth, status);
}

// Runtime API key override (not persisted to disk)
await modelRuntime.setRuntimeApiKey("anthropic", "sk-my-temp-key");

// Custom credential and model locations
const customRuntime = await ModelRuntime.create({
  authPath: "/my/app/auth.json",
  modelsPath: "/my/app/models.json",
});

// Or inject any pi-ai CredentialStore
const credentials = new InMemoryCredentialStore();
const inMemoryRuntime = await ModelRuntime.create({ credentials });

const { session } = await createAgentSession({
  modelRuntime: customRuntime,
});

login()、logout()、setRuntimeApiKey() 和 removeRuntimeApiKey() 会在受影响提供商的缓存/内置目录、组合信息以及可用性快照在本地达成一致后再完成,不会等待远程目录刷新到最新。如果凭据已提交但本地同步失败,这些方法会以导出的 CredentialSynchronizationError 拒绝;此时应检查其 providerId、operation、credential 和 cause 字段,而不是盲目重试凭据变更。

公开的模型/认证操作以及 ModelRuntime.create({ signal }) 都接受可选的中止信号,省略时不设时限。远程目录新鲜度的截止时间策略由 SDK 应用自己掌握:

const signal = AbortSignal.timeout(15_000);
const result = await modelRuntime.refresh({
  providers: ["anthropic"],
  signal,
});
if (result.aborted) console.warn("Catalog refresh timed out; using cached models");
for (const [providerId, error] of result.errors) {
  console.warn(`Could not refresh ${providerId}:`, error);
}

失败或超时的网络刷新不会撤销已成功的凭据操作。refresh() 会开启新的提供商代(generation),因此不会卡在之前停滞的刷新后面,过期的代此后也无法再发布结果。

参见 examples/sdk/09-api-keys-and-oauth.ts

系统提示词

使用 ResourceLoader 覆盖系统提示词:

import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";

const loader = new DefaultResourceLoader({
  systemPromptOverride: () => "You are a helpful assistant.",
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

参见 examples/sdk/03-custom-prompt.ts

工具

指定要启用哪些内置工具:

  • 内置工具名称:read、bash、powershell、edit、write、grep、find、ls
  • 默认内置:read、bash、edit、write
  • noTools: "all" 禁用所有工具
  • noTools: "builtin" 禁用默认内置工具,同时保留扩展和自定义工具
  • excludeTools 在 tools 白名单生效之后禁用指定的内置、扩展或自定义工具名

edit 工具会返回 details.diff 供 pi 的 TUI 显示,并返回 details.patch 作为标准 unified patch 供 SDK 使用方使用。

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

// Read-only mode
const { session } = await createAgentSession({
  tools: ["read", "grep", "find", "ls"],
});

// Pick specific tools
const { session } = await createAgentSession({
  tools: ["read", "bash", "grep"],
});

// Use PowerShell instead of Bash on Windows
const { session } = await createAgentSession({
  tools: ["read", "powershell", "edit", "write"],
});

// Disable one tool while keeping the rest available
const { session } = await createAgentSession({
  excludeTools: ["ask_question"],
});

使用自定义 cwd 的工具

当你传入自定义 cwd 时,createAgentSession() 会为该 cwd 构建所选的内置工具。

import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";

const cwd = "/path/to/project";

// Use default tools for custom cwd
const { session } = await createAgentSession({
  cwd,
  sessionManager: SessionManager.inMemory(cwd),
});

// Or pick specific tools for custom cwd
const { session } = await createAgentSession({
  cwd,
  tools: ["read", "bash", "grep"],
  sessionManager: SessionManager.inMemory(cwd),
});

参见 examples/sdk/05-tools.ts

自定义工具

import { Type } from "typebox";
import { createAgentSession, defineTool } from "@earendil-works/pi-coding-agent";

// Inline custom tool
const myTool = defineTool({
  name: "my_tool",
  label: "My Tool",
  description: "Does something useful",
  parameters: Type.Object({
    input: Type.String({ description: "Input value" }),
  }),
  execute: async (_toolCallId, params) => ({
    content: [{ type: "text", text: `Result: ${params.input}` }],
    details: {},
  }),
});

// Pass custom tools directly
const { session } = await createAgentSession({
  customTools: [myTool],
});

对于独立定义以及 customTools: [myTool] 这类数组,请使用 defineTool()。内联的 pi.registerTool({ ... }) 已经能正确推断参数类型。

通过 customTools 传入的自定义工具会与扩展注册的工具合并。ResourceLoader 加载的扩展也可以通过 pi.registerTool() 注册工具。

如果你传入了 tools,请把想要启用的每个自定义或扩展工具名都列进去,例如 tools: ["read", "bash", "my_tool"]。

参见 examples/sdk/05-tools.ts

扩展

扩展由 ResourceLoader 加载。DefaultResourceLoader 会从 ~/.pi/agent/extensions/、.pi/extensions/ 以及 settings.json 中配置的扩展来源发现扩展。

import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";

const loader = new DefaultResourceLoader({
  additionalExtensionPaths: ["/path/to/my-extension.ts"],
  extensionFactories: [
    (pi) => {
      pi.on("agent_start", () => {
        console.log("[Inline Extension] Agent starting");
      });
    },
  ],
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

扩展可以注册工具、订阅事件、添加命令等。完整 API 请参见 extensions.md。

具名内联扩展: 默认情况下,内联工厂在启动时的扩展列表中显示为 <inline:1>、<inline:2> 等。要改为显示一个描述性名称,可以这样包装工厂函数:

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

const myProvider: InlineExtension = {
  name: "my-provider",
  factory: (pi) => {
    pi.on("agent_start", () => {
      console.log("[my-provider] Agent starting");
    });
  },
};

const loader = new DefaultResourceLoader({
  extensionFactories: [myProvider],
});

这样就会显示为 <inline:my-provider> 而不是 <inline:1>。为了向后兼容,裸工厂函数仍然可用。

事件总线: 扩展之间可以通过 pi.events 通信。如果需要从外部发送或监听事件,可以向 DefaultResourceLoader 传入一个共享的 eventBus:

import { createEventBus, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";

const eventBus = createEventBus();
const loader = new DefaultResourceLoader({
  eventBus,
});
await loader.reload();

eventBus.on("my-extension:status", (data) => console.log(data));

参见 examples/sdk/06-extensions.ts 和 docs/extensions.md

技能

import {
  createAgentSession,
  DefaultResourceLoader,
  type Skill,
} from "@earendil-works/pi-coding-agent";

const customSkill: Skill = {
  name: "my-skill",
  description: "Custom instructions",
  filePath: "/path/to/SKILL.md",
  baseDir: "/path/to",
  source: "custom",
};

const loader = new DefaultResourceLoader({
  skillsOverride: (current) => ({
    skills: [...current.skills, customSkill],
    diagnostics: current.diagnostics,
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

参见 examples/sdk/04-skills.ts

上下文文件

import { createAgentSession, DefaultResourceLoader } from "@earendil-works/pi-coding-agent";

const loader = new DefaultResourceLoader({
  agentsFilesOverride: (current) => ({
    agentsFiles: [
      ...current.agentsFiles,
      { path: "/virtual/AGENTS.md", content: "# Guidelines\n\n- Be concise" },
    ],
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

参见 examples/sdk/07-context-files.ts

斜杠命令

import {
  createAgentSession,
  DefaultResourceLoader,
  type PromptTemplate,
} from "@earendil-works/pi-coding-agent";

const customCommand: PromptTemplate = {
  name: "deploy",
  description: "Deploy the application",
  source: "(custom)",
  content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
};

const loader = new DefaultResourceLoader({
  promptsOverride: (current) => ({
    prompts: [...current.prompts, customCommand],
    diagnostics: current.diagnostics,
  }),
});
await loader.reload();

const { session } = await createAgentSession({ resourceLoader: loader });

参见 examples/sdk/08-prompt-templates.ts

会话管理

会话采用树状结构,通过 id/parentId 相互链接,支持原地分支。

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSession,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

// In-memory (no persistence)
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
});

// New persistent session
const { session: persisted } = await createAgentSession({
  sessionManager: SessionManager.create(process.cwd()),
});

// Continue most recent
const { session: continued, modelFallbackMessage } = await createAgentSession({
  sessionManager: SessionManager.continueRecent(process.cwd()),
});
if (modelFallbackMessage) {
  console.log("Note:", modelFallbackMessage);
}

// Open specific file
const { session: opened } = await createAgentSession({
  sessionManager: SessionManager.open("/path/to/session.jsonl"),
});

// Resume a session kept outside the filesystem, e.g. in a database
const { session: restored } = await createAgentSession({
  sessionManager: SessionManager.inMemory(process.cwd(), { id: sessionId }, entries),
});

// List sessions
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());

// Session replacement API for /new, /resume, /fork, /clone, and import flows.
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({
      services,
      sessionManager,
      sessionStartEvent,
    })),
    services,
    diagnostics: services.diagnostics,
  };
};

const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

// Replace the active session with a fresh one
await runtime.newSession();

// Replace the active session with another saved session
await runtime.switchSession("/path/to/session.jsonl");

// Replace the active session with a fork from a specific user entry
await runtime.fork("entry-id");

// Clone the active path through a specific entry
await runtime.fork("entry-id", { position: "at" });

SessionManager 树 API:

const sm = SessionManager.open("/path/to/session.jsonl");

// Session listing
const currentProjectSessions = await SessionManager.list(process.cwd());
const allSessions = await SessionManager.listAll(process.cwd());

// Tree traversal
const entries = sm.getEntries();        // All entries (excludes header)
const tree = sm.getTree();              // Full tree structure
const path = sm.getPath();              // Path from root to current leaf
const leaf = sm.getLeafEntry();         // Current leaf entry
const entry = sm.getEntry(id);          // Get entry by ID
const children = sm.getChildren(id);    // Direct children of entry

// Labels
const label = sm.getLabel(id);          // Get label for entry
sm.appendLabelChange(id, "checkpoint"); // Set label

// Branching
sm.branch(entryId);                     // Move leaf to earlier entry
sm.branchWithSummary(id, "Summary...");  // Branch with context summary
sm.createBranchedSession(leafId);       // Extract path to new file

参见 examples/sdk/11-sessions.ts 和 会话格式

设置管理

import { createAgentSession, SettingsManager, SessionManager } from "@earendil-works/pi-coding-agent";

// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
  settingsManager: SettingsManager.create(),
});

// With overrides
const settingsManager = SettingsManager.create();
settingsManager.applyOverrides({
  compaction: { enabled: false },
  retry: { enabled: true, maxRetries: 5 },
});
const { session } = await createAgentSession({ settingsManager });

// In-memory (no file I/O, for testing)
const { session } = await createAgentSession({
  settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
  sessionManager: SessionManager.inMemory(),
});

// Custom directories
const { session } = await createAgentSession({
  settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
});

静态工厂:

  • SettingsManager.create(cwd?, agentDir?) - 从文件加载
  • SettingsManager.inMemory(settings?) - 无文件 I/O

项目级设置:

设置从两个位置加载并合并:

  1. 全局:~/.pi/agent/settings.json
  2. 项目:<cwd>/.pi/settings.json

项目设置覆盖全局设置,嵌套对象按键合并。setter 默认修改全局设置。

持久化与错误处理语义:

  • 设置的 getter/setter 对内存中的状态是同步的。
  • setter 会异步地把持久化写入排入队列。
  • 当需要一个持久化边界时(例如进程退出前,或测试中断言文件内容之前),请调用 await settingsManager.flush()。
  • SettingsManager 不会打印设置 I/O 错误。请使用 settingsManager.drainErrors() 并在你的应用层上报。

参见 examples/sdk/10-settings.ts

ResourceLoader

使用 DefaultResourceLoader 发现扩展、技能、提示词模板、主题和上下文文件。

import {
  DefaultResourceLoader,
  getAgentDir,
} from "@earendil-works/pi-coding-agent";

const loader = new DefaultResourceLoader({
  cwd,
  agentDir: getAgentDir(),
});
await loader.reload();

const extensions = loader.getExtensions();
const skills = loader.getSkills();
const prompts = loader.getPrompts();
const themes = loader.getThemes();
const contextFiles = loader.getAgentsFiles().agentsFiles;

返回值

createAgentSession() 返回:

interface CreateAgentSessionResult {
  // The session
  session: AgentSession;
  
  // Extensions result (for runner setup)
  extensionsResult: LoadExtensionsResult;
  
  // Warning if session model couldn't be restored
  modelFallbackMessage?: string;
}

interface LoadExtensionsResult {
  extensions: Extension[];
  errors: Array<{ path: string; error: string }>;
  runtime: ExtensionRuntime;
}

完整示例

import { getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import {
  createAgentSession,
  DefaultResourceLoader,
  defineTool,
  ModelRuntime,
  SessionManager,
  SettingsManager,
} from "@earendil-works/pi-coding-agent";

const modelRuntime = await ModelRuntime.create({
  authPath: "/custom/agent/auth.json",
  modelsPath: "/custom/agent/models.json",
});
if (process.env.MY_KEY) {
  await modelRuntime.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}

// Inline tool
const statusTool = defineTool({
  name: "status",
  label: "Status",
  description: "Get system status",
  parameters: Type.Object({}),
  execute: async () => ({
    content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
    details: {},
  }),
});

const model = getModel("anthropic", "claude-opus-4-5");
if (!model) throw new Error("Model not found");

// In-memory settings with overrides
const settingsManager = SettingsManager.inMemory({
  compaction: { enabled: false },
  retry: { enabled: true, maxRetries: 2 },
});

const loader = new DefaultResourceLoader({
  cwd: process.cwd(),
  agentDir: "/custom/agent",
  settingsManager,
  systemPromptOverride: () => "You are a minimal assistant. Be concise.",
});
await loader.reload();

const { session } = await createAgentSession({
  cwd: process.cwd(),
  agentDir: "/custom/agent",

  model,
  thinkingLevel: "off",
  modelRuntime,

  tools: ["read", "bash", "status"],
  customTools: [statusTool],
  resourceLoader: loader,

  sessionManager: SessionManager.inMemory(),
  settingsManager,
});

session.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await session.prompt("Get status and list files.");

运行模式

SDK 导出了一组运行模式工具,用于在 createAgentSession() 之上构建自定义界面:

InteractiveMode

完整的 TUI 交互模式,带编辑器、聊天历史和所有内置命令:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  InteractiveMode,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

const mode = new InteractiveMode(runtime, {
  migratedProviders: [],
  modelFallbackMessage: undefined,
  initialMessage: "Hello",
  initialImages: [],
  initialMessages: [],
});

await mode.run();

runPrintMode

单次模式:发送提示词、输出结果、退出:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  runPrintMode,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

await runPrintMode(runtime, {
  mode: "text",
  initialMessage: "Hello",
  initialImages: [],
  messages: ["Follow up"],
});

runRpcMode

用于子进程集成的 JSON-RPC 模式:

import {
  type CreateAgentSessionRuntimeFactory,
  createAgentSessionFromServices,
  createAgentSessionRuntime,
  createAgentSessionServices,
  getAgentDir,
  runRpcMode,
  SessionManager,
} from "@earendil-works/pi-coding-agent";

const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
  const services = await createAgentSessionServices({ cwd });
  return {
    ...(await createAgentSessionFromServices({ services, sessionManager, sessionStartEvent })),
    services,
    diagnostics: services.diagnostics,
  };
};
const runtime = await createAgentSessionRuntime(createRuntime, {
  cwd: process.cwd(),
  agentDir: getAgentDir(),
  sessionManager: SessionManager.create(process.cwd()),
});

await runRpcMode(runtime);

JSON 协议请参见 RPC 文档。

RPC 模式替代方案

如果要做基于子进程的集成,又不想基于 SDK 构建,可以直接使用 CLI:

pi --mode rpc --no-session

JSON 协议请参见 RPC 文档。

以下情况优先选择 SDK:

  • 需要类型安全
  • 你在同一个 Node.js 进程中
  • 需要直接访问智能体状态
  • 想以编程方式定制工具/扩展

以下情况优先选择 RPC 模式:

  • 从其他语言集成
  • 需要进程隔离
  • 在构建与语言无关的客户端

导出

主入口导出:

// Factory
createAgentSession
createAgentSessionRuntime
AgentSessionRuntime

// Auth and Models
ModelRuntime // implements pi-ai Models and owns credential storage
ModelRegistry // synchronous extension compatibility facade
CredentialSynchronizationError
resolveCliModel
resolveModelScopeWithDiagnostics

// Resource loading
DefaultResourceLoader
type ResourceLoader
createEventBus

// Constants and helpers
CONFIG_DIR_NAME
defineTool
getAgentDir
getPackageDir
getReadmePath
getDocsPath
getExamplesPath

// Session management
SessionManager
SettingsManager

// Tool factories
createCodingTools
createReadOnlyTools
createReadTool, createBashTool, createPowerShellTool, createEditTool, createWriteTool
createGrepTool, createFindTool, createLsTool

// Types
type CreateAgentSessionOptions
type CreateAgentSessionResult
type ExtensionFactory
type InlineExtension
type ExtensionAPI
type ToolDefinition
type Skill
type PromptTemplate
type Tool

关于扩展类型,完整 API 请参见 extensions.md。

21 RPC 模式

介绍如何通过 stdin/stdout 上的 JSON 协议以无头方式驱动 pi 编码智能体。

RPC 模式让你能够通过 stdin/stdout 上的 JSON 协议以无头(headless)方式运行编码智能体。它适合把智能体嵌入其他应用、IDE 或自定义 UI。

Node.js/TypeScript 用户请注意:如果你在构建 Node.js 应用,可以考虑直接使用 @earendil-works/pi-coding-agent 中的 AgentSession,而不是启动子进程。API 参见 src/core/agent-session.ts。基于子进程的 TypeScript 客户端参见 src/modes/rpc/rpc-client.ts。

启动 RPC 模式

pi --mode rpc [options]

常用选项:

  • --provider <name>:设置 LLM 提供商(anthropic、openai、google 等)
  • --model <pattern>:模型模式或 ID(支持 provider/id 以及可选的 :<thinking>)
  • --name <name> / -n <name>:启动时设置会话显示名称
  • --no-session:禁用会话持久化
  • --session-dir <path>:自定义会话存储目录

协议概览

  • 命令:发送到 stdin 的 JSON 对象,每行一个
  • 响应:带有 type: "response" 的 JSON 对象,表示命令执行成功或失败
  • 事件:以 JSON 行形式流式输出到 stdout 的智能体事件

所有命令都支持可选的 id 字段,用于请求/响应关联。如果提供了 id,对应的响应会包含相同的 id。bash_execution_update 事件也会包含其来源 bash 命令的 id。

分帧

RPC 模式采用严格的 JSONL 语义,仅以 LF(\n)作为记录分隔符。

这一点对客户端很重要:

  • 只按 \n 拆分记录
  • 接受可选的 \r\n 输入,方式是去掉末尾的 \r
  • 不要使用会把 Unicode 分隔符当作换行符的通用行读取器

尤其要注意,Node 的 readline 并不符合 RPC 模式协议的要求,因为它还会在 U+2028 和 U+2029 处拆分,而这两个字符在 JSON 字符串中是合法的。

命令

发送提示

prompt

向智能体发送一条用户提示。命令响应在提示被接受、入队或处理完毕后发出;接受之后,事件仍会继续异步流式输出。

{"id": "req-1", "type": "prompt", "message": "Hello, world!"}

带图片:

{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

流式输出期间:如果智能体已在流式输出中,你必须指定 streamingBehavior 才能把消息入队:

{"type": "prompt", "message": "New instruction", "streamingBehavior": "steer"}
  • "steer":在智能体运行期间把消息加入队列。当前助手回合执行完其工具调用之后、下一次 LLM 调用之前,消息会被投递。
  • "followUp":等到智能体完成。只有在智能体停止后,消息才会被投递。

如果智能体正在流式输出而未指定 streamingBehavior,命令会返回错误。

扩展命令:如果消息是扩展命令(例如 /mycommand),即使在流式输出期间也会立即执行。扩展命令通过 pi.sendMessage() 自行管理其 LLM 交互。

输入展开:技能命令(/skill:name)和提示词模板(/template)会在发送/入队之前先被展开。

响应:

{"id": "req-1", "type": "response", "command": "prompt", "success": true}

success: true 表示提示已被接受、入队或立即处理;success: false 表示提示在接受之前即被拒绝。接受之后的失败会通过正常的事件和消息流报告,而不会针对同一请求 id 再发出一个 response。

images 字段可选。每张图片采用 ImageContent 格式:{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}。

steer

在智能体运行期间把一条转向消息入队。当前助手回合执行完其工具调用之后、下一次 LLM 调用之前,消息会被投递。技能命令和提示词模板会被展开。不允许扩展命令(请改用 prompt)。

{"type": "steer", "message": "Stop and do this instead"}

带图片:

{"type": "steer", "message": "Look at this instead", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

images 字段可选。每张图片采用 ImageContent 格式(与 prompt 相同)。

响应:

{"type": "response", "command": "steer", "success": true}

关于如何控制转向消息的处理方式,参见 set_steering_mode。

follow_up

把一条后续消息入队,待智能体完成后再处理。只有当智能体不再有工具调用或转向消息时,消息才会被投递。技能命令和提示词模板会被展开。不允许扩展命令(请改用 prompt)。

{"type": "follow_up", "message": "After you're done, also do this"}

带图片:

{"type": "follow_up", "message": "Also check this image", "images": [{"type": "image", "data": "base64-encoded-data", "mimeType": "image/png"}]}

images 字段可选。每张图片采用 ImageContent 格式(与 prompt 相同)。

响应:

{"type": "response", "command": "follow_up", "success": true}

关于如何控制后续消息的处理方式,参见 set_follow_up_mode。

abort

中止当前操作,并等待会话进入空闲状态后再返回响应。

{"type": "abort"}

响应:

{"type": "response", "command": "abort", "success": true}

clear_queue

移除已排队的转向和后续消息,并返回它们的文本。

{"type": "clear_queue"}

响应:

{
  "type": "response",
  "command": "clear_queue",
  "success": true,
  "data": {
    "steering": ["Change direction"],
    "followUp": ["Summarize when finished"]
  }
}

要实现交互式的 Esc 行为,可以先发送 clear_queue 再发送 abort,然后把返回的文本恢复到客户端编辑器里。若排队消息仍保留在会话中,abort 会继续处理它们。

new_session

开启一个全新会话。该操作可以被 session_before_switch 扩展事件处理器取消。

{"type": "new_session"}

带可选的父会话跟踪:

{"type": "new_session", "parentSession": "/path/to/parent-session.jsonl"}

响应:

{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}

如果被扩展取消:

{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}

状态

get_state

获取当前会话状态。

{"type": "get_state"}

响应:

{
  "type": "response",
  "command": "get_state",
  "success": true,
  "data": {
    "model": {...},
    "thinkingLevel": "medium",
    "isStreaming": false,
    "isCompacting": false,
    "steeringMode": "all",
    "followUpMode": "one-at-a-time",
    "sessionFile": "/path/to/session.jsonl",
    "sessionId": "abc123",
    "sessionName": "my-feature-work",
    "autoCompactionEnabled": true,
    "messageCount": 5,
    "pendingMessageCount": 0
  }
}

model 字段是完整的 Model 对象或 null。sessionName 字段是通过 set_session_name 设置的显示名称,未设置时省略。

get_messages

获取会话中的所有消息。

{"type": "get_messages"}

响应:

{
  "type": "response",
  "command": "get_messages",
  "success": true,
  "data": {"messages": [...]}
}

消息为 AgentMessage 对象(参见消息类型)。

模型

set_model

切换到特定模型。

{"type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4-20250514"}

响应包含完整的 Model 对象:

{
  "type": "response",
  "command": "set_model",
  "success": true,
  "data": {...}
}

cycle_model

切换到下一个可用模型。如果只有一个可用模型,则返回 null 数据。

{"type": "cycle_model"}

响应:

{
  "type": "response",
  "command": "cycle_model",
  "success": true,
  "data": {
    "model": {...},
    "thinkingLevel": "medium",
    "isScoped": false
  }
}

model 字段是完整的 Model 对象。

get_available_models

列出所有已配置的模型。

{"type": "get_available_models"}

响应包含一个由完整 Model 对象组成的数组:

{
  "type": "response",
  "command": "get_available_models",
  "success": true,
  "data": {
    "models": [...]
  }
}

思考

set_thinking_level

为支持该功能的模型设置推理/思考级别。

{"type": "set_thinking_level", "level": "high"}

级别:"off"、"minimal"、"low"、"medium"、"high"、"xhigh"、"max"

"xhigh" 和 "max" 仅在所选模型支持时才会暴露。部分模型(包括 GPT-5.6)两者都支持。

响应:

{"type": "response", "command": "set_thinking_level", "success": true}

cycle_thinking_level

在可用的思考级别之间循环切换。模型不支持思考时返回 null 数据。

{"type": "cycle_thinking_level"}

响应:

{
  "type": "response",
  "command": "cycle_thinking_level",
  "success": true,
  "data": {"level": "high"}
}

get_available_thinking_levels

列出当前模型支持的思考级别。不支持推理的模型返回 ["off"]。

{"type": "get_available_thinking_levels"}

响应:

{
  "type": "response",
  "command": "get_available_thinking_levels",
  "success": true,
  "data": {
    "levels": ["off", "minimal", "low", "medium", "high"]
  }
}

队列模式

set_steering_mode

控制转向消息(来自 steer)的投递方式。

{"type": "set_steering_mode", "mode": "one-at-a-time"}

模式:

  • "all":当前助手回合执行完其工具调用之后,投递所有转向消息
  • "one-at-a-time":每个完成的助手回合投递一条转向消息(默认)

响应:

{"type": "response", "command": "set_steering_mode", "success": true}

set_follow_up_mode

控制后续消息(来自 follow_up)的投递方式。

{"type": "set_follow_up_mode", "mode": "one-at-a-time"}

模式:

  • "all":智能体完成时投递所有后续消息
  • "one-at-a-time":每次智能体完成时投递一条后续消息(默认)

响应:

{"type": "response", "command": "set_follow_up_mode", "success": true}

上下文压缩

compact

手动压缩会话上下文,以减少 token 用量。

{"type": "compact"}

使用自定义指令:

{"type": "compact", "customInstructions": "Focus on code changes"}

响应:

{
  "type": "response",
  "command": "compact",
  "success": true,
  "data": {
    "summary": "Summary of conversation...",
    "firstKeptEntryId": "abc123",
    "tokensBefore": 150000,
    "estimatedTokensAfter": 32000,
    "usage": {
      "input": 32000,
      "output": 1200,
      "cacheRead": 0,
      "cacheWrite": 0,
      "totalTokens": 33200,
      "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}
    },
    "details": {}
  }
}

estimatedTokensAfter 是对压缩后立即重建的消息上下文的启发式估算,并不是提供商给出的精确 token 计数。usage 报告的是生成摘要的那一次或多次 LLM 调用,自定义压缩处理器可能会省略该字段。

set_auto_compaction

在上下文即将占满时启用或禁用自动压缩。

{"type": "set_auto_compaction", "enabled": true}

响应:

{"type": "response", "command": "set_auto_compaction", "success": true}

重试

set_auto_retry

在出现瞬时错误(过载、限流、5xx)时启用或禁用自动重试。

{"type": "set_auto_retry", "enabled": true}

响应:

{"type": "response", "command": "set_auto_retry", "success": true}

abort_retry

中止正在进行的重试(取消等待并停止重试)。

{"type": "abort_retry"}

响应:

{"type": "response", "command": "abort_retry", "success": true}

Bash

bash

执行 shell 命令并把输出加入会话上下文。命令运行期间,输出以 bash_execution_update 事件流式输出;响应中包含最终结果。

{"id": "req-1", "type": "bash", "command": "ls -la"}

包含 id 可以把流式的 bash_execution_update 事件与该命令关联起来。

响应:

{
  "id": "req-1",
  "type": "response",
  "command": "bash",
  "success": true,
  "data": {
    "output": "total 48\ndrwxr-xr-x ...",
    "exitCode": 0,
    "cancelled": false,
    "truncated": false
  }
}

如果输出被截断,还会包含 fullOutputPath:

{
  "type": "response",
  "command": "bash",
  "success": true,
  "data": {
    "output": "truncated output...",
    "exitCode": 0,
    "cancelled": false,
    "truncated": true,
    "fullOutputPath": "/tmp/pi-bash-abc123.log"
  }
}

bash 结果如何进入 LLM:

bash 命令立即执行并返回一个 BashResult。在内部,会创建一个 BashExecutionMessage 并存入智能体的消息状态。

当下一个 prompt 命令发送时,所有消息(包括 BashExecutionMessage)都会在发送给 LLM 之前先被转换。BashExecutionMessage 会被转换成如下格式的 UserMessage:

Ran `ls -la`
```
total 48
drwxr-xr-x ...
```

这意味着:

  1. bash 输出在下一次 prompt 时才进入 LLM 上下文,而不是立即进入
  2. 在一次 prompt 之前可以执行多条 bash 命令;所有输出都会被包含

abort_bash

中止正在运行的 bash 命令。

{"type": "abort_bash"}

响应:

{"type": "response", "command": "abort_bash", "success": true}

会话

get_session_stats

获取 token 用量、成本统计以及当前上下文窗口的使用情况。

{"type": "get_session_stats"}

响应:

{
  "type": "response",
  "command": "get_session_stats",
  "success": true,
  "data": {
    "sessionFile": "/path/to/session.jsonl",
    "sessionId": "abc123",
    "userMessages": 5,
    "assistantMessages": 5,
    "toolCalls": 12,
    "toolResults": 12,
    "totalMessages": 22,
    "tokens": {
      "input": 50000,
      "output": 10000,
      "cacheRead": 40000,
      "cacheWrite": 5000,
      "total": 105000
    },
    "cost": 0.45,
    "contextUsage": {
      "tokens": 60000,
      "contextWindow": 200000,
      "percent": 30
    }
  }
}

tokens 和 cost 涵盖整个会话中的助手消息、工具报告的用量,以及压缩和分支摘要的生成。contextUsage 包含用于压缩和底部状态栏显示的实际当前上下文窗口估算。

当没有可用模型或上下文窗口时,contextUsage 会被省略。刚完成压缩后,直到新的压缩后助手响应提供有效用量数据之前,contextUsage.tokens 和 contextUsage.percent 均为 null。

export_html

将会话导出为 HTML 文件。

{"type": "export_html"}

使用自定义路径:

{"type": "export_html", "outputPath": "/tmp/session.html"}

响应:

{
  "type": "response",
  "command": "export_html",
  "success": true,
  "data": {"path": "/tmp/session.html"}
}

switch_session

加载另一个会话文件。该操作可以被 session_before_switch 扩展事件处理器取消。

{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}

响应:

{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}

如果切换被扩展取消:

{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}

fork

从活动分支上较早的某条用户消息处创建新的分叉。该操作可以被 session_before_fork 扩展事件处理器取消。返回作为分叉起点的那条消息的文本。

{"type": "fork", "entryId": "abc123"}

响应:

{
  "type": "response",
  "command": "fork",
  "success": true,
  "data": {"text": "The original prompt text...", "cancelled": false}
}

如果分叉被扩展取消:

{
  "type": "response",
  "command": "fork",
  "success": true,
  "data": {"text": "The original prompt text...", "cancelled": true}
}

clone

把当前活动分支在当前位置复制为一个新会话。该操作可以被 session_before_fork 扩展事件处理器取消。

{"type": "clone"}

响应:

{
  "type": "response",
  "command": "clone",
  "success": true,
  "data": {"cancelled": false}
}

如果克隆被扩展取消:

{
  "type": "response",
  "command": "clone",
  "success": true,
  "data": {"cancelled": true}
}

get_fork_messages

获取可用于分叉的用户消息。

{"type": "get_fork_messages"}

响应:

{
  "type": "response",
  "command": "get_fork_messages",
  "success": true,
  "data": {
    "messages": [
      {"entryId": "abc123", "text": "First prompt..."},
      {"entryId": "def456", "text": "Second prompt..."}
    ]
  }
}

get_entries

按追加顺序获取所有会话条目(不含会话头)。会话是一棵具有稳定 id 的只追加条目树,因此条目 id 可以充当持久游标:把你已见过的最后一个条目 id 作为 since 传入,即可只获取严格位于其后的条目,即使客户端重启也一样有效。与 get_messages 不同,它包含压缩前的历史和被放弃的分支。

{"type": "get_entries"}

使用游标:

{"type": "get_entries", "since": "abc123"}

响应:

{
  "type": "response",
  "command": "get_entries",
  "success": true,
  "data": {
    "entries": [
      {"type": "message", "id": "def456", "parentId": "abc123", "timestamp": "...", "message": {"role": "user", "...": "..."}}
    ],
    "leafId": "def456"
  }
}

leafId 是当前叶子条目的 id(空会话时为 null),客户端一次往返即可判断活动分支是否移动。如果 since 不匹配任何条目 id,响应为 success: false。

get_tree

以条目树的形式获取会话。每个节点为 {entry, children, label?, labelTimestamp?}。格式良好的会话只有一个根;孤儿条目(父链断裂的条目)也会作为根出现。

{"type": "get_tree"}

响应:

{
  "type": "response",
  "command": "get_tree",
  "success": true,
  "data": {
    "tree": [
      {
        "entry": {"type": "message", "id": "abc123", "parentId": null, "...": "..."},
        "children": [
          {"entry": {"type": "message", "id": "def456", "parentId": "abc123", "...": "..."}, "children": []}
        ]
      }
    ],
    "leafId": "def456"
  }
}

get_last_assistant_text

获取最后一条助手消息的文本内容。

{"type": "get_last_assistant_text"}

响应:

{
  "type": "response",
  "command": "get_last_assistant_text",
  "success": true,
  "data": {"text": "The assistant's response..."}
}

如果没有助手消息,则返回 {"text": null}。

set_session_name

为当前会话设置显示名称。该名称会出现在会话列表中,便于识别会话。

{"type": "set_session_name", "name": "my-feature-work"}

响应:

{
  "type": "response",
  "command": "set_session_name",
  "success": true
}

当前会话名称可通过 get_state 的 sessionName 字段获取。要在启动 RPC 模式时设置初始名称,请为 pi --mode rpc 进程传入 --name <name> 或 -n <name>。

可用命令

get_commands

获取可用命令(扩展命令、提示词模板和技能)。这些命令可以通过 prompt 命令调用,只需加上 / 前缀。

{"type": "get_commands"}

响应:

{
  "type": "response",
  "command": "get_commands",
  "success": true,
  "data": {
    "commands": [
      {"name": "session-name", "description": "Set or clear session name", "source": "extension", "path": "/home/user/.pi/agent/extensions/session.ts"},
      {"name": "fix-tests", "description": "Fix failing tests", "source": "prompt", "location": "project", "path": "/home/user/myproject/.pi/agent/prompts/fix-tests.md"},
      {"name": "skill:brave-search", "description": "Web search via Brave API", "source": "skill", "location": "user", "path": "/home/user/.pi/agent/skills/brave-search/SKILL.md"}
    ]
  }
}

每条命令包含:

  • name:命令名(用 /name 调用)
  • description:人类可读的描述(扩展命令可选)
  • source:命令的种类:
    • "extension":由扩展中的 pi.registerCommand() 注册
    • "prompt":从提示词模板 .md 文件加载
    • "skill":从技能目录加载(名称带 skill: 前缀)
  • location:加载来源(可选,扩展没有该字段):
    • "user":用户级(~/.pi/agent/)
    • "project":项目级(./.pi/agent/)
    • "path":通过 CLI 或设置显式指定的路径
  • path:命令源文件的绝对路径(可选)

注意:不包含内置 TUI 命令(/settings、/hotkeys 等)。它们只在交互模式下处理,如果通过 prompt 发送并不会执行。

事件

事件在智能体运行期间以 JSON 行的形式流式输出到 stdout。事件通常不包含 id 字段;当来源 bash 命令提供了 id 时,bash_execution_update 会包含该 id。

事件类型

事件说明
agent_start智能体开始处理
agent_end一次低层智能体运行完成(之后可能仍有重试、压缩或排队的延续)
agent_settled智能体运行已完全稳定;不再有自动重试、压缩重试或排队的延续
turn_start新回合开始
turn_end回合完成(包含助手消息和工具结果)
message_start消息开始
message_update流式更新(文本/思考/工具调用增量)
message_end消息完成
bash_execution_update直接 RPC bash 命令的输出块
tool_execution_start工具开始执行
tool_execution_update工具执行进度(流式输出)
tool_execution_end工具执行完成
queue_update待处理的转向/后续队列发生变化
compaction_start压缩开始
compaction_end压缩完成
auto_retry_start自动重试开始(发生在瞬时错误之后)
auto_retry_end自动重试结束(成功或最终失败)
summarization_retry_scheduled已为瞬时的压缩或分支摘要生成错误安排重试
summarization_retry_attempt_start重试的摘要生成请求开始
summarization_retry_finished摘要生成重试循环结束
extension_error扩展抛出了错误

agent_start

当智能体开始处理一条提示时发出。

{"type": "agent_start"}

agent_end

当一次低层智能体运行完成时发出。包含这次运行期间生成的所有消息。如果 willRetry 为 true,随后会进行自动重试。

{
  "type": "agent_end",
  "messages": [...],
  "willRetry": false
}

agent_settled

在完整的会话级运行稳定之后发出。此时,pi 不会再通过重试、压缩重试或排队的后续消息自动继续。

{"type": "agent_settled"}

turn_start / turn_end

一个回合(turn)由一次助手响应及其产生的工具调用和结果组成。

{"type": "turn_start"}
{
  "type": "turn_end",
  "message": {...},
  "toolResults": [...]
}

message_start / message_end

在消息开始和完成时发出。message 字段包含一个 AgentMessage。

{"type": "message_start", "message": {...}}
{"type": "message_end", "message": {...}}

message_update(流式)

在助手消息流式输出期间发出。包含一个增量(delta)事件,不含累积的消息快照。

{
  "type": "message_update",
  "usage": {
    "input": 100,
    "output": 1,
    "cacheRead": 0,
    "cacheWrite": 0,
    "totalTokens": 101,
    "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0, "total": 0}
  },
  "assistantMessageEvent": {
    "type": "text_delta",
    "contentIndex": 0,
    "delta": "Hello "
  }
}

assistantMessageEvent 字段包含以下增量类型之一:

类型说明
text_start文本内容块开始
text_delta文本内容块
text_end文本内容块结束
thinking_start思考块开始
thinking_delta思考内容块
thinking_end思考块结束
toolcall_start工具调用开始(包含 id 和 toolName)
toolcall_delta工具调用参数块
toolcall_end工具调用结束(包含完整的 toolCall 对象)

流式输出文本响应的示例:

{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0}}
{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":" world"}}
{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"Hello world"}}

顶层的 usage 字段包含提供商报告的最新累计用量。如果提供商在流式输出期间不报告用量,该字段可能一直为零,直到完成。

启动工具调用的示例:

{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"toolcall_start","contentIndex":1,"id":"call_abc123","toolName":"write"}}

message_update 有意省略了以往的累积 message 字段和 assistantMessageEvent.partial。需要实时部分消息的客户端必须利用 contentIndex,从 message_start 及后续事件自行组装。应以 message_end.message 为准。对于工具调用,toolcall_start 会提供调用 id 和 toolName;请为参数缓冲 toolcall_delta.delta。toolcall_end.toolCall 包含已完成的调用。

bash_execution_update

直接 bash 命令的每个输出块发出一次。id 与命令的 id 匹配,客户端可据此将输出关联到正确的命令。

命令运行期间,事件会流式输出全部输出,即使最终 bash 响应的 output 被截断。

{
  "type": "bash_execution_update",
  "id": "req-1",
  "delta": "total 48\n"
}

tool_execution_start / tool_execution_update / tool_execution_end

在工具开始执行、流式输出进度以及执行完成时发出。

{
  "type": "tool_execution_start",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "args": {"command": "ls -la"}
}

执行期间,tool_execution_update 事件会流式输出部分结果(例如随到随发的 bash 输出):

{
  "type": "tool_execution_update",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "args": {"command": "ls -la"},
  "partialResult": {
    "content": [{"type": "text", "text": "partial output so far..."}],
    "details": {"truncation": null, "fullOutputPath": null}
  }
}

完成时:

{
  "type": "tool_execution_end",
  "toolCallId": "call_abc123",
  "toolName": "bash",
  "result": {
    "content": [{"type": "text", "text": "total 48\n..."}],
    "details": {...}
  },
  "isError": false
}

使用 toolCallId 关联事件。tool_execution_update 中的 partialResult 包含到目前为止累积的输出(而不只是增量),客户端可以在每次更新时直接替换自己的显示。

queue_update

每当待处理的转向或后续队列发生变化时发出。

{
  "type": "queue_update",
  "steering": ["Focus on error handling"],
  "followUp": ["After that, summarize the result"]
}

compaction_start / compaction_end

在压缩运行时发出,无论是手动还是自动。

{"type": "compaction_start", "reason": "threshold"}

reason 字段为 "manual"、"threshold" 或 "overflow"。

{
  "type": "compaction_end",
  "reason": "threshold",
  "result": {
    "summary": "Summary of conversation...",
    "firstKeptEntryId": "abc123",
    "tokensBefore": 150000,
    "estimatedTokensAfter": 32000,
    "usage": {
      "input": 32000,
      "output": 1200,
      "cacheRead": 0,
      "cacheWrite": 0,
      "totalTokens": 33200,
      "cost": {"input": 0.01, "output": 0.02, "cacheRead": 0, "cacheWrite": 0, "total": 0.03}
    },
    "details": {}
  },
  "aborted": false,
  "willRetry": false
}

如果 reason 为 "overflow" 且压缩成功,则 willRetry 为 true,智能体会自动重试该提示。

如果压缩被中止,result 为 null 且 aborted 为 true。

如果压缩失败(例如 API 配额超限),result 为 null、aborted 为 false,errorMessage 包含错误描述。

auto_retry_start / auto_retry_end

在瞬时错误(过载、限流、5xx)之后触发自动重试时发出。

{
  "type": "auto_retry_start",
  "attempt": 1,
  "maxAttempts": 3,
  "delayMs": 2000,
  "errorMessage": "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
}
{
  "type": "auto_retry_end",
  "success": true,
  "attempt": 2
}

最终失败(超过最大重试次数)时:

{
  "type": "auto_retry_end",
  "success": false,
  "attempt": 3,
  "finalError": "529 overloaded_error: Overloaded"
}

summarization_retry_scheduled / summarization_retry_attempt_start / summarization_retry_finished

当压缩或分支摘要生成在瞬时提供商错误后重试时发出。这些事件使用与助手回合自动重试相同的重试设置。

{
  "type": "summarization_retry_scheduled",
  "attempt": 1,
  "maxAttempts": 3,
  "delayMs": 2000,
  "errorMessage": "terminated"
}
{
  "type": "summarization_retry_attempt_start",
  "source": "compaction",
  "reason": "threshold"
}

对于分支摘要,source 为 "branchSummary",且没有 reason 字段。

{
  "type": "summarization_retry_finished"
}

extension_error

当扩展抛出错误时发出。

{
  "type": "extension_error",
  "extensionPath": "/path/to/extension.ts",
  "event": "tool_call",
  "error": "Error message..."
}

扩展 UI 协议

扩展可以通过 ctx.ui.select()、ctx.ui.confirm() 等方法请求用户交互。在 RPC 模式中,这些调用会被转换为一个构建在基础命令/事件流之上的请求/响应子协议。

扩展 UI 方法分为两类:

  • 对话框方法(select、confirm、input、editor):在 stdout 上发出一个 extension_ui_request,并阻塞等待客户端在 stdin 上发回带有匹配 id 的 extension_ui_response。
  • 即发即弃方法(notify、setStatus、setWidget、setTitle、set_editor_text):在 stdout 上发出 extension_ui_request,但不期待响应。客户端可以显示这些信息,也可以忽略。

如果对话框方法带有 timeout 字段,超时到期时智能体一侧会自动以默认值解决该请求。客户端不需要跟踪超时。

一些 ExtensionUIContext 方法在 RPC 模式中不受支持或被降级,因为它们需要直接访问 TUI:

  • custom() 返回 undefined
  • setWorkingMessage()、setWorkingIndicator()、setFooter()、setHeader()、setEditorComponent()、setToolsExpanded() 是空操作
  • getEditorText() 返回 ""
  • getToolsExpanded() 返回 false
  • pasteToEditor() 委托给 setEditorText()(不做粘贴/收起处理)
  • getAllThemes() 返回 []
  • getTheme() 返回 undefined
  • setTheme() 返回 { success: false, error: "..." }

注意:RPC 模式下 ctx.mode 为 "rpc",且 ctx.hasUI 为 true,因为对话框方法和即发即弃方法都能通过扩展 UI 子协议正常工作。要保护 custom() 这类需要真实终端的 TUI 专属功能,请使用 ctx.mode === "tui" 判断。

扩展 UI 请求(stdout)

所有请求都带有 type: "extension_ui_request"、唯一的 id 和 method 字段。

select

提示用户从列表中选择。带 timeout 字段的对话框方法会包含以毫秒为单位的超时值;如果客户端没有及时响应,智能体会自动以 undefined 作为结果。

{
  "type": "extension_ui_request",
  "id": "uuid-1",
  "method": "select",
  "title": "Allow dangerous command?",
  "options": ["Allow", "Block"],
  "timeout": 10000
}

预期响应:带有 value(选中的选项字符串)或 cancelled: true 的 extension_ui_response。

confirm

提示用户进行是/否确认。

{
  "type": "extension_ui_request",
  "id": "uuid-2",
  "method": "confirm",
  "title": "Clear session?",
  "message": "All messages will be lost.",
  "timeout": 5000
}

预期响应:带有 confirmed: true/false 或 cancelled: true 的 extension_ui_response。

input

提示用户输入自由格式的文本。

{
  "type": "extension_ui_request",
  "id": "uuid-3",
  "method": "input",
  "title": "Enter a value",
  "placeholder": "type something..."
}

预期响应:带有 value(输入的文本)或 cancelled: true 的 extension_ui_response。

editor

打开一个多行文本编辑器,可选预填内容。

{
  "type": "extension_ui_request",
  "id": "uuid-4",
  "method": "editor",
  "title": "Edit some text",
  "prefill": "Line 1\nLine 2\nLine 3"
}

预期响应:带有 value(编辑后的文本)或 cancelled: true 的 extension_ui_response。

notify

显示一条通知。即发即弃,不期待响应。

{
  "type": "extension_ui_request",
  "id": "uuid-5",
  "method": "notify",
  "message": "Command blocked by user",
  "notifyType": "warning"
}

notifyType 字段为 "info"、"warning" 或 "error"。省略时默认为 "info"。

setStatus

在底部/状态栏中设置或清除一个状态项。即发即弃。

{
  "type": "extension_ui_request",
  "id": "uuid-6",
  "method": "setStatus",
  "statusKey": "my-ext",
  "statusText": "Turn 3 running..."
}

发送 statusText: undefined(或省略该字段)即可清除该键的状态项。

setWidget

设置或清除显示在编辑器上方或下方的小组件(widget,多行文本块)。即发即弃。

{
  "type": "extension_ui_request",
  "id": "uuid-7",
  "method": "setWidget",
  "widgetKey": "my-ext",
  "widgetLines": ["--- My Widget ---", "Line 1", "Line 2"],
  "widgetPlacement": "aboveEditor"
}

发送 widgetLines: undefined(或省略该字段)即可清除小组件。widgetPlacement 字段为 "aboveEditor"(默认)或 "belowEditor"。RPC 模式只支持字符串数组;组件工厂会被忽略。

setTitle

设置终端窗口/标签页标题。即发即弃。

{
  "type": "extension_ui_request",
  "id": "uuid-8",
  "method": "setTitle",
  "title": "pi - my project"
}

set_editor_text

在输入编辑器中设置文本。即发即弃。

{
  "type": "extension_ui_request",
  "id": "uuid-9",
  "method": "set_editor_text",
  "text": "prefilled text for the user"
}

扩展 UI 响应(stdin)

只有对话框方法(select、confirm、input、editor)需要发送响应。id 必须与请求匹配。

值响应(select、input、editor)

{"type": "extension_ui_response", "id": "uuid-1", "value": "Allow"}

确认响应(confirm)

{"type": "extension_ui_response", "id": "uuid-2", "confirmed": true}

取消响应(任意对话框)

关闭任意对话框方法。扩展会收到 undefined(select/input/editor)或 false(confirm)。

{"type": "extension_ui_response", "id": "uuid-3", "cancelled": true}

错误处理

失败的命令会返回 success: false 的响应:

{
  "type": "response",
  "command": "set_model",
  "success": false,
  "error": "Model not found: invalid/model"
}

解析错误:

{
  "type": "response",
  "command": "parse",
  "success": false,
  "error": "Failed to parse command: Unexpected token..."
}

类型

源码文件:

Model

{
  "id": "claude-sonnet-4-20250514",
  "name": "Claude Sonnet 4",
  "api": "anthropic-messages",
  "provider": "anthropic",
  "baseUrl": "https://api.anthropic.com",
  "reasoning": true,
  "input": ["text", "image"],
  "contextWindow": 200000,
  "maxTokens": 16384,
  "cost": {
    "input": 3.0,
    "output": 15.0,
    "cacheRead": 0.3,
    "cacheWrite": 3.75
  }
}

UserMessage

{
  "role": "user",
  "content": "Hello!",
  "timestamp": 1733234567890,
  "attachments": []
}

content 字段可以是字符串,也可以是 TextContent/ImageContent 块的数组。

AssistantMessage

{
  "role": "assistant",
  "content": [
    {"type": "text", "text": "Hello! How can I help?"},
    {"type": "thinking", "thinking": "User is greeting me..."},
    {"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
  ],
  "api": "anthropic-messages",
  "provider": "anthropic",
  "model": "claude-sonnet-4-20250514",
  "usage": {
    "input": 100,
    "output": 50,
    "cacheRead": 0,
    "cacheWrite": 0,
    "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
  },
  "stopReason": "stop",
  "timestamp": 1733234567890
}

停止原因:"stop"、"length"、"toolUse"、"error"、"aborted"

ToolResultMessage

{
  "role": "toolResult",
  "toolCallId": "call_123",
  "toolName": "bash",
  "content": [{"type": "text", "text": "total 48\ndrwxr-xr-x ..."}],
  "usage": {
    "input": 100,
    "output": 50,
    "cacheRead": 0,
    "cacheWrite": 0,
    "totalTokens": 150,
    "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
  },
  "isError": false,
  "timestamp": 1733234567890
}

usage 为可选字段,报告由工具执行的嵌套 LLM 工作。存在时,它会计入会话的 token 和成本总计。

BashExecutionMessage

由 bash RPC 命令创建(而不是由 LLM 工具调用创建):

{
  "role": "bashExecution",
  "command": "ls -la",
  "output": "total 48\ndrwxr-xr-x ...",
  "exitCode": 0,
  "cancelled": false,
  "truncated": false,
  "fullOutputPath": null,
  "timestamp": 1733234567890
}

Attachment

{
  "id": "img1",
  "type": "image",
  "fileName": "photo.jpg",
  "mimeType": "image/jpeg",
  "size": 102400,
  "content": "base64-encoded-data...",
  "extractedText": null,
  "preview": null
}

示例:基础客户端(Python)

import subprocess
import json

proc = subprocess.Popen(
    ["pi", "--mode", "rpc", "--no-session"],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    text=True
)

def send(cmd):
    proc.stdin.write(json.dumps(cmd) + "\n")
    proc.stdin.flush()

def read_events():
    for line in proc.stdout:
        yield json.loads(line)

# Send prompt
send({"type": "prompt", "message": "Hello!"})

# Process events
for event in read_events():
    if event.get("type") == "message_update":
        delta = event.get("assistantMessageEvent", {})
        if delta.get("type") == "text_delta":
            print(delta["delta"], end="", flush=True)
    
    if event.get("type") == "agent_end":
        print()
        break

示例:交互式客户端(Node.js)

完整的交互式示例参见 test/rpc-example.ts,类型化客户端实现参见 src/modes/rpc/rpc-client.ts。

处理扩展 UI 协议的完整示例参见 examples/rpc-extension-ui.ts,它与扩展 examples/extensions/rpc-demo.ts 配套使用。

const { spawn } = require("child_process");
const { StringDecoder } = require("string_decoder");

const agent = spawn("pi", ["--mode", "rpc", "--no-session"]);

function attachJsonlReader(stream, onLine) {
    const decoder = new StringDecoder("utf8");
    let buffer = "";

    stream.on("data", (chunk) => {
        buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);

        while (true) {
            const newlineIndex = buffer.indexOf("\n");
            if (newlineIndex === -1) break;

            let line = buffer.slice(0, newlineIndex);
            buffer = buffer.slice(newlineIndex + 1);
            if (line.endsWith("\r")) line = line.slice(0, -1);
            onLine(line);
        }
    });

    stream.on("end", () => {
        buffer += decoder.end();
        if (buffer.length > 0) {
            onLine(buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer);
        }
    });
}

attachJsonlReader(agent.stdout, (line) => {
    const event = JSON.parse(line);

    if (event.type === "message_update") {
        const { assistantMessageEvent } = event;
        if (assistantMessageEvent.type === "text_delta") {
            process.stdout.write(assistantMessageEvent.delta);
        }
    }
});

// Send prompt
agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n");

// Abort on Ctrl+C
process.on("SIGINT", () => {
    agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
});

22 JSON 事件流模式

以 JSON Lines 输出 pi 会话事件,便于将 pi 集成到其他工具或自定义界面。
pi --mode json "Your prompt"

该模式将所有会话事件以 JSON 行的形式输出到 stdout。适合将 pi 集成到其他工具或自定义界面。

事件类型

线上传输的事件使用 JsonAgentSessionEvent。它与 AgentSessionEvent 一致,区别在于流式消息更新会省略累积快照:

type WithoutPartial<T> = T extends { partial: unknown } ? Omit<T, "partial"> : T;

type JsonAssistantMessageEvent<T> = T extends { type: "toolcall_start"; partial: unknown }
  ? WithoutPartial<T> & { id: string; toolName: string }
  : WithoutPartial<T>;

type JsonAgentSessionEvent =
  | Exclude<AgentSessionEvent, { type: "message_update" }>
  | {
      type: "message_update";
      usage: Usage;
      assistantMessageEvent: JsonAssistantMessageEvent<AssistantMessageEvent>;
    };

queue_update 会在待处理的转向队列和后续任务队列发生变化时输出完整队列。compaction_start 和 compaction_end 同时覆盖手动和自动上下文压缩。

其他基础事件来自 AgentEvent:

type AgentEvent =
  // Agent lifecycle
  | { type: "agent_start" }
  | { type: "agent_end"; messages: AgentMessage[] }
  // Turn lifecycle
  | { type: "turn_start" }
  | { type: "turn_end"; message: AgentMessage; toolResults: ToolResultMessage[] }
  // Message lifecycle
  | { type: "message_start"; message: AgentMessage }
  | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
  | { type: "message_end"; message: AgentMessage }
  // Tool execution
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
  | { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError: boolean };

消息类型

基础消息定义于 packages/ai/src/types.ts:

  • UserMessage(第 134 行)
  • AssistantMessage(第 140 行)
  • ToolResultMessage(第 152 行)

扩展消息定义于 packages/coding-agent/src/core/messages.ts:

  • BashExecutionMessage(第 29 行)
  • CustomMessage(第 46 行)
  • BranchSummaryMessage(第 55 行)
  • CompactionSummaryMessage(第 62 行)

输出格式

每一行都是一个 JSON 对象。第一行是会话头:

{"type":"session","version":3,"id":"uuid","timestamp":"...","cwd":"/path"}

随后是按发生顺序输出的事件:

{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"assistant","content":[],...}}
{"type":"message_update","usage":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello"}}
{"type":"message_end","message":{...}}
{"type":"turn_end","message":{...},"toolResults":[]}
{"type":"agent_end","messages":[...]}

message_update 记录仅包含增量。它们同时省略了累积的 message 字段和 assistantMessageEvent.partial,以保持流大小的线性增长。顶层的 usage 字段包含 提供商报告的最新累计用量;如果提供商只在完成时上报用量,该字段可能一直为零。如有需要, 可以使用 contentIndex 和 delta 拼装实时的文本、思考内容或工具调用参数。toolcall_start 事件还包含大小固定的 id 和 toolName 字段。message_end 包含最终权威的消息。

示例

pi --mode json "List files" 2>/dev/null | jq -c 'select(.type == "message_end")'

23 TUI 组件

介绍 pi 的 TUI 组件系统:组件接口、IME 支持、覆盖层、内置组件及键盘鼠标输入。

pi 可以帮你创建 TUI 组件。可以请它为你的使用场景构建一个。

扩展和自定义工具可以渲染自定义 TUI 组件,构建交互式用户界面。本页介绍组件系统以及可用的小组件。

源码: @earendil-works/pi-tui

组件接口

所有组件都要实现:

interface Component {
  render(width: number): string[];
  handleInput?(data: string): void;
  handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
  wantsKeyRelease?: boolean;
  invalidate(): void;
}
方法说明
render(width)返回字符串数组(每行一个)。每行不得超过 width。
handleInput?(data)在组件获得焦点时接收键盘输入。
handleMouse?(event)在全屏模式下接收归一化的指针输入。
wantsKeyRelease?若为 true,组件会接收按键释放事件(Kitty 协议)。默认:false。
invalidate()清除缓存的渲染状态。在主题变更时调用。

TUI 会在每一行渲染输出的末尾附加完整的 SGR 重置和 OSC 8 重置。样式不会跨行保留。如果你要输出带样式的多行文本,请逐行重新应用样式,或使用 wrapTextWithAnsi(),让折行后的每一行都保留样式。

Focusable 接口(IME 支持)

显示文本光标并需要 IME(输入法编辑器)支持的组件应当实现 Focusable 接口:

import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";

class MyInput implements Component, Focusable {
  focused: boolean = false;  // Set by TUI when focus changes
  
  render(width: number): string[] {
    const marker = this.focused ? CURSOR_MARKER : "";
    // Emit marker right before the fake cursor
    return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`];
  }
}

当 Focusable 组件获得焦点时,TUI 会:

  1. 将组件的 focused 设置为 true
  2. 扫描渲染输出中的 CURSOR_MARKER(一个零宽 APC 转义序列)
  3. 将硬件终端光标定位到该位置
  4. 仅在启用了 showHardwareCursor 时才显示硬件光标

光标默认保持隐藏。这样既能继续渲染伪造的光标,又能为那些在光标隐藏时仍跟踪 IME 候选窗口的终端定位硬件光标。某些终端要求 IME 定位必须有可见的硬件光标;可以通过渲染器的 showHardwareCursor 构造参数或 setShowHardwareCursor(true) 来启用。pi 在创建渲染器之前还会把 PI_HARDWARE_CURSOR=1 映射到该设置。内置的 Editor 和 Input 组件已经实现了该接口。

内嵌输入组件的容器

当容器组件(对话框、选择器等)包含 Input 或 Editor 子组件时,容器必须实现 Focusable,并把焦点状态传递给子组件。否则,硬件光标将无法为 IME 输入正确定位。

import { Container, type Focusable, Input } from "@earendil-works/pi-tui";

class SearchDialog extends Container implements Focusable {
  private searchInput: Input;

  // Focusable implementation - propagate to child input for IME cursor positioning
  private _focused = false;
  get focused(): boolean {
    return this._focused;
  }
  set focused(value: boolean) {
    this._focused = value;
    this.searchInput.focused = value;
  }

  constructor() {
    super();
    this.searchInput = new Input();
    this.addChild(this.searchInput);
  }
}

如果没有这个传递,使用输入法(中文、日文、韩文等)输入时,候选窗口会显示在屏幕上错误的位置。

使用组件

在扩展中通过 ctx.ui.custom() 使用:

pi.on("session_start", async (_event, ctx) => {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
});

在自定义工具中通过 ctx.ui.custom() 使用:

async execute(toolCallId, params, signal, onUpdate, ctx) {
  const result = await ctx.ui.custom<string | null>((tui, theme, keybindings, done) =>
    new MyComponent({
      theme,
      keybindings,
      onChange: () => tui.requestRender(),
      onSelect: (value) => done(value),
      onCancel: () => done(null),
    })
  );
  // Use result...
}

覆盖层

覆盖层(overlay)在现有内容之上渲染组件,而不会清空屏幕。向 ctx.ui.custom() 传入 { overlay: true }:

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new MyDialog({ onClose: done }),
  { overlay: true }
);

要控制定位和尺寸,可使用 overlayOptions:

const result = await ctx.ui.custom<string | null>(
  (tui, theme, keybindings, done) => new SidePanel({ onClose: done }),
  {
    overlay: true,
    overlayOptions: {
      // Size: number or percentage string
      width: "50%",          // 50% of terminal width
      minWidth: 40,          // minimum 40 columns
      maxHeight: "80%",      // max 80% of terminal height

      // Position: anchor-based (default: "center")
      anchor: "right-center", // 9 positions: center, top-left, top-center, etc.
      offsetX: -2,            // offset from anchor
      offsetY: 0,

      // Or percentage/absolute positioning
      row: "25%",            // 25% from top
      col: 10,               // column 10

      // Margins
      margin: 2,             // all sides, or { top, right, bottom, left }

      // Responsive: hide on narrow terminals
      visible: (termWidth, termHeight) => termWidth >= 80,
    },
    // Get handle for programmatic focus and visibility control
    onHandle: (handle) => {
      // handle.focus() - focus this overlay and bring it to the visual front
      // handle.unfocus() - release input to normal fallback
      // handle.unfocus({ target }) - release input to a specific component or null
      // handle.setHidden(true/false) - toggle visibility
      // handle.hide() - permanently remove
    },
  }
);

覆盖层焦点

获得焦点的可见覆盖层会在临时的非覆盖层 UI 期间保持输入所有权。如果覆盖层打开另一个未带 { overlay: true } 的 ctx.ui.custom() 组件,那么在这个替换 UI 处于活动状态期间由它接收输入;当它关闭后,获得焦点的覆盖层可以收回输入。

当某个可见覆盖层应当停止持有输入、让 TUI 回退到另一个可见的捕获型覆盖层或之前的焦点目标时,使用 handle.unfocus()。当某个特定组件应当在覆盖层保持可见的同时接收输入时,使用 handle.unfocus({ target })。传入 { target: null } 会刻意让焦点悬空,直到再次设置焦点。

覆盖层生命周期

覆盖层组件在关闭时会被销毁。不要复用旧引用——请创建新的实例:

// Wrong - stale reference
let menu: MenuComponent;
await ctx.ui.custom((_, __, ___, done) => {
  menu = new MenuComponent(done);
  return menu;
}, { overlay: true });
setActiveComponent(menu);  // Disposed

// Correct - re-call to re-show
const showMenu = () => ctx.ui.custom((_, __, ___, done) => 
  new MenuComponent(done), { overlay: true });

await showMenu();  // First show
await showMenu();  // "Back" = just call again

涵盖锚点定位、边距、堆叠、响应式可见性和动画的完整示例,请参见 overlay-qa-tests.ts。

内置组件

从 @earendil-works/pi-tui 导入:

import { Text, Box, Container, Spacer, Markdown } from "@earendil-works/pi-tui";

Text

带自动换行的多行文本。

const text = new Text(
  "Hello World",    // content
  1,                // paddingX (default: 1)
  1,                // paddingY (default: 1)
  (s) => bgGray(s)  // optional background function
);
text.setText("Updated");

Box

带内边距和背景色的容器。

const box = new Box(
  1,                // paddingX
  1,                // paddingY
  (s) => bgGray(s)  // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));

Container

垂直排列子组件。

const container = new Container();
container.addChild(component1);
container.addChild(component2);
container.removeChild(component1);

Spacer

垂直空白区域。

const spacer = new Spacer(2);  // 2 empty lines

Markdown

渲染 markdown 并带语法高亮。

const md = new Markdown(
  "# Title\n\nSome **bold** text",
  1,        // paddingX
  1,        // paddingY
  theme     // MarkdownTheme (see below)
);
md.setText("Updated markdown");

Image

在支持的终端(Kitty、iTerm2、Ghostty、WezTerm、Warp)中渲染图片。

const image = new Image(
  base64Data,   // base64-encoded image
  "image/png",  // MIME type
  theme,        // ImageTheme
  { maxWidthCells: 80, maxHeightCells: 24 }
);

键盘输入

使用 matchesKey() 进行按键检测:

import { matchesKey, Key } from "@earendil-works/pi-tui";

handleInput(data: string) {
  if (matchesKey(data, Key.up)) {
    this.selectedIndex--;
  } else if (matchesKey(data, Key.enter)) {
    this.onSelect?.(this.selectedIndex);
  } else if (matchesKey(data, Key.escape)) {
    this.onCancel?.();
  } else if (matchesKey(data, Key.ctrl("c"))) {
    // Ctrl+C
  }
}

按键标识符(使用 Key.* 可获得自动补全,也可使用字符串字面量):

  • 基本按键:Key.enter、Key.escape、Key.tab、Key.space、Key.backspace、Key.delete、Key.home、Key.end
  • 方向键:Key.up、Key.down、Key.left、Key.right
  • 带修饰键:Key.ctrl("c")、Key.shift("tab")、Key.alt("left")、Key.ctrlShift("p")
  • 字符串格式同样有效:"enter"、"ctrl+c"、"shift+tab"、"ctrl+shift+p"

鼠标输入

全屏模式会把归一化的按下、释放、点击、移动、拖拽和滚轮事件路由给组件和覆盖层。返回 { handled: true } 可抑制默认行为,capture: true 可保留拖拽/释放所有权,focus: true 可请求键盘焦点,悬停或释放导致组件外观发生可见变化时返回 render: true。按下、点击、拖拽和滚轮事件默认会触发渲染;无实际作用的移动/释放事件则不会。

import { MouseRegion } from "@earendil-works/pi-tui";

const clickable = new MouseRegion(content, (event) => {
  if (event.type !== "click" || event.button !== "left") return undefined;
  expanded = !expanded;
  return { handled: true };
});

未处理的滚轮输入会滚动最近的 ScrollView;未处理的主键拖拽会保留会话记录的选区。OSC 8 链接的优先级高于父级点击区域。Input、Editor、SelectList 和 SettingsList 自带全屏鼠标行为。普通模式不会捕获鼠标输入,因为滚动回溯区由终端自己管理。

行宽

关键要求: render() 返回的每一行都不得超过 width 参数。

import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";

render(width: number): string[] {
  // Truncate long lines
  return [truncateToWidth(this.text, width)];
}

工具函数:

  • visibleWidth(str) - 获取显示宽度(忽略 ANSI 码)
  • truncateToWidth(str, width, ellipsis?) - 截断文本,可选省略号
  • wrapTextWithAnsi(str, width) - 保留 ANSI 码的自动换行

创建自定义组件

示例:交互式选择器

import {
  matchesKey, Key,
  truncateToWidth, visibleWidth
} from "@earendil-works/pi-tui";

class MySelector {
  private items: string[];
  private selected = 0;
  private cachedWidth?: number;
  private cachedLines?: string[];
  
  public onSelect?: (item: string) => void;
  public onCancel?: () => void;

  constructor(items: string[]) {
    this.items = items;
  }

  handleInput(data: string): void {
    if (matchesKey(data, Key.up) && this.selected > 0) {
      this.selected--;
      this.invalidate();
    } else if (matchesKey(data, Key.down) && this.selected < this.items.length - 1) {
      this.selected++;
      this.invalidate();
    } else if (matchesKey(data, Key.enter)) {
      this.onSelect?.(this.items[this.selected]);
    } else if (matchesKey(data, Key.escape)) {
      this.onCancel?.();
    }
  }

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }

    this.cachedLines = this.items.map((item, i) => {
      const prefix = i === this.selected ? "> " : "  ";
      return truncateToWidth(prefix + item, width);
    });
    this.cachedWidth = width;
    return this.cachedLines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

在扩展中的用法:

pi.registerCommand("pick", {
  description: "Pick an item",
  handler: async (_args, ctx) => {
    const items = ["Option A", "Option B", "Option C"];
    const selected = await ctx.ui.custom<string | null>((tui, _theme, _keybindings, done) => {
      const selector = new MySelector(items);
      selector.onSelect = done;
      selector.onCancel = () => done(null);

      return {
        render: (width) => selector.render(width),
        handleInput: (data) => {
          selector.handleInput(data);
          tui.requestRender();
        },
        invalidate: () => selector.invalidate(),
      };
    });

    if (selected !== null) {
      ctx.ui.notify(`Selected: ${selected}`, "info");
    }
  }
});

主题化

组件接受主题对象来进行样式设置。

在 renderCall/renderResult 中,使用 theme 参数:

renderResult(result, options, theme, context) {
  // Use theme.fg() for foreground colors
  return new Text(theme.fg("success", "Done!"), 0, 0);
  
  // Use theme.bg() for background colors
  const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}

前景色(theme.fg(color, text)):

类别颜色
常规text、accent、muted、dim、searchMatchText
状态success、error、warning
边框border、borderAccent、borderMuted
消息userMessageText、customMessageText、customMessageLabel
工具toolTitle、toolOutput
差异toolDiffAdded、toolDiffRemoved、toolDiffContext
MarkdownmdHeading、mdLink、mdLinkUrl、mdCode、mdCodeBlock、mdCodeBlockBorder、mdQuote、mdQuoteBorder、mdHr、mdListBullet
语法syntaxComment、syntaxKeyword、syntaxFunction、syntaxVariable、syntaxString、syntaxNumber、syntaxType、syntaxOperator、syntaxPunctuation
思考强度thinkingOff、thinkingMinimal、thinkingLow、thinkingMedium、thinkingHigh、thinkingXhigh、thinkingMax
模式bashMode

背景色(theme.bg(color, text)):

selectedBg、searchMatchBg、userMessageBg、customMessageBg、toolPendingBg、toolSuccessBg、toolErrorBg

对 Markdown,使用 getMarkdownTheme():

import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Markdown } from "@earendil-works/pi-tui";

renderResult(result, options, theme, context) {
  const mdTheme = getMarkdownTheme();
  return new Markdown(result.details.markdown, 0, 0, mdTheme);
}

对自定义组件,定义你自己的主题接口:

interface MyTheme {
  selected: (s: string) => string;
  normal: (s: string) => string;
}

调试日志

设置 PI_TUI_WRITE_LOG 可以捕获写入 stdout 的原始 ANSI 流。

PI_TUI_WRITE_LOG=/tmp/tui-ansi.log npx tsx packages/tui/test/chat-simple.ts

性能

尽可能缓存渲染输出:

class CachedComponent {
  private cachedWidth?: number;
  private cachedLines?: string[];

  render(width: number): string[] {
    if (this.cachedLines && this.cachedWidth === width) {
      return this.cachedLines;
    }
    // ... compute lines ...
    this.cachedWidth = width;
    this.cachedLines = lines;
    return lines;
  }

  invalidate(): void {
    this.cachedWidth = undefined;
    this.cachedLines = undefined;
  }
}

状态变化时调用 invalidate(),然后使用注入的 tui.requestRender() 触发重新渲染。

失效与主题变更

主题变更时,TUI 会对所有组件调用 invalidate() 来清除其缓存。组件必须正确实现 invalidate(),主题变更才能生效。

问题所在

如果组件把主题颜色预先烘焙进字符串(通过 theme.fg()、theme.bg() 等)并缓存下来,那么缓存字符串中就包含旧主题的 ANSI 转义码。如果组件把带主题的内容单独存储,仅清除渲染缓存是不够的。

错误做法(主题颜色不会更新):

class BadComponent extends Container {
  private content: Text;

  constructor(message: string, theme: Theme) {
    super();
    // Pre-baked theme colors stored in Text component
    this.content = new Text(theme.fg("accent", message), 1, 0);
    this.addChild(this.content);
  }
  // No invalidate override - parent's invalidate only clears
  // child render caches, not the pre-baked content
}

解决方案

用主题颜色构建内容的组件,必须在 invalidate() 被调用时重建该内容:

class GoodComponent extends Container {
  private message: string;
  private content: Text;

  constructor(message: string) {
    super();
    this.message = message;
    this.content = new Text("", 1, 0);
    this.addChild(this.content);
    this.updateDisplay();
  }

  private updateDisplay(): void {
    // Rebuild content with current theme
    this.content.setText(theme.fg("accent", this.message));
  }

  override invalidate(): void {
    super.invalidate();  // Clear child caches
    this.updateDisplay(); // Rebuild with new theme
  }
}

模式:失效时重建

对于内容复杂的组件:

class ComplexComponent extends Container {
  private data: SomeData;

  constructor(data: SomeData) {
    super();
    this.data = data;
    this.rebuild();
  }

  private rebuild(): void {
    this.clear();  // Remove all children

    // Build UI with current theme
    this.addChild(new Text(theme.fg("accent", theme.bold("Title")), 1, 0));
    this.addChild(new Spacer(1));

    for (const item of this.data.items) {
      const color = item.active ? "success" : "muted";
      this.addChild(new Text(theme.fg(color, item.label), 1, 0));
    }
  }

  override invalidate(): void {
    super.invalidate();
    this.rebuild();
  }
}

何时需要该模式

以下情况需要该模式:

  1. 预先烘焙主题颜色 - 使用 theme.fg() 或 theme.bg() 创建带样式的字符串,并存储在子组件中
  2. 语法高亮 - 使用 highlightCode(),它会应用基于主题的语法颜色
  3. 复杂布局 - 构建嵌入了主题颜色的子组件树

以下情况不需要该模式:

  1. 使用主题回调 - 传入在渲染期间调用的函数,如 (text) => theme.fg("accent", text)
  2. 简单容器 - 只是分组其他组件,不添加带主题的内容
  3. 无状态渲染 - 在每次 render() 调用中重新计算带主题的输出(无缓存)

常用模式

这些模式覆盖了扩展中最常见的 UI 需求。请直接复制这些模式,而不是从零开始构建。

模式 1:选择对话框(SelectList)

用于让用户从选项列表中挑选。使用 @earendil-works/pi-tui 的 SelectList,配合 DynamicBorder 做边框。

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("pick", {
  handler: async (_args, ctx) => {
    const items: SelectItem[] = [
      { value: "opt1", label: "Option 1", description: "First option" },
      { value: "opt2", label: "Option 2", description: "Second option" },
      { value: "opt3", label: "Option 3" },  // description is optional
    ];

    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const container = new Container();

      // Top border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      // Title
      container.addChild(new Text(theme.fg("accent", theme.bold("Pick an Option")), 1, 0));

      // SelectList with theme
      const selectList = new SelectList(items, Math.min(items.length, 10), {
        selectedPrefix: (t) => theme.fg("accent", t),
        selectedText: (t) => theme.fg("accent", t),
        description: (t) => theme.fg("muted", t),
        scrollInfo: (t) => theme.fg("dim", t),
        noMatch: (t) => theme.fg("warning", t),
      });
      selectList.onSelect = (item) => done(item.value);
      selectList.onCancel = () => done(null);
      container.addChild(selectList);

      // Help text
      container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel"), 1, 0));

      // Bottom border
      container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => { selectList.handleInput(data); tui.requestRender(); },
      };
    });

    if (result) {
      ctx.ui.notify(`Selected: ${result}`, "info");
    }
  },
});

示例: preset.ts、tools.ts

模式 2:可取消的异步操作(BorderedLoader)

用于耗时且应可取消的操作。BorderedLoader 会显示一个 spinner,并处理 escape 取消。

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

pi.registerCommand("fetch", {
  handler: async (_args, ctx) => {
    const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
      const loader = new BorderedLoader(tui, theme, "Fetching data...");
      loader.onAbort = () => done(null);

      // Do async work
      fetchData(loader.signal)
        .then((data) => done(data))
        .catch(() => done(null));

      return loader;
    });

    if (result === null) {
      ctx.ui.notify("Cancelled", "info");
    } else {
      ctx.ui.setEditorText(result);
    }
  },
});

示例: qna.ts、handoff.ts

模式 3:设置与开关(SettingsList)

用于切换多项设置。使用 @earendil-works/pi-tui 的 SettingsList,配合 getSettingsListTheme()。

import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";

pi.registerCommand("settings", {
  handler: async (_args, ctx) => {
    const items: SettingItem[] = [
      { id: "verbose", label: "Verbose mode", currentValue: "off", values: ["on", "off"] },
      { id: "color", label: "Color output", currentValue: "on", values: ["on", "off"] },
    ];

    await ctx.ui.custom((_tui, theme, _kb, done) => {
      const container = new Container();
      container.addChild(new Text(theme.fg("accent", theme.bold("Settings")), 1, 1));

      const settingsList = new SettingsList(
        items,
        Math.min(items.length + 2, 15),
        getSettingsListTheme(),
        (id, newValue) => {
          // Handle value change
          ctx.ui.notify(`${id} = ${newValue}`, "info");
        },
        () => done(undefined),  // On close
        { enableSearch: true }, // Optional: enable fuzzy search by label
      );
      container.addChild(settingsList);

      return {
        render: (w) => container.render(w),
        invalidate: () => container.invalidate(),
        handleInput: (data) => settingsList.handleInput?.(data),
      };
    });
  },
});

示例: tools.ts

模式 4:持久状态指示器

在页脚显示跨渲染持久保留的状态。适合用作模式指示器。

// Set status (shown in footer)
ctx.ui.setStatus("my-ext", ctx.ui.theme.fg("accent", "● active"));

// Clear status
ctx.ui.setStatus("my-ext", undefined);

示例: status-line.ts、plan-mode/index.ts、preset.ts

模式 4b:自定义工作指示器

自定义 pi 流式输出响应期间显示的内联工作指示器。

// Static indicator
ctx.ui.setWorkingIndicator({ frames: [ctx.ui.theme.fg("accent", "●")] });

// Custom animated indicator
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,
});

// Hide the indicator entirely
ctx.ui.setWorkingIndicator({ frames: [] });

// Restore pi's default spinner
ctx.ui.setWorkingIndicator();

这只影响普通的流式工作指示器。上下文压缩和重试的加载动画保持其内置样式。自定义帧会原样渲染,因此扩展需要在需要时自行添加颜色。

示例: working-indicator.ts

模式 5:编辑器上方/下方的小组件

在输入编辑器上方或下方显示持久内容。适合待办列表、进度展示。

// Simple string array (above editor by default)
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"]);

// Render below the editor
ctx.ui.setWidget("my-widget", ["Line 1", "Line 2"], { placement: "belowEditor" });

// Or with theme
ctx.ui.setWidget("my-widget", (_tui, theme) => {
  const lines = items.map((item, i) =>
    item.done
      ? theme.fg("success", "✓ ") + theme.fg("muted", item.text)
      : theme.fg("dim", "○ ") + item.text
  );
  return {
    render: () => lines,
    invalidate: () => {},
  };
});

// Clear
ctx.ui.setWidget("my-widget", undefined);

示例: plan-mode/index.ts

替换页脚。footerData 暴露了扩展无法通过其他途径访问的数据。

ctx.ui.setFooter((tui, theme, footerData) => ({
  invalidate() {},
  render(width: number): string[] {
    // footerData.getGitBranch(): string | null
    // footerData.getExtensionStatuses(): ReadonlyMap<string, string>
    return [`${ctx.model?.id} (${footerData.getGitBranch() || "no git"})`];
  },
  dispose: footerData.onBranchChange(() => tui.requestRender()), // reactive
}));

ctx.ui.setFooter(undefined); // restore default

token 统计信息可通过 ctx.sessionManager.getBranch() 和 ctx.model 获取。

示例: custom-footer.ts

模式 7:自定义编辑器(vim 模式等)

用自定义实现替换主输入编辑器。适合模态编辑(vim)、不同的快捷键方案(emacs)或特殊的输入处理。

import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";

type Mode = "normal" | "insert";

class VimEditor extends CustomEditor {
  private mode: Mode = "insert";

  handleInput(data: string): void {
    // Escape: switch to normal mode, or pass through for app handling
    if (matchesKey(data, "escape")) {
      if (this.mode === "insert") {
        this.mode = "normal";
        return;
      }
      // In normal mode, escape aborts agent (handled by CustomEditor)
      super.handleInput(data);
      return;
    }

    // Insert mode: pass everything to CustomEditor
    if (this.mode === "insert") {
      super.handleInput(data);
      return;
    }

    // Normal mode: vim-style navigation
    switch (data) {
      case "i": this.mode = "insert"; return;
      case "h": super.handleInput("\x1b[D"); return; // Left
      case "j": super.handleInput("\x1b[B"); return; // Down
      case "k": super.handleInput("\x1b[A"); return; // Up
      case "l": super.handleInput("\x1b[C"); return; // Right
    }
    // Pass unhandled keys to super (ctrl+c, etc.), but filter printable chars
    if (data.length === 1 && data.charCodeAt(0) >= 32) return;
    super.handleInput(data);
  }

  render(width: number): string[] {
    const lines = super.render(width);
    // Add mode indicator to bottom border (use truncateToWidth for ANSI-safe truncation)
    if (lines.length > 0) {
      const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
      const lastLine = lines[lines.length - 1]!;
      // Pass "" as ellipsis to avoid adding "..." when truncating
      lines[lines.length - 1] = truncateToWidth(lastLine, width - label.length, "") + label;
    }
    return lines;
  }
}

export default function (pi: ExtensionAPI) {
  pi.on("session_start", (_event, ctx) => {
    // Factory receives the TUI, theme, and keybindings from the app
    ctx.ui.setEditorComponent((tui, theme, keybindings) =>
      new VimEditor(tui, theme, keybindings)
    );
  });
}

要点:

  • 继承 CustomEditor(而不是基础 Editor),以获得应用快捷键(escape 中止、ctrl+d 退出、模型切换等)
  • 对你不处理的按键,调用 super.handleInput(data)
  • 状态 spinner:自定义编辑器默认保留独立的状态行。传入 { embedWorkingStatus: true } 作为 CustomEditor 构造函数的第四个参数,可将工作、上下文压缩、分支摘要和重试的 spinner 嵌入编辑器边框。
  • 工厂模式:setEditorComponent 接收一个工厂函数,其参数为 tui、theme 和 keybindings
  • 传入 undefined 可恢复默认编辑器:ctx.ui.setEditorComponent(undefined)

示例: modal-editor.ts

关键规则

  1. 始终使用回调传入的 theme - 不要直接导入主题。使用 ctx.ui.custom((tui, theme, keybindings, done) => ...) 回调中的 theme。

  2. 始终为 DynamicBorder 的颜色参数标注类型 - 写 (s: string) => theme.fg("accent", s),而不是 (s) => theme.fg("accent", s)。

  3. 状态变化后调用 tui.requestRender() - 在 handleInput 中更新状态后调用 tui.requestRender()。

  4. 返回三方法对象 - 自定义组件需要 { render, invalidate, handleInput }。

  5. 使用现有组件 - SelectList、SettingsList、BorderedLoader 已覆盖 90% 的场景。不要重复造轮子。

示例

24 Windows 配置

pi 在 Windows 上默认使用 Git Bash,也可改用 PowerShell 工具运行命令。

Pi 在 Windows 上默认使用 Git Bash。查找顺序如下:

  1. ~/.pi/agent/settings.json 中的自定义路径
  2. Git Bash(C:\Program Files\Git\bin\bash.exe)
  3. PATH 上的 bash.exe(Cygwin、MSYS2、WSL)

对大多数用户来说,安装 Git for Windows 就足够了。

PowerShell 工具

可选的 powershell 工具在 pwsh.exe 可用时通过它运行命令,否则使用 Windows PowerShell。它会以 -NoProfile -NonInteractive -ExecutionPolicy Bypass 参数启动 PowerShell。管理员强制执行的执行策略仍然可能优先生效。

使用 defaultTools 替换面向模型的 bash 工具:

{
  "defaultTools": ["read", "powershell", "edit", "write"]
}

也可以同时启用两者来对比行为:

{
  "defaultTools": ["read", "bash", "powershell", "edit", "write"]
}

! 和 !! 编辑器命令仍然使用 Bash。

自定义 Bash 路径

{
  "shellPath": "C:\\cygwin64\\bin\\bash.exe"
}

25 Termux(Android)配置

在 Android 的 Termux 环境中安装并运行 pi,含剪贴板与存储权限说明。

Pi 可以通过 Termux 在 Android 上运行。Termux 是一个面向 Android 的终端模拟器和 Linux 环境。

前置条件

  1. 从 GitHub 或 F-Droid 安装 Termux(不要从 Google Play 安装,那个版本已弃用)
  2. 从 GitHub 或 F-Droid 安装 Termux:API,用于剪贴板和其他设备集成

安装

# Update packages
pkg update && pkg upgrade

# Install dependencies
pkg install nodejs termux-api git

# Install pi
npm install -g --ignore-scripts @earendil-works/pi-coding-agent

# Create config directory
mkdir -p ~/.pi/agent

# Run pi
pi

剪贴板支持

在 Termux 中运行时,剪贴板操作使用 termux-clipboard-set 和 termux-clipboard-get。这些功能需要安装 Termux:API 应用。

Termux 不支持图片剪贴板(ctrl+v 图片粘贴功能无法使用)。

Termux 的 AGENTS.md 示例

创建 ~/.pi/agent/AGENTS.md,帮助智能体了解 Termux 环境:

# Agent Environment: Termux on Android

## Location
- **OS**: Android (Termux terminal emulator)
- **Home**: `/data/data/com.termux/files/home`
- **Prefix**: `/data/data/com.termux/files/usr`
- **Shared storage**: `/storage/emulated/0` (Downloads, Documents, etc.)

## Opening URLs
```bash
termux-open-url "https://example.com"
```

## Opening Files
```bash
termux-open file.pdf          # Opens with default app
termux-open --chooser image.jpg      # Choose app
```

## Clipboard
```bash
termux-clipboard-set "text"   # Copy
termux-clipboard-get          # Paste
```

## Notifications
```bash
termux-notification -t "Title" -c "Content"
```

## Device Info
```bash
termux-battery-status         # Battery info
termux-wifi-connectioninfo    # WiFi info
termux-telephony-deviceinfo   # Device info
```

## Sharing
```bash
termux-share -a send file.txt # Share file
```

## Other Useful Commands
```bash
termux-toast "message"        # Quick toast popup
termux-vibrate                # Vibrate device
termux-tts-speak "hello"      # Text to speech
termux-camera-photo out.jpg   # Take photo
```

## Notes
- Termux:API app must be installed for `termux-*` commands
- Use `pkg install termux-api` for the command-line tools
- Storage permission needed for `/storage/emulated/0` access

限制

  • 没有图片剪贴板:Termux 剪贴板 API 只支持文本
  • 存储访问:要访问 /storage/emulated/0 中的文件(Downloads 等),请先运行一次 termux-setup-storage 授予权限

故障排除

剪贴板不可用

确保两个应用都已安装:

  1. Termux(来自 GitHub 或 F-Droid)
  2. Termux:API(来自 GitHub 或 F-Droid)

然后安装 CLI 工具:

pkg install termux-api

共享存储权限被拒绝

运行一次以下命令以授予存储权限:

termux-setup-storage

Node.js 安装问题

如果 npm 安装失败,请尝试清除缓存:

npm cache clean --force

26 tmux 配置

在 tmux 中正确传递 Shift+Enter、Ctrl+Enter 等修饰键的推荐配置。

Pi 可以在 tmux 中运行,但 tmux 默认会剥离某些按键的修饰键信息。如果不进行配置,Shift+Enter 和 Ctrl+Enter 通常无法与普通的 Enter 区分开。

在 ~/.tmux.conf 中添加:

set -g extended-keys on
set -g extended-keys-format csi-u

然后完全重启 tmux:

tmux kill-server
tmux

当 Kitty 键盘协议不可用时,Pi 会自动请求扩展按键上报。设置 extended-keys-format csi-u 后,tmux 会以 CSI-u 格式转发带修饰键的按键,这是最可靠的配置。extended-keys-format 选项要求 tmux 3.5 或更高版本。

如果只设置:

set -g extended-keys on

tmux 会默认使用 extended-keys-format xterm。当应用程序请求扩展按键上报时,带修饰键的按键会以 xterm modifyOtherKeys 格式转发,例如:

  • Ctrl+C → \x1b[27;5;99~
  • Ctrl+D → \x1b[27;5;100~
  • Ctrl+Enter → \x1b[27;5;13~

设置 extended-keys-format csi-u 后,同样的按键会以如下方式转发:

  • Ctrl+C → \x1b[99;5u
  • Ctrl+D → \x1b[100;5u
  • Ctrl+Enter → \x1b[13;5u

Pi 支持这两种格式,但在 tmux 环境下推荐使用 csi-u。

这解决了什么问题

如果没有 tmux 扩展按键,带修饰键的 Enter 会退化为传统序列:

按键未启用扩展按键使用 csi-u
Enter\r\r
Shift+Enter\r\x1b[13;2u
Ctrl+Enter\r\x1b[13;5u
Alt/Option+Enter\x1b\r\x1b[13;3u

这会影响默认快捷键(Enter 提交、Shift+Enter 换行),以及所有使用带修饰键 Enter 的自定义快捷键。

环境要求

  • tmux 3.5 或更高版本(用于 extended-keys-format csi-u,运行 tmux -V 可检查版本)
  • 支持扩展按键的终端模拟器(Ghostty、Kitty、iTerm2、WezTerm、Windows Terminal)

对于 tmux 3.2 到 3.4,请省略 extended-keys-format csi-u;Pi 仍然支持 tmux 默认的 xterm modifyOtherKeys 格式。

27 终端配置

各终端模拟器对 Kitty 键盘协议的支持情况与按键配置方法。

Pi 使用 Kitty 键盘协议来可靠地检测修饰键。大多数现代终端都支持该协议,但有些需要配置。

能力覆盖

Pi 会自动检测 OSC 8 超链接、内联图片协议和真彩色。如果在终端代理或多路复用器背后检测失败,可以使用这些高级覆盖项:

能力环境变量JSON 设置
OSC 8 超链接PI_HYPERLINKS=1|0|autoterminal.hyperlinks: true|false|"auto"
内联图片PI_IMAGE_PROTOCOL=kitty|iterm2|none|autoterminal.images: "kitty"|"iterm2"|false|"auto"
真彩色PI_TRUE_COLOR=1|0|autoterminal.trueColor: true|false|"auto"

设置的优先级高于环境变量;不设置或设为 auto 则保持自动检测。只强制启用完整终端链路都支持的能力,因为不支持的转义序列可能破坏渲染。

Kitty

开箱即用。

iTerm2

常规 TUI 模式

开箱即用。

全屏 TUI 模式

Pi 接管整个视口,因此 iTerm2 会发送鼠标滚轮报告,而不是滚动它原生的回滚缓冲区。在 iTerm2 默认的快速触控板行为下,这些报告可能丢失大部分加速滚轮增量,使全屏滚动比常规滚动慢得多。

如果在全屏模式下,快速的滚轮手势每次只能移动大约一行:

  1. 打开 iTerm2 → Settings → Advanced。
  2. 搜索 Trackpad scrolls fast? 并把它设为 No。

这是对整个 iTerm2 生效的折中方案,也可能改变原生触控板滚动。底层行为见 iTerm2 issue 9619。

Apple Terminal

可用时,Pi 会启用增强按键上报。如果 Terminal.app 仍为 Shift+Enter 发送普通回车,pi 会使用本地 macOS 修饰键回退,把该回车当作 Shift+Enter 处理。

这个回退只在 pi 与 Terminal.app 运行在同一台 Mac 上时才有效。它无法通过远程 SSH 检测本地键盘。

Ghostty

在你的 Ghostty 配置中添加(macOS 上是 ~/Library/Application Support/com.mitchellh.ghostty/config,Linux 上是 ~/.config/ghostty/config):

keybind = alt+backspace=text:\x1b\x7f

较旧版本的 Claude Code 可能添加过这条 Ghostty 映射:

keybind = shift+enter=text:\n

该映射会发送原始换行字节。在 pi 内部,它与 Ctrl+J 无法区分,因此 tmux 和 pi 都看不到真正的 shift+enter 键事件。

如果你添加这条映射仅仅是为了 Claude Code 2.x 或更新版本,可以把它删除;除非你想在 tmux 中使用 Claude Code——那种情况下仍然需要这条 Ghostty 映射。

Pi 已将 Ctrl+J 绑定为默认的换行别名,因此 Shift+Enter 在 tmux 中可以经由该重映射继续工作,无需额外的 pi 配置。

全屏 TUI 模式

在全屏模式下,链接仍然可以点击,但当 pi 捕获鼠标输入时,Ghostty 不会显示悬停下划线,也不会显示左下角的 URL 预览。在 macOS 上按住 Shift+Command、在 Linux 上按住 Shift+Ctrl,可以使用 Ghostty 的原生链接处理。

WezTerm

WezTerm 通常可以通过 xterm modifyOtherKeys 开箱即用地支持 Shift+Enter。要显式使用 Kitty 键盘协议,请创建 ~/.wezterm.lua:

local wezterm = require 'wezterm'
local config = wezterm.config_builder()
config.enable_kitty_keyboard = true
return config

在 macOS 上,WezTerm 默认把 Option+Enter 绑定为全屏。要把 Option+Enter 用作 pi 的后续消息排队,请添加这个按键覆盖:

local wezterm = require 'wezterm'
local config = wezterm.config_builder()
config.keys = {
  {
    key = 'Enter',
    mods = 'ALT',
    action = wezterm.action.SendString('\x1b[13;3u'),
  },
}
return config

如果你已经有 config.keys 表,把这个条目加进去即可。

在 WSL 上,WezTerm 可能需要可见的硬件光标来定位输入法候选窗口。如果 CJK 输入法候选没有跟随文本光标,请在运行 pi 前设置 PI_HARDWARE_CURSOR=1,或在设置中把 showHardwareCursor 设为 true。

Alacritty

Alacritty 通常可以开箱即用地支持 Shift+Enter。在 macOS 上,Option+Enter 到达时可能变成普通的 Enter。要把 Option+Enter 用作 pi 的后续消息排队,请在 ~/.config/alacritty/alacritty.toml 中添加:

[[keyboard.bindings]]
key = "Enter"
mods = "Alt"
chars = "\u001b[13;3u"

修改配置后请重启 Alacritty。

VS Code(集成终端)

VS Code 1.109.5 及更新版本默认在集成终端中启用 Kitty 键盘协议,因此 Shift+Enter 应当开箱即用。

早于 1.109.5 的 VS Code 版本需要为 Shift+Enter 配置显式的终端按键绑定。

keybindings.json 的位置:

  • macOS:~/Library/Application Support/Code/User/keybindings.json
  • Linux:~/.config/Code/User/keybindings.json
  • Windows:%APPDATA%\\Code\\User\\keybindings.json

在 keybindings.json 中添加:

{
  "key": "shift+enter",
  "command": "workbench.action.terminal.sendSequence",
  "args": { "text": "\u001b[13;2u" },
  "when": "terminalFocus"
}

Zed(集成终端)

把这些按键绑定添加到你的 Zed keymap.json:

{
  "context": "Terminal",
  "bindings": {
    "shift-enter": ["terminal::SendText", "\u001b[13;2u"],
    "ctrl--": ["terminal::SendText", "\u001b[45;5u"],
    "ctrl-alt-]": ["terminal::SendText", "\u001b[93;7u"]
  }
}

Windows Terminal

Pi 在原生 Windows 或 WSL 上运行时使用 Windows 风格的快捷键:

  • Alt+V 粘贴图片或剪贴板文本。
  • Ctrl+F 在全屏模式下搜索会话记录,Ctrl+Up/Ctrl+Down 在标记的消息之间跳转。
  • Alt+P 切换到上一个模型。
  • Ctrl+Z 在原生 Windows 上撤销编辑;WSL 使用 Alt+Z,以便 Ctrl+Z 可以挂起 pi。
  • Ctrl+Q 排队一条后续消息,Alt+Q 恢复排队的消息。

在 settings.json 中添加(Ctrl+Shift+, 或 Settings → Open JSON file),以转发用于插入新行的 Shift+Enter:

{
  "actions": [
    {
      "command": { "action": "sendInput", "input": "\u001b[13;2u" },
      "keys": "shift+enter"
    }
  ]
}

Windows Terminal 默认把 Alt+Enter 绑定为全屏。如果想用它代替 pi 默认的 Ctrl+Q 来排队后续消息,请配置 Windows Terminal 发送该键,并在 pi 中把 app.message.followUp 绑定到 alt+enter。

如果你已经有 actions 数组,把这个对象加进去即可。修改设置后,请完全关闭并重新打开 Windows Terminal。

xfce4-terminal、terminator

这些终端对转义序列的支持有限。Ctrl+Enter 和 Shift+Enter 这类带修饰的回车键无法与普通 Enter 区分,导致 submit: ["ctrl+enter"] 这类自定义快捷键无法工作。

为获得最佳体验,请使用支持 Kitty 键盘协议的终端:

IntelliJ IDEA(集成终端)

内置终端对转义序列的支持有限。在 IntelliJ 的终端中,Shift+Enter 无法与 Enter 区分。

如果你希望显示硬件光标,请在运行 pi 前设置 PI_HARDWARE_CURSOR=1(默认关闭以保持兼容)。

为获得最佳体验,建议使用专门的终端模拟器。

28 Shell 别名

让 pi 在非交互式 bash 中启用你的 shell 别名的配置方法。

Pi 以非交互模式(bash -c)运行 bash,默认不会展开别名。

要启用你的 shell 别名,请在 ~/.pi/agent/settings.json 中添加:

{
  "shellCommandPrefix": "shopt -s expand_aliases\neval \"$(grep '^alias ' ~/.zshrc)\""
}

请根据你的 shell 配置调整路径(~/.zshrc、~/.bashrc 等)。

29 开发

从源码构建与测试 pi,涵盖 fork 品牌重塑、路径解析与项目结构。

更多准则请参阅 AGENTS.md。

环境搭建

git clone https://github.com/earendil-works/pi
cd pi
npm install
npm run build

从源码运行:

/path/to/pi/pi-test.sh

该脚本可以在任意目录下运行。Pi 会保留调用者当前的工作目录。

实验性远程测试框架

远程测试框架(remote harness)的服务器/客户端集成仅用于开发。在仓库中运行:

PI_EXPERIMENTAL=1 ./pi-test.sh server
PI_EXPERIMENTAL=1 ./pi-test.sh client

PI_SERVER_DIR 覆盖服务器配置档案和套接字目录(默认为 ~/.pi/server)。当未指定 --server-id 时,PI_SERVER_ID 用于选择逻辑服务器 ID。

client 和 experimental/plugin 包子路径只在源码检出中且 source 条件下才能解析。它们的实现以及 server/client 命令不会包含在 npm 包和独立二进制文件中。pi-client、pi-protocol 和 pi-server 是 coding-agent 的开发依赖,而非运行时依赖。本地 SDK 和 stdio RPC API 保持不变。

Fork 与品牌重塑

通过 package.json 配置:

{
  "piConfig": {
    "name": "pi",
    "configDir": ".pi"
  }
}

在你的 fork 中修改 name、configDir 和 bin 字段。这会影响 CLI 横幅、配置路径和环境变量名称。

路径解析

三种执行模式:npm 安装、独立二进制、从源码以 tsx 运行。

对于包内资源,始终使用 src/config.ts:

import { getPackageDir, getThemeDir } from "./config.js";

对于包内资源,切勿直接使用 __dirname。

调试命令

/debug(隐藏命令)会将以下内容写入 ~/.pi/agent/pi-debug.log:

  • 渲染后的 TUI 行(含 ANSI 码)
  • 最近发送给 LLM 的消息

测试

./test.sh                         # Run non-LLM tests (no API keys needed)
npm test                          # Run all tests
npm test -- test/specific.test.ts # Run specific test

已发布包冒烟测试

构建完成后,运行 npm run check:package-install。它会打包公开的各个包,并在仓库之外的临时目录中仅将 coding-agent 作为直接依赖安装。本地 tarball 会有选择地覆盖已声明的传递依赖,而不会安装仅用于开发的包。该检查验证 SDK 导入和 CLI 启动,不需要凭据,也不会发起模型请求。

npm run check 还会检查运行时依赖声明,并拒绝通过导入将被排除的开发源码带进某个包的构建产物。

项目结构

packages/
  ai/           # LLM provider abstraction
  agent/        # Agent loop and message types  
  tui/          # Terminal UI components
  coding-agent/ # CLI and interactive mode