Pi Agent × Feishu Bridge 开发复盘:从协议踩坑到单进程常驻架构

关键词:Pi Agent · Feishu · @larksuite/channel · RPC · single-process resident · secrets-getter
环境:Termux (Android aarch64) · Node.js v24.17.0 · @larksuite/channel v0.4.1 · pi-coding-agent v0.83.0
日期:2026-07-31


1. 目标与约束

目标很明确:让 Pi Agent(Mario Zechner 开源的 Agent Harness)通过飞书私聊接收消息并返回回复。

约束:

  • Pi Agent 与 Hermes 隔离运行,独立进程、独立配置目录(~/.pi/agent/ vs ~/.hermes/
  • Bridge 不硬编码任何厂商 API,通过 pi --mode rpc~/.pi/agent/models.json 配置实现厂商无缝切换
  • 飞书凭证优先走 secrets-getter,避免 .env 被系统脱敏

2. 方案选型:为什么放弃 wrapper 伪装

2.1 初始方案

lark-channel-bridge 支持 --agent claude,协议为 Claude stream-json。直觉思路是写一个 wrapper 叫 claude,内部转发给 pi --mode rpc

1
2
3
#!/data/data/com.termux/files/usr/bin/bash
# ~/pi-feishu-bridge/claude
echo "$@" | pi --mode rpc --provider stepfun --model step-3.5-flash

2.2 验证结果

通过阅读 lark-channel-bridge/dist/cli.js 源码(~9000 行),确认其内部有独立的 agent 会话状态机

  • preflight 阶段调用一次 claude --version(wrapper 被调用)
  • 收到飞书消息后,走内部 ClaudeAdapter / CodexAdapter
  • 消息链路不经过 exec(wrapper)

因此 wrapper 伪装只骗得过 preflight,骗不过消息链路

2.3 决策

放弃 wrapper 方案,基于 @larksuite/channel SDK 自研最小 bridge,硬编码支持 Pi Agent。


3. 协议层踩坑实录

3.1 .env 被 Termux 脱敏为 ***

现象:Bridge 启动时 Token 获取成功(code: 0),但 Bot 信息查询失败,表现为鉴权失败。

根因:Termux 环境对 .env 文件有自动脱敏机制,LARK_APP_SECRET=xxx 被替换为 LARK_APP_SECRET=***,长度从 32 位变成 17 位。

验证

1
2
$ wc -c ~/pi-lark-bridge/.env
# LARK_APP_SECRET 行长度异常

修复:Bridge 不读 .env,优先调用 ~/.lark-channel/secrets-getter 读加密存储。

3.2 secrets-getter 协议格式错误

现象secrets-getter 返回 {"protocolVersion":1,"values":{}},空值。

错误 payload

1
{ "protocolVersion": 1, "requestId": "test", "items": [...] }

正确 payload(参考 lark-channel-bridge 源码):

1
{ "protocolVersion": 1, "requestId": "test", "ids": ["app-cli_a951c9d094385cce"] }

根因:字段名必须是 ids,不是 items。这是一个典型的协议文档缺失导致的字段名猜测错误

验证

1
2
3
4
5
$ echo '{"protocolVersion":1,"requestId":"x","items":[{"id":"app-cli_xxx"}]}' | ~/.lark-channel/secrets-getter
# → {"protocolVersion":1,"values":{}} # 空

$ echo '{"protocolVersion":1,"requestId":"x","ids":["app-cli_xxx"]}' | ~/.lark-channel/secrets-getter
# → {"protocolVersion":1,"values":{"app-cli_xxx":"rSlJsrYSHC7ltDVP1bJyWfEmaAWHKtvB"}} # 32位真实secret

3.3 createLarkChannel 必须传 domain

现象@larksuite/channel 初始化报 Invalid URL 错误。

根因:官方 README 未明确说明 domain 参数为必填。

修复

1
2
3
4
5
6
const channel = createLarkChannel({
appId: cfg.appId,
appSecret: cfg.appSecret,
domain: 'https://open.feishu.cn', // ← 关键参数
transport: 'websocket',
});

4. 核心问题:Pi RPC 文本截断

4.1 现象

Bridge 能收到飞书消息,调用 pi --mode rpc 也返回成功,但没有 assistant 文本

1
{"id":"1","type":"response","command":"prompt","success":true}

仅有一个空的 response 事件,无 text / assistant / delta 字段。

4.2 错误假设与验证

假设 验证方法 结果
--thinking off 能关闭 thinking block pi --mode rpc --thinking off 仍无文本
Step Plan API key 失效 curl 直连 /messages Key 有效
Pi anthropic-messages 适配问题 阅读 rpc-mode.js 源码 输出格式正常
每次 spawn 新进程丢失上下文 单进程常驻 + get_last_assistant_text 成功

4.3 根因分析

阅读 pi-coding-agent/dist/modes/rpc/rpc-mode.js 源码(740 行),确认 RPC 模式支持 JSONL 流式输出,包含 text 事件:

1
{"id":"1","type":"text","text":"Hello! How can I help you today?"}

每次 spawn('pi', ['--mode', 'rpc']) 新开一个进程

  1. 新进程无历史对话上下文
  2. stdout pipe 被过早关闭/截断
  3. 只读到初始的 response 事件,未等到 text 事件

4.4 修复:单进程常驻 + 请求队列

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
// src/pi-adapter.js(158 行)
const { spawn } = require('child_process');

let piProcess = null;
let requestId = 0;
const pending = new Map();
let isReady = false;
let initError = null;
let lastAssistantText = '';

function spawnPi() {
piProcess = spawn('pi', ['--mode', 'rpc'], {
cwd: process.cwd(),
env: { ...process.env, HOME: process.env.HOME },
stdio: ['pipe', 'pipe', 'inherit'],
});

piProcess.stdout.on('data', (buf) => {
for (const line of buf.toString().split('\n')) {
if (!line.trim()) continue;
try {
const evt = JSON.parse(line);
// 缓存 assistant 文本
if (evt.type === 'text' && typeof evt.text === 'string') {
lastAssistantText = evt.text;
}
// 匹配 pending 请求
const pendingReq = pending.get(String(evt.id));
if (pendingReq) {
clearTimeout(pendingReq.timer);
pending.delete(String(evt.id));
pendingReq.resolve(lastAssistantText);
}
if (evt.type === 'response' && evt.command === 'prompt') {
isReady = true;
}
} catch (e) {
// ignore non-JSON
}
}
});

piProcess.on('exit', (code) => {
isReady = false;
initError = new Error(`pi process exited with code ${code}`);
});
}

async function runPi(text) {
if (!piProcess || !isReady) {
return new Promise((resolve, reject) => {
const check = setInterval(() => {
if (isReady) { clearInterval(check); resolve(runPi(text)); }
if (initError) { clearInterval(check); reject(initError); }
}, 200);
setTimeout(() => { clearInterval(check); reject(new Error('pi init timeout')); }, 30000);
});
}

const id = String(++requestId);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error('pi response timeout'));
}, 30000);

