Pi Agent 完整使用与开发指南

Pi Coding Agent v0.83.0 · 2026-08-01 · 生产级实战手册
调研自源码 + 官方 30+ 文档 + 飞书集成实战 (pi-lark-bridge)

写在前面

这本手册不是 Pi 官方文档的搬运,而是一份生产级实战手册。它的目标读者分两类:

  1. 飞书集成开发者:要把 Pi 嵌进 IM 机器人、消息网关、自动化系统的工程师。重点看 Chapter 8 (RPC 协议) + Chapter 9 (SDK 集成) + Chapter 10 (架构)
  2. 通用 Pi 用户:想在终端里用 Pi 干活的开发者、系统管理员。重点看 Chapter 2 (5 分钟上手) + Chapter 3 (models.json) + Chapter 4 (模式详解)

每章都按”原理 → 操作 → 实战 → 坑”四段式组织。代码块都经过实际验证,可以直接复制运行。

章节导航

# 章节 核心内容 受众重点
1 Chapter 1 · 安装与环境准备 npm/curl/Termux/WSL 四种安装路径、API key vs OAuth 两种认证 通用
2 Chapter 2 · 5 分钟快速上手 第一次 interactive / print / RPC 调用 通用
3 Chapter 3 · models.json 完整配置 Provider/Model schema、MiniMax m3 实战 通用 + 集成
4 Chapter 4 · 运行模式详解 interactive/print/RPC/SDK 四种模式 通用 + 集成
5 Chapter 5 · 工具系统 内置工具 + 扩展 + custom tools 通用 + 集成
6 Chapter 6 · System Prompts 与 Context Files AGENTS.md / SYSTEM.md / Skills 通用
7 Chapter 7 · 会话管理 session 文件格式 + branching + compaction 通用
8 Chapter 8 · RPC 协议参考 JSONL 帧 + event 目录 + pi-lark-bridge 源码 集成必读
9 Chapter 9 · SDK 与程序化集成 createAgentSession + 嵌入模板 集成必读
10 Chapter 10 · 架构深读 模块依赖 + 状态机 + 7 张 Mermaid 图 集成必读
11 Chapter 11 · 故障排查与最佳实践 12 类常见错误 + 7 个坑 + 性能调优 全部

为什么需要这本手册

Pi 的官方文档(30+ 篇)覆盖完整但散落在 30+ 个 markdown 文件,缺乏结构化索引和实战串联。本书做了三件事:

  1. 按集成场景重组:把”如何把 Pi 塞进我的 IM bot”作为最高优先级的叙事线
  2. 补充实战章节:基于 pi-lark-bridge 真实集成代码(已上线的飞书机器人),给出可复用模板
  3. 代码即文档:所有 CLI 命令、JSON 配置、TypeScript 代码均来自真实项目,可直接复制运行

适用版本

  • Pi Coding Agent: 0.83.0 (2026-07-29)
  • Node.js: 18+
  • 平台: Linux / macOS / Termux (Android) / Windows WSL
  • OS 验证环境: Termux on Android 10, Node 20

Chapter 1 · 安装与环境准备

本章覆盖 Pi 的四种主流安装路径(npm / curl / Termux / Windows WSL)、认证方式选择、目录结构、与常见代理的兼容性验证。
实战环境:Termux on Android 10 / Node 20 / Pi 0.83.0,所有命令均已验证可执行。

1.1 安装方式选择

Pi 通过 npm 分发,支持四种主流安装路径

场景 推荐方式 一行命令
桌面 Linux/macOS npm 全局安装 npm i -g --ignore-scripts @earendil-works/pi-coding-agent
CI / Docker / 临时容器 curl 一键安装 curl -fsSL https://pi.dev/install.sh | sh
Android 手机/平板 Termux + npm 1.4 节
Windows WSL2 + npm 1.5 节

1.1.1 为什么 --ignore-scripts

Pi 的 npm 包不需要 install scripts(不依赖 postinstall 钩子下载二进制)。--ignore-scripts 是显式声明这一点,避免某些 sandbox 环境卡在 hook 上。

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

1.1.2 安装位置

  • 全局命令: pi → 通常在 /usr/local/bin/pi~/.npm-global/bin/pi(取决于 npm 配置)
  • 代码包: /usr/local/lib/node_modules/@earendil-works/pi-coding-agent/
  • 运行配置目录: ~/.pi/agent/(用户级,每个用户独立)
  • 会话/凭据: 全部存在 ~/.pi/agent/

验证安装:

1
2
3
4
5
6
7
8
pi --version
# 0.83.0

which pi
# /usr/local/bin/pi

ls /usr/local/lib/node_modules/@earendil-works/pi-coding-agent/dist/
# cli.js core/ modes/ index.js main.js rpc-entry.js ...

1.1.3 卸载

1
2
3
4
5
6
7
8
# npm 安装
npm uninstall -g @earendil-works/pi-coding-agent

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

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

卸载不会清理 ~/.pi/agent/(凭据、session、扩展、skills 都保留),需要手动 rm -rf ~/.pi/agent/ 才能彻底。

1.2 认证方式

Pi 支持两类认证,互斥但并存

1.2.1 方式 A:API Key(推荐用于 CI / 自定义模型)

1
2
3
4
5
export ANTHROPIC_API_KEY=sk-ant-...        # Claude
export OPENAI_API_KEY=sk-... # OpenAI
export MINIMAX_CN_API_KEY=sk-cp-... # MiniMax(CN 区域)
export GEMINI_API_KEY=... # Google AI Studio
export STEPFUN_API_KEY=... # Step Plan

启动时 Pi 自动识别。如果多个变量都设置了,Pi 会在 /model 面板里都列出来供你选。

实用技巧:Pi 也认自定义环境变量(在 models.json 里通过 $VAR_NAME 引用),这样不用把 key 写死:

1
2
3
4
5
6
7
8
9
{
"providers": {
"my-provider": {
"baseUrl": "https://api.example.com/v1",
"api": "openai-completions",
"apiKey": "$MY_API_KEY"
}
}
}

启动前 export MY_API_KEY=sk-... 即可。

1.2.2 方式 B:OAuth 订阅(Claude Pro/Max、ChatGPT Plus/Pro、GitHub Copilot)

启动 Pi 后在交互模式里:

1
/login

然后从列表选一个订阅 provider。会走浏览器 OAuth flow,token 存在 ~/.pi/agent/auth.json(600 权限)。

Headless 环境(SSH、容器):/login 走不通时,Pi 0.83+ 支持直接粘贴回调 URL:

1
2
3
/login
> 选 OpenRouter
> 粘贴: https://openrouter.ai/auth/callback?code=...

1.2.3 我们项目实际配置

飞书集成项目用的是 API Key + 自定义 provider(详见 Chapter 3):

1
2
3
# ~/.hermes/.env(自动脱敏,仅本机)
MINIMAX_CN_API_KEY=sk-cp-...
MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1

Pi 启动时通过 ~/.pi/agent/models.json 引用 MINIMAX_CN_API_KEY避免把 key 写死在配置文件里

1.3 目录结构

安装后 ~/.pi/agent/ 长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
~/.pi/agent/
├── auth.json # 600 权限,OAuth token / apiKey 缓存
├── models.json # 自定义 providers + models
├── models-store.json # Pi 自动生成的 id 索引
├── settings.json # 用户偏好(主题、键位、思考级别)
├── AGENTS.md # 全局 system prompt (可选)
├── sessions/ # 会话历史(JSONL 格式)
├── packages/ # 本地安装的 Pi 扩展包
├── extensions/ # 本地开发的扩展
├── skills/ # 用户自定义 skills
├── prompts/ # prompt templates
├── themes/ # 自定义主题
└── export-html/ # HTML 导出模板

关键洞察

  • auth.json 必须保持 chmod 600——Pi 启动会校验,权限不对会直接报错退出
  • models.json 改了之后不用重启,在 interactive 模式按 /model 就能看到新加的 provider
  • sessions/ 按日期分目录 2026-08-01/2026-07-31/

1.3.1 自定义配置目录

如果想隔离(比如一台机器跑多个项目各用不同 key):

1
2
export PI_CODING_AGENT_DIR=/path/to/alt/.pi/agent
pi

PI_CODING_AGENT_SESSION_DIR 可以单独覆盖 session 路径,CLI 的 --session-dir 优先级更高。

1.4 Termux 安装

在 Android 上跑 Pi 的官方推荐路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 装 Termux (从 F-Droid 或 GitHub, **不要** Google Play)
# 2. 装 Termux:API (剪贴板/设备集成需要)
# 3. 初始化系统
pkg update && pkg upgrade

# 4. 装依赖
pkg install nodejs termux-api git

# 5. 装 Pi
npm install -g --ignore-scripts @earendil-works/pi-coding-agent

# 6. 准备配置目录
mkdir -p ~/.pi/agent
chmod 700 ~/.pi/agent

# 7. 启动
pi

1.4.1 Termux 专属坑

  1. /tmp 只读:Pi 默认会把临时日志写 /tmp,在 Termux 上要手动指定 TMPDIR

    1
    2
    export TMPDIR=$HOME/.tmp
    mkdir -p $TMPDIR
  2. HOME 路径特殊/data/data/com.termux/files/home,不是 /root/home/user。脚本里要写绝对路径。

  3. 剪贴板:依赖 termux-clipboard-set / termux-clipboard-get。需要 Termux:API app 配合(光 CLI 不够),且要授权 Android 存储权限。

  4. Ctrl+V 图片粘贴不支持:官方文档明确说 Termux 没法用这个 feature。建议通过 /path/to/image 直接传文件。

  5. npm 全局路径/data/data/com.termux/files/usr/lib/node_modules/,不是 /usr/lib/node_modules/。手动 link 时要小心。

1.4.2 推荐 AGENTS.md

写到 ~/.pi/agent/AGENTS.md

1
2
3
4
5
6
7
8
9
10
11
# 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

1
2
termux-open file.pdf
termux-open --chooser image.jpg

Clipboard

1
2
termux-clipboard-set "text to copy"
termux-clipboard-get
1
2
3
4
5
6
7
8
9
10
11

## 1.5 Windows WSL 安装

Pi 不原生支持 Windows,要在 WSL2 跑:

```powershell
# PowerShell (管理员)
wsl --install
wsl --set-default-version 2

# 装 Ubuntu 22.04 LTS (Microsoft Store)

进 WSL 后按 Linux 流程走:

1
2
3
4
5
6
7
8
9
sudo apt update && sudo apt upgrade -y
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs git

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

# 验证
pi --version

1.5.1 WSL 与 Windows 文件互通

  • Windows 文件:/mnt/c/Users/<you>/...
  • WSL 文件:\\wsl$\Ubuntu\home\<you>\...
  • 建议 Pi 项目放在 WSL 文件系统~/projects/),不要放 /mnt/c/——后者慢 10-100 倍

1.6 验证安装完整性

装完跑这几条确认一切 OK:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 命令能找到
which pi && pi --version

# 2. 模块能加载
node -e "import('@earendil-works/pi-coding-agent').then(m => console.log(Object.keys(m)))"
# 期望输出: ['createAgentSession', ...]

# 3. RPC entry 可用
node -e "import('@earendil-works/pi-coding-agent/rpc-entry').then(m => console.log('ok'))"

# 4. 最小集成测(5 秒)
pi -p "回复 OK"
# 期望输出: OK

# 5. 配置目录权限正确
ls -la ~/.pi/agent/
# auth.json 必须是 -rw------- (600)

任意一步失败,回到对应章节查。

1.7 升级

1
2
3
npm update -g @earendil-works/pi-coding-agent
# 或
npm install -g --ignore-scripts @earendil-works/pi-coding-agent@latest

升级不会覆盖你的 ~/.pi/agent/(npm 全局包升级不影响用户配置)。新版本的 settings 迁移由 Pi 启动时自动跑。

小结

  • 安装走 npm i -g --ignore-scripts,加上 --ignore-scripts 是惯例
  • 认证分 API Key 和 OAuth 两条路,飞书集成项目统一走 API Key + 自定义 provider
  • Termux 上要处理 /tmp 只读、HOME 路径、剪贴板依赖
  • 装完用 pi -p "回复 OK" 验证最直观

Chapter 2 · 5 分钟快速上手

本章用三个最短路径带你跑通 Pi:一次 interactive 会话 → 一次 print 调用 → 一次 RPC 调用
所有命令均已在 Pi 0.83.0 上验证。

2.1 准备工作

确认你已经按 Chapter 1 装好 Pi,且至少配了一种认证:

1
2
3
4
pi --version
# 0.83.0

export MINIMAX_CN_API_KEY=sk-cp-... # 或你的 provider 对应的 key

如果你还没装,回到 Chapter 1 安装

2.2 第一次:Interactive(交互模式)

最常见的用法,人在终端里和 Pi 对话

1
2
mkdir -p ~/projects/hello-pi && cd ~/projects/hello-pi
pi

启动后看到 TUI,输入:

1
> Create a hello world Node.js script and run it

Pi 会:

  1. write 工具创建 hello.js
  2. bash 工具跑 node hello.js
  3. 把输出读回来给你看

整个过程在终端里实时显示工具调用。退出:Ctrl+C 或输入 /exit

2.2.1 关键交互

  • @ 引用文件:在 prompt 里输入 @,触发 fuzzy search 文件
    1
    > @src/app.ts 帮我找出所有的 bug
  • ! 直接执行 shell:不经过 LLM,自己跑命令
    1
    > !ls -la
  • !! 把输出喂给 LLM:自己跑命令但把结果当作 context
    1
    > !!git diff HEAD~1
  • Ctrl+V 粘贴图片(不支持 Termux):多模态模型可以看图

2.2.2 实用命令

命令 作用
/model 切换 provider/model
/login OAuth 登录
/compact 压缩上下文
/branch 会话分叉
/reload 重载 AGENTS.md / 扩展
/export 导出会话为 HTML
/sessions 列历史会话
/help 看所有命令

2.3 第二次:Print(命令行单次)

适合 CI / 脚本调用,一次问一句话,回一次答。

1
2
3
4
5
6
7
8
9
10
pi -p "What files are in this directory?"
# Assistant: I'll list the files for you...
# [uses bash: ls -la]
# Assistant: This directory contains ...

# 简单文本问题(不需要工具)
pi -p "Say hello in 3 languages"
# Assistant: 1. English: Hello
# 2. Spanish: Hola
# 3. Japanese: こんにちは

2.3.1 常用 flags

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 指定模型
pi -p "你好" --provider minimax-cn --model minimax-m3

# 不保存 session(默认保存到 ~/.pi/agent/sessions/)
pi -p "quick question" --no-session

# 用上一次的 session 继续
pi -p "继续上一轮" --continue

# 改 session 名
pi -p "fix bug" --name "bug-fix-session"

# 加 system prompt
pi -p "summarize" --system-prompt "你是一个简洁的助手,回复不超过 50 字"

# 离线模式(不查 pi.dev 新版本)
pi -p "test" --offline

2.3.2 与 Claude Code / Codex 的区别

pi -p 在功能上等价于 claude -p / codex exec,但:

  • Pi 支持自定义 provider(任意 OpenAI 兼容 endpoint)
  • Pi 的 session 是 JSONL(人类可读、可 grep)
  • Pi 的 print 输出包含 thinking block(可关闭)

2.4 第三次:RPC(协议集成)

这是 飞书集成 / IDE 嵌入 / Web 前端 的标准入口。Pi 作为子进程,通过 stdin/stdout JSON line 通信。

2.4.1 最简 RPC 调用

启动一个 Pi 子进程,发一条 prompt,收到回复后退出:

1
2
3
4
# 用 print 模式直接看效果(不启动 RPC)
pi --mode rpc <<'EOF'
{"id":"1","type":"prompt","message":"Say hi"}
EOF

输出:

1
2
3
4
5
6
{"type":"response","command":"prompt","success":true,"id":"1"}
{"type":"agent_start",...}
{"type":"message_start","message":{"role":"user",...}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Hi"}}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Hi! How can I help?"}]}}
{"type":"agent_end",...}

2.4.2 Node.js 客户端(飞书集成就用这个)

不要自己 spawn + 解析 JSON line!用 Pi 自带的 RpcClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { RpcClient } from "@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-client.js";

const client = new RpcClient({
cliPath: "/path/to/pi/dist/cli.js",
cwd: process.cwd(),
provider: "minimax-cn",
model: "minimax-m3",
});

await client.start();

const events = await client.promptAndWait("用一句话介绍你自己", undefined, 60000);
console.log(`共 ${events.length} 个事件`);

const textEvent = events.find(
e => e.type === "message_end" && e.message?.role === "assistant"
);
if (textEvent) {
const textBlock = textEvent.message.content.find(c => c.type === "text");
console.log("回复:", textBlock.text);
}

await client.stop();

2.4.3 多轮对话

RpcClient 内置请求队列,单进程常驻

1
2
3
4
5
6
7
8
// 第一轮
const e1 = await client.promptAndWait("你好");
// 第二轮(带上下文)
const e2 = await client.promptAndWait("你叫什么");
// 切换模型
await client.setModel({ provider: "anthropic", id: "claude-opus-4" });
// 再次发问
const e3 = await client.promptAndWait("介绍下 claude opus 4");

重点promptAndWait 会等当前 turn 完全结束才返回。如果中途调用,会自动排队。

2.5 选择哪个模式?

场景 推荐
自己用、探索代码 pi (interactive)
一次性问答、写脚本 pi -p "..." (print)
CI / cron / 自动化 pi -p "..." --no-session
IM 机器人、Web IDE、自动化平台 RPC + RpcClient
自定义 UI / deep 嵌入 SDK createAgentSession

2.6 下一步


Chapter 3 · models.json 完整配置

本章是 Pi 配置的核心:所有自定义 provider / 模型都在 ~/.pi/agent/models.json 里定义。
重点:基于飞书集成项目实战,演示 MiniMax m3 / Step Plan / Anthropic / OpenAI / 自定义代理 5 种典型配置。

3.1 为什么需要 models.json

Pi 内置了一堆主流 provider(Anthropic、OpenAI、Google、Mistral、xAI、Bedrock 等),但:

  • 新模型发布 → 内置版本不一定及时跟进
  • 私有部署(vLLM、Ollama、LM Studio、自建代理)→ 必须自己配
  • 中国区域 provider(MiniMax、Step Plan、DeepSeek、Kimi、智谱)→ 需要 baseUrl
  • 多 provider 共存 → 不用每次 --provider xxx/model 面板直接列出来

models.json 解决了以上所有问题,改了不用重启 Pi,在 interactive 模式按 /model 立刻生效。

3.2 文件位置 & 权限

1
2
~/.pi/agent/models.json
# 权限不限,但通常 644 即可

完整 schema 在源码 dist/core/model-config.js 里(TypeBox 定义)。下面挑重点讲。

3.3 顶层结构

1
2
3
4
5
6
7
8
{
"providers": {
"<provider-id>": {
// ProviderConfig
},
...
}
}

注意:key 是 provider id,不是 provider 名字(minimax-cn vs “MiniMax China”)。

3.4 Provider 字段(ProviderConfig)

字段 类型 必填 说明
name string - 显示名(不写就用 provider id)
baseUrl string - API endpoint(OpenAI 协议需要 /v1 后缀)
api enum 协议类型(见下表)
apiKey string - API key。$ENV_VAR 会自动展开
oauth "radius" - OAuth provider 标识
headers object - 自定义 HTTP headers
compat object - 协议兼容性配置
authHeader bool - true = 把 apiKey 放 Authorization header;false = 放 body 或 query
models array - Provider 自带的模型列表
modelOverrides object - 覆盖内置 provider 的某些字段

3.4.1 api 协议类型

api 值 适用
anthropic-messages Claude、MiniMax、Step Plan、任何 Anthropic 兼容代理
openai-completions OpenAI、DeepSeek、Ollama、vLLM、LM Studio、绝大多数代理
openai-responses OpenAI Responses API (新)
google-generative-ai Google Gemini / AI Studio
bedrock AWS Bedrock
mistral Mistral
vercel-ai-gateway Vercel AI Gateway

重点:选错 api 协议会导致 stream 解析失败、tool call 不工作。

3.5 Model 字段(ModelDefinition)

字段 必填 说明
id 模型 ID,传给 provider 的实际值
name - 显示名
api - 覆盖 provider 的 api(极少数情况)
baseUrl - 覆盖 provider 的 baseUrl
reasoning - true = 启用思考模式
thinkingLevelMap - 思考级别映射(如 {"off": null, "high": "max"}
input - ["text"] / ["text", "image"] 声明支持多模态
contextWindow - 上下文 token 数
maxTokens - 单次输出最大 token
cost - { input, output, cacheRead, cacheWrite } 美元/百万 token
headers - per-model 自定义 headers
compat - per-model 兼容性覆盖

最少配置:只要 id 就够。

3.6 实战配置:5 种典型场景

3.6.1 MiniMax m3(飞书集成项目真实使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
{
"providers": {
"minimax-cn": {
"baseUrl": "https://api.minimaxi.com/anthropic",
"api": "anthropic-messages",
"apiKey": "$MINIMAX_CN_API_KEY",
"models": [
{
"id": "minimax-m3",
"name": "MiniMax M3 (anthropic)",
"reasoning": true,
"input": ["text"],
"contextWindow": 200000,
"maxTokens": 16384
},
{
"id": "MiniMax-M2.7-highspeed",
"name": "MiniMax M2.7 高速度",
"reasoning": false,
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 8192
},
{
"id": "MiniMax-M2.7",
"name": "MiniMax M2.7",
"reasoning": false,
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 8192
}
]
}
}
}

关键点:

  • baseUrl/anthropic 子路径(不是 /v1),因为 MiniMax 的 Anthropic 兼容端点在那里
  • api: "anthropic-messages"(不是 openai-completions)
  • apiKey: "$MINIMAX_CN_API_KEY" 自动展开环境变量,配置文件里不出现明文 key

启动前:export MINIMAX_CN_API_KEY=sk-cp-...

3.6.2 Step Plan(StepFun)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
"providers": {
"stepfun": {
"baseUrl": "https://api.stepfun.com/step_plan",
"api": "anthropic-messages",
"apiKey": "$STEPFUN_API_KEY",
"models": [
{
"id": "step-3.5-flash",
"name": "Step 3.5 Flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 128000,
"maxTokens": 16384
},
{
"id": "step-1o-turbo-vision",
"name": "Step 1o Turbo Vision",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 32000,
"maxTokens": 8192
}
]
}
}
}

注意 step-3.5-flash支持 thinking 的——但实测在 RPC 模式下会幻觉出 tool_call XML(详见 Chapter 11 故障排查)。

3.6.3 Anthropic(直接用)

1
2
3
4
5
6
7
8
9
10
11
12
{
"providers": {
"anthropic": {
"api": "anthropic-messages",
"apiKey": "$ANTHROPIC_API_KEY",
"models": [
{ "id": "claude-opus-4-20250514", "reasoning": true, "contextWindow": 200000, "maxTokens": 32000 },
{ "id": "claude-sonnet-4-20250514", "reasoning": false, "contextWindow": 200000, "maxTokens": 16000 }
]
}
}
}

如果你直接 export ANTHROPIC_API_KEY=... 启动 Pi,Pi 会自动用内置 anthropic provider,这条配置是可选的

3.6.4 DeepSeek(OpenAI 协议)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"providers": {
"deepseek": {
"baseUrl": "https://api.deepseek.com",
"api": "openai-completions",
"apiKey": "$DEEPSEEK_API_KEY",
"models": [
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner",
"reasoning": true,
"contextWindow": 64000,
"maxTokens": 8192
}
]
}
}
}

注意 baseUrl 不带 /v1——OpenAI 协议下 Pi 会自己补 /chat/completions 等路径。

3.6.5 本地 Ollama / vLLM / LM Studio

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"providers": {
"ollama": {
"baseUrl": "http://localhost:11434/v1",
"api": "openai-completions",
"apiKey": "ollama",
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false
},
"models": [
{ "id": "llama3.1:8b", "name": "Llama 3.1 8B", "reasoning": false, "contextWindow": 128000 },
{ "id": "qwen2.5-coder:7b", "name": "Qwen 2.5 Coder 7B", "reasoning": false, "contextWindow": 32000 },
{ "id": "gpt-oss:20b", "name": "GPT-OSS 20B", "reasoning": true }
]
}
}
}

compat.supportsDeveloperRole: false 是因为 Ollama 等本地服务不理解 developer role(这是 OpenAI reasoning 模型专用),Pi 会改用 system role 兼容。

3.7 兼容性配置(compat)

3.7.1 Anthropic 兼容(AnthropicMessagesCompat)

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"compat": {
"supportsEagerToolInputStreaming": true,
"supportsLongCacheRetention": false,
"sendSessionAffinityHeaders": false,
"supportsCacheControlOnTools": true,
"supportsTemperature": true,
"forceAdaptiveThinking": false,
"allowEmptySignature": false,
"supportsStrictTools": false,
"supportsToolReferences": false
}
}

针对不同代理的具体兼容性配置不同。踩坑

  • 如果代理不认 temperature 字段 → supportsTemperature: false
  • 如果 thinking signature 校验失败 → allowEmptySignature: true
  • 如果 tool 描述的 cache_control 报错 → supportsCacheControlOnTools: false

3.7.2 OpenAI 兼容(OpenAICompletionsCompat)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"compat": {
"supportsStore": false,
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"supportsUsageInStreaming": true,
"maxTokensField": "max_tokens",
"requiresToolResultName": false,
"requiresAssistantAfterToolResult": false,
"requiresThinkingAsText": false,
"thinkingFormat": "openai",
"cacheControlFormat": "anthropic"
}
}

最常见的踩坑:

  • supportsDeveloperRole: false → 本地 Ollama/vLLM 必备
  • supportsReasoningEffort: false → 同上
  • maxTokensField: "max_completion_tokens" → OpenAI 新模型;"max_tokens" → 旧模型

3.8 覆盖内置 Provider(modelOverrides)

Pi 内置的 anthropic/openai 都有模型列表。如果想给内置 provider 加模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"providers": {
"anthropic": {
"modelOverrides": {
"claude-opus-5-20250901": {
"name": "Claude Opus 5",
"reasoning": true,
"contextWindow": 1000000,
"maxTokens": 64000
}
}
}
}
}

这样 /model 面板会列出 Opus 5,不需要重写整个 provider 配置

3.9 认证:apiKey vs OAuth vs 环境变量

3.9.1 三种方式并存

1
2
3
4
5
6
7
8
9
10
{
"providers": {
"my-provider": {
"apiKey": "$MY_KEY", // 1. 环境变量引用
"headers": {
"X-Real-Token": "$OTHER" // 2. headers 里也能引用
}
}
}
}

启动 Pi 前 export MY_KEY=sk-...配置文件永远不出现明文

3.9.2 Pi 0.83+ 新增能力

1
2
3
4
5
# 导出 API key(自动 OAuth 刷新 + 最短有效期保证)
pi auth print-api-key --provider anthropic

# 导出 Bearer token
pi auth print-bearer-token --provider openai-codex

这两个命令可以配合外部客户端(比如我们的 pi-lark-bridge),不用手动维护 token。

3.10 验证 models.json

1
2
3
4
5
6
7
8
# Pi 启动时会自动校验 schema,出错直接退出
pi --version
# OK

# 打开 Pi,看 /model 面板是否包含你加的 provider
pi
> /model
# 期望: minimax-cn, stepfun, deepseek, ollama 都在列表里

排错

1
2
3
4
5
6
7
8
9
10
11
# schema 错误时 Pi 会打印具体哪个字段错
cat ~/.pi/agent/models.json | jq .
# 验证 JSON 合法性

# 用 Pi 自带的检查
pi /model
# 如果没看到你的 provider,检查:
# 1. JSON 语法 (jq parse)
# 2. apiKey 环境变量已 export
# 3. baseUrl 拼写
# 4. models[].id 是否必填

3.11 小结

  • models.json 是 Pi 配置的中枢
  • Provider 字段:必填 api,常用 baseUrl + apiKey
  • Model 字段:最少 id,其他都有合理默认
  • 环境变量引用"$VAR_NAME" 避免明文 key
  • 兼容性配置 compat:解决 90% 的”连上但行为不对”问题
  • 改了不重启/model 立刻刷新

下一步:Chapter 4 运行模式详解 →


Chapter 4 · 运行模式详解

Pi 有 4 种运行模式:interactive(TUI)、print(CLI 单次)、RPC(stdin/stdout 协议)、SDK(嵌入式 API)。
本章用决策树 + 实战案例帮你选对模式,避免在错误场景里挣扎。

4.1 模式全景对比

维度 Interactive Print RPC SDK
入口 pi pi -p "..." pi --mode rpc createAgentSession()
输入 终端 + 文件 命令行参数 stdin JSON 函数调用
输出 TUI 渲染 stdout 文本 stdout JSON Event 对象
多轮 ✅ session 持久化 ❌ 一次一答 ✅ 自动排队 ✅ 手动管理
流式 ✅ 实时 默认关闭 (可开) ✅ 实时 ✅ 订阅事件
工具 ✅ 全部
OAuth /login 环境变量 环境变量 环境变量 / apiKey 直接传
适用 探索代码 CI / 一次性问答 IM 机器人 / Web IDE 深度嵌入

4.2 Interactive 模式

4.2.1 启动 & 退出

1
2
3
pi                    # 当前目录
pi /path/to/project # 指定工作目录
pi @file1.ts @file2.ts "review these" # 预填文件 + 任务

退出:Ctrl+C/exit/quit

4.2.2 关键命令速查

命令 功能
/model 切换 provider/model
/login OAuth 登录
/resume 列历史会话
/new 开始新会话
/compact [prompt] 压缩上下文(可选指令)
/branch 从某条消息分叉
/tree 树状导航
/fork 创建新 session 文件
/name <name> 给当前会话起名
/reload 重载 AGENTS.md / 扩展
/export [file] 导出会话为 HTML
/share 上传为 GitHub gist
/skills 列出 skills
/extensions 列出扩展
/sessions 列当前项目历史
/exit 退出

完整 30+ 个命令见 dist/cli.js 的 CommandDef 表。

4.2.3 关键快捷键

按键 功能
Ctrl+C 取消当前操作 / 退出
Ctrl+D EOF / 退出
Ctrl+L 清屏
Ctrl+O 折叠/展开工具输出
Ctrl+T 折叠/展开 thinking block
Ctrl+V / Alt+V 粘贴图片(Termux 不支持)
↑/↓ 历史 prompt
Tab 自动补全
Esc 取消编辑

完整快捷键见 docs/keybindings.md

4.2.4 消息队列(busy mode)

Pi 在工作时还能接受输入,由 display.busy_input_mode 控制:

模式 行为
queue (默认) 输入排队,等当前 turn 完成后插入
steer 输入立即接管当前 turn(中断 LLM)
interrupt Ctrl+C 立即中断当前 turn
1
2
3
4
# ~/.pi/agent/settings.json
{
"busyInputMode": "steer"
}

4.3 Print 模式

4.3.1 基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 单次问答
pi -p "Say hello"

# 不保存 session
pi -p "test" --no-session

# 继续上一次 session
pi -p "继续" --continue

# 指定 model
pi -p "你好" --provider minimax-cn --model minimax-m3

# 给个 system prompt
pi -p "summarize" --system-prompt "你回复不超过 50 字"

# 命名 session(CI 友好)
pi -p "fix bug" --name "bug-2026-08-01"

4.3.2 输出格式

默认输出纯文本。如果想结构化输出:

1
pi -p "列出当前目录文件" --json

JSON 模式输出:

1
2
3
4
5
6
{
"text": "Here are the files...",
"usage": {"input": 150, "output": 200},
"model": "minimax-m3",
"sessionId": "abc123"
}

4.3.3 流式 vs 非流式

1
2
3
4
5
# 默认非流式(一次输出全部)
pi -p "long task"

# 流式(边生成边输出,需要 --json)
pi -p "long task" --json --stream

飞书集成项目里 print 模式用于测试和调试,生产用 RPC。

4.4 RPC 模式

4.4.1 协议基础

1
2
3
stdin  → JSON command (one per line, LF delimited)
stdout → JSON events / responses (one per line)
stderr → 日志(如果开启)

LF 严格:用 \n(LF)做记录分隔符,不要用 \r\n。Node 的 readline 默认拆 U+2028U+2029不兼容——Pi 0.83+ 文档明确警告。

4.4.2 启动

1
2
3
4
5
6
7
8
9
pi --mode rpc [options]

options:
--provider <name> LLM provider
--model <id> model id
--name <name> / -n session display name
--no-session 不保存
--session-dir <path> 自定义 session 目录
--thinking <level> 初始思考级别

4.4.3 命令(stdin → Pi)

命令 用途
prompt 发用户消息
steer 流式期间排队接管
follow_up 流式结束后追加
abort 中断当前 turn
new_session 开启新会话
get_state 查询当前状态
get_messages 拉所有消息
get_session_info 当前 session 元数据
set_model 切换模型
set_thinking_level 切换思考级别
set_steering_mode 改 steer 行为
set_follow_up_mode 改 follow_up 行为
compact 压缩上下文
set_session_name 改 session 名
get_commands 列扩展命令
get_available_models 列可用模型

每条命令可带 id,响应里同 id 回传(请求/响应关联)。

4.4.4 事件(stdout ← Pi)

事件分四类:

  1. lifecycleagent_startagent_endturn_startturn_end
  2. messagemessage_startmessage_updatemessage_end
  3. tooltool_calltool_resulttool_execution_starttool_execution_updatetool_execution_end
  4. statemodel_selectthinking_level_changesession_stats

详细目录见 Chapter 8 RPC 协议参考

4.4.5 错误处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 命令失败
{
"type": "response",
"command": "prompt",
"success": false,
"id": "req-1",
"error": "Rate limit exceeded, retry in 60s"
}

// 内部错误通过 message 事件传递
{
"type": "message_end",
"message": {
"role": "assistant",
"stopReason": "error",
"errorMessage": "..."
}
}

重点success: true 表示命令被接受,不代表 turn 完成。turn 失败通过正常 event 流报告。

4.4.6 实战模板

详见 Chapter 8Chapter 9

4.5 SDK 模式

4.5.1 两种集成方式

方式 适用 代码量
RPC 模式(子进程) Node/Node-bridge、跨语言集成
SDK 模式(库嵌入) Node-only 嵌入、性能敏感 多但可控

官方建议(rpc.md 顶部原文):

If you’re building a Node.js/TypeScript application, consider using AgentSession directly from @earendil-works/pi-coding-agent instead of spawning a subprocess.

4.5.2 SDK 入口

1
2
3
4
5
import { 
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";

或子进程 RPC:

1
import { RpcClient } from "@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-client.js";

4.5.3 决策树

1
2
3
4
5
6
你的语言?
├── Node.js/TypeScript → SDK 模式(首选)
│ ├── 想要完整控制 model/tools/event → createAgentSession
│ └── 想要开箱即用 RPC → RpcClient
└── 其他(Python/Go/Rust/Java)
└── 子进程 RPC(spawn + JSON line 通信)

4.5.4 飞书集成项目用了哪个?

RPC + RpcClient(Node.js)。原因:

  1. 飞书 SDK 是 Node,Pi 是 Node,同进程没必要走子进程
  2. RpcClient 是单进程常驻,比每条消息 spawn 新进程快 100x
  3. RPC 协议简单,调试方便(直接在 stdio 看 JSON)

详见 Chapter 9 实战案例

4.6 模式切换的常见误区

误区 1:用 print 跑多轮

1
2
3
4
5
6
7
# ❌ 错误:每次都丢失上下文
pi -p "你好"
pi -p "你叫什么" # 不记得上一轮!

# ✅ 正确:使用 --continue 复用 session
pi -p "你好"
pi -p "你叫什么" --continue

或直接 RPC / Interactive。

误区 2:用 RPC 跑 CLI 单次任务

1
2
3
4
5
# ❌ 杀鸡用牛刀
echo '{"type":"prompt","message":"hi"}' | pi --mode rpc

# ✅ 用 print 就行
pi -p "hi"

误区 3:用 SDK 处理跨语言

1
2
3
4
5
6
# Python 项目不应该硬塞 Node SDK
# ❌ 错误:起 Node 子进程调 SDK
subprocess.run(["node", "embed-pi.js"])

# ✅ 正确:起 pi --mode rpc 子进程
subprocess.Popen(["pi", "--mode", "rpc"], stdin=PIPE, stdout=PIPE)

4.7 小结

  • 选模式三步:自己用 → interactive;自动化/CI → print;集成/嵌入 → RPC(跨语言)/ SDK(Node)
  • RPC + RpcClient 是飞书集成项目的标准答案
  • 改模式不改语义:所有模式共享同一套模型配置、tools、session
  • 出错了看 success: false 还是 event 里的 stopReason: "error"两者意义不同

下一步:Chapter 5 工具系统 →


Chapter 5 · 工具系统

Pi 默认给 LLM 4 个写权限工具(read/write/edit/bash)+ 3 个只读工具(grep/find/ls),加扩展可无限扩展。
本章讲清楚:内置工具行为、参数、限制如何禁用/启用如何写自定义工具

5.1 内置工具清单

工具 读写 默认启用 用途
read R 读文件(按行/字节范围)
write W 创建/覆盖文件
edit W 精确替换(多份备份可回滚)
bash E 执行 shell 命令
grep R ✅(按需) ripgrep 内容搜索
find R ✅(按需) 文件查找
ls R ✅(按需) 目录列表

E = Execute(bash 不直接读写文件系统,但能跑任何 shell 命令,包括读和写)

5.2 read:读取文件

1
2
3
4
5
{
path: string; // 文件绝对路径
offset?: number; // 起始行(0-indexed)
limit?: number; // 读多少行
}

特性

  • 默认读取整个文件,超大文件自动截断(按行)
  • 二进制文件检测到自动拒绝(避免 LLM 读图片/可执行)
  • 支持图片(多模态模型):input: ["text", "image"] 的模型会传 image content block
  • 输出包含行号,方便和 edit 配合

限制

  • 单次读取有最大行数(约 2000 行)
  • LLM 可能跳读(不让它跳):用 offset + limit 强制分块

实战

1
2
> @src/app.ts  # 直接把文件喂给 prompt
> 读 src/app.ts 第 50-100 行 # offset/limit 自动

5.3 write:创建/覆盖文件

1
2
3
4
{
path: string;
content: string;
}

特性

  • 完整覆盖,不存在则创建
  • 自动创建父目录
  • 写完自动 chmod(保留执行位 if 文件是脚本)
  • 写入前 Pi 会做基本校验:路径在 trust 范围内吗?

安全

  • .envsecrets.json 等文件名 Pi 会警告(trust 决策里)
  • 可以用扩展拦截(tool_call event)

5.4 edit:精确替换(推荐用这个)

1
2
3
4
5
6
7
{
path: string;
edits: Array<{
oldText: string; // 必须精确匹配
newText: string; // 替换为
}>;
}

特性

  • 基于 oldText 匹配,不基于行号(line shifting 自动适应)
  • 多份 oldText 时按出现顺序匹配
  • 不匹配会整文件报错,不会部分成功
  • LLM 看不到的差异会被自动捕获(缩进、空白)

实战技巧

1
2
3
4
5
6
7
8
// 一次 edit 做多份替换(原子操作)
{
path: "/src/app.ts",
edits: [
{ oldText: "import foo from 'foo';", newText: "import foo from 'foo';\nimport bar from 'bar';" },
{ oldText: "function legacy() {}", newText: "" } // 删除
]
}

:oldText 必须精确匹配(whitespace、换行都算)。LLM 经常搞错,建议给它看上下文行号后再 edit。

5.5 bash:执行 shell

1
2
3
4
5
6
{
command: string;
cwd?: string; // 工作目录(默认当前 session cwd)
timeout?: number; // 超时 ms(默认 120000)
env?: Record<string, string>; // 额外环境变量
}

安全机制

  1. user_bash 扩展事件:扩展可以拦所有 bash 调用
  2. 环境变量注入:Pi 自动注入 PI_SESSION_ID / PI_SESSION_FILE / PI_PROVIDER / PI_MODEL / PI_REASONING_LEVEL(详见 docs/environment-variables.md
  3. 超时:默认 2 分钟,可以 timeout 参数拉长
  4. 取消:用户按 Ctrl+C 会取消所有并发 bash(0.83+ 修复了只取消一个的 bug)

沙箱:Pi 不内置沙箱。本地跑等于你 user 的权限。生产用 container / VM(详见 docs/security.md)。

环境变量关闭

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

这样嵌套 Pi 进程不会暴露父 session 的 metadata。

5.6 grep:内容搜索

1
2
3
4
5
6
7
{
pattern: string; // regex
path?: string; // 默认 cwd
include?: string; // glob filter (e.g. "*.ts")
exclude?: string; // glob filter
caseSensitive?: boolean;
}

底层用 ripgrep(不是纯 JS 实现),性能极好。

5.7 find & ls:文件查找

find

1
2
3
4
5
6
{
pattern: string; // glob
path?: string;
type?: "file" | "directory" | "any";
maxDepth?: number;
}

ls

1
2
3
4
{
path?: string;
showHidden?: boolean;
}

5.8 启用 / 禁用工具

5.8.1 全局配置(settings.json)

1
2
3
4
5
6
{
"tools": {
"enabled": ["read", "edit", "bash", "grep", "find", "ls"],
"disabled": ["write"]
}
}

5.8.2 CLI 启动时指定

1
2
pi --tools read,bash,grep
pi --no-write # 禁写

5.8.3 SDK 中指定

1
2
3
4
5
6
const { session } = await createAgentSession({
tools: ["read", "bash", "grep"], // 白名单
excludeTools: ["edit", "write"], // 黑名单
noTools: true, // 全关(极简模式)
customTools: [myToolDef], // 加自定义
});

5.8.4 RPC 模式动态切换

RPC 不直接支持运行时注册工具。要在启动前通过配置文件(models.json 不行,那是模型配置):

  • 用 SDK 模式启动,自己注册工具,再走 RPC 调用 Pi 内部(不现实)
  • 或 fork Pi 子进程 + 自定义 cli.js(重)

实战

  • 大多数场景用 SDK 模式 + customTools
  • 飞书集成项目不用自定义工具——LLM 只需要 read/bash/edit/write 就能干所有活

5.8.4 扩展工具(TypeScript 扩展)

写到 ~/.pi/agent/extensions/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ~/.pi/agent/extensions/weather.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "weather",
label: "天气",
description: "查天气",
parameters: Type.Object({ city: Type.String() }),
async execute(args, ctx) {
const resp = await fetch(`https://wttr.in/${args.city}?format=3`);
return { content: [{ type: "text", text: await resp.text() }] };
},
});
}

启动时自动加载(global extensions),或项目级 .pi/extensions/,或 CLI -e ./path.ts

5.9 工具执行的事件订阅

扩展可以监听 6 类工具事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pi.on("tool_call", async (event, ctx) => {
// event.toolName, event.input
// 返回 { block: true, reason: "..." } 阻止执行
});

pi.on("tool_result", async (event, ctx) => {
// event.toolName, event.result
});

pi.on("tool_execution_start", ...);
pi.on("tool_execution_update", ...);
pi.on("tool_execution_end", ...);

pi.on("user_bash", async (event, ctx) => {
// 用户自己跑的 ! 命令(不是 LLM 调用)
});

实战:飞书集成项目没装工具扩展——bash 工具的 ! 命令场景不需要拦。

5.10 工具结果截断与输出累积

超大输出(git diff、build log)会触发两阶段保护

  1. 软截断:显示前 N 行 + “…(N more lines, .. to expand)” 提示
  2. 用户按 Ctrl+O 展开:调 tool_execution_update 拉全文

源码在 dist/core/tools/output-accumulator.jsdist/core/tools/truncate.js

实战建议

  • LLM 跑 npm test 卡住 → 加 | head -50 限制输出
  • LLM 跑 git log 拉太多 → 给个 --max-count=20

5.11 工具 vs MCP

Pi 支持 MCP(在 ~/.pi/agent/settings.json 配置),但用法上:

场景 推荐
简单 API 调用 自定义工具(pi.registerTool
复杂协议(MCP 已有服务) MCP server
数据库 / 外部 SaaS MCP server
单文件 / 单操作 自定义工具

实战:飞书集成项目不用 MCP——只是 Pi 接收飞书消息并回复,不需要 LLM 调外部服务。

5.12 小结

  • 内置 7 个工具(read/write/edit/bash + grep/find/ls)
  • 写权限工具(write/edit/bash)默认启用,生产必加扩展拦截
  • 自定义工具:TypeBox 定义 schema + execute 函数
  • bash 沙箱靠 OS(容器/VM),Pi 不内置
  • 环境变量自动注入 PI_* 给所有 bash 调用

下一步:Chapter 6 System Prompts 与 Context Files →


Chapter 6 · System Prompts 与 Context Files

这一章讲 Pi 怎么”加载外部知识”:AGENTS.md、CLAUDE.md、SYSTEM.md、Skills、Prompt Templates、Settings.json。
重点:加载顺序、优先级、如何避坑

6.1 Context Files 加载机制

Pi 启动时按以下顺序扫描 context files(多文件叠加,不是覆盖):

  1. Global: ~/.pi/agent/AGENTS.md
  2. Ancestor walk:从 cwd 往 root 走,每个目录找 AGENTS.mdCLAUDE.md离 cwd 最近的优先
  3. Current cwd: AGENTS.mdCLAUDE.md
  4. Pi-managed: ~/.pi/agent/SYSTEM.md / ~/.pi/agent/APPEND_SYSTEM.md

6.1.1 AGENTS.md vs CLAUDE.md

文件 谁读
AGENTS.md Pi、Cursor、Aider 等都认
CLAUDE.md Claude Code、Pi

建议:项目里两个都放,内容相同。Pi 优先 AGENTS.md

6.1.2 实战模板

~/.pi/agent/AGENTS.md(全局):

1
2
3
4
5
6
7
8
9
10
11
# Hermes 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`

## Conventions
- 临时文件放 `~/.tmp/`**不要用 /tmp**(只读)
- shell scripts 加 `set -euo pipefail`
- 凭证存 `~/.hermes/.env`(不会自动脱敏),不要写进任何脚本

项目 AGENTS.md

1
2
3
4
5
6
7
8
9
# Pi Agent Manual Project

## Build
- `cd ~/pi-agent-manual && ls chapters/` 验证章节齐全
- 部署到 ai.bloodysky.top 用 scp + nginx reload

## Don't
- 不要用 Mermaid flowchart(用户已拒绝)
- 不要手搓 SVG(用户已确认细节无法满足)

6.1.3 加载时机

  • Interactive / RPC:每次 session 启动加载一次
  • 改了之后:按 /reload 重载
  • Trust:项目级 AGENTS.md 不需要 trust(context_files_loading 默认开),但项目级 SYSTEM.md 需要 trust

6.2 SYSTEM.md & APPEND_SYSTEM.md

Pi 自己的 system prompt 通过 ~/.pi/agent/SYSTEM.mdAPPEND_SYSTEM.md 注入:

文件 作用
SYSTEM.md 完全替换 Pi 默认 system prompt(不推荐)
APPEND_SYSTEM.md 在默认 system prompt 之后追加(推荐)

6.2.1 实战:APPEND_SYSTEM.md

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Hermes Agent Guidelines

## Communication Style
- 简洁、生产级、不啰嗦
- 失败时给出根因,不要 restart 万能论
- 主动列出"我做了什么"+"下一步建议"

## Code Standards
- 不做软链接(venv 内部库豁免)
- 凭证只在 .env,绝不写到聊天或日志
- 改动前 review,动手前确认

## Privacy
- 隐私/凭证:禁止传输密码
- 用户偏好中文

这个文件让 Pi 在飞书集成里表现得像 Hermes Agent而不是通用 coding agent。

6.3 Skills(技能包)

Skills 是按需加载的能力包。Agent Skills 标准,Pi 实现了完整规范。

6.3.1 加载位置(多级)

1
2
3
4
5
6
7
Global:       ~/.pi/agent/skills/         (用户级)
~/.agents/skills/
Project: .pi/skills/ (项目级,需 trust)
.agents/skills/ (cwd + ancestor 目录,到 git root)
Packages: skills/ in installed pi packages
Settings: settings.json skills 数组
CLI: --skill <path>

6.3.2 Skill 结构

1
2
3
4
5
my-skill/
├── SKILL.md # 主说明(必填)
├── scripts/ # 可执行脚本
├── assets/ # 资源文件
└── references/ # 补充文档

SKILL.md 模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
---
name: my-skill
description: Brief one-line description for the LLM
---

# My Skill

When the user asks about X, do Y:

1. First step
2. Second step

Reference: @assets/template.md

6.3.3 触发方式

1
2
3
4
5
6
# 自动触发
LLM 看到 description 匹配就自动加载

# 手动触发
/skill:my-skill
/skill:my-skill arg1 arg2

6.3.4 实战:飞书集成项目用 skill 吗?

没用。飞书消息进来是单一通道,LLM 只需要”读 bash 跑命令 + 回答”两件事,skill 没必要。

通用 Pi 用户会重度用 skill,比如:

  • /skill:pdf-tools 处理 PDF
  • /skill:brave-search 搜索网络
  • /skill:code-review 审查代码

6.4 Prompt Templates

/name 命令触发,比 skill 更轻量(纯 Markdown 片段)。

6.4.1 位置

1
2
3
4
~/.pi/agent/prompts/*.md     # global
.pi/prompts/*.md # project (需 trust)
prompts/ in installed packages
--prompt-template <path> # CLI

6.4.2 模板格式

1
2
3
4
5
6
7
8
9
---
description: 审查 PR
argument-hint: "<PR-URL>"
---

请审查以下 PR 的改动:
- URL: {{arg}}
- 关注点:性能、安全、可读性
- 输出格式:表格列出每个文件的改动评分

文件名 review.md → 命令 /review

6.4.3 触发

1
> /review https://github.com/foo/bar/pull/123

{{arg}} 自动替换为后续参数。

6.5 Settings.json(settings 而非 prompt)

settings 控制 Pi 行为,不影响 prompt 内容。完整清单在 docs/settings.md

6.5.1 常用设置

1
2
3
4
5
6
7
8
9
10
11
{
"theme": "dark",
"defaultProvider": "minimax-cn",
"defaultModel": "minimax-m3",
"defaultThinkingLevel": "medium",
"hideThinkingBlock": false,
"defaultProjectTrust": "ask",
"busyInputMode": "queue",
"showReasoning": true,
"externalEditor": "code --wait"
}

6.5.2 项目级 settings

.pi/settings.json 覆盖全局。CI / 团队共享:

1
2
3
4
5
6
7
{
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-20250514",
"tools": {
"disabled": ["bash"]
}
}

6.6 实战:飞书集成的 system prompt 组装

飞书集成项目里 Pi 看到的 system prompt 是这么拼出来的:

1
2
3
4
5
6
7
[Pi 默认 system prompt]
+ [~/.pi/agent/APPEND_SYSTEM.md] (Hermes guidelines)
+ [~/.pi/agent/skills/] 自动发现的 skill 描述 (XML)
+ [~/.pi/agent/AGENTS.md] (Termux 环境说明)
+ [models.json] 当前 model 信息
+ [session 历史] 之前对话
+ [user prompt]

APPEND_SYSTEM.md 让 Pi 知道这是 Hermes Agent 而非通用 coding agent。

6.7 常见坑

坑 1:AGENTS.md 写太多

LLM context window 有限。AGENTS.md 每个 token 都要付费。只写必要的约束,不要写文档。

坑 2:APPEND_SYSTEM.md 覆盖默认行为

APPEND_SYSTEM.md 是追加不是覆盖。如果要禁用某个默认行为,需要用 settings(hideThinkingBlock 等),不是 prompt。

坑 3:项目 trust 被拒

1
[warning] Project not trusted, .pi/SYSTEM.md not loaded

解决:/trust 命令,或 defaultProjectTrust: "always"(不推荐)。

坑 4:Skill 不触发

LLM 经常不主动调用 skill。强制方式:

  1. 在 prompt 里明确说”用 skill X”
  2. 直接 /skill:x
  3. 在 SKILL.md 的 description 里写更明显的触发词

6.8 小结

  • Context 文件分四层:global → ancestor → cwd → pi-managed
  • AGENTS.md / CLAUDE.md 是项目指令主载体
  • APPEND_SYSTEM.md 是 Hermes 行为定制的入口(飞书集成项目用)
  • Skills 按需加载(自动 + 手动)
  • Prompt Templates/name 触发的 Markdown 片段
  • Settings.json 是行为配置,不影响 prompt 内容

下一步:Chapter 7 会话管理 →


Chapter 7 · 会话管理

Pi 的 session 是 JSONL 文件 + 树状结构,支持 branching、compaction、resume。
本章讲清:session 文件怎么读、怎么写、怎么通过 RPC 操作

7.1 Session 文件存储

1
2
3
4
5
~/.pi/agent/sessions/
└── --home-user-projects-myproject--/
├── 2026-08-01T07-21-abc123.jsonl ← 一次会话
├── 2026-07-31T18-15-def456.jsonl
└── 2026-07-30T09-42-789xyz.jsonl

目录名规则:cwd 的 / 替换为 -,前后加 --

例:/home/user/projects/myproject--home-user-projects-myproject--

7.2 JSONL 文件结构

每行一个 JSON 对象,类型有:

7.2.1 头(第一行)

1
2
3
4
5
6
7
8
9
{
"type": "session",
"version": 3,
"id": "abc123",
"name": "Refactor auth",
"cwd": "/path/to/project",
"timestamp": 1722500000000,
"parentSession": null
}

7.2.2 消息行

1
2
3
4
5
6
7
8
9
10
{
"type": "message",
"id": "msg-1",
"parentId": null,
"timestamp": 1722500001000,
"message": {
"role": "user",
"content": "Hello, can you help me refactor auth?"
}
}

7.2.3 树状结构

通过 id / parentId 形成 DAG:

1
2
3
4
5
msg-1 (user) ← parentId: null
└─ msg-2 (assistant) ← parentId: msg-1
├─ msg-3 (toolCall) ← parentId: msg-2
│ └─ msg-4 (toolResult) ← parentId: msg-3
└─ msg-5 (user, branch) ← parentId: msg-2

当前活跃叶(active leaf)保存一个隐式指针。/tree 让你跳到任何点。

7.3 Session 版本

Version 变化
1 线性(已废弃,自动迁移)
2 引入树状 id/parentId
3 hookMessagecustom(扩展统一)

Pi 启动时自动把 v1/v2 迁到 v3。

7.4 消息类型(AgentMessage)

类型 Role 用途
UserMessage user 用户输入
AssistantMessage assistant LLM 回复
ToolResultMessage toolResult 工具执行结果
BashExecutionMessage - Bash 输出(人类可读 + 完整记录)
CustomMessage custom 扩展消息(0.83+ 统一)
CompactionEntry - 上下文压缩产物
BranchSummaryEntry - Branch 切换总结

7.4.1 AssistantMessage 内容块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
interface AssistantMessage {
role: "assistant";
content: Array<
| TextContent // { type: "text", text: string }
| ThinkingContent // { type: "thinking", thinking: string }
| ToolCall // { type: "toolCall", id, name, arguments }
>;
api: string; // "anthropic-messages" / "openai-completions" / ...
provider: string; // "minimax-cn" / "anthropic" / ...
model: string; // model id
usage: Usage; // { input, output, cacheRead, cacheWrite, total }
stopReason: "stop" | "length" | "toolUse" | "error" | "aborted";
errorMessage?: string;
timestamp: number;
}

实战:飞书桥接的关键

1
2
3
4
5
6
7
8
9
10
11
12
// pi-adapter.js 我们项目的核心代码
function extractAssistantText(events) {
for (let i = events.length - 1; i >= 0; i--) {
const evt = events[i];
if (evt.type === 'message_end' && evt.message?.role === 'assistant') {
const content = evt.message.content || [];
const textBlock = content.find(c => c.type === 'text');
if (textBlock?.text) return textBlock.text.trim();
}
}
return null;
}

关键 insight:assistant 的 content 是数组,有 text / thinking / toolCall 三种块。LLM 可能在一个 turn 里多次发 thinking 然后发 text(也可能混着 toolCall)。只看最后一个 text block 是最稳的做法。

7.5 Compaction(上下文压缩)

7.5.1 自动触发

1
contextTokens > contextWindow - reserveTokens

reserveTokens 默认 16384,settings.json 可改。

7.5.2 手动触发

1
2
> /compact
> /compact 重点保留 bug 修复相关上下文

7.5.3 工作流程

1
2
3
4
5
1. 倒序遍历 message 累计 token,到 keepRecentTokens (默认 20k) 停
2. 拿前面的消息 + 上次 compaction summary 喂给 LLM
3. LLM 生成结构化 summary(包含累积的文件操作清单)
4. 追加 CompactionEntry 到 session
5. Session 重载:summary + 保留的近期消息

7.5.4 RPC 触发

1
{"type": "compact", "options": {"customInstructions": "保留关键决策"}}

7.6 Branching(分支)

7.6.1 Interactive 命令

命令 作用
/tree 树状导航 + 编辑回退
/fork 创建新 session 文件(从某条消息分叉)
/clone 复制当前活跃分支到新 session

7.6.2 RPC 操作

RPC 不直接支持 /tree UI。实现方式

1
2
3
4
5
6
// 通过 SDK 直接操作
import { SessionManager } from "@earendil-works/pi-coding-agent";

const sm = SessionManager.open(sessionFilePath);
const tree = await sm.getTree();
await sm.navigate(tree.entries[3].id); // 跳到第 4 个 entry

7.6.3 Branch Summarization

/tree 跳到旧 entry 时,Pi 自动生成 branch summary

  • 当前活跃分支的 LLM 生成
  • 总结”从旧点到现在发生了什么”
  • 保留上下文连续性

7.7 Session 操作 CLI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 列历史
pi -r

# 继续最近一次
pi -c

# 指定 session(路径或 ID 前缀)
pi --session 2026-08-01T07-21-abc
pi --session /path/to/session.jsonl

# Fork
pi --fork 2026-08-01T07-21-abc

# 不存 session(一次性)
pi --no-session -p "test"

# 自定义 session 目录
pi --session-dir ~/alt-sessions

7.8 RPC 模式下的 Session 操作

操作 RPC 命令
查状态 get_state
拉消息 get_messages
拉 session info get_session_info
改 session 名 set_session_name
开新 session new_session
压缩 compact

7.8.1 飞书桥接的 session 策略

关键决策:每个飞书用户 / 每个 chat 一个 session?

我们项目用 per-chat session(每个 chat_id 一个独立 session 文件):

1
2
3
4
5
6
7
8
9
10
// 伪代码
const sessionFile = path.join(SESSION_DIR, `${chatId}.jsonl`);

if (!fs.existsSync(sessionFile)) {
// 新 chat, 创建空 session
await client.newSession({ parentSession: null });
} else {
// 已有 chat, 用 --session 加载
await client.start({ session: sessionFile });
}

优点

  • 用户重开 chat 仍有完整历史
  • 多用户隔离
  • session 文件可审计

缺点

  • 文件数量爆炸(每个 chat 一个)
  • 需要清理逻辑(90 天前的删)

7.9 Session Manager(SDK)

1
2
3
4
5
6
7
8
9
10
11
12
import { SessionManager } from "@earendil-works/pi-coding-agent";

// 内存模式(SDK 默认)
const sm = SessionManager.inMemory();

// 文件模式
const sm = SessionManager.open("/path/to/session.jsonl");

// 操作
await sm.appendEntry(entry);
const tree = await sm.getTree();
await sm.navigate(entryId, { summarize: true });

7.9.1 飞书桥接为什么用 RPC + 文件 session 而不是 SDK?

维度 RPC + 文件 SDK
隔离 Pi 子进程独立(崩溃不影响主程序) 同进程,崩溃影响主程序
配置 改 models.json 重启即可 改 SDK 配置要重启 Node
调试 单独 log 目录 混在主程序 log
性能 稍慢(IPC 序列化) 略快

我们选 RPC 是稳定性优先

7.10 Session 清理

1
2
3
4
5
6
7
8
# 手动
rm ~/.pi/agent/sessions/--path--/old.jsonl

# /resume 里删(推荐)
> /resume
# 选中要删的 session
# Ctrl+D
# 确认

Pi 0.83+ 优先用 trash CLI,避免物理删除:

1
which trash  # 不存在就装: brew install trash / apt install trash-cli

7.11 Session 监控与成本

session 头里有 usage 累计:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"usage": {
"input": 15000,
"output": 8000,
"cacheRead": 50000,
"cacheWrite": 10000,
"total": 83000,
"cost": {
"input": 0.045,
"output": 0.12,
"cacheRead": 0.015,
"cacheWrite": 0.025,
"total": 0.205
}
}
}

飞书集成项目不计算成本——单次消息 cost 很低,没必要。

7.12 实战:session 文件监控

我们项目里有个小工具,定期把 session 文件归档:

1
2
# ~/pi-lark-bridge/scripts/archive-old-sessions.sh
find ~/.pi/agent/sessions/ -name "*.jsonl" -mtime +90 -exec trash {} \;

配合 cron:

1
2
3
4
# ~/.hermes/cron/...
name: archive-old-sessions
schedule: 0 3 * * 0
prompt: ~/pi-lark-bridge/scripts/archive-old-sessions.sh

7.13 小结

  • Session = JSONL 文件 + 树状 DAG
  • 三种操作:append(新消息)、navigate(跳叶)、compact(压缩)
  • Assistant message content 是数组:text + thinking + toolCall 混合
  • 飞书集成用 per-chat session 文件
  • RPC + 文件 session 比 SDK + in-memory 更稳定

下一步:Chapter 8 RPC 协议参考(飞书集成重头戏)→


Chapter 8 · RPC 协议参考(飞书集成重头戏)

本章是 飞书 / IM bot 集成的核心:完整 RPC 协议参考 + pi-lark-bridge 实战源码剖析。
不读这一章,集成会踩 90% 的坑。

8.1 协议基础

1
2
3
stdin  → JSON command (one per line, LF delimited)
stdout → JSON events / responses (one per line)
stderr → 日志(可选)

8.1.1 Framing 严格规则

  • LF (\n) 是唯一的记录分隔符
  • 不要用 \r\n——但客户端要能处理服务端发的 \r\n(剥离 trailing \r
  • 不能用 Node readline——它会把 U+2028U+2029 当分隔符,但这两个字符在 JSON 字符串里合法

正确写法

1
2
3
4
5
6
7
8
9
10
11
// 用 split('\n') 而不是 readline
let buffer = '';
proc.stdout.on('data', chunk => {
buffer += chunk;
let idx;
while ((idx = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, idx).replace(/\r$/, '');
buffer = buffer.slice(idx + 1);
if (line) handle(JSON.parse(line));
}
});

8.2 启动 RPC 模式

1
2
3
4
5
pi --mode rpc \
--provider minimax-cn \
--model minimax-m3 \
--name "feishu-bridge" \
--session-dir /path/to/sessions

常用 flags:

Flag 说明
--provider <name> LLM provider
--model <id> model ID 或 pattern(provider/id:thinking
--name / -n session 显示名
--no-session 不持久化
--session-dir 自定义 session 目录
--thinking <level> 初始思考级别

8.3 命令目录(stdin → Pi)

按使用频度排序:

8.3.1 消息类

命令 字段 用途
prompt message, images?, streamingBehavior? 发用户消息
steer message, images? 流式期间插入新指令
follow_up message, images? 流式结束后追加
abort - 中断当前 turn

8.3.2 状态类

命令 用途
get_state 查 session 状态(model、thinking level、isStreaming 等)
get_messages 拉所有消息
get_session_stats 用量统计
get_last_assistant_text 只取最后一次 assistant 的文本(飞书桥接关键

8.3.3 模型 / 思考级别

命令 用途
set_model 切换 provider/model
cycle_model 循环切换
get_available_models 列所有可用
set_thinking_level off/minimal/low/medium/high/xhigh/max
cycle_thinking_level 循环切换
get_available_thinking_levels 列可用

8.3.4 队列行为

命令 用途
set_steering_mode all/one-at-a-time/none
set_follow_up_mode 同上

8.3.5 压缩

命令 用途
compact customInstructions?
set_auto_compaction enabled: bool

8.3.6 Bash(直接执行)

命令 用途
bash command, cwd?, timeout?, env? 直接跑命令
abort_bash 中断

8.3.7 Session 切换

命令 用途
new_session parentSession? 开新
switch_session sessionPath 切到已存
fork entryId 从某 entry 分叉
clone 复制当前活跃分支
export_html outputPath? 导出 HTML
set_session_name 改 session 显示名

8.3.8 重试 / 扩展 UI

命令 用途
set_auto_retry enabled, maxRetries?
abort_retry 中断
get_commands 列扩展命令

完整 30+ 命令见 docs/rpc.md

8.4 事件目录(stdout ← Pi)

事件分四类。

8.4.1 Lifecycle

1
2
3
4
5
{"type": "agent_start"}
{"type": "agent_end", "messages": [...]}
{"type": "agent_settled"} // 0.83+ 新增
{"type": "turn_start"}
{"type": "turn_end", "message": {...}, "toolResults": [...]}

8.4.2 Message

1
2
3
{"type": "message_start", "message": {"role": "user", "content": "..."}}
{"type": "message_update", "assistantMessageEvent": {"type": "text_delta", "delta": "Hi"}}
{"type": "message_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi"}]}}

message_updateassistantMessageEvent 子类型:

type 字段 说明
text_delta delta 文本增量
thinking_delta delta thinking 增量
tool_call_start toolCall tool call 开始
tool_call_delta contentDelta tool call 参数增量
tool_call_end toolCall tool call 结束

8.4.3 Tool Execution

1
2
3
{"type": "tool_execution_start", "toolCallId": "...", "toolName": "bash", "args": {...}}
{"type": "tool_execution_update", "toolCallId": "...", "partialResult": {...}}
{"type": "tool_execution_end", "toolCallId": "...", "result": {...}, "isError": false}

8.4.4 Bash Execution(特殊)

1
{"type": "bash_execution_update", "id": "req-1", "command": "...", "output": "...", "exitCode": null}

重要id 对应发出 bash 命令的 id,用于把异步输出对应回原始调用。

8.4.5 Queue / Compaction / Retry

1
2
3
4
5
6
{"type": "queue_update", "queuedPromptCount": 2}
{"type": "compaction_start", "reason": "threshold"}
{"type": "compaction_end", "compactionEntry": {...}}
{"type": "auto_retry_start", "attempt": 1, "maxAttempts": 3, "delayMs": 2000}
{"type": "auto_retry_end", "success": true}
{"type": "summarization_retry_scheduled", ...}

8.4.6 Extension UI Request(stdout → 客户端)

扩展可能弹出 UI 询问用户:

1
2
3
4
{"type": "extension_ui_request", "id": "ui-1", "method": "select", "title": "Choose", "options": ["a", "b"]}
{"type": "extension_ui_request", "id": "ui-2", "method": "confirm", "title": "OK?", "message": "..."}
{"type": "extension_ui_request", "id": "ui-3", "method": "input", "title": "Name", "prompt": "..."}
{"type": "extension_ui_request", "id": "ui-4", "method": "notify", "message": "...", "level": "info"}

客户端从 stdin 回:

1
2
3
4
{"id": "ui-1", "type": "extension_ui_response", "value": "a"}
{"id": "ui-2", "type": "extension_ui_response", "value": true}
{"id": "ui-3", "type": "extension_ui_response", "value": "my answer"}
{"id": "ui-4", "type": "extension_ui_response", "value": null} // notify 不需要回

8.5 完整 RPC 会话示例

1
2
# 启动 Pi
pi --mode rpc --provider minimax-cn --model minimax-m3

stdin 输入(每行一条):

1
{"id":"1","type":"prompt","message":"Say hello"}

stdout 输出(按顺序):

1
2
3
4
5
6
7
8
9
10
11
{"type":"agent_start"}
{"type":"turn_start"}
{"type":"message_start","message":{"role":"user","content":"Say hello","timestamp":...}}
{"type":"message_end","message":{"role":"user","content":"Say hello",...}}
{"type":"message_start","message":{"role":"assistant","content":[],"api":"anthropic-messages","provider":"minimax-cn","model":"minimax-m3"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"Hello"}}
{"type":"message_update","assistantMessageEvent":{"type":"text_delta","delta":"!"}}
{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"Hello!"}],"stopReason":"stop","usage":{...}}}
{"type":"turn_end","message":{...},"toolResults":[]}
{"type":"agent_end","messages":[...]}
{"id":"1","type":"response","command":"prompt","success":true}

关键response 在最后才到(turn 完成后),中间都是事件流。

8.6 RpcClient 源码剖析

不要自己写 spawn + JSON line 解析——直接用 Pi 自带的 RpcClient

1
2
// dist/modes/rpc/rpc-client.js
import { RpcClient } from "@earendil-works/pi-coding-agent/dist/modes/rpc/rpc-client.js";

8.6.1 构造

1
2
3
4
5
6
7
const client = new RpcClient({
cliPath: "/path/to/pi/dist/cli.js", // 必填
cwd: process.cwd(),
env: { ...process.env, HOME: process.env.HOME },
provider: "minimax-cn", // 启动 model
model: "minimax-m3",
});

8.6.2 生命周期

1
2
3
await client.start();    // spawn 子进程 + 等待初始化
// ... use ...
await client.stop(); // 优雅关闭(发送 abort + 等响应)

8.6.3 核心方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 等到指定 prompt 完成,返回所有事件
const events = await client.promptAndWait(
"Hello",
{ images?: [...] }, // 可选 PromptOptions
60000 // timeout ms
);

// 取最后一次 assistant 文本
const text = await client.getLastAssistantText();

// 异步(不阻塞)
client.sendPrompt("..."); // 不 wait
await client.promptAndWait("..."); // wait

// 模型控制
await client.setModel({ provider: "anthropic", id: "claude-opus-4" });
await client.setThinkingLevel("high");

// 中断
await client.abort();

// Bash 直调
await client.bash("ls -la", { cwd: "/tmp" });

8.6.4 内部实现要点

RpcClient 维护:

  • _events: AgentSessionEvent[] —— 所有事件缓存
  • _requestQueue: Map<string, Promise> —— requestId 队列
  • _streamBuffer: string —— JSONL 解析缓冲
  • _proc: ChildProcess —— pi 子进程

promptAndWait 工作流:

1
2
3
4
5
1. 递增 requestId
2. 清空 _events 引用(但不删 _events 数组,让旧的事件保留直到新 prompt 进来)
3. 发 prompt 命令
4. 订阅 _events 直到 agent_end 事件
5. resolve(events)

:如果上一个 prompt 还没 agent_end 就发新 prompt,会自动排队,等当前 turn 完成才执行。这正是我们想要的”单进程常驻多轮”行为。

8.7 pi-lark-bridge 实战源码

我们项目的核心文件 src/pi-adapter.js(53 行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const path = require('path');

let client = null;

async function getClient() {
if (client) return client;
const { RpcClient } = await import('/data/.../dist/modes/rpc/rpc-client.js');
client = new RpcClient({
cliPath: '/data/.../dist/cli.js',
cwd: process.cwd(),
env: { ...process.env, HOME: process.env.HOME },
provider: 'minimax-cn',
model: 'minimax-m3',
});
await client.start();
return client;
}

function extractAssistantText(events) {
// 倒序遍历: 找最后一个 assistant message_end
for (let i = events.length - 1; i >= 0; i--) {
const evt = events[i];
if (evt.type === 'message_end' && evt.message?.role === 'assistant') {
const content = evt.message.content || [];
const textBlock = content.find(c => c.type === 'text');
if (textBlock?.text) return textBlock.text.trim();
}
}
return null;
}

async function runPi(prompt, timeoutMs = 120000) {
try {
const clientInstance = await getClient();
const events = await clientInstance.promptAndWait(prompt, undefined, timeoutMs);
const text = extractAssistantText(events);
if (text) return text;
return 'pi 未返回有效回复,请稍后重试。';
} catch (err) {
return `pi 调用异常: ${err.message}`;
}
}

module.exports = { runPi };

8.7.1 关键设计

  1. 单 client 实例:全局只 start() 一次,所有飞书消息走同一个 Pi 子进程
  2. 倒序遍历:找最后一个 assistant message(LLM 可能在一次 turn 里发多个 message,比如先解释后回答)
  3. 找 text block:assistant content 数组里只看 type: "text",忽略 thinkingtoolCall
  4. fallback 友好:找不到时返回中文提示,不抛异常

8.7.2 飞书侧(src/index.js,简化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const lark = require('@larksuiteoapi/node-sdk');
const { runPi } = require('./pi-adapter');
const { getConfig } = require('./config');

const client = new lark.Client({
appId: getConfig('FEISHU_APP_ID'),
appSecret: getConfig('FEISHU_APP_SECRET'),
});

const eventDispatcher = new lark.EventDispatcher({
encryptKey: '...',
verificationToken: '...',
}).register({
'im.message.receive_v1': async (data) => {
const { message } = data;
const chatId = message.chat_id;
const text = JSON.parse(message.content).text;
const msgId = message.message_id;

// 1. 先发一条"已收到"占位消息(可选)
// await client.im.message.create({...});

// 2. 调 Pi
const reply = await runPi(text, 120000);

// 3. 回复飞书(interactive 或 text)
await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
msg_type: 'text',
content: JSON.stringify({ text: reply }),
},
});
},
});

const wsClient = new lark.WSClient({
appId: getConfig('FEISHU_APP_ID'),
appSecret: getConfig('FEISHU_APP_SECRET'),
});
wsClient.start({ eventDispatcher });

8.8 RPC 错误处理

8.8.1 三层错误

表现
命令接受失败 response.success: false + error 字段
命令接受后 turn 失败 message_end.stopReason: "error" + errorMessage
子进程崩溃 stdout EOF / pipe broken

8.8.2 重连机制

RpcClient 不自动重连。我们的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let client = null;

async function getClient() {
if (client) return client;
try {
client = new RpcClient({ ... });
await client.start();
return client;
} catch (err) {
console.error('start failed', err);
client = null; // 下次重试
throw err;
}
}

下次 getClient() 时会重新 start()

8.8.3 超时处理

promptAndWait(prompt, undefined, 60000) 60 秒超时。

如果 Pi 在调一个长 bash 任务:

1
2
3
4
5
6
7
// 用 abort 而不是干等
const timeoutId = setTimeout(() => client.abort(), 30000);
try {
const events = await client.promptAndWait("...", undefined, 60000);
} finally {
clearTimeout(timeoutId);
}

8.9 RPC 调试技巧

8.9.1 直接看 stdout

1
2
3
# 把 pi 的 stdout 重定向到文件
pi --mode rpc --provider minimax-cn --model minimax-m3 \
> /tmp/pi-rpc.log 2>&1

然后从另一窗口观察:

1
tail -f /tmp/pi-rpc.log | jq .

8.9.2 node –inspect

1
2
node --inspect-brk your-bridge.js
# Chrome DevTools 打开 chrome://inspect

8.9.3 RpcClient 的内部状态

1
2
console.log('queue size:', client._requestQueue.size);
console.log('events:', client._events.length);

(虽然 _ 前缀是 private,但调试时能看)

8.10 RPC 安全

RPC 模式没有内置认证。本地用没问题;远程用要自己加 TLS / auth:

1
2
3
# 用 socat 包一层 TLS
socat -d TCP-LISTEN:9999,reuseaddr,fork OPENSSL:server.pem,cert=cert.pem
# 然后客户端连 9999 而不是 unix socket

生产环境建议:

  • 用 SSH 端口转发(ssh -L 9999:localhost:9999
  • 或用 unix socket(/tmp/pi.sock),加文件权限 600
  • 或用 TLS wrapper(stunnel / socat)

8.11 小结

  • RPC 协议:stdin 命令 + stdout 事件 + LF 分隔
  • 不用 Node readline(U+2028/U+2029 陷阱)
  • 用官方 RpcClient,单进程常驻多轮
  • 取文本只找 message_end.message.role=assistant.content[type=text],倒序
  • 飞书集成:lark SDK WS 客户端 + RpcClient,两个进程稳定通信

下一步:Chapter 9 SDK 与程序化集成 →


Chapter 9 · SDK 与程序化集成(飞书集成重头戏)

本章讲 Pi 作为库嵌入 Node 应用createAgentSession、自定义工具、扩展、生命周期。
与 Chapter 8 RPC 互补:RPC 用于跨进程 / 跨语言,SDK 用于 Node 同进程深度嵌入

9.1 SDK vs RPC:怎么选

维度 RPC SDK
进程 子进程(spawn) 同进程
语言 任意(Python/Go/Rust) Node/TypeScript only
性能 略慢(IPC 序列化) 略快
稳定性 Pi 崩了不影响主程序 Pi 崩了 = 主程序崩
调试 单独 log 目录 混在主程序 log
高级能力 受限 完整(事件订阅、自定义工具、compaction hook 等)
飞书集成项目 ✅ 选了 RPC

官方建议:Node 项目优先 SDK。我们选 RPC 是稳定性优先 + 调试隔离。

9.2 安装

1
2
3
npm install @earendil-works/pi-coding-agent
# 或已经全局装了
# import 直接用

如果用 SDK + 嵌入到自己的 npm 项目里:

1
2
3
4
5
{
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.83.0"
}
}

9.3 核心 API

9.3.1 最小集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 this directory?");

9.3.2 AgentSession 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
interface AgentSession {
// 消息
prompt(text: string, options?: PromptOptions): Promise<void>;
steer(text: string): Promise<void>;
followUp(text: string): Promise<void>;
abort(): Promise<void>;

// 事件
subscribe(listener: (event: AgentSessionEvent) => void): () => void;

// 模型 / 思考
setModel(model: Model): Promise<void>;
setThinkingLevel(level: ThinkingLevel): void;
cycleModel(): Promise<ModelCycleResult | undefined>;
cycleThinkingLevel(): ThinkingLevel | undefined;

// 压缩
compact(customInstructions?: string): Promise<CompactionResult>;
abortCompaction(): void;

// Session 信息
sessionFile: string | undefined;
sessionId: string;

// Agent 状态访问
agent: Agent;
model: Model | undefined;
thinkingLevel: ThinkingLevel;
messages: AgentMessage[];
isStreaming: boolean;

// 树状导航
navigateTree(targetId: string, options?: {
summarize?: boolean;
customInstructions?: string;
replaceInstructions?: boolean;
label?: string;
}): Promise<{ editorText?: string; cancelled: boolean }>;

// 清理
dispose(): void;
}

9.4 进阶:createAgentSessionRuntime

当需要换 session / fork / clone 时,AgentSession 不够,要用 runtime:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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()),
});

Runtime 操作

1
2
3
4
await runtime.newSession();           // 开新
await runtime.switchSession(path); // 切
await runtime.fork(entryId); // 分叉
await runtime.importFromJsonl(jsonl); // 导入

:runtime 操作后 runtime.session 变了,event 订阅要重新绑

1
2
3
4
5
6
7
let session = runtime.session;
let unsubscribe = session.subscribe(() => {});

await runtime.newSession();
unsubscribe();
session = runtime.session;
unsubscribe = session.subscribe(() => {});

9.5 事件订阅

9.5.1 Event 类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type AgentSessionEvent =
| AgentStartEvent
| AgentEndEvent
| TurnStartEvent
| TurnEndEvent
| MessageStartEvent
| MessageUpdateEvent
| MessageEndEvent
| ToolExecutionStartEvent
| ToolExecutionUpdateEvent
| ToolExecutionEndEvent
| BashExecutionUpdateEvent
| CompactionStartEvent
| CompactionEndEvent
| AutoRetryStartEvent
| AutoRetryEndEvent
| ModelSelectEvent
| ThinkingLevelChangeEvent
| SessionStatsEvent
| ExtensionErrorEvent;

9.5.2 实战:把 assistant 文本流式输出到 WebSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { WebSocketServer } from "ws";

const wss = new WebSocketServer({ port: 8080 });

wss.on("connection", (ws) => {
// 每个 WebSocket 连接 = 一个独立 session
const { session } = await createAgentSession({...});

const unsubscribe = session.subscribe((event) => {
if (event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta") {
ws.send(JSON.stringify({
type: "delta",
text: event.assistantMessageEvent.delta
}));
}
if (event.type === "agent_end") {
ws.send(JSON.stringify({ type: "done" }));
unsubscribe();
session.dispose();
}
});

ws.on("message", (msg) => {
const { text } = JSON.parse(msg);
session.prompt(text);
});
});

9.6 自定义工具(custom tools)

9.6.1 定义工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import { Type } from "typebox";

const weatherTool = {
name: "weather",
label: "查天气",
description: "Get current weather for a city using wttr.in",

parameters: Type.Object({
city: Type.String({ description: "City name in English" }),
}),

async execute(args, ctx) {
const { city } = args;
const resp = await fetch(`https://wttr.in/${city}?format=3`);
if (!resp.ok) {
return {
content: [{ type: "text", text: `wttr.in error: ${resp.status}` }],
isError: true,
};
}
return {
content: [{ type: "text", text: await resp.text() }],
details: { source: "wttr.in", fetchedAt: Date.now() },
};
},

// 可选:自定义 TUI 渲染
renderCall(args, theme) {
return theme.faded(`🌤️ weather(${args.city})`);
},
renderResult(result, options, theme) {
return theme.faded(result.content[0].text);
},
};

9.6.2 注册到 session

1
2
3
const { session } = await createAgentSession({
customTools: [weatherTool],
});

LLM 现在可以用 weather 工具了。

9.6.3 飞书集成项目为什么不用 custom tools?

我们只需要 read/bash/edit/write 就能干所有事(Pi 默认工具)。LLM 通过 bash 调外部 API 也很自然:

1
2
3
> 用 curl 查北京今天天气
> [bash: curl wttr.in/Beijing]
> 北京: 🌤️ +18°C

少一个工具 = 少一个 schema 注入到 system prompt = 省 token

9.7 自定义 Bash 工具

createBashTool 是 Pi 暴露的工厂方法,可以包装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { createBashTool } from "@earendil-works/pi-coding-agent/dist/core/tools/bash";

const customBash = createBashTool(cwd, {
// 拦所有 bash 命令(可改 input / block)
spawnHook: (ctx) => ({
...ctx,
env: { ...ctx.env, CI: "1" },
}),

// 关闭 session 环境变量注入
exposeSessionEnvironment: false,

// 自定义超时
defaultTimeout: 60000,
});

实战:可以加一个 allowedCommands 白名单:

1
2
3
4
5
6
7
8
const customBash = createBashTool(cwd, {
spawnHook: (ctx) => {
if (/rm -rf|sudo/.test(ctx.command)) {
throw new Error("blocked by policy");
}
return ctx;
},
});

9.8 Extension(TypeScript 扩展)

比 custom tool 更重,可以订阅全部事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ~/.pi/agent/extensions/my-extension.ts
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
// 1. 自定义命令
pi.registerCommand({
name: "greet",
description: "Say hello",
handler: async (args, ctx) => {
ctx.ui.notify("Hello from extension!", "info");
},
});

// 2. 拦 tool call
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "bash" &&
/sudo|rm -rf/.test(event.input.command)) {
return { block: true, reason: "policy violation" };
}
});

// 3. 监听事件
pi.on("session_start", async (_event, ctx) => {
ctx.ui.notify("Session started", "info");
});

pi.on("agent_end", async (event, ctx) => {
console.log(`turn ended, ${event.messages.length} messages`);
});
}

加载位置:

  • ~/.pi/agent/extensions/(global,auto-load)
  • .pi/extensions/(project,需 trust)
  • pi -e ./my-extension.ts(CLI 临时加载)

9.9 完整实战:飞书集成改用 SDK

如果我们改用 SDK(理论上可行,实际我们没用):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import { 
createAgentSession,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import * as lark from "@larksuiteoapi/node-sdk";
import * as fs from "fs";
import * as path from "path";

const SESSION_DIR = path.join(process.env.HOME, ".pi-fake-bridge-sessions");
fs.mkdirSync(SESSION_DIR, { recursive: true });

// 1. 启动一个全局 Pi session
const modelRuntime = await ModelRuntime.create();
const { session } = await createAgentSession({
sessionManager: SessionManager.create(SESSION_DIR),
modelRuntime,
cwd: process.cwd(),
});

// 2. 事件订阅: 收齐一个 turn 的文本
let pendingText = "";
session.subscribe((event) => {
if (event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta") {
pendingText += event.assistantMessageEvent.delta;
}
});

// 3. 飞书 WS 客户端
const client = new lark.Client({...});
const eventDispatcher = new lark.EventDispatcher({...}).register({
"im.message.receive_v1": async (data) => {
const { message } = data;
const text = JSON.parse(message.content).text;
const chatId = message.chat_id;

pendingText = "";
await session.prompt(text);

await client.im.message.create({
params: { receive_id_type: "chat_id" },
data: {
receive_id: chatId,
msg_type: "text",
content: JSON.stringify({ text: pendingText.trim() }),
},
});
},
});

const wsClient = new lark.WSClient({...});
wsClient.start({ eventDispatcher });

优点

  • 不用 spawn 子进程
  • 事件订阅更精确
  • 同一进程,性能略好

缺点

  • session 没隔离(Pi 崩 = 整个 bot 崩)
  • 调试 log 混在一起
  • 没法独立升级 Pi 版本

9.10 SDK 调试

1
2
3
4
5
6
7
8
9
# Node inspect
node --inspect-brk my-bot.js

# TypeScript
node --import tsx/esm --inspect-brk my-bot.ts

# 远程调试
node --inspect-brk=0.0.0.0:9229 my-bot.js
# 然后从 Chrome chrome://inspect 连

打印 session 状态:

1
2
3
4
5
6
console.log({
model: session.model,
thinkingLevel: session.thinkingLevel,
messageCount: session.messages.length,
isStreaming: session.isStreaming,
});

9.11 SDK 性能 vs RPC

实测(Pi 0.83.0,MiniMax m3,单条 100 字消息):

指标 RPC SDK
冷启动 800ms 50ms
单 turn 5-8s 5-8s(同 LLM 速度)
内存 +30MB(Pi 子进程) +20MB(同进程)
稳定性 子进程崩了主程序无影响 Pi 崩了 = 主程序崩

9.12 小结

  • Node 项目优先 SDK(同进程),跨语言或稳定性优先用 RPC(子进程)
  • createAgentSession 一次拿到 session,订阅事件流
  • createAgentSessionRuntime 用于换 session / fork
  • Custom tools / extensions 都很容易加,但默认工具能覆盖 90% 场景
  • 飞书集成项目用 RPC + RpcClient,稳定性 + 隔离性优先

下一步:Chapter 10 架构深读 →


Chapter 10 · 架构深读

本章从源码层面拆解 Pi 的架构:模块依赖、启动流程、ProviderComposer、AgentSession 状态机、消息流。
重点:让飞书集成开发者能精确理解 Pi 的行为,预判 / 调试问题。
基于 Pi v0.83.0 源码(/data/data/com.termux/files/usr/lib/node_modules/@earendil-works/pi-coding-agent/dist/)。

10.1 全局模块依赖图

flowchart TB
    subgraph CLI["入口层 (Entry)"]
        CLIJS["dist/cli.js
(17 行, shebang + import)"]
        MAIN["dist/main.js
(746 行, 模式分发)"]
        RPCENTRY["dist/rpc-entry.js
(RPC 子进程入口)"]
        PKGCLI["dist/package-manager-cli.js"]
    end

    subgraph MODES["模式层 (modes/)"]
        PRINT["modes/print-mode.js"]
        RPC["modes/rpc/rpc-mode.js
+ rpc-client.js + rpc-types.js"]
        INTER["modes/interactive/
(TUI)"]
    end

    subgraph CORE["核心层 (core/)"]
        AGENTSES["core/agent-session.js
(会话状态机)"]
        AGENTSRV["core/agent-session-services.js
(cwd-bound services)"]
        AGENTRT["core/agent-session-runtime.js
(session 替换)"]
        SDK["core/sdk.js
(createAgentSession)"]
        MODEL["core/model-config.js
(models.json schema)"]
        RUNTIME["core/model-runtime.js
(ModelRuntime)"]
        COMPOSER["core/provider-composer.js
(3 层 provider 合并)"]
        SESSMGR["core/session-manager.js
(JSONL 持久化)"]
        RESLOAD["core/resource-loader.js
(extensions / skills / prompts 发现)"]
    end

    subgraph TOOLS["工具层 (core/tools/)"]
        READ["read.js"]
        WRITE["write.js"]
        EDIT["edit.js"]
        BASH["bash.js"]
        GREP["grep.js"]
        FIND["find.js"]
        LS["ls.js"]
    end

    subgraph EXT["扩展层"]
        EXTEN["dist/extensions/
(pi.sendMessage / 自定义)"]
    end

    subgraph PKGS["外部依赖"]
        AI["@earendil-works/pi-ai
(LLM 客户端)"]
        CORE2["@earendil-works/pi-agent-core
(Agent class)"]
    end

    CLIJS --> MAIN
    MAIN --> MODES
    MAIN --> CORE
    RPCENTRY --> RPC

    PRINT --> SDK
    RPC --> SDK
    INTER --> SDK

    SDK --> AGENTSES
    SDK --> RESLOAD
    SDK --> RUNTIME
    SDK --> MODEL
    SDK --> SESSMGR
    SDK --> TOOLS

    AGENTRT --> AGENTSRV
    AGENTRT --> AGENTSES

    RUNTIME --> COMPOSER
    RUNTIME --> MODEL
    COMPOSER --> AI

    AGENTSES --> CORE2
    AGENTSES --> AI

    EXTEN -.订阅.-> AGENTSES
    RESLOAD -.加载.-> EXTEN

10.1.1 关键文件清单

文件 行数 职责
dist/cli.js 17 shebang + import('./main.js')
dist/main.js 746 CLI 模式分发(interactive/print/rpc/json/sdk)
dist/index.js 46 包主入口(导出 createAgentSession 等)
dist/rpc-entry.js - RPC 子进程入口
dist/core/agent-session.js ~2700 会话状态机 + 事件流
dist/core/model-config.js ~250 models.json 加载 + schema 校验
dist/core/provider-composer.js ~400 built-in + models.json + extensions 三层合并
dist/core/session-manager.js - JSONL 文件读写
dist/modes/rpc/rpc-mode.js ~700 RPC 模式命令处理
dist/modes/rpc/rpc-client.js - 官方 TypeScript RpcClient

10.2 启动流程

sequenceDiagram
    participant U as 用户
    participant SH as Shell
    participant CLI as cli.js
    participant M as main.js
    participant SDK as sdk.js
    participant SRV as agent-session-services.js
    participant SES as agent-session.js
    participant PI as pi 子进程 / 同进程

    U->>SH: pi --mode rpc --provider minimax-cn
    SH->>CLI: spawn
    CLI->>M: dynamic import
    M->>M: parse argv
    M->>M: dispatch by mode
    alt RPC mode
        M->>PI: rpc-entry.js (子进程)
    else SDK mode
        M->>SDK: createAgentSession({...})
        SDK->>SRV: createAgentSessionServices({cwd})
        SRV->>SRV: ModelRuntime.create()
        SRV->>SRV: DefaultResourceLoader.reload()
        SRV->>SRV: SettingsManager.create()
        SRV-->>SDK: {services, diagnostics}
        SDK->>SES: createAgentSessionFromServices({services})
        SES->>SES: new Agent(model, systemPrompt, tools)
        SES->>SES: subscribe to agent events
        SES-->>SDK: {session}
        SDK-->>M: {session, model, ...}
    end

10.2.1 CLI 解析

main.js 用自定义 argv 解析(不引第三方包):

  • --mode <mode>: interactive / print / json / rpc / sdk
  • --provider <name>
  • --model <id>
  • --name / -n
  • --no-session
  • --session-dir

10.2.2 模式分发

1
2
3
4
5
6
7
8
9
10
// dist/main.js 简化
if (options.mode === "rpc") {
await runRpcMode(options);
} else if (options.mode === "json") {
await runJsonMode(options);
} else if (options.print) {
await runPrintMode(options);
} else {
await runInteractiveMode(options);
}

10.2.3 RPC 模式启动

1
2
3
4
5
6
7
8
9
// dist/rpc-entry.js
process.env.PI_CODING_AGENT = "true";

const { runRpcSession } = await import("./modes/rpc/rpc-mode.js");
await runRpcSession({
provider: process.argv...,
model: process.argv...,
// ...
});

runRpcSession 起 stdin/stdout 双工流,循环读 JSON line → 派发到 handler。

10.3 ProviderComposer(三层 provider 叠加)

ProviderComposerPi 配置系统的核心。它把三层配置合成最终可用的 provider map:

1
2
3
4
5
6
7
8
9
10
11
┌────────────────────────────────────────────────────┐
│ Built-in Providers(dist/extensions/ 内置) │ ← 第一层
├────────────────────────────────────────────────────┤
│ ~/.pi/agent/models.json 里的 providers │ ← 第二层(用户配置)
├────────────────────────────────────────────────────┤
│ Extensions 注册的自定义 providers │ ← 第三层(运行时)
└────────────────────────────────────────────────────┘

合并 + modelOverrides 生效

最终 Model 对象(含 baseUrl / api / apiKey / models)

10.3.1 modelFromJson 单 model 构建

provider-composer.js:46-78

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function modelFromJson(providerId, definition, providerConfig, defaults) {
const api = definition.api ?? providerConfig.api ?? defaults?.api;
// 校验必填
if (!api) throw new Error(`Provider ${providerId}, model ${definition.id}: no "api" specified`);

const baseUrl = definition.baseUrl ?? providerConfig.baseUrl ?? defaults?.baseUrl;
if (!baseUrl) throw new Error(`Provider ${providerId}: "baseUrl" is required`);

return {
id: definition.id,
name: definition.name ?? definition.id,
api,
provider: providerId,
baseUrl,
reasoning: definition.reasoning ?? false,
thinkingLevelMap: definition.thinkingLevelMap,
input: definition.input ?? ["text"],
cost: definition.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: definition.contextWindow ?? 128000,
maxTokens: definition.maxTokens ?? 16384,
compat: mergeCompat(providerConfig.compat, definition.compat),
};
}

重点:层层 fallback(model → provider → defaults),所以 id 必填,其他都有默认。

10.3.2 modelOverrides 合并

provider-composer.js:21-44applyModelOverride(model, override)

可覆盖字段:

  • name, reasoning, thinkingLevelMap, input
  • cost(deep merge)
  • contextWindow, maxTokens
  • compat(deep merge openRouterRouting, vercelGatewayRouting, chatTemplateKwargs

compat.mergeCompat 处理嵌套对象:

1
2
3
4
5
6
7
8
9
10
11
function mergeCompat(base, override) {
const merged = { ...base, ...override };
for (const key of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs"]) {
const baseValue = base?.[key];
const overrideValue = override[key];
if (typeof baseValue === "object" || typeof overrideValue === "object") {
merged[key] = { ...baseValue, ...overrideValue };
}
}
return merged;
}

10.3.3 三层叠加顺序

1
2
3
4
5
6
1. Built-in (e.g. anthropic, openai)
↓ modelOverrides 应用
2. models.json 全局层
↓ modelOverrides 应用
3. extensions 运行时注册
↓ 后注册覆盖先注册

实战:你可以在 ~/.pi/agent/models.json 里给内置 anthropic provider 加 claude-opus-5

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"providers": {
"anthropic": {
"modelOverrides": {
"claude-opus-5-20250901": {
"reasoning": true,
"contextWindow": 1000000,
"maxTokens": 64000
}
}
}
}
}

10.4 ModelConfig 生命周期

dist/core/model-config.js

stateDiagram-v2
    [*] --> LoadAttempt: ModelRuntime.create()
    LoadAttempt --> ParseOK: JSON.parse OK
    LoadAttempt --> ParseFail: ENOENT / SyntaxError
    ParseOK --> SchemaOK: TypeBox Check pass
    ParseOK --> SchemaFail: 校验失败
    SchemaOK --> Frozen: deepFreeze + structuredClone
    Frozen --> [*]: ModelConfig instance (immutable)
    ParseFail --> [*]: ModelConfig with error
    SchemaFail --> [*]: ModelConfig with error

10.4.1 三种状态

状态 行为
加载成功 冻结(deepFreeze + structuredClone)→ 不可变 Map
JSON 错 返回 ModelConfig with error string
Schema 错 返回 ModelConfig with error string(具体字段路径)

10.4.2 不可变性

ProviderConfigSchema 校验后深度冻结

1
2
3
4
5
const config = parsed as ModelsJson;
const providers = new Map();
for (const [id, provider] of Object.entries(config.providers)) {
providers.set(id, deepFreeze(structuredClone(provider)));
}

好处:多线程读无锁,运行时改不了配置。

实战:Pi 在 RPC 模式下收到 set_model 命令时,不会重载 models.json,而是直接用新 model 对象。

10.5 AgentSession 状态机

stateDiagram-v2
    [*] --> Idle: createAgentSession 完成
    Idle --> Streaming: prompt() / steer()
    Streaming --> ToolExecuting: tool_call
    ToolExecuting --> Streaming: tool_result (继续 LLM)
    Streaming --> Compacting: auto-compaction 触发 / compact() 命令
    Compacting --> Idle: compaction_end
    Streaming --> Idle: turn_end (stopReason: stop)
    Streaming --> Idle: turn_end (stopReason: length)
    Streaming --> Idle: turn_end (stopReason: error)
    Streaming --> Idle: turn_end (stopReason: aborted)
    Streaming --> Streaming: queue_update (有 follow_up)
    Idle --> Aborted: abort()
    Streaming --> Aborted: abort()
    Compacting --> Aborted: abortCompaction()
    Aborted --> Idle: 恢复

10.5.1 关键方法

方法 状态转换
prompt() Idle → Streaming
steer() / followUp() 排队(不立即转状态)
abort() * → Aborted
compact() Idle/Streaming → Compacting
abortCompaction() Compacting → Idle
setModel() 不转状态,运行时换 model

10.5.2 Event 流

AgentSession 通过 EventBus 发 30+ 事件类型:

1
2
3
4
5
6
7
8
9
10
11
// 简化版实际顺序
agent_start
turn_start
message_start (user)
message_end (user)
message_start (assistant, content: [])
message_update text_delta "Hi"
message_update text_delta "!"
message_end (assistant, content: [{type: "text", text: "Hi!"}], stopReason: "stop")
turn_end
agent_end

10.6 RPC 模式内部

flowchart LR
    subgraph CLI["客户端 (bridge)"]
        A[飞书消息] --> B[index.js
lark SDK WS]
        B --> C[runPi]
        C --> D[RpcClient]
    end

    subgraph PI["pi 子进程"]
        E[stdin] --> F[RPCCommand parser]
        F --> G[RpcSession]
        G --> H[AgentSession]
        H --> I[EventBus]
        I --> J[stdout writer]
        J --> K[stdout]
    end

    D -->|promptAndWait| E
    K -->|JSONL events| D
    D -->|extractAssistantText| C
    C -->|text| B
    B -->|im.message.create| L[飞书 API]

10.6.1 RpcSession 内部组件

dist/modes/rpc/rpc-mode.js

1
2
3
4
5
6
7
8
9
10
11
┌────────────────────────────────────────────────┐
│ RpcSession │
│ ├── stdinReader (Line-by-line JSON parser) │
│ ├── RPCCommandRouter │
│ │ ├── prompt, steer, follow_up │
│ │ ├── set_model, set_thinking_level │
│ │ ├── compact, new_session │
│ │ └── ...30+ 命令 │
│ ├── AgentSession (复用 SDK session) │
│ └── stdoutWriter (JSON line 序列化) │
└────────────────────────────────────────────────┘

10.6.2 关键命令实现

prompt(最常用)

1
2
3
4
5
6
7
8
9
10
11
12
// rpc-mode.js 简化
async function handlePrompt(cmd, session) {
// 1. 解析 message + images + streamingBehavior
// 2. 调用 session.prompt(cmd.message, cmd)
// 3. 写 response { success: true }
// 4. 不 wait(事件会通过 stdout 自动流出)
}

async function handleGetLastAssistantText(cmd, session) {
const text = await session.getLastAssistantText();
return { success: true, data: text };
}

10.6.3 RpcClient 内部

dist/modes/rpc/rpc-client.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class RpcClient {
private _proc: ChildProcess;
private _events: AgentSessionEvent[] = []; // 全部事件缓存
private _requestMap = new Map<string, { // requestId → resolver
resolve: (events: AgentSessionEvent[]) => void;
reject: (err: Error) => void;
}>();
private _streamBuffer = ""; // JSONL buffer

async start() {
this._proc = spawn(process.execPath, [this.cliPath, "--mode", "rpc", ...]);
this._proc.stdout.on("data", this._handleStdoutData.bind(this));
this._proc.stderr.on("data", this._handleStderr.bind(this));
this._proc.stdin.on("error", ...);
}

async promptAndWait(message, options, timeoutMs) {
const id = randomUUID();
return new Promise((resolve, reject) => {
this._requestMap.set(id, { resolve, reject });
// 清空 _events(保留引用但下次 push 覆盖)
this._events = [];
const timeoutHandle = setTimeout(() => {
this._requestMap.delete(id);
reject(new Error("timeout"));
}, timeoutMs);

// 发 prompt 命令
this._proc.stdin.write(JSON.stringify({ id, type: "prompt", message }) + "\n");

// resolve 由 _handleStdoutData 触发(agent_end 时)
});
}
}

  • _events 数组不清空旧数据,直到新 prompt push 第一个 event 才开始覆盖
  • 如果上一次 prompt 没 agent_end 就发新 prompt → 排队(等当前 turn 完成才执行)
  • pid file 用于优雅关闭:stop()abort 命令后等 5s,超时 kill

10.7 SDK 模式内部

flowchart TB
    A[createAgentSession] --> B[createAgentSessionServices]
    B --> C[ModelRuntime.create]
    B --> D[DefaultResourceLoader]
    B --> E[SettingsManager.create]
    B --> F[PendingExtensionRegister]
    
    C --> C1[load ~/.pi/agent/auth.json]
    C --> C2[load ~/.pi/agent/models.json]
    C --> C3[merge 3-tier providers]
    
    D --> D1[extensions/.ts 发现]
    D --> D2[skills/ 发现]
    D --> D3[prompts/ 发现]
    D --> D4[themes/ 发现]
    
    A --> G[createAgentSessionFromServices]
    G --> H[new Agent]
    H --> H1[model]
    H --> H2[systemPrompt]
    H --> H3[tools]
    G --> I[new AgentSession]
    I --> J[bind Extensions]
    I --> K[subscribe EventBus]

10.7.1 createAgentSessionServices

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// core/agent-session-services.js 简化
export async function createAgentSessionServices({ cwd, agentDir }) {
const modelRuntime = await ModelRuntime.create({...});
const settingsManager = SettingsManager.create(cwd, agentDir);
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();

// 注册 extension providers
const diagnostics = [];
for (const { name, config, extensionPath } of
resourceLoader.getExtensions().runtime.pendingProviderRegistrations) {
try {
modelRuntime.registerProvider(name, config);
} catch (error) {
diagnostics.push({ type: "error", message: `Extension ${extensionPath}: ${error.message}` });
}
}

await modelRuntime.refresh({ allowNetwork: false });
return { cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics };
}

10.7.2 createAgentSessionFromServices

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// core/sdk.js 简化
export async function createAgentSessionFromServices({ services, sessionManager, ... }) {
const { modelRuntime } = services;
const { session, modelFallbackMessage } = await createAgentSession({
sessionManager,
modelRuntime,
cwd: services.cwd,
agentDir: services.agentDir,
settingsManager: services.settingsManager,
resourceLoader: services.resourceLoader,
// ... 把 services 全透传
});
return { session, modelFallbackMessage };
}

10.7.3 AgentSession 构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// core/agent-session.js 简化
class AgentSession {
constructor({ model, systemPrompt, tools, sessionManager, ... }) {
this.agent = new Agent({
model,
systemPrompt,
tools,
// Agent 来自 @earendil-works/pi-agent-core, 真正干活的循环在这里
});
this.sessionManager = sessionManager;
// ...
this.agent.subscribe(this._handleAgentEvent.bind(this));
}

_handleAgentEvent(event) {
// 转换 agent event → AgentSessionEvent
// 写 session 文件(如果非 inMemory)
// 触发订阅者回调
}
}

10.8 消息流

sequenceDiagram
    participant U as User
    participant API as createAgentSession
    participant SM as SessionManager
    participant AG as Agent
    participant LLM as pi-ai (LLM client)
    participant TR as Tool runtime
    participant FS as Filesystem

    U->>API: prompt("Read README and summarize")
    API->>AG: agent.prompt(message)
    AG->>SM: append UserMessage
    SM->>FS: write JSONL line
    
    AG->>LLM: stream(messages, tools)
    LLM-->>AG: AssistantMessage { content: [ToolCall(read, {path:"README.md"})] }
    AG->>SM: append AssistantMessage
    
    AG->>TR: execute tool_call
    TR->>FS: cat README.md
    FS-->>TR: file content
    TR-->>AG: ToolResultMessage
    AG->>SM: append ToolResultMessage
    
    AG->>LLM: stream(messages + tool_result)
    LLM-->>AG: AssistantMessage { content: [TextContent("This is a...")] }
    AG->>SM: append AssistantMessage
    
    AG-->>API: agent_end
    API-->>U: Promise resolve

10.8.1 Agent Loop

@earendil-works/pi-agent-core/Agent 类的核心循环:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while (true) {
stream_response = await LLM.stream(state.messages, state.model)
assistant_message = await stream_response.collect()
state.messages.push(assistant_message)

if (assistant_message.stopReason === "toolUse") {
for (tool_call in assistant_message.content.filter(isToolCall)) {
result = await tools[tool_call.name].execute(tool_call.args)
state.messages.push(ToolResultMessage(tool_call.id, result))
}
// loop continue, LLM sees tool results
} else {
break // stopReason: stop / length / error / aborted
}
}

10.8.2 飞书集成里的关键代码

1
2
3
4
5
6
7
8
9
10
11
12
// pi-adapter.js 我们项目
function extractAssistantText(events) {
for (let i = events.length - 1; i >= 0; i--) {
const evt = events[i];
if (evt.type === 'message_end' && evt.message?.role === 'assistant') {
const content = evt.message.content || [];
const textBlock = content.find(c => c.type === 'text');
if (textBlock?.text) return textBlock.text.trim();
}
}
return null;
}

为什么倒序遍历

  • 一个 turn 可能产生多个 assistant message_end(tool call 后再来一个)
  • 我们要的是最后一个 text block

为什么只找 type === "text"

  • 还要过滤掉 thinkingtoolCall
  • LLM 经常先发 thinking block(带原因),然后 text block(最终回答)

10.9 启动耗时分解(实测)

我们在 Termux on Android 10 实测 MiniMax m3 + RPC 模式:

阶段 耗时
spawn Node + cli.js 200ms
parse argv + dispatch 10ms
runRpcSession 初始化 50ms
createAgentSessionServices 400ms
createAgentSession (modelRuntime + Agent) 100ms
stdin/stdout pipe open 40ms
总计 ~800ms

SDK 模式同进程略快(~50ms),但实际生产中单次 prompt耗时主要由 LLM 决定(5-8s),启动开销可忽略。

10.10 实战建议

10.10.1 不要 hack 核心模块

Pi 升级频繁(v0.83.0 比 v0.82.0 改了很多 API)。飞书集成项目只用了公开 APIRpcClient),即使 Pi 升级到 v0.90 也能直接用。

10.10.2 用事件订阅代替 polling

1
2
3
4
5
6
7
8
9
10
// ❌ 错误:poll get_state
setInterval(async () => {
const state = await client.get_state();
if (state.isStreaming) ...
}, 1000);

// ✅ 正确:subscribe 事件
client.subscribe((event) => {
if (event.type === "agent_end") ...
});

10.10.3 RpcClient 永远单实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 正确
let client = null;
async function getClient() {
if (client) return client;
client = new RpcClient({...});
await client.start();
return client;
}

// ❌ 错误:每条消息新 client(上下文丢失)
async function handleMessage(text) {
const client = new RpcClient({...});
await client.start();
// ...
}

10.10.4 处理 RPC 子进程崩溃

1
2
3
4
5
6
proc.on('exit', (code, signal) => {
if (code !== 0) {
console.error(`pi 子进程退出 code=${code} signal=${signal}`);
client = null; // 下次 getClient() 重启
}
});

10.11 小结

  • 模块分四层:CLI 入口 → 模式层(modes) → 核心层(core) → 外部依赖(pi-ai / pi-agent-core)
  • ProviderComposer 三层叠加:built-in + models.json + extensions
  • ModelConfig 加载即冻结,运行时改不了
  • AgentSession 状态机:idle / streaming / tool-executing / compacting / aborted
  • RPC 模式内部用 SDK session + stdin/stdout JSONL
  • 飞书集成用官方 RpcClient只碰公开 API

下一步:Chapter 11 故障排查与最佳实践 →


Chapter 11 · 故障排查与最佳实践

本章汇总 Pi + 飞书集成项目实际踩过的坑。每个问题都包含:症状、根因、修复、预防
按”出错频度”排序。

11.1 models.json schema 错误

症状

启动 Pi 报:

1
2
Invalid models.json schema:
- providers.minimax-cn.models.0.id: must be a string

根因

JSON 合法但不符合 Pi 的 TypeBox schema。常见字段:

  • api 必须是允许的值(anthropic-messages / openai-completions / openai-responses / google-generative-ai / bedrock / mistral / vercel-ai-gateway
  • contextWindow 必须正整数
  • maxTokens 必须正整数
  • models[].id 必填

修复

jq 验证:

1
cat ~/.pi/agent/models.json | jq .

然后对照 Chapter 3 字段表。

预防

改完配置后立刻 pi --version 验证(schema 错误会在启动时报)。

11.2 apiKey 环境变量没展开

症状

Pi 启动后 /model 看不到自定义 provider,或报 401:

1
Authentication failed for provider minimax-cn

根因

models.json 里写了 "apiKey": "$MINIMAX_CN_API_KEY",但启动前没 export 这个环境变量。

修复

1
2
export MINIMAX_CN_API_KEY=sk-cp-...
pi

或写到 ~/.bashrc / ~/.zshrc

1
2
echo 'export MINIMAX_CN_API_KEY=sk-cp-...' >> ~/.bashrc
source ~/.bashrc

预防

飞书集成项目的强制流程

1
2
# 启动前检查
[ -z "$MINIMAX_CN_API_KEY" ] && echo "ERROR: MINIMAX_CN_API_KEY 未设置" && exit 1

11.3 RPC 模式 assistant 文本”丢”了

症状

bridge 收到飞书消息,Pi 处理了,但 runPi() 返回空字符串或 “pi 未返回有效回复”。

根因(我们踩过的)

11.3.1 模型不输出 text block

某些 reasoning 模型(如 Step Plan step-3.5-flash)在 RPC 模式下只输出 thinking block,没有 text block:

1
2
3
4
5
6
7
8
9
10
{
"type": "message_end",
"message": {
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "用户问..."},
{"type": "text", "text": "<tool_call>..."} // ← 但内容是幻觉的 tool_call
]
}
}

修复:换模型(MiniMax m3 在 RPC 下稳定),或在 system prompt 里强制要求 text 回复

1
总是先用 text block 给出最终答案,不要只用 thinking。

11.3.2 文本里有 tool_call XML 幻觉

某些模型会把”想调工具”的意图写成 XML 文本放进 text block:

1
{"type": "text", "text": "<tool_call>\n<function=bash>\n<parameter=command>env | grep '^PI_'</parameter>\n</function>\n</tool_call>"}

bridge 没过滤这个,看到 text 就直接发给飞书,用户看到乱码。

修复(Plan A 已落地):bridge 端过滤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function extractAssistantText(events) {
for (let i = events.length - 1; i >= 0; i--) {
const evt = events[i];
if (evt.type === 'message_end' && evt.message?.role === 'assistant') {
const content = evt.message.content || [];
for (const block of content) {
if (block.type === 'text') {
// 过滤掉 tool_call 幻觉
const cleaned = block.text
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, '')
.replace(/<function=[\s\S]*?<\/function>/g, '')
.trim();
if (cleaned) return cleaned;
}
}
}
}
return null;
}

11.3.3 找错 message_end(顺序问题)

bridge 拿到的 events 数组里多个 message_end,找错了:

1
2
3
4
5
// ❌ 错误:拿第一个
const first = events.find(e => e.type === 'message_end' && e.message.role === 'assistant');

// ✅ 正确:拿最后一个
const last = [...events].reverse().find(e => ...);

修复:始终倒序遍历(详见 Chapter 10)。

11.4 Pi 子进程频繁崩溃

症状

bridge log 看到:

1
2
3
[bridge] calling runPi...
[error] pi 子进程 exit code=null signal=SIGKILL
[bridge] runPi done pi 未返回有效回复

根因

  • OOM(Android 内存不足)
  • 超时(60s prompt 超时被 kill)
  • bash 工具跑长任务(如 npm install

修复

  1. 加大 timeout
1
const events = await client.promptAndWait("...", undefined, 180000);  // 3 分钟
  1. 监 exit 事件自动重启
1
2
3
4
client._proc.on('exit', (code, signal) => {
console.error(`pi exited code=${code} signal=${signal}`);
client = null; // 下次 getClient() 重启
});
  1. 避免长 bash
1
2
> 帮我装 npm 依赖
> [bash: npm install] ← 可能超时

拆成多步:

1
2
> 1. 看 package.json 列出依赖
> 2. 单独装每个依赖(每个带 timeout)

11.5 “Model names cannot contain spaces” 错误

症状(Hermes 端,不在 Pi)

1
Error: Model names cannot contain spaces.

根因

Hermes 的 /model 命令校验模型名 不能有空格。这跟 Pi 没关系。

修复

模型 ID 用连字符或下划线:

1
2
❌ MiniMax M3
✅ minimax-m3 或 MiniMax-M3

我们项目用 minimax-m3(lowercase + 连字符)。

11.6 RPC 子进程 spawn 太慢

症状

每条飞书消息首次延迟 1.5s(spawn + connect + 初始化)。

根因

RpcClient 默认每条 prompt 重新检查 start()

1
2
3
4
5
6
7
8
// ❌ 错误:每次新建 client
async function runPi(text) {
const client = new RpcClient({...});
await client.start(); // 800ms!
const events = await client.promptAndWait(text, undefined, 60000);
await client.stop();
return extractAssistantText(events);
}

修复

单进程常驻(Plan A 已落地):

1
2
3
4
5
6
7
8
9
10
11
12
let client = null;
async function getClient() {
if (client) return client;
client = new RpcClient({...});
await client.start(); // 只跑一次
return client;
}
async function runPi(text) {
const client = await getClient();
const events = await client.promptAndWait(text, undefined, 60000);
return extractAssistantText(events);
}

实测:常驻后单条消息延迟从 1.5s → 0.1s(RPC 调用开销)。

11.7 “Connection refused” on Feishu WS

症状

bridge log:

1
[error] feishu WS 断开,code=1006

根因

  • appSecret 错了(最常见)
  • 网络问题(Termux 后台被杀)
  • 没设 larkWSClient(用的是老的 lark.Client

修复

  1. 验证 appSecret(用 secrets-getter 或 .env)
  2. 加 ping watchdog:
1
2
3
4
5
6
setInterval(() => {
if (!wsClient.isConnected()) {
console.warn('飞书 WS 断开,重连');
wsClient.start({ eventDispatcher });
}
}, 30000);
  1. Termux 加 wake lock:termux-wake-lock(需要 root 或 Termux:API app 授权)

11.8 LLM 返回 “Tool use not supported”

症状

message_end 里:

1
{"stopReason": "error", "errorMessage": "Tool use not supported by this model"}

根因

模型不支持 tool call(老模型或纯文本模型)。

修复

  1. 换支持 tool call 的模型(绝大多数现代模型都行)
  2. 或在 models.json 里加 compat.requiresToolResultName: false 等兼容配置
  3. 或干脆禁用 tools
1
pi --no-tools -p "纯对话"

11.9 Bash 工具被 sandbox 拦

症状

LLM 调 bash 报:

1
[error] Permission denied for command: sudo apt install

根因

不是 Pi 拦的,是OS 级权限(Termux 没 root)。

修复

  • 换不需要 sudo 的命令
  • 或在 ~/.bashrc 里 alias sudo 到 echo
  • 或用 tsu(Termux su 替代)

11.10 Session 文件爆炸

症状

1
2
du -sh ~/.pi/agent/sessions/
# 10GB!

根因

每个 session 是 JSONL,永不删除(除非手动)。飞书集成 per-chat session 后,每天可能产生几百个新文件。

修复

写个 cron 脚本定期清理:

1
2
3
4
#!/bin/bash
# ~/pi-lark-bridge/scripts/archive-old-sessions.sh
find ~/.pi/agent/sessions/ -name "*.jsonl" -mtime +90 -exec trash {} \;
find ~/.pi/agent/sessions/ -name "*.jsonl" -size +50M -exec trash {} \;

加 cron:

1
2
3
4
# ~/.hermes/cron/...
name: archive-old-sessions
schedule: 0 3 * * 0 # 每周日 3 点
prompt: bash ~/pi-lark-bridge/scripts/archive-old-sessions.sh

预防

控制每 chat session 大小:定期 /compact 或用 pi --no-session

11.11 Provider baseUrl 拼错

症状

1
[error] ECONNREFUSED api.minimaxi.com:443

或:

1
404 Not Found

根因

  • /v1 后缀(OpenAI 协议)
  • /anthropic 子路径(Anthropic 兼容端点)
  • 多打 / 少打 / 拼错

修复

实测常用 baseUrl

Provider baseUrl
MiniMax (anthropic) https://api.minimaxi.com/anthropic
Step Plan (anthropic) https://api.stepfun.com/step_plan
DeepSeek (openai) https://api.deepseek.com
Ollama (openai) http://localhost:11434/v1
OpenAI 官方 https://api.openai.com/v1
Anthropic 官方 (不填,用内置)

预防

curl 手动测一下:

1
2
3
curl -X POST $baseUrl/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-d '{"model":"...","messages":[]}'

11.12 上下文超限(context overflow)

症状

1
[error] context length exceeded: 200000 > 195840 (limit - reserve)

根因

对话太长,超过 contextWindow - reserveTokens

修复

  1. 主动 /compact 压缩上下文
  2. 调大 reserveTokens
1
2
3
4
5
6
// ~/.pi/agent/settings.json
{
"compaction": {
"reserveTokens": 32768
}
}
  1. 切到 context window 更大的模型

11.13 总结:飞书集成项目踩过的 7 大坑

# 问题 根因 修复
1 RPC 模式 assistant 文本”丢” text block 含 tool_call XML 幻觉 提取时过滤 + 倒序
2 子进程频繁 spawn 慢 每条消息新建 client 单进程常驻
3 Hermes 报 “Model names cannot contain spaces” /model 命令校验 模型 ID 用 minimax-m3 形式
4 Pi 路径未脱敏导致 baseUrl 错 .env 被系统脱敏 用 secrets-getter 或环境变量
5 Step Plan 在 RPC 下不稳定 thinking → tool_call XML 幻觉 切到 MiniMax m3
6 飞书 WS 断开 appSecret 错 / Termux 后台被杀 secrets-getter + wake lock
7 session 文件无限增长 永不删除 cron 自动归档

11.14 性能调优清单

  • 单 RpcClient 实例常驻
  • 加合理的 timeout(30-180s)
  • Pi 子进程挂了自动重启
  • session 文件定期归档
  • 上下文超过 80% 触发 /compact
  • minimax-m3 而非 step-3.5-flash(RPC 下更稳)
  • models.json 用 $ENV_VAR 而非明文 apiKey
  • appSecret / apiKey 走 secrets-getter
  • 飞书 WS 加重连 watchdog
  • Termux 加 wake-lock

11.15 监控指标(生产级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 飞书桥接应该记录的指标
const metrics = {
totalMessages: 0,
successMessages: 0,
failedMessages: 0,
avgLatency: 0,
p95Latency: 0,
rpcRestarts: 0,
compactTriggered: 0,
};

// 每次 runPi 后
const start = Date.now();
const text = await runPi(message, 120000);
metrics.totalMessages++;
if (text) metrics.successMessages++;
else metrics.failedMessages++;
metrics.avgLatency = (metrics.avgLatency * 0.95) + ((Date.now() - start) * 0.05);

11.16 小结

  • 配置错误最常见(apiKey / baseUrl / schema)
  • RPC 文本”丢” 是模型 + 协议组合问题,必须双端防御(filter + retry)
  • 单进程常驻比每条 spawn 快 10x
  • .env 文件会被 Hermes 脱敏,用 secrets-getter 或环境变量
  • session 文件要定期清理,否则 OOM
  • Pi 升级会改 API,只依赖公开 API(RpcClient)

完结 🎉

至此,Pi Agent 完整使用与开发指南的 11 章全部写完。完整索引见开头的章节导航

如有问题或要贡献新章节,请联系 Hermes Agent(bs 的 AI 助手)。