cli-wechat-bridge 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +60 -43
  2. package/bin/_run-entry.mjs +24 -0
  3. package/bin/wechat-bridge-pi.mjs +5 -0
  4. package/bin/wechat-pi-start.mjs +5 -0
  5. package/bin/wechat-pi.mjs +5 -0
  6. package/dist/bridge/bridge-adapter-common.js +2 -0
  7. package/dist/bridge/bridge-adapters.claude.js +18 -0
  8. package/dist/bridge/bridge-adapters.codex.js +83 -2
  9. package/dist/bridge/bridge-adapters.core.js +36 -2
  10. package/dist/bridge/bridge-adapters.js +5 -0
  11. package/dist/bridge/bridge-adapters.opencode.js +278 -32
  12. package/dist/bridge/bridge-adapters.pi.js +697 -0
  13. package/dist/bridge/bridge-adapters.shared.js +44 -46
  14. package/dist/bridge/bridge-final-reply.js +8 -4
  15. package/dist/bridge/bridge-process-reaper.js +58 -3
  16. package/dist/bridge/bridge-state.js +15 -6
  17. package/dist/bridge/bridge-utils.js +129 -30
  18. package/dist/bridge/claude-hook.js +7 -0
  19. package/dist/bridge/claude-hooks.js +10 -2
  20. package/dist/bridge/wechat-bridge.js +20 -8
  21. package/dist/commands/check-update.js +15 -4
  22. package/dist/companion/local-companion-link.js +6 -1
  23. package/dist/companion/local-companion-start.js +29 -8
  24. package/dist/companion/local-companion.js +3 -3
  25. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  26. package/dist/daemon/wechat-daemon.js +126 -25
  27. package/dist/i18n/messages-en.js +12 -2
  28. package/dist/i18n/messages-zh.js +12 -2
  29. package/dist/utils/doctor.js +85 -2
  30. package/dist/utils/version-checker.js +77 -44
  31. package/dist/wechat/channel-config.js +28 -0
  32. package/dist/wechat/setup.js +2 -0
  33. package/dist/wechat/wechat-transport.js +49 -13
  34. package/package.json +120 -100
  35. package/scripts/ensure-node-pty-permissions.mjs +73 -0
package/README.md CHANGED
@@ -7,14 +7,15 @@
7
7
  <p align="center">
8
8
  <a href="https://github.com/UNLINEARITY/CLI-WeChat-Bridge"><img alt="GitHub stars" src="https://img.shields.io/github/stars/UNLINEARITY/CLI-WeChat-Bridge?label=Stars&amp;style=for-the-badge&amp;logo=github&amp;color=0891b2&amp;labelColor=1c1917"></a>
9
9
  <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm version" src="https://img.shields.io/npm/v/cli-wechat-bridge?label=npm&amp;style=for-the-badge&amp;logo=npm&amp;color=cb3837&amp;labelColor=1c1917"></a>
10
- <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dm/cli-wechat-bridge?label=Downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=16a34a&amp;labelColor=1c1917"></a>
11
- <a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/dm/@unlinearity/cli-wechat-bridge?label=Scoped%20downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=15803d&amp;labelColor=1c1917"></a>
10
+ <a href="https://www.npmjs.com/package/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dt/cli-wechat-bridge?label=Downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=16a34a&amp;labelColor=1c1917"></a>
11
+ <a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="scoped npm downloads" src="https://img.shields.io/npm/dt/@unlinearity/cli-wechat-bridge?label=Scoped%20downloads&amp;style=for-the-badge&amp;logo=npm&amp;color=15803d&amp;labelColor=1c1917"></a>
12
12
  <a href="https://www.npmjs.com/package/cli-wechat-bridge">
13
13
  <img alt="License" src="https://img.shields.io/badge/License-AGPL--3.0-7c3aed?style=for-the-badge&labelColor=1c1917">
14
14
  </a>
15
+ <a href="https://github.com/UNLINEARITY/CLI-WeChat-Bridge/actions/workflows/ci.yml"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/UNLINEARITY/CLI-WeChat-Bridge/ci.yml?branch=main&label=CI&style=for-the-badge&logo=githubactions&logoColor=white&labelColor=1c1917"></a>
15
16
  </p>
16
17
 