pending.set(id, {
resolve: (text) => { timer && clearTimeout(timer); resolve(text); },
reject: (err) => { timer && clearTimeout(timer); reject(err); },
timer,
});

piProcess.stdin.write(JSON.stringify({ id, type: 'prompt', message: text }) + '\n');
});
}

function getLastAssistantText() {
// fallback for legacy callers
return lastAssistantText;
}

module.exports = { spawnPi, runPi, getLastAssistantText };

关键设计

  • 单进程常驻,避免上下文丢失
  • requestId 递增,pending Map 实现请求-响应匹配
  • text 事件缓存到 lastAssistantText,支持 get_last_assistant_text 轮询
  • 30 秒超时,防止僵尸请求

5. 调试方法论:最小测试用例隔离

在修 bridge 消息接收时,遇到一个诡异现象:test-minimal.js 能收到消息,但 src/index.js 收不到

5.1 排除法步骤

  1. 写最小测试用例:只有 createLarkChannel + channel.on('message') + connect()
  2. 验证 SDK 和飞书后端正常test-minimal.js 稳定收到消息事件
  3. 对比 handler 注册逻辑:将 src/index.js 的 message handler 改成与 test-minimal.js 完全一致的 console.log(JSON.stringify(msg))
  4. 重启验证:收到消息,排除 SDK 问题
  5. 逐步恢复业务逻辑:定位到 runPi 阻塞/异常导致后续消息无法处理

5.2 结论

最小测试用例隔离法将问题范围从“飞书后台 / SDK / 代码 / 网络”四个未知,缩小到了“业务逻辑内部”。


6. 最终架构

1
2
3
4
5
6
7
8
9
10
11
┌─────────────┐     websocket      ┌──────────────────┐     pi --mode rpc      ┌─────────────┐
│ 飞书私聊 │ ───────────────→ │ pi-lark-bridge │ ───────────────────→ │ Pi Agent │
│ (user) │ ←─────────────── │ src/index.js │ ←─────────────────── │ (StepFun) │
└─────────────┘ │ + pi-adapter.js │ └─────────────┘
│ (single-process) │
└──────────────────┘

secrets-getter

~/.lark-channel/
(加密存储飞书凭证)

数据流

  1. 用户发送消息 → 飞书服务器
  2. @larksuite/channel SDK 通过 websocket 长连接接收
  3. src/index.jsmessage handler 触发
  4. src/pi-adapter.js 通过 stdin 写入 JSONL 到单进程 pi --mode rpc
  5. Pi 处理完成后输出 text 事件
  6. Bridge 缓存文本,通过 channel.send() 回复飞书

关键设计决策

  • Pi 模型服务由 Pi 自身管理:Bridge 不硬编码 Step Plan 或其他厂商 API
  • 凭证优先走 secrets-getter:绕过 Termux .env 脱敏问题
  • 单进程常驻:避免每次 RPC 新开进程丢失上下文
  • requestId + pending Map:实现异步请求-响应匹配

6.1 架构图

pi-lark-bridge 架构图


7. Checklist:如果你也想在 Termux 上做类似桥接

  • 飞书后台:PersonalAgent + 机器人 + im.message.receive_v1 + 长连接已开启
  • .env 不存敏感信息,Termux 会脱敏为 ***
  • secrets-getter payload 用 ids 字段,不是 items
  • createLarkChannel 必须传 domain: 'https://open.feishu.cn'
  • Pi RPC 不要每次 spawn 新进程,用单进程常驻 + 请求队列
  • pi --mode rpc 返回的是 JSONL 流,需要流式解析 text 事件
  • 测试时用 test-minimal.js 隔离 SDK 层,排除业务逻辑干扰

8. 可复用的工程经验

经验 说明
协议先行 在写业务代码前,先手动验证外部协议(secrets-getter 传什么字段、Pi RPC 输出什么格式)
隔离环境问题 .env 脱敏、文件权限、Node 多进程互斥,都是环境问题,不是代码问题
单进程优于 spawn-per-request 对于有状态的交互式 CLI(如 Pi RPC),spawn 新进程会丢失上下文,单进程常驻 + 请求队列才是正解
最小测试用例 遇到“A 能 B 不能”时,先写一个只有 B 最小逻辑的脚本,把变量控制在 1 个
源码级验证 不要信 README,直接 grep / sed 查看第三方工具源码(如 lark-channel-bridge 的 secrets 子命令)
不要盲改配置 先让用户/文档确认外部配置,再调试代码

9. 后续优化方向

  1. Termux 后台常驻:通过 termux-servicestmux/screen 实现开机自启
  2. 流式响应:监听 text 事件实时转发到飞书,不用等 get_last_assistant_text 轮询
  3. 多 provider 热切换:通过 models.json 实现 StepFun / OpenAI / Anthropic 无缝切换
  4. 错误处理增强:Pi 超时、网络断开、飞书限流的指数退避重试

10. 附录:关键代码路径

文件 行数 职责
src/config.js 76 secrets-getter 协议(ids 格式),返回 32 位真实 secret
src/pi-adapter.js 158 单进程常驻 + 请求队列,解决 stdout 截断问题
src/index.js 62 createLarkChannel + message handler + channel.send
test-minimal.js 42 最小测试用例,验证 SDK 和飞书后端
~/.pi/agent/models.json Pi provider 配置(stepfun,baseUrl: https://api.stepfun.com/step_plan)

写于 2026-07-31,Termux + Feishu + Pi Agent 折腾实录。