17
- **命令行工具的微信桥接**:本项目将微信消息桥接到本地运行的 [`Codex`](https://github.com/openai/codex)、[`Claude Code`](https://code.claude.com/docs/en/overview) 和 [`OpenCode`](https://github.com/anomalyco/opencode),同时把本地输出、审批请求与运行状态同步回微信。
18
+ **命令行工具的微信桥接**:本项目将微信消息桥接到本地运行的 [`Codex`](https://github.com/openai/codex)、[`Claude Code`](https://code.claude.com/docs/en/overview)、[`OpenCode`](https://github.com/anomalyco/opencode) 和 [`Pi`](https://github.com/earendil-works/pi),同时把本地输出、审批请求与运行状态同步回微信。
18
19
 
19
20
  项目围绕本地工作流设计,重点是保留**本地原生终端体验**:你仍然在本地使用原生 CLI 和高级启动参数,微信负责远程输入、结果回流与状态同步。
20
21
 
@@ -26,14 +27,14 @@
26
27
  - [运行配置](docs/configuration.md):数据目录、上传大小限制、调试开关等环境变量。
27
28
  - [开发说明](docs/development.md):源码运行、测试、构建、打包和全局 smoke 验证。
28
29
  - [发布说明](docs/releases/README.md):各版本变更与升级说明。
29
- - [通信架构](docs/architecture.md):三种 CLI 适配器的通信机制、PTY 依赖分析和技术决策。
30
+ - [通信架构](docs/architecture.md):各 CLI 适配器的通信机制、PTY / RPC 依赖分析和技术决策。
30
31
 
31
32
  ## 这个项目解决什么问题?
32
33
 
33
34
  本项目适合这样的使用场景:
34
35
 
35
36
  - 你的主工作流仍在本地终端中进行;
36
- - 你希望继续使用 Codex、Claude Code、OpenCode 等原生 CLI,而不是迁移到网页或托管机器人;
37
+ - 你希望继续使用 Codex、Claude Code、OpenCode、Pi 等原生 CLI,而不是迁移到网页或托管机器人;
37
38
  - 你希望离开电脑后,仍能通过微信向本地会话发送请求,并接收必要输出和状态更新。
38
39
 
39
40
  本项目不试图把微信变成新的主工作界面。它的定位是:
@@ -50,7 +51,8 @@
50
51
  - 已安装以下任意一种本地 CLI,并尽量保持最新版本:
51
52
  - [Codex](https://github.com/openai/codex)
52
53
  - [Claude Code](https://code.claude.com/docs/en/overview)
53
- - [OpenCode](https://github.com/anomalyco/opencode)
54
+ - [OpenCode](https://github.com/anomalyco/opencode) `>= 1.18.0 < 2.0.0`
55
+ - [Pi](https://github.com/earendil-works/pi)(已验证 `0.84.2`;需要本机可执行 `pi` 命令)
54
56
 
55
57
  ### 2. 安装
56
58
 
@@ -60,14 +62,14 @@
60
62
  npm install -g cli-wechat-bridge@latest
61
63
  ```
62
64
 
63
- 安装后,可以在任意项目目录中使用 `wechat-daemon`、`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start` 等命令。
65
+ 安装后,可以在任意项目目录中使用 `wechat-daemon`、`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start`、`wechat-pi-start` 等命令。
64
66
 
65
67
  兼容性说明:旧包名 `@unlinearity/cli-wechat-bridge` 会继续同步发布,已经安装旧包名的用户可以正常升级;新用户优先使用更短的 `cli-wechat-bridge`。
66
68
 
67
69
  <details>
68
70
  <summary><b>安装遇到问题?(node-pty 原生模块)</b></summary>
69
71
 
70
- 本项目使用 `node-pty` 为 CLI 适配器提供完整终端模拟。**Claude Code 适配器当前通过 PTY 交互模式工作**,node-pty 不可用时会回退到兼容模式,但 Claude Code 在此模式下可能无法正常桥接;Codex 适配器主要通过 WebSocket RPC 通信,通常不受影响;OpenCode 适配器完全不依赖 node-pty。
72
+ 本项目使用 `node-pty` 为 CLI 适配器提供完整终端模拟。**Claude Code 适配器当前通过 PTY 交互模式工作**,node-pty 不可用时会回退到兼容模式,但 Claude Code 在此模式下可能无法正常桥接;Codex 适配器主要通过 WebSocket RPC 通信,通常不受影响;OpenCode 适配器不依赖 node-pty;Pi 直接继承可见 companion 的真实终端,也不需要 node-pty 模拟。
71
73
 
72
74
  **Linux 用户**(最常见):需要原生模块编译工具:
73
75
 
@@ -112,13 +114,13 @@ wechat-setup
112
114
 
113
115
  登录成功后,程序会清理旧的同步游标和上下文 token,避免旧会话状态污染新的登录状态。数据目录、状态文件和旧版本迁移说明见 [问题排查](docs/troubleshooting.md#数据目录与状态文件)。
114
116
 
115
- 首次安装或微信登录过期时,`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start` 也会在前台提示扫码登录。
117
+ 首次安装或微信登录过期时,`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start`、`wechat-pi-start` 也会在前台提示扫码登录。
116
118
 
117
119
  ### 4. 先从微信发一条同步消息(重要)
118
120
 
119
121
  启动 bridge 后,建议先在微信里向 Bot 发送一条消息,例如 `hello`、你要执行的任务,或任意一句话。这样 bridge 能拿到最新的微信会话 `context_token`,之后本地终端中的输入、最终回复和审批提示才能稳定同步回微信。
120
122
 
121
- 如果冷启动或长时间闲置后直接从本地终端先发消息,bridge 通常仍会捕获这条本地输入并交给 Codex / Claude Code / OpenCode 处理,但回发到微信时可能因为旧的 `context_token` 失效而失败。表现是:本地已经有回复,微信暂时收不到;等你先从微信发来一条消息后,后续双向同步就能恢复正常。
123
+ 如果冷启动或长时间闲置后直接从本地终端先发消息,bridge 通常仍会捕获这条本地输入并交给 Codex / Claude Code / OpenCode / Pi 处理,但回发到微信时可能因为旧的 `context_token` 失效而失败。表现是:本地已经有回复,微信暂时收不到;等你先从微信发来一条消息后,后续双向同步就能恢复正常。
122
124
 
123
125
  ### 5. 单命令启动桥接(兼容模式)
124
126
 
@@ -134,7 +136,8 @@ cd D:\work\your-project
134
136
  | --- | --- |
135
137
  | Codex | `wechat-codex-start` |
136
138
  | Claude Code | `wechat-claude-start` |
137
- | OpenCode | `wechat-opencode-start` |
139
+ | OpenCode | `wechat-opencode-start` |
140
+ | Pi | `wechat-pi-start` |
138
141
 
139
142
  这些启动器会自动完成以下动作:
140
143
 
@@ -145,17 +148,18 @@ cd D:\work\your-project
145
148
  5. 等待当前目录对应的本地 companion endpoint 就绪;
146
149
  6. 打开可见的本地 CLI 会话。
147
150
 
148
- 没有 daemon 时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 仍按**单活工作区切换器**工作:
151
+ 没有 daemon 时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` / `wechat-pi-start` 仍按**单活工作区切换器**工作:
149
152
 
150
- - 同一时间只有一个项目与微信对话;
151
- - 在当前目录重复执行是幂等的;
152
- - 如果当前目录已经有可见 companion / panel 在运行,则不会重复打开第二个窗口;
153
+ - 同一时间只有一个项目与微信对话;
154
+ - Codex 在当前目录重复执行时会复用已有会话;Claude Code、OpenCode 和 Pi 启动器默认新建会话;
155
+ - Pi 如需显式恢复上一次会话,可使用 `wechat-pi-start --session-start-mode restore`;
156
+ - 如果当前目录已经有可见 companion / panel 在运行,则不会重复打开第二个窗口;
153
157
  - 如果检测到可见端仍在运行但 worker 状态异常(如 `stopped` / `error`),会自动重启 bridge 再重新打开可见端;
154
158
  - 在其他目录执行会显式切换活动工作区。
155
159
 
156
160
  ### 6. 常驻 daemon 模式(支持多 CLI 切换)
157
161
 
158
- 如果你希望微信连接长期保持在线,并在 Codex / Claude Code / OpenCode 之间来回切换,可以在项目目录启动统一 daemon:
162
+ 如果你希望微信连接长期保持在线,并在 Codex / Claude Code / OpenCode / Pi 之间来回切换,可以在项目目录启动统一 daemon:
159
163
 
160
164
  ```bash
161
165
  cd D:\work\your-project
@@ -166,11 +170,12 @@ wechat-daemon
166
170
 
167
171
  | 指令 | 行为 |
168
172
  | --- | --- |
169
- | `/codex` | 切换到 Codex |
170
- | `/claude` | 切换到 Claude Code |
171
- | `/opencode` | 切换到 OpenCode |
173
+ | `/codex [prompt]` | 切换到 Codex;携带 prompt 时切换后立即转发剩余文本 |
174
+ | `/claude [prompt]` | 切换到 Claude Code;携带 prompt 时切换后立即转发剩余文本 |
175
+ | `/opencode [prompt]` | 切换到 OpenCode;携带 prompt 时切换后立即转发剩余文本 |
176
+ | `/pi [prompt]` | 切换到 Pi;携带 prompt 时切换后立即转发剩余文本 |
172
177
 
173
- daemon 启动后,后续切换都可以直接从微信发起;如果对应 CLI 还没有可见窗口,daemon 会自动打开或复用它,不需要再手动运行 `wechat-codex`、`wechat-claude` 或 `wechat-opencode`。
178
+ daemon 启动后,后续切换都可以直接从微信发起;如果对应 CLI 还没有可见窗口,daemon 会自动打开或复用它,不需要再手动运行 `wechat-codex`、`wechat-claude`、`wechat-opencode` 或 `wechat-pi`。
174
179
 
175
180
  ![多CLI 示例](docs/images/image-9.png)
176
181
 
@@ -178,10 +183,11 @@ daemon 启动后,后续切换都可以直接从微信发起;如果对应 CLI
178
183
 
179
184
  - daemon 绑定启动时的工作目录;暂不支持在微信里切换工作目录;
180
185
  - 启动时会自动接管并清理旧的单 bridge 进程、失效 lock 和旧 endpoint;
181
- - 切换适配器不会关闭之前的 CLI;
182
- - 如果对应适配器已经有可见 CLI 在运行,则直接复用;
183
- - 如果还没有对应 CLI,daemon 会自动打开一个新的可见终端;
184
- - Codex / Claude / OpenCode 的重要输出都会带上 `[codex]`、`[claude]`、`[opencode]` 标签再发回微信;
186
+ - 切换适配器不会关闭之前的 CLI;
187
+ - 如果对应适配器已经有可见 CLI 在运行,则直接复用;
188
+ - 如果还没有对应 CLI,daemon 会自动打开一个新的可见终端;
189
+ - 如果当前活动 CLI 的本地窗口被关闭,下一条普通微信输入会自动重开可见终端;Codex 会重置旧 runtime 的残留 busy/turn 状态并建立新的可见 thread;
190
+ - Codex / Claude / OpenCode / Pi 的重要输出都会带上 `[codex]`、`[claude]`、`[opencode]`、`[pi]` 标签再发回微信;
185
191
  - 可以在微信里发送 `/daemon-stop` 停止 daemon。
186
192
 
187
193
  也可以在启动时指定初始 CLI:
@@ -191,7 +197,9 @@ wechat-daemon --adapter codex
191
197
  wechat-daemon --adapter claude --profile work
192
198
  ```
193
199
 
194
- 当同一工作目录已有 `wechat-daemon` 在运行时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 会自动委托给 daemon:请求 daemon 切到对应 CLI,并在需要时打开可见终端,不会停止 daemon 或关闭其他 CLI。
200
+ 当同一工作目录已有 `wechat-daemon` 在运行时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` / `wechat-pi-start` 会自动委托给 daemon:请求 daemon 切到对应 CLI,并在需要时打开可见终端,不会停止 daemon 或关闭其他 CLI。
201
+
202
+ 其中 `wechat-pi-start` 仍表示启动一个新的 Pi session;如果 Pi TUI 已经可见,daemon 会在现有窗口中创建新 session。微信中的 `/pi` 仅用于切换适配器,仍会复用已经连接的 Pi TUI。
195
203
 
196
204
  ### 7. 手动双终端模式(高级调试)
197
205
 
@@ -201,7 +209,8 @@ wechat-daemon --adapter claude --profile work
201
209
  | --- | --- | --- |
202
210
  | Codex | `wechat-bridge-codex` | `wechat-codex` |
203
211
  | Claude Code | `wechat-bridge-claude` | `wechat-claude` |
204
- | OpenCode | `wechat-bridge-opencode` | `wechat-opencode` |
212
+ | OpenCode | `wechat-bridge-opencode` | `wechat-opencode` |
213
+ | Pi | `wechat-bridge-pi` | `wechat-pi` |
205
214
 
206
215
  ## 适配器支持情况
207
216
 
@@ -211,7 +220,7 @@ wechat-daemon --adapter claude --profile work
211
220
 
212
221
  微信发来的图片和普通文件也会被接收并保存到本地:
213
222
 
214
- - bridge 会将本地路径追加到转发给 Codex / Claude Code / OpenCode 的 prompt 中,模型可按需读取或解析这些文件;
223
+ - bridge 会将本地路径追加到转发给 Codex / Claude Code / OpenCode / Pi 的 prompt 中,模型可按需读取或解析这些文件;
215
224
  - 当前不会自动 OCR 图片,也不会自动抽取 PDF / DOCX 正文;如需解析,由本地 CLI 根据路径完成。
216
225
  - 具体保存位置见 [问题排查](docs/troubleshooting.md#数据目录与状态文件)。
217
226
 
@@ -219,7 +228,10 @@ wechat-daemon --adapter claude --profile work
219
228
  | --- | --- | --- |
220
229
  | `codex` | 已接入 | 双终端模式;本地 companion 作为线程权威;微信跟随本地线程 |
221
230
  | `claude` | 已接入 | `wechat-bridge-claude` + `wechat-claude` 的双终端 companion 模式;会话切换、最终回复与审批元数据按 Claude session 语义同步 |
222
- | `opencode` | 已接入 | [`OpenCode`](https://github.com/anomalyco/opencode) 适配器;`wechat-bridge-opencode` + `wechat-opencode` 的双终端 companion 模式;支持本地 session 切换跟随,微信侧支持 `/new` / `/new-session` |
231
+ | `opencode` | 已接入 | [`OpenCode`](https://github.com/anomalyco/opencode) 适配器;`wechat-bridge-opencode` + `wechat-opencode` 的双终端 companion 模式;支持本地 session 切换跟随,微信侧支持 `/new` / `/new-session` |
232
+ | `pi` | 已接入 | `wechat-bridge-pi` + `wechat-pi`;`wechat-pi` 启动用户原生 Pi TUI,并通过本地 extension 让微信接管同一 session,支持最终回复、停止、新建和恢复 session |
233
+
234
+ Pi 按全权限本地代理运行:bridge 不增加工具审批层,并传入 `--approve` 信任当前项目;读写文件和执行命令均使用启动 `wechat-pi` 的本地用户权限。原生 TUI 的主题、快捷键、模型选择和 extension UI 都会保留。`wechat-pi` 本身就是被微信接管的 Pi TUI;不要再启动第二个 Pi 进程同时写入同一个 session 文件。
223
235
 
224
236
  ### Codex 示例
225
237
 
@@ -247,7 +259,8 @@ OpenCode 模式下,微信侧支持 `/new` 或 `/new-session` 创建新 session
247
259
  | 常驻 daemon | `wechat-daemon` |
248
260
  | Codex | `wechat-bridge-codex`、`wechat-codex`、`wechat-codex-start` |
249
261
  | Claude Code | `wechat-bridge-claude`、`wechat-claude`、`wechat-claude-start` |
250
- | OpenCode | `wechat-bridge-opencode`、`wechat-opencode`、`wechat-opencode-start` |
262
+ | OpenCode | `wechat-bridge-opencode`、`wechat-opencode`、`wechat-opencode-start` |
263
+ | Pi | `wechat-bridge-pi`、`wechat-pi`、`wechat-pi-start` |
251
264
 
252
265
  ### Daemon CLI 参数
253
266
 
@@ -266,7 +279,7 @@ wechat-daemon --adapter claude --profile work
266
279
  支持参数:
267
280
 
268
281
  - `--cwd <path>`:指定 daemon 绑定的工作目录;
269
- - `--adapter <codex|claude|opencode>`:启动 daemon 后立即切换到指定 CLI;
282
+ - `--adapter <codex|claude|opencode|pi>`:启动 daemon 后立即切换到指定 CLI;
270
283
  - `--profile <name-or-path>`:传给 daemon 创建的对应适配器;
271
284
  - `--no-open`:只创建 bridge slot,不自动打开可见 CLI。
272
285
 
@@ -277,7 +290,8 @@ wechat-daemon --adapter claude --profile work
277
290
  - `wechat-bridge`
278
291
  - `wechat-bridge-codex`
279
292
  - `wechat-bridge-claude`
280
- - `wechat-bridge-opencode`
293
+ - `wechat-bridge-opencode`
294
+ - `wechat-bridge-pi`
281
295
 
282
296
  示例:
283
297
 
@@ -285,13 +299,14 @@ wechat-daemon --adapter claude --profile work
285
299
  wechat-bridge --adapter codex --cwd D:\work\my-project
286
300
  wechat-bridge-codex --cwd D:\work\my-project
287
301
  wechat-bridge-claude --profile work
288
- wechat-bridge-opencode --cwd D:\work\my-project
302
+ wechat-bridge-opencode --cwd D:\work\my-project
303
+ wechat-bridge-pi --cwd D:\work\my-project
289
304
  wechat-bridge-codex --lifecycle companion_bound
290
305
  ```
291
306
 
292
307
  支持参数:
293
308
 
294
- - `--adapter <codex|claude|opencode>`:通用入口 `wechat-bridge` 需要显式指定适配器;
309
+ - `--adapter <codex|claude|opencode|pi>`:通用入口 `wechat-bridge` 需要显式指定适配器;
295
310
  - `--cwd <path>`:指定工作目录;
296
311
  - `--profile <name-or-path>`:向适配器传入 profile;
297
312
  - `--lifecycle <persistent|companion_bound>`:指定 bridge 生命周期;`wechat-*-start` 会使用 `companion_bound`。
@@ -303,16 +318,18 @@ wechat-bridge-codex --lifecycle companion_bound
303
318
  ```bash
304
319
  wechat-codex-start --cwd D:\work\my-project
305
320
  wechat-claude-start --profile work
306
- wechat-opencode-start --cwd D:\work\my-project
321
+ wechat-opencode-start --cwd D:\work\my-project
322
+ wechat-pi-start --cwd D:\work\my-project --model openai/gpt-5.6-sol
307
323
  ```
308
324
 
309
325
  支持参数:
310
326
 
311
327
  - `--cwd <path>`:显式指定 bridge / companion 对应的工作目录;
312
- - `--profile <name-or-path>`:转发给后台启动的 `wechat-bridge-codex` / `wechat-bridge-claude` / `wechat-bridge-opencode`;
313
- - `--timeout-ms <ms>`:等待当前目录 endpoint 的最长时间,默认 `15000`。
328
+ - `--profile <name-or-path>`:转发给后台启动的 `wechat-bridge-codex` / `wechat-bridge-claude` / `wechat-bridge-opencode` / `wechat-bridge-pi`;
329
+ - `--timeout-ms <ms>`:等待当前目录 endpoint 的最长时间,默认 `15000`。
330
+ - `--session-start-mode <restore|new>`:显式选择恢复或新建会话;Pi 启动器默认 `new`。
314
331
 
315
- 高级用法:除上述启动器参数外,未知参数会继续透传给可见的底层 CLI。这样既能保留微信登录、工作区切换与 bridge 生命周期管理,也能启用 Codex / Claude Code 自己的高级启动模式。
332
+ 高级用法:除上述启动器参数外,未知参数会继续透传给可见的底层 CLI。这样既能保留微信登录、工作区切换与 bridge 生命周期管理,也能启用 Codex / Claude Code / OpenCode / Pi 自己的高级启动模式。
316
333
 
317
334
  ```bash
318
335
  wechat-codex-start --yolo
@@ -328,18 +345,18 @@ wechat-claude-start --model sonnet --dangerously-skip-permissions
328
345
  | 指令 | 说明 |
329
346
  | --- | --- |
330
347
  | 普通文本 | 发送到当前活动会话 |
331
- | `/codex` / `/claude` / `/opencode` | daemon 模式下切换活动 CLI;已有 CLI 会复用,没有则自动打开 |
348
+ | `/codex [prompt]` / `/claude [prompt]` / `/opencode [prompt]` / `/pi [prompt]` | daemon 模式下切换活动 CLI;已有 CLI 会复用,没有则自动打开;可选 prompt 会在切换成功后立即转发 |
332
349
  | `/status` | 查看 bridge 当前状态 |
333
350
  | `/stop` | 中断当前任务 |
334
351
  | `/reset` | 重建当前本地会话 |
335
- | `/new` 或 `/new-session` | OpenCode 模式下新建 session |
352
+ | `/new` 或 `/new-session` | OpenCode 或 Pi 模式下新建 session |
336
353
  | `/confirm` / `/deny` | 处理 CLI 权限请求;需要一次性 code 的请求会在消息中提示具体确认格式 |
337
354
  | `/daemon-stop` | daemon 模式下停止常驻进程 |
338
355
  | `/bindings` | 查看当前所有表情绑定 |
339
356
  | `/bind [表情] /命令` | 绑定表情到指定命令 |
340
357
  | `/unbind [表情]` | 解除指定表情的绑定 |
341
358
 
342
- 说明:微信侧 `/resume` 目前暂时保持禁用;需要切换 Codex / Claude / OpenCode 会话时,优先在本地 companion 中使用原生 `/resume`、`/new` 或对应 CLI 命令,微信会跟随本地活动会话。
359
+ 说明:微信侧 `/resume` 目前暂时保持禁用;需要切换 Codex / Claude / OpenCode / Pi 会话时,优先在本地 companion 中使用 `/new` 或对应 CLI 的会话命令,微信会跟随本地活动会话。
343
360
 
344
361
  ### 表情绑定
345
362
 
@@ -377,16 +394,16 @@ Daemon 模式支持将微信表情映射为命令,在微信中发送表情即
377
394
 
378
395
  本项目采用“当前目录即当前工作区”的模型:
379
396
 
380
- - 从哪个目录启动 `wechat-bridge-codex` / `wechat-bridge-claude` / `wechat-bridge-opencode`,哪个目录就是当前工作区;
397
+ - 从哪个目录启动 `wechat-bridge-codex` / `wechat-bridge-claude` / `wechat-bridge-opencode` / `wechat-bridge-pi`,哪个目录就是当前工作区;
381
398
  - 对应的本地 companion 必须连接同一工作区;
382
399
  - 不同工作区的运行状态相互隔离。
383
400
 
384
401
  目前支持两种运行模型:
385
402
 
386
403
  - 独立 bridge:单 owner、单 bridge、单活动工作区;
387
- - `wechat-daemon`:单 owner、单 daemon、绑定一个启动工作区,但可在这个工作区内同时保留 Codex / Claude Code / OpenCode 三个 CLI slot。
404
+ - `wechat-daemon`:单 owner、单 daemon、绑定一个启动工作区,但可在这个工作区内同时保留 Codex / Claude Code / OpenCode / Pi 四个 CLI slot。
388
405
 
389
- 对 `wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 来说,这意味着:
406
+ 对 `wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` / `wechat-pi-start` 来说,这意味着:
390
407
 
391
408
  - 如果同一工作区已有 daemon,它们会委托 daemon 切换 CLI;
392
409
  - 如果没有 daemon,它们会作为**单活工作区切换器**工作;
@@ -6,8 +6,32 @@ import { fileURLToPath } from "node:url";
6
6
 
7
7
  const BIN_DIR = path.dirname(fileURLToPath(import.meta.url));
8
8
  const PROJECT_DIR = path.resolve(BIN_DIR, "..");
9
+ const MIN_NODE_MAJOR = 24;
10
+
11
+ function ensureSupportedNodeVersion() {
12
+ if (process.env.CLI_BRIDGE_SKIP_NODE_CHECK === "1") {
13
+ return;
14
+ }
15
+
16
+ const major = Number(process.versions.node.split(".")[0]);
17
+ if (Number.isFinite(major) && major >= MIN_NODE_MAJOR) {
18
+ return;
19
+ }
20
+
21
+ process.stderr.write(
22
+ [
23
+ `[cli-wechat-bridge] Node.js >= ${MIN_NODE_MAJOR} is required, but you are running ${process.version}.`,
24
+ `[cli-wechat-bridge] 需要 Node.js >= ${MIN_NODE_MAJOR},当前版本为 ${process.version}。`,
25
+ "Install the latest LTS from https://nodejs.org/ (or via nvm), then retry.",
26
+ "Set CLI_BRIDGE_SKIP_NODE_CHECK=1 to bypass this check at your own risk.",
27
+ "",
28
+ ].join("\n"),
29
+ );
30
+ process.exit(1);
31
+ }
9
32
 
10
33
  export function runJsEntry(relativeEntryPath, extraArgs = []) {
34
+ ensureSupportedNodeVersion();
11
35
  const entryPath = path.join(PROJECT_DIR, relativeEntryPath);
12
36
  const child = spawn(
13
37
  process.execPath,
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runJsEntry } from "./_run-entry.mjs";
4
+
5
+ runJsEntry("dist/bridge/wechat-bridge.js", ["--adapter", "pi"]);
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runJsEntry } from "./_run-entry.mjs";
4
+
5
+ runJsEntry("dist/companion/local-companion-start.js", ["--adapter", "pi"]);
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runJsEntry } from "./_run-entry.mjs";
4
+
5
+ runJsEntry("dist/companion/local-companion.js", ["--adapter", "pi"]);
@@ -61,6 +61,8 @@ export function getLocalCompanionCommandName(kind) {
61
61
  return "wechat-claude";
62
62
  case "opencode":
63
63
  return "wechat-opencode";
64
+ case "pi":
65
+ return "wechat-pi";
64
66
  default:
65
67
  return "local companion";
66
68
  }
@@ -393,6 +393,14 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
393
393
  socket.setEncoding("utf8");
394
394
  socket.on("data", (chunk) => {
395
395
  buffer += chunk;
396
+ // Cap the buffer: a single hook frame is tiny, so a frame larger
397
+ // than 1 MiB means a misbehaving client (e.g. one that never sends a
398
+ // newline). Without this the buffer could grow unbounded. Sibling
399
+ // IPC paths already cap their buffers; this one did not.
400
+ if (buffer.length > 1024 * 1024) {
401
+ socket.destroy();
402
+ return;
403
+ }
396
404
  while (true) {
397
405
  const newlineIndex = buffer.indexOf("\n");
398
406
  if (newlineIndex < 0) {
@@ -903,6 +911,16 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
903
911
  try {
904
912
  await super.reset();
905
913
  }
914
+ catch (error) {
915
+ // recoverFromInvalidResume is invoked fire-and-forget (void) and there is
916
+ // no global unhandled-rejection handler, so a throwing reset() must be
917
+ // caught here to avoid crashing the bridge.
918
+ this.emit({
919
+ type: "fatal_error",
920
+ message: `Failed to restart Claude after invalid resume: ${String(error)}`,
921
+ timestamp: nowIso(),
922
+ });
923
+ }
906
924
  finally {
907
925
  this.recoveringInvalidResume = false;
908
926
  }
@@ -10,6 +10,7 @@ import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
10
10
  import { CODEX_REMOTE_AUTH_TOKEN_ENV, LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
11
11
  const { CODEX_APP_SERVER_HOST, CODEX_APP_SERVER_READY_TIMEOUT_MS, CODEX_FINAL_REPLY_SETTLE_DELAY_MS, CODEX_RECENT_SESSION_KEY_LIMIT, CODEX_RPC_CONNECT_RETRY_MS, CODEX_RPC_RECONNECT_TIMEOUT_MS, CODEX_SESSION_FALLBACK_SCAN_INTERVAL_MS, CODEX_SESSION_LOCAL_MIRROR_FALLBACK_WINDOW_MS, CODEX_SESSION_POLL_INTERVAL_MS, CODEX_STARTUP_WARMUP_MS, CODEX_THREAD_SIGNAL_TTL_MS, INTERRUPT_SETTLE_DELAY_MS, appendBoundedLog, buildCodexApprovalRequest, buildCodexCliArgs, buildCodexDynamicToolCallFailureResponse, buildCodexMcpServerElicitationDeclineResponse, buildCodexPermissionsRequestApprovalResponse, buildCodexUserInputRequest, coerceWebSocketMessageData, delay, describeUnknownError, extractCodexFinalTextFromItem, extractCodexThreadFollowIdFromStatusChanged, extractCodexThreadStartedThreadId, extractCodexUserMessageText, findCodexSessionFile, findRecentCodexSessionFileForCwd, getCodexRpcRequestId, getCodexApprovalAutoResponse, getCodexWechatOutboundAttachmentDenyMessage, getNotificationThreadId, getNotificationTurnId, isRecord, isRecentIsoTimestamp, listCodexResumeSessions, normalizeComparablePath, normalizeCodexRpcError, reserveLocalPort, resolveSpawnTarget, shouldAutoCompleteCodexWechatTurnAfterFinalReply, shouldIgnoreCodexSessionReplayEntry, shouldRecoverCodexStaleBusyState, waitForTcpPort, } = shared;
12
12
  const CODEX_LOCAL_THREAD_ANNOUNCE_SETTLE_MS = 150;
13
+ const CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS = 120_000;
13
14
  export class CodexPtyAdapter extends AbstractPtyAdapter {
14
15
  runtimeKind = "codex_runtime_host";
15
16
  appServer = null;
@@ -38,6 +39,8 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
38
39
  pendingThreadFollowId = null;
39
40
  pendingApprovalRequests = [];
40
41
  pendingUserInputRequest = null;
42
+ pendingUserInputTimer = null;
43
+ userInputAutoResolutionDelayMs = CODEX_NON_BLOCKING_USER_INPUT_TIMEOUT_MS;
41
44
  queuedTurnNotifications = [];
42
45
  queuedTurnServerRequests = [];
43
46
  mirroredUserInputTurnIds = new Set();
@@ -526,6 +529,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
526
529
  this.sessionPartialLine = "";
527
530
  }
528
531
  if (stat.size === this.sessionReadOffset) {
532
+ this.flushCompleteSessionPartialLine();
529
533
  return;
530
534
  }
531
535
  const fd = fs.openSync(this.sessionFilePath, "r");
@@ -551,6 +555,22 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
551
555
  for (const line of lines) {
552
556
  this.handleSessionLogLine(line);
553
557
  }
558
+ this.flushCompleteSessionPartialLine();
559
+ }
560
+ flushCompleteSessionPartialLine() {
561
+ const line = this.sessionPartialLine.trim();
562
+ if (!line) {
563
+ this.sessionPartialLine = "";
564
+ return;
565
+ }
566
+ try {
567
+ JSON.parse(line);
568
+ }
569
+ catch {
570
+ return;
571
+ }
572
+ this.sessionPartialLine = "";
573
+ this.handleSessionLogLine(line);
554
574
  }
555
575
  seedSessionReplayCutoff(startedAtMs) {
556
576
  if (this.sessionIgnoreBeforeMs !== null ||
@@ -937,6 +957,31 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
937
957
  child.stderr.on("data", (chunk) => {
938
958
  this.appServerLog = appendBoundedLog(this.appServerLog, chunk);
939
959
  });
960
+ child.once("error", (error) => {
961
+ // spawn itself failed (ENOENT/EACCES/EMFILE). Without this listener the
962
+ // 'error' event is unhandled and crashes the bridge. Mirror the exit
963
+ // handler's cleanup so the failure surfaces as a fatal_error instead.
964
+ const expectedShutdown = shouldSuppressCodexTransportFatalError({
965
+ transportShuttingDown: this.appServerShuttingDown,
966
+ shuttingDown: this.shuttingDown,
967
+ cleanPanelExitInProgress: this.cleanPanelExitInProgress,
968
+ });
969
+ this.appServer = null;
970
+ this.appServerPort = null;
971
+ this.appServerShuttingDown = false;
972
+ this.deleteAppServerAuthTokenFile();
973
+ this.appServerAuthToken = null;
974
+ if (expectedShutdown) {
975
+ return;
976
+ }
977
+ const details = this.describeAppServerLog();
978
+ this.emit({
979
+ type: "fatal_error",
980
+ message: `codex app-server failed to start: ${String(error)}${details}`,
981
+ timestamp: nowIso(),
982
+ });
983
+ this.terminateCodexClient();
984
+ });
940
985
  child.on("exit", (code, signal) => {
941
986
  const expectedShutdown = shouldSuppressCodexTransportFatalError({
942
987
  transportShuttingDown: this.appServerShuttingDown,
@@ -1186,6 +1231,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1186
1231
  },
1187
1232
  capabilities: {
1188
1233
  experimentalApi: true,
1234
+ requestAttestation: false,
1189
1235
  },
1190
1236
  });
1191
1237
  }
@@ -1218,8 +1264,6 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1218
1264
  approvalsReviewer: "user",
1219
1265
  sandbox: "workspace-write",
1220
1266
  serviceName: "wechat-bridge",
1221
- experimentalRawEvents: false,
1222
- persistExtendedHistory: true,
1223
1267
  });
1224
1268
  const threadId = this.extractThreadIdFromResponse(response);
1225
1269
  if (!threadId) {
@@ -1615,6 +1659,10 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1615
1659
  this.state.pendingApprovalOrigin = undefined;
1616
1660
  }
1617
1661
  clearPendingUserInputState() {
1662
+ if (this.pendingUserInputTimer) {
1663
+ clearTimeout(this.pendingUserInputTimer);
1664
+ this.pendingUserInputTimer = null;
1665
+ }
1618
1666
  this.pendingUserInputRequest = null;
1619
1667
  this.state.pendingUserInput = null;
1620
1668
  this.state.pendingUserInputOrigin = undefined;
@@ -1791,6 +1839,9 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1791
1839
  if (!threadId || !turnId) {
1792
1840
  return null;
1793
1841
  }
1842
+ if (this.hasCompletedTurn(turnId)) {
1843
+ return null;
1844
+ }
1794
1845
  if (this.bridgeOwnedTurnIds.has(turnId)) {
1795
1846
  return {
1796
1847
  threadId,
@@ -1901,6 +1952,15 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1901
1952
  }
1902
1953
  }
1903
1954
  handleRpcServerRequest(requestId, method, params) {
1955
+ if (method === "currentTime/read") {
1956
+ this.sendRpcMessage({
1957
+ id: requestId,
1958
+ result: {
1959
+ currentTimeAt: Math.floor(Date.now() / 1_000),
1960
+ },
1961
+ });
1962
+ return;
1963
+ }
1904
1964
  if (method === "mcpServer/elicitation/request") {
1905
1965
  this.sendRpcMessage({
1906
1966
  id: requestId,
@@ -2051,6 +2111,7 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2051
2111
  threadId: trackedTurn.threadId,
2052
2112
  turnId: trackedTurn.turnId,
2053
2113
  origin: trackedTurn.origin,
2114
+ isBlocking: params.isBlocking !== false,
2054
2115
  };
2055
2116
  this.state.pendingUserInput = request;
2056
2117
  this.state.pendingUserInputOrigin = trackedTurn.origin;
@@ -2061,6 +2122,26 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
2061
2122
  request,
2062
2123
  timestamp: nowIso(),
2063
2124
  });
2125
+ if (this.pendingUserInputRequest.isBlocking) {
2126
+ return;
2127
+ }
2128
+ const pendingRequest = this.pendingUserInputRequest;
2129
+ this.pendingUserInputTimer = setTimeout(() => {
2130
+ if (this.pendingUserInputRequest !== pendingRequest) {
2131
+ return;
2132
+ }
2133
+ this.sendRpcMessage({
2134
+ id: pendingRequest.requestId,
2135
+ result: {
2136
+ answers: {},
2137
+ },
2138
+ });
2139
+ this.clearPendingUserInputState();
2140
+ if (this.state.status === "awaiting_input") {
2141
+ this.setStatus("busy", "Codex non-blocking user input timed out.");
2142
+ }
2143
+ }, this.userInputAutoResolutionDelayMs);
2144
+ this.pendingUserInputTimer.unref?.();
2064
2145
  }
2065
2146
  handleThreadStatusChanged(params) {
2066
2147
  const threadId = extractCodexThreadFollowIdFromStatusChanged(params);
@@ -1,4 +1,5 @@
1
1
  import net from "node:net";
2
+ import path from "node:path";
2
3
  import { spawn as spawnPty } from "node-pty";
3
4
  import { t } from "../i18n/index.js";
4
5
  import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, sendLocalCompanionMessage, updateLocalCompanionOccupancy, writeLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
@@ -6,6 +7,38 @@ import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./
6
7
  import * as shared from "./bridge-adapters.shared.js";
7
8
  import { LOCAL_CLIENT_PROTOCOL_VERSION } from "../runtime/runtime-types.js";
8
9
  const { CODEX_APP_SERVER_HOST, INTERRUPT_SETTLE_DELAY_MS, buildCliEnvironment, buildPtySpawnOptions, buildSpawnDiagnostic, getLocalCompanionCommandName, getSharedSessionIdFromAdapterState, LOCAL_COMPANION_RECONNECT_GRACE_MS, resolveSpawnTarget, spawnFallbackProcess, } = shared;
10
+ /**
11
+ * On Windows, node-pty (ConPTY) uses CreateProcess which can execute .cmd/.bat
12
+ * files directly — no need for the cmd.exe wrapper that resolveSpawnTarget adds
13
+ * for child_process.spawn compatibility. The wrapper causes double-quoting when
14
+ * the script path contains spaces: node-pty quotes the already-quoted path,
15
+ * making cmd.exe treat the literal quotes as part of the filename.
16
+ */
17
+ function unwrapCmdExeForPty(rawTarget) {
18
+ if (process.platform !== "win32") {
19
+ return rawTarget;
20
+ }
21
+ const fileName = path.basename(rawTarget.file).toLowerCase();
22
+ if (fileName !== "cmd.exe") {
23
+ return rawTarget;
24
+ }
25
+ const args = rawTarget.args;
26
+ if (args.length < 4 || args[0] !== "/d" || args[1] !== "/s" || args[2] !== "/c") {
27
+ return rawTarget;
28
+ }
29
+ // Strip surrounding double-quotes from the /c argument to recover the
30
+ // actual script path. quoteForCmd wraps paths containing spaces in
31
+ // double-quotes, so we reverse that here.
32
+ const commandLine = args[3] ?? "";
33
+ const actualScript = commandLine.replace(/^"(.*)"$/s, "$1");
34
+ // Remaining args beyond the /c handler (args[4..]) are extra arguments
35
+ // that were passed through wrapWithCmdExe; forward them as the new target's
36
+ // args so they stay prepended before buildSpawnArgs().
37
+ return {
38
+ file: actualScript,
39
+ args: args.slice(4),
40
+ };
41
+ }
9
42
  export class LocalCompanionProxyAdapter {
10
43
  options;
11
44
  state;
@@ -35,7 +68,7 @@ export class LocalCompanionProxyAdapter {
35
68
  sharedThreadId: options.kind === "codex" || options.kind === "opencode"
36
69
  ? initialSharedSessionId
37
70
  : undefined,
38
- activeRuntimeSessionId: options.kind === "claude" || options.kind === "opencode"
71
+ activeRuntimeSessionId: options.kind === "claude" || options.kind === "opencode" || options.kind === "pi"
39
72
  ? initialSharedSessionId
40
73
  : undefined,
41
74
  resumeConversationId: shouldRestoreInitialSession && options.kind === "claude"
@@ -513,7 +546,8 @@ export class AbstractPtyAdapter {
513
546
  this.setStatus("starting", `Starting ${this.options.kind} adapter...`);
514
547
  let spawnTarget = null;
515
548
  try {
516
- spawnTarget = resolveSpawnTarget(this.options.command, this.options.kind);
549
+ const rawTarget = resolveSpawnTarget(this.options.command, this.options.kind);
550
+ spawnTarget = unwrapCmdExeForPty(rawTarget);
517
551
  const env = this.buildEnv();
518
552
  const spawnArgs = [...spawnTarget.args, ...this.buildSpawnArgs()];
519
553
  let ptyProcess;
@@ -2,6 +2,7 @@ import { ClaudeCompanionAdapter } from "./bridge-adapters.claude.js";
2
2
  import { LocalCompanionProxyAdapter } from "./bridge-adapters.core.js";
3
3
  import { CodexPtyAdapter } from "./bridge-adapters.codex.js";
4
4
  import { OpenCodeServerAdapter } from "./bridge-adapters.opencode.js";
5
+ import { PiTuiAdapter } from "./bridge-adapters.pi.js";
5
6
  import { ShellAdapter } from "./bridge-adapters.shell.js";
6
7
  export * from "./bridge-adapters.shared.js";
7
8
  export function createBridgeAdapter(options) {
@@ -18,6 +19,10 @@ export function createBridgeAdapter(options) {
18
19
  return options.renderMode === "companion"
19
20
  ? new OpenCodeServerAdapter(options)
20
21
  : new LocalCompanionProxyAdapter(options);
22
+ case "pi":
23
+ return options.renderMode === "companion"
24
+ ? new PiTuiAdapter(options)
25
+ : new LocalCompanionProxyAdapter(options);
21
26
  case "shell":
22
27
  return new ShellAdapter(options);
23
28
  default: