@unlinearity/cli-wechat-bridge 1.0.4 → 1.0.5
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.
- package/README.md +128 -22
- package/bin/wechat-daemon.mjs +5 -0
- package/dist/bridge/bridge-adapters.core.js +46 -6
- package/dist/bridge/bridge-controller.js +6 -2
- package/dist/bridge/bridge-utils.js +43 -7
- package/dist/bridge/wechat-bridge.js +44 -6
- package/dist/companion/codex-remote-client.js +8 -4
- package/dist/companion/local-companion-link.js +56 -26
- package/dist/companion/local-companion-start.js +48 -5
- package/dist/companion/local-companion.js +3 -1
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- package/dist/wechat/channel-config.js +126 -33
- package/dist/wechat/standalone-bot.js +5 -6
- package/dist/wechat/wechat-channel.js +21 -10
- package/dist/wechat/wechat-transport.js +291 -9
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -2,9 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
<p align='center'><img src='docs/images/logo.png' width=90%></p>
|
|
4
4
|
|
|
5
|
+
<p align="center"><img alt="Typing SVG" src="https://readme-typing-svg.herokuapp.com?font=JetBrains+Mono&weight=600&duration=4000&pause=500&color=06C763&center=true&vCenter=true&width=660&lines=CLI+WeChat+Bridge+%E5%BE%AE%E4%BF%A1%E6%A1%A5%E6%8E%A5;%E7%9C%9F%E6%AD%A3%E7%9A%84%E2%80%9C%E7%BB%88%E7%AB%AF-%E5%BE%AE%E4%BF%A1%E2%80%9D%E5%8E%9F%E7%94%9F%E5%8F%8C%E5%90%91%E4%BA%A4%E4%BA%92"></p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
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&style=for-the-badge&logo=github&color=0891b2&labelColor=1c1917"></a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="npm version" src="https://img.shields.io/npm/v/@unlinearity/cli-wechat-bridge?label=npm&style=for-the-badge&logo=npm&color=cb3837&labelColor=1c1917"></a>
|
|
10
|
+
<a href="https://www.npmjs.com/package/@unlinearity/cli-wechat-bridge"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@unlinearity/cli-wechat-bridge?label=Downloads&style=for-the-badge&logo=npm&color=16a34a&labelColor=1c1917"></a>
|
|
11
|
+
<a href="https://github.com/UNLINEARITY/CLI-WeChat-Bridge/blob/main/LICENSE.txt"><img alt="License" src="https://img.shields.io/github/license/UNLINEARITY/CLI-WeChat-Bridge?label=License&style=for-the-badge&color=7c3aed&labelColor=1c1917"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
5
14
|
**命令行工具的微信桥接**:本项目用于桥接微信消息与本地运行的 [`Codex`](https://github.com/openai/codex)、[`Claude Code`](https://code.claude.com/docs/en/overview)、`OpenCode` 或持久化 `powershell.exe` 会话,并将本地输出、审批请求与运行状态同步回微信。
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
当前实现以本地工作流为中心展开,重点是保留**本地原生终端体验**,支持原生使用以及高级参数注入,并在此基础上提供微信侧的远程输入、结果回流与状态同步能力。
|
|
8
17
|
|
|
9
18
|
<p align='center'><img src='docs/images/animation.webp' width=90%></p>
|
|
10
19
|
|
|
@@ -26,9 +35,8 @@
|
|
|
26
35
|
|
|
27
36
|
### 1. 环境要求
|
|
28
37
|
|
|
29
|
-
- Windows 为当前主要验证环境
|
|
30
38
|
- [Node.js](https://nodejs.org/en/download) `>= 24.0.0`(建议直接安装官网 LTS 版本)
|
|
31
|
-
- [Bun](https://bun.sh/docs/installation) `>= 1.0.0`
|
|
39
|
+
- [Bun](https://bun.sh/docs/installation) `>= 1.0.0` (开发者需要,普通用户无需安装)
|
|
32
40
|
- 已安装以下至少一种本地 CLI:(最好更新至最新版本)
|
|
33
41
|
- [Codex](https://github.com/openai/codex)
|
|
34
42
|
- [Claude Code](https://code.claude.com/docs/en/overview)
|
|
@@ -43,7 +51,7 @@
|
|
|
43
51
|
npm install -g @unlinearity/cli-wechat-bridge@latest
|
|
44
52
|
```
|
|
45
53
|
|
|
46
|
-
安装后可在任意项目目录中直接使用 `wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start` 等命令。
|
|
54
|
+
安装后可在任意项目目录中直接使用 `wechat-daemon`、`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start` 等命令。
|
|
47
55
|
|
|
48
56
|
如果你希望从源码运行或参与开发,也可以克隆仓库并安装依赖:
|
|
49
57
|
|
|
@@ -113,14 +121,60 @@ bun run setup # 绑定微信 ClawBot
|
|
|
113
121
|
默认凭据文件路径:
|
|
114
122
|
|
|
115
123
|
```text
|
|
116
|
-
~/.
|
|
124
|
+
~/.cli-bridge/account.json
|
|
117
125
|
```
|
|
118
126
|
|
|
119
127
|
登录成功后会清理旧的 `sync_buf.txt` 和 `context_tokens.json`,避免旧会话状态污染新的登录状态。
|
|
120
128
|
|
|
129
|
+
从旧版本升级时,bridge 会自动从旧的 `~/.claude/channels/wechat` 迁移数据到 `~/.cli-bridge`,包括登录凭据、同步游标、上下文 token、工作区状态和已接收附件;已有的新目录文件不会被覆盖,旧目录也会原样保留。旧运行锁不会迁移,旧 `bridge.log` 会保存为 `legacy-bridge.log`,避免覆盖新的运行日志。
|
|
130
|
+
|
|
121
131
|
如果是首次安装或微信登录已过期,`wechat-codex-start`、`wechat-claude-start`、`wechat-opencode-start` 也会在前台提示扫码登录。
|
|
122
132
|
|
|
123
|
-
### 4.
|
|
133
|
+
### 4. 先从微信发一条同步消息(重要)
|
|
134
|
+
|
|
135
|
+
启动 bridge 后,建议先在微信里向 Bot 发送一条消息,例如 `hello`、你要执行的任务或任意一句话。这样 bridge 能拿到最新的微信会话 `context_token`,之后本地终端里的输入、最终回复和审批提示才能稳定同步回微信。
|
|
136
|
+
|
|
137
|
+
如果冷启动或长时间闲置后直接从本地终端先发消息,bridge 通常仍会捕获这条本地输入并交给 Codex / Claude Code / OpenCode 处理,但微信侧出站发送可能因为旧的 `context_token` 失效而失败。表现就是:本地已经有回复,微信暂时收不到;等你先从微信发来一条消息后,后续双向同步就能恢复正常。
|
|
138
|
+
|
|
139
|
+
### 5. 常驻 daemon 模式(推荐用于多 CLI 切换)
|
|
140
|
+
|
|
141
|
+
如果你希望微信连接长期保持在线,并在 Codex / Claude Code / OpenCode 之间来回切换,推荐在项目目录启动一个统一 daemon:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
cd D:\work\your-project
|
|
145
|
+
wechat-daemon
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
启动后,在微信里发送以下指令即可选择当前活动终端:
|
|
149
|
+
|
|
150
|
+
| 指令 | 行为 |
|
|
151
|
+
| --- | --- |
|
|
152
|
+
| `/codex` | 切换到 Codex |
|
|
153
|
+
| `/claude` | 切换到 Claude Code |
|
|
154
|
+
| `/opencode` | 切换到 OpenCode |
|
|
155
|
+
|
|
156
|
+
daemon 启动后,后续切换都可以直接从微信发起;如果对应 CLI 还没有可见窗口,daemon 会自动打开或复用它,不需要再手动运行 `wechat-codex`、`wechat-claude` 或 `wechat-opencode`。
|
|
157
|
+
|
|
158
|
+
daemon 的 v1 行为是:
|
|
159
|
+
|
|
160
|
+
- daemon 绑定启动时的工作目录;暂不支持在微信里切换工作目录;
|
|
161
|
+
- 启动时会自动接管并清理旧的单 bridge 进程、失效 lock 和旧 endpoint;
|
|
162
|
+
- 切换适配器不会关闭之前的 CLI;
|
|
163
|
+
- 如果对应适配器已经有可见 CLI 在运行,则直接复用;
|
|
164
|
+
- 如果还没有对应 CLI,daemon 会自动打开一个新的可见终端;
|
|
165
|
+
- Codex / Claude / OpenCode 的重要输出都会带上 `[codex]`、`[claude]`、`[opencode]` 标签再发回微信;
|
|
166
|
+
- 可以在微信发送 `/daemon-stop` 停止 daemon。
|
|
167
|
+
|
|
168
|
+
也可以启动时直接打开某个初始 CLI:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
wechat-daemon --adapter codex
|
|
172
|
+
wechat-daemon --adapter claude --profile work
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
当同一工作目录已有 `wechat-daemon` 在运行时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 会自动委托给 daemon:请求 daemon 切到对应 CLI,并在需要时打开可见终端,而不是停止 daemon 或关闭其他 CLI。
|
|
176
|
+
|
|
177
|
+
### 6. 单命令启动桥接(兼容模式)
|
|
124
178
|
|
|
125
179
|
先进入你要操作的项目目录:
|
|
126
180
|
|
|
@@ -139,12 +193,13 @@ cd D:\work\your-project
|
|
|
139
193
|
三者会自动完成以下动作:
|
|
140
194
|
|
|
141
195
|
1. 校验或刷新微信登录凭据;
|
|
142
|
-
2.
|
|
143
|
-
3.
|
|
144
|
-
4.
|
|
145
|
-
5.
|
|
196
|
+
2. 如果当前目录已有 `wechat-daemon`,则委托 daemon 切换到对应 CLI;
|
|
197
|
+
3. 如果没有 daemon,则复用当前目录已运行的 bridge,或在当前目录启动新的 bridge;
|
|
198
|
+
4. 如果独立 bridge 正在服务其他目录,则停止旧 bridge 并切换到当前目录;
|
|
199
|
+
5. 等待当前目录对应的本地 companion endpoint 就绪;
|
|
200
|
+
6. 打开可见的本地 CLI 会话。
|
|
146
201
|
|
|
147
|
-
|
|
202
|
+
在没有 daemon 时,`wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 仍按**单活工作区切换器**工作:
|
|
148
203
|
|
|
149
204
|
- 同一时间只有一个项目与微信对话;
|
|
150
205
|
- 在当前目录重复执行是幂等的;
|
|
@@ -152,7 +207,7 @@ cd D:\work\your-project
|
|
|
152
207
|
- 如果检测到可见端仍在但 worker 状态异常(如 `stopped` / `error`),会自动重启 bridge 再重新打开可见端;
|
|
153
208
|
- 在其他目录执行会显式切换活动工作区。
|
|
154
209
|
|
|
155
|
-
###
|
|
210
|
+
### 7. 手动双终端模式(调试开发)
|
|
156
211
|
|
|
157
212
|
如果你希望明确观察 bridge 与本地 CLI companion,也可以分两个终端启动。
|
|
158
213
|
|
|
@@ -168,6 +223,12 @@ cd D:\work\your-project
|
|
|
168
223
|
|
|
169
224
|

|
|
170
225
|
|
|
226
|
+
微信发来的图片和普通文件也会被接收并保存到本地数据目录:
|
|
227
|
+
|
|
228
|
+
- 保存位置:`~/.cli-bridge/inbound-attachments/<日期>/`
|
|
229
|
+
- bridge 会把本地路径追加到转发给 Codex / Claude Code / OpenCode 的 prompt 中,模型可按需读取或解析这些文件;
|
|
230
|
+
- 当前不会自动 OCR 图片,也不会自动抽取 PDF / DOCX 正文;解析动作由本地 CLI 根据路径完成。
|
|
231
|
+
|
|
171
232
|
| 适配器 | 当前状态 | 说明 |
|
|
172
233
|
| --- | --- | --- |
|
|
173
234
|
| `codex` | 已接入 | 双终端模式;本地 companion 为线程权威;微信跟随本地线程 |
|
|
@@ -223,6 +284,7 @@ OpenCode 模式下,微信侧支持 `/new` 或 `/new-session` 创建新 session
|
|
|
223
284
|
| 类型 | 命令 |
|
|
224
285
|
| --- | --- |
|
|
225
286
|
| 登录与更新 | `wechat-setup`、`wechat-check-update` |
|
|
287
|
+
| 常驻 daemon | `wechat-daemon` |
|
|
226
288
|
| Codex | `wechat-bridge-codex`、`wechat-codex`、`wechat-codex-start` |
|
|
227
289
|
| Claude Code | `wechat-bridge-claude`、`wechat-claude`、`wechat-claude-start` |
|
|
228
290
|
| OpenCode | `wechat-bridge-opencode`、`wechat-opencode`、`wechat-opencode-start` |
|
|
@@ -232,6 +294,7 @@ OpenCode 模式下,微信侧支持 `/new` 或 `/new-session` 创建新 session
|
|
|
232
294
|
|
|
233
295
|
```bash
|
|
234
296
|
bun run setup
|
|
297
|
+
bun run daemon
|
|
235
298
|
bun run bridge:codex
|
|
236
299
|
bun run codex:panel
|
|
237
300
|
bun run codex:start
|
|
@@ -247,6 +310,27 @@ bun run check
|
|
|
247
310
|
bun run test
|
|
248
311
|
```
|
|
249
312
|
|
|
313
|
+
### Daemon CLI 参数
|
|
314
|
+
|
|
315
|
+
适用于:
|
|
316
|
+
|
|
317
|
+
- `wechat-daemon`
|
|
318
|
+
|
|
319
|
+
示例:
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
wechat-daemon --cwd D:\work\my-project
|
|
323
|
+
wechat-daemon --adapter codex
|
|
324
|
+
wechat-daemon --adapter claude --profile work
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
支持参数:
|
|
328
|
+
|
|
329
|
+
- `--cwd <path>`:指定 daemon 绑定的工作目录;
|
|
330
|
+
- `--adapter <codex|claude|opencode>`:启动 daemon 后立即切换到指定 CLI;
|
|
331
|
+
- `--profile <name-or-path>`:传给 daemon 创建的适配器;
|
|
332
|
+
- `--no-open`:只创建 bridge slot,不自动打开可见 CLI。
|
|
333
|
+
|
|
250
334
|
### Bridge CLI 参数
|
|
251
335
|
|
|
252
336
|
适用于:
|
|
@@ -292,16 +376,29 @@ wechat-opencode-start --cwd D:\work\my-project
|
|
|
292
376
|
- `--profile <name-or-path>`:转发给后台启动的 `wechat-bridge-codex` / `wechat-bridge-claude` / `wechat-bridge-opencode`;
|
|
293
377
|
- `--timeout-ms <ms>`:等待当前目录 endpoint 的最长时间,默认 `15000`。
|
|
294
378
|
|
|
379
|
+
高级用法:除上述启动器参数外,未知参数会继续透传给可见的底层 CLI。这样可以在保留微信登录、工作区切换与 bridge 生命周期管理的同时,启用 Codex / Claude Code 自己的高级启动模式。
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
wechat-codex-start --yolo
|
|
383
|
+
wechat-codex-start --model gpt-5.2 --yolo
|
|
384
|
+
wechat-claude-start --dangerously-skip-permissions
|
|
385
|
+
wechat-claude-start --model sonnet --dangerously-skip-permissions
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
其中 `--yolo` 会传给 Codex;`--dangerously-skip-permissions` 会传给 Claude Code。它们只影响本地可见 CLI,不会覆盖 bridge 托管的 Codex remote 连接参数、Claude settings、微信登录状态或当前工作区锁。由于这两类参数会降低审批保护,建议只在可信工作区中使用。
|
|
389
|
+
|
|
295
390
|
## 微信侧支持的指令
|
|
296
391
|
|
|
297
392
|
| 指令 | 说明 |
|
|
298
393
|
| --- | --- |
|
|
299
394
|
| 普通文本 | 发送给当前活动会话 |
|
|
395
|
+
| `/codex` / `/claude` / `/opencode` | daemon 模式下切换活动 CLI;已有 CLI 会复用,没有则自动打开 |
|
|
300
396
|
| `/status` | 查看 bridge 当前状态 |
|
|
301
397
|
| `/stop` | 中断当前任务 |
|
|
302
398
|
| `/reset` | 重建当前本地会话 |
|
|
303
399
|
| `/new` 或 `/new-session` | OpenCode 模式下新建 session |
|
|
304
400
|
| `/confirm` / `/deny` | 处理 Claude Code 权限请求;shell 等需要一次性 code 的请求会在消息中提示具体确认格式 |
|
|
401
|
+
| `/daemon-stop` | daemon 模式下停止常驻进程 |
|
|
305
402
|
|
|
306
403
|
说明:微信侧 `/resume` 目前仍保持禁用;需要切换 Codex / Claude / OpenCode 会话时,优先在本地 companion 中使用原生 `/resume`、`/new` 或对应 CLI 命令,微信会跟随本地活动会话。
|
|
307
404
|
|
|
@@ -313,15 +410,15 @@ wechat-opencode-start --cwd D:\work\my-project
|
|
|
313
410
|
- 对应的本地 companion 必须连接同一工作区;
|
|
314
411
|
- 不同工作区的状态文件相互隔离。
|
|
315
412
|
|
|
316
|
-
|
|
413
|
+
当前支持两种运行模型:
|
|
317
414
|
|
|
318
|
-
-
|
|
319
|
-
-
|
|
320
|
-
- 单活动工作区
|
|
415
|
+
- 独立 bridge:单 owner、单 bridge、单活动工作区;
|
|
416
|
+
- `wechat-daemon`:单 owner、单 daemon、绑定一个启动工作区,但可在这个工作区内同时保留 Codex / Claude Code / OpenCode 三个 CLI slot。
|
|
321
417
|
|
|
322
418
|
对 `wechat-codex-start` / `wechat-claude-start` / `wechat-opencode-start` 来说,这意味着:
|
|
323
419
|
|
|
324
|
-
-
|
|
420
|
+
- 如果同一工作区已有 daemon,它们会委托 daemon 切换 CLI;
|
|
421
|
+
- 如果没有 daemon,它们是**单活工作区切换器**;
|
|
325
422
|
- 当前目录重复执行是幂等的;
|
|
326
423
|
- 其他目录重复执行会触发工作区切换,而不是并行多开。
|
|
327
424
|
|
|
@@ -330,7 +427,7 @@ wechat-opencode-start --cwd D:\work\my-project
|
|
|
330
427
|
默认数据目录:
|
|
331
428
|
|
|
332
429
|
```text
|
|
333
|
-
~/.
|
|
430
|
+
~/.cli-bridge
|
|
334
431
|
```
|
|
335
432
|
|
|
336
433
|
主要文件如下:
|
|
@@ -342,21 +439,28 @@ wechat-opencode-start --cwd D:\work\my-project
|
|
|
342
439
|
| `context_tokens.json` | 微信上下文 token 缓存 |
|
|
343
440
|
| `bridge.log` | bridge 运行日志 |
|
|
344
441
|
| `bridge.lock.json` | bridge 运行锁 |
|
|
442
|
+
| `daemon-endpoint.json` | `wechat-daemon` 本地 IPC endpoint |
|
|
443
|
+
| `inbound-attachments/` | 微信入站图片和普通文件的本地保存目录 |
|
|
345
444
|
| `workspaces/<workspace-key>/bridge-state.json` | 当前工作区状态 |
|
|
346
445
|
| `workspaces/<workspace-key>/codex-panel-endpoint.json` | 当前工作区 companion endpoint;文件名保留历史兼容 |
|
|
446
|
+
| `workspaces/<workspace-key>/<adapter>-companion-endpoint.json` | daemon / 多适配器模式下的适配器独立 companion endpoint |
|
|
347
447
|
|
|
348
448
|
### 环境变量
|
|
349
449
|
|
|
350
450
|
| 变量名 | 说明 |
|
|
351
451
|
| --- | --- |
|
|
352
452
|
| `WECHAT_ILINK_BASE_URL` | 覆盖默认 iLink API 地址 |
|
|
353
|
-
| `
|
|
453
|
+
| `CLI_BRIDGE_DATA_DIR` | 覆盖默认数据目录 |
|
|
354
454
|
| `WECHAT_MAX_IMAGE_MB` | 覆盖图片上传大小限制,默认 20 MB |
|
|
355
455
|
| `WECHAT_MAX_FILE_MB` | 覆盖普通文件上传大小限制,默认 50 MB |
|
|
356
456
|
| `WECHAT_MAX_VOICE_MB` | 覆盖语音上传大小限制,默认 20 MB |
|
|
357
457
|
| `WECHAT_MAX_VIDEO_MB` | 覆盖视频上传大小限制,默认 100 MB |
|
|
458
|
+
| `WECHAT_MAX_INBOUND_IMAGE_MB` | 覆盖微信入站图片下载大小限制,默认 20 MB |
|
|
459
|
+
| `WECHAT_MAX_INBOUND_FILE_MB` | 覆盖微信入站普通文件下载大小限制,默认 50 MB |
|
|
358
460
|
| `WECHAT_OPENCODE_DEBUG` | 开启 OpenCode 适配器调试输出 |
|
|
359
461
|
|
|
462
|
+
旧版 `CLAUDE_WECHAT_CHANNEL_DATA_DIR` 不再作为活动数据目录配置项;如旧环境中设置过该变量,它只会被视为一次性旧数据迁移来源。新的自定义目录请使用 `CLI_BRIDGE_DATA_DIR`。
|
|
463
|
+
|
|
360
464
|
## 版本更新
|
|
361
465
|
|
|
362
466
|
### 检查更新
|
|
@@ -442,7 +546,7 @@ npm link
|
|
|
442
546
|
3. 检查:
|
|
443
547
|
|
|
444
548
|
```text
|
|
445
|
-
~/.
|
|
549
|
+
~/.cli-bridge/bridge.log
|
|
446
550
|
```
|
|
447
551
|
|
|
448
552
|
### 6. 本地 `/resume` 后微信不同步
|
|
@@ -456,7 +560,7 @@ npm link
|
|
|
456
560
|
如果 `bridge-state.json` 看起来正常,但微信仍然没有收到回复,优先检查:
|
|
457
561
|
|
|
458
562
|
```text
|
|
459
|
-
~/.
|
|
563
|
+
~/.cli-bridge/bridge.log
|
|
460
564
|
```
|
|
461
565
|
|
|
462
566
|
如果日志中出现 `wechat_send_failed`、`UND_ERR_CONNECT_TIMEOUT` 或 `ilinkai.weixin.qq.com:443`,通常是 bridge 到 iLink 的出站网络或代理问题,而不是本地 CLI 没有完成任务。请确认当前终端是否继承 `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`,以及 Node 是否需要 `NODE_OPTIONS=--use-env-proxy`。
|
|
@@ -465,7 +569,7 @@ npm link
|
|
|
465
569
|
|
|
466
570
|
- 微信 `/resume` 暂时被禁用(可能导致对话双向不稳定)
|
|
467
571
|
- Codex 不支持底层任务的远程审批!建议给好权限,正常使用,几乎不会主动请求较为低级的远程审批;Claude Code 支持远程审批,但是测试的不够,如有问题可以提 issue。
|
|
468
|
-
-
|
|
572
|
+
- `wechat-daemon` v1 绑定启动工作区;暂不支持从微信切换到另一个本地目录。
|
|
469
573
|
|
|
470
574
|
## 开发说明
|
|
471
575
|
|
|
@@ -474,6 +578,8 @@ npm link
|
|
|
474
578
|
| 文件 | 作用 |
|
|
475
579
|
| --- | --- |
|
|
476
580
|
| `src/bridge/wechat-bridge.ts` | bridge 主事件循环 |
|
|
581
|
+
| `src/daemon/wechat-daemon.ts` | 常驻 WeChat daemon 与多 CLI slot 管理 |
|
|
582
|
+
| `src/daemon/daemon-link.ts` | daemon 本地 IPC endpoint 与请求协议 |
|
|
477
583
|
| `src/bridge/bridge-adapters.ts` | `codex` / `claude` / `opencode` / `shell` 适配器入口 |
|
|
478
584
|
| `src/bridge/bridge-adapters.opencode.ts` | OpenCode 适配器实现 |
|
|
479
585
|
| `src/companion/local-companion.ts` | `wechat-claude` / `wechat-opencode` 本地 companion 入口 |
|
|
@@ -47,7 +47,10 @@ export class LocalCompanionProxyAdapter {
|
|
|
47
47
|
this.shuttingDown = false;
|
|
48
48
|
this.expectedCloseReason = null;
|
|
49
49
|
this.clearReconnectShutdownTimer();
|
|
50
|
-
this.setStatus("starting",
|
|
50
|
+
this.setStatus("starting", formatLocalCompanionStartupMessage({
|
|
51
|
+
kind: this.options.kind,
|
|
52
|
+
launchMode: this.options.companionLaunchMode,
|
|
53
|
+
}));
|
|
51
54
|
await new Promise((resolve, reject) => {
|
|
52
55
|
const server = net.createServer((socket) => {
|
|
53
56
|
this.handlePanelSocket(socket);
|
|
@@ -133,7 +136,9 @@ export class LocalCompanionProxyAdapter {
|
|
|
133
136
|
this.expectedCloseReason = null;
|
|
134
137
|
this.clearReconnectShutdownTimer();
|
|
135
138
|
this.rejectPendingRequests(`${this.options.kind} companion proxy is shutting down.`);
|
|
136
|
-
clearLocalCompanionEndpoint(this.options.cwd, this.endpoint?.instanceId
|
|
139
|
+
clearLocalCompanionEndpoint(this.options.cwd, this.endpoint?.instanceId, {
|
|
140
|
+
adapter: this.options.kind,
|
|
141
|
+
});
|
|
137
142
|
if (this.socket) {
|
|
138
143
|
try {
|
|
139
144
|
sendLocalCompanionMessage(this.socket, {
|
|
@@ -185,10 +190,15 @@ export class LocalCompanionProxyAdapter {
|
|
|
185
190
|
this.expectedCloseReason = null;
|
|
186
191
|
this.socket = socket;
|
|
187
192
|
this.detachMessageListener = detachListener;
|
|
193
|
+
const companionConnectedAt = nowIso();
|
|
194
|
+
if (this.endpoint) {
|
|
195
|
+
this.endpoint.companionPid = message.companionPid;
|
|
196
|
+
this.endpoint.companionConnectedAt = companionConnectedAt;
|
|
197
|
+
}
|
|
188
198
|
updateLocalCompanionOccupancy(this.options.cwd, {
|
|
189
199
|
companionPid: message.companionPid,
|
|
190
|
-
companionConnectedAt
|
|
191
|
-
}, this.endpoint?.instanceId);
|
|
200
|
+
companionConnectedAt,
|
|
201
|
+
}, this.endpoint?.instanceId, { adapter: this.options.kind });
|
|
192
202
|
sendLocalCompanionMessage(socket, { type: "hello_ack" });
|
|
193
203
|
return;
|
|
194
204
|
}
|
|
@@ -198,7 +208,9 @@ export class LocalCompanionProxyAdapter {
|
|
|
198
208
|
if (this.socket === socket) {
|
|
199
209
|
const expectedCloseReason = this.expectedCloseReason;
|
|
200
210
|
this.expectedCloseReason = null;
|
|
201
|
-
clearLocalCompanionOccupancy(this.options.cwd, this.endpoint?.instanceId
|
|
211
|
+
clearLocalCompanionOccupancy(this.options.cwd, this.endpoint?.instanceId, {
|
|
212
|
+
adapter: this.options.kind,
|
|
213
|
+
});
|
|
202
214
|
this.detachPanelSocket();
|
|
203
215
|
if (!this.shuttingDown) {
|
|
204
216
|
this.handleCompanionDisconnect(expectedCloseReason);
|
|
@@ -296,6 +308,7 @@ export class LocalCompanionProxyAdapter {
|
|
|
296
308
|
lifecycle: this.options.lifecycle,
|
|
297
309
|
expectedClose: isExpectedLocalCompanionClose(expectedCloseReason),
|
|
298
310
|
reconnectGraceMs: LOCAL_COMPANION_RECONNECT_GRACE_MS,
|
|
311
|
+
launchMode: this.options.companionLaunchMode,
|
|
299
312
|
});
|
|
300
313
|
if (disposition.action === "shutdown") {
|
|
301
314
|
this.clearReconnectShutdownTimer();
|
|
@@ -362,7 +375,10 @@ export class LocalCompanionProxyAdapter {
|
|
|
362
375
|
async sendRequest(payload) {
|
|
363
376
|
const socket = this.socket;
|
|
364
377
|
if (!socket) {
|
|
365
|
-
throw new Error(
|
|
378
|
+
throw new Error(formatCompanionNotConnectedMessage({
|
|
379
|
+
kind: this.options.kind,
|
|
380
|
+
launchMode: this.options.companionLaunchMode,
|
|
381
|
+
}));
|
|
366
382
|
}
|
|
367
383
|
if (!this.state.pid && payload.command !== "dispose") {
|
|
368
384
|
throw new Error(`${this.options.kind} companion is connected but not ready yet. Wait for it to finish starting.`);
|
|
@@ -389,6 +405,18 @@ export function buildCompanionHealthPatch(state, timestamp) {
|
|
|
389
405
|
companionWorkerPid: state.pid,
|
|
390
406
|
};
|
|
391
407
|
}
|
|
408
|
+
export function formatLocalCompanionStartupMessage(params) {
|
|
409
|
+
if (params.launchMode === "daemon_auto") {
|
|
410
|
+
return `Waiting for daemon-managed ${params.kind} companion connection. The daemon will open or reuse the visible CLI automatically.`;
|
|
411
|
+
}
|
|
412
|
+
return `Waiting for manual ${params.kind} companion connection. Run "${getLocalCompanionCommandName(params.kind)}" in a second terminal for this directory.`;
|
|
413
|
+
}
|
|
414
|
+
export function formatCompanionNotConnectedMessage(params) {
|
|
415
|
+
if (params.launchMode === "daemon_auto") {
|
|
416
|
+
return `${params.kind} companion is not connected yet. Send /${params.kind} in WeChat to open or reconnect the visible CLI automatically.`;
|
|
417
|
+
}
|
|
418
|
+
return `${params.kind} companion is not connected. Run "${getLocalCompanionCommandName(params.kind)}" in a second terminal for this directory.`;
|
|
419
|
+
}
|
|
392
420
|
export function isExpectedLocalCompanionClose(reason) {
|
|
393
421
|
return typeof reason === "string" && reason.length > 0;
|
|
394
422
|
}
|
|
@@ -411,11 +439,23 @@ export function getCompanionDisconnectDisposition(params) {
|
|
|
411
439
|
};
|
|
412
440
|
}
|
|
413
441
|
if (params.expectedClose) {
|
|
442
|
+
if (params.launchMode === "daemon_auto") {
|
|
443
|
+
return {
|
|
444
|
+
action: "await_manual_reconnect",
|
|
445
|
+
message: `${params.kind} companion closed. Send /${params.kind} in WeChat to reopen the visible CLI automatically.`,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
414
448
|
return {
|
|
415
449
|
action: "await_manual_reconnect",
|
|
416
450
|
message: `${params.kind} companion closed. Run "${commandName}" again in a second terminal for this directory.`,
|
|
417
451
|
};
|
|
418
452
|
}
|
|
453
|
+
if (params.launchMode === "daemon_auto") {
|
|
454
|
+
return {
|
|
455
|
+
action: "await_manual_reconnect",
|
|
456
|
+
message: `${params.kind} companion disconnected unexpectedly. Send /${params.kind} in WeChat to reopen the visible CLI automatically.`,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
419
459
|
return {
|
|
420
460
|
action: "await_manual_reconnect",
|
|
421
461
|
message: `${params.kind} companion disconnected unexpectedly. Run "${commandName}" again in a second terminal for this directory to reconnect.`,
|
|
@@ -17,7 +17,9 @@ export class BridgeController {
|
|
|
17
17
|
this.clearLocalClientEndpoint();
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
|
-
const existing = readLocalCompanionEndpoint(this.cwd
|
|
20
|
+
const existing = readLocalCompanionEndpoint(this.cwd, {
|
|
21
|
+
adapter: endpoint.kind,
|
|
22
|
+
});
|
|
21
23
|
const adapterState = this.adapter.getState();
|
|
22
24
|
const nextEndpoint = existing?.instanceId === endpoint.instanceId
|
|
23
25
|
? {
|
|
@@ -38,7 +40,9 @@ export class BridgeController {
|
|
|
38
40
|
writeLocalCompanionEndpoint(nextEndpoint);
|
|
39
41
|
}
|
|
40
42
|
clearLocalClientEndpoint() {
|
|
41
|
-
clearLocalCompanionEndpoint(this.cwd, this.endpointInstanceId ?? undefined
|
|
43
|
+
clearLocalCompanionEndpoint(this.cwd, this.endpointInstanceId ?? undefined, {
|
|
44
|
+
adapter: this.adapter.getState().kind,
|
|
45
|
+
});
|
|
42
46
|
this.endpointInstanceId = null;
|
|
43
47
|
}
|
|
44
48
|
}
|
|
@@ -211,15 +211,51 @@ export function shouldInjectWechatAttachmentPrompt(text) {
|
|
|
211
211
|
mentionsLocalPath ||
|
|
212
212
|
looksLikeShortSendCommand);
|
|
213
213
|
}
|
|
214
|
-
|
|
215
|
-
if (!
|
|
216
|
-
return
|
|
214
|
+
function formatPromptByteSize(bytes) {
|
|
215
|
+
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {
|
|
216
|
+
return null;
|
|
217
217
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
218
|
+
if (bytes >= 1024 * 1024) {
|
|
219
|
+
return `${(bytes / (1024 * 1024)).toFixed(bytes >= 100 * 1024 * 1024 ? 0 : 1)} MB`;
|
|
220
|
+
}
|
|
221
|
+
if (bytes >= 1024) {
|
|
222
|
+
return `${(bytes / 1024).toFixed(bytes >= 100 * 1024 ? 0 : 1)} KB`;
|
|
223
|
+
}
|
|
224
|
+
return `${bytes} B`;
|
|
225
|
+
}
|
|
226
|
+
function formatWechatInboundAttachmentPrompt(attachments) {
|
|
227
|
+
const lines = [
|
|
228
|
+
"[WeChat inbound attachments]",
|
|
229
|
+
"The user sent attachment files through WeChat. They have been saved locally. Read the local paths directly if the user's request requires inspecting them.",
|
|
230
|
+
];
|
|
231
|
+
attachments.forEach((attachment, index) => {
|
|
232
|
+
const sizeText = formatPromptByteSize(attachment.sizeBytes);
|
|
233
|
+
const metadata = [
|
|
234
|
+
`kind=${attachment.kind}`,
|
|
235
|
+
attachment.fileName ? `name=${attachment.fileName}` : "",
|
|
236
|
+
sizeText ? `size=${sizeText}` : "",
|
|
237
|
+
].filter(Boolean);
|
|
238
|
+
lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
|
|
239
|
+
});
|
|
240
|
+
return lines.join("\n");
|
|
241
|
+
}
|
|
242
|
+
export function buildWechatInboundPrompt(text, attachments = []) {
|
|
243
|
+
const trimmedAttachments = attachments.filter((attachment) => attachment.path.trim());
|
|
244
|
+
if (!trimmedAttachments.length) {
|
|
245
|
+
if (!shouldInjectWechatAttachmentPrompt(text)) {
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
const normalized = normalizeOutput(text).trim();
|
|
249
|
+
if (!normalized) {
|
|
250
|
+
return text;
|
|
251
|
+
}
|
|
252
|
+
return `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${normalized}`;
|
|
221
253
|
}
|
|
222
|
-
|
|
254
|
+
const baseText = normalizeOutput(text).trim() || "Received WeChat attachment(s).";
|
|
255
|
+
const userPrompt = shouldInjectWechatAttachmentPrompt(baseText)
|
|
256
|
+
? `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${baseText.trim()}`
|
|
257
|
+
: baseText;
|
|
258
|
+
return `${userPrompt.trim()}\n\n${formatWechatInboundAttachmentPrompt(trimmedAttachments)}`;
|
|
223
259
|
}
|
|
224
260
|
export function parseWechatFinalReply(text) {
|
|
225
261
|
const normalized = normalizeOutput(text);
|
|
@@ -9,8 +9,9 @@ import { BridgeStateStore } from "./bridge-state.js";
|
|
|
9
9
|
import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
|
|
10
10
|
import { createRuntimeHost } from "../runtime/create-runtime-host.js";
|
|
11
11
|
import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
|
|
12
|
-
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
|
|
12
|
+
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
|
|
13
13
|
import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
|
|
14
|
+
import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
|
|
14
15
|
const POLL_RETRY_BASE_MS = 1_000;
|
|
15
16
|
const POLL_RETRY_MAX_MS = 30_000;
|
|
16
17
|
const PARENT_PROCESS_POLL_MS = 5_000;
|
|
@@ -75,16 +76,22 @@ export function formatUserFacingInboundError(params) {
|
|
|
75
76
|
export function formatWechatSendFailureLogEntry(params) {
|
|
76
77
|
return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
77
78
|
}
|
|
79
|
+
export function formatWechatContextTokenStaleLogEntry(params) {
|
|
80
|
+
return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
81
|
+
}
|
|
78
82
|
function formatWechatSendRetryLogEntry(params) {
|
|
79
83
|
return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
|
|
80
84
|
}
|
|
81
|
-
function isRetryableWechatSendError(error) {
|
|
85
|
+
export function isRetryableWechatSendError(error) {
|
|
86
|
+
if (isWechatContextTokenStaleError(error)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
82
89
|
const classification = classifyWechatTransportError(error);
|
|
83
90
|
if (classification.retryable) {
|
|
84
91
|
return true;
|
|
85
92
|
}
|
|
86
93
|
const details = describeWechatTransportError(error);
|
|
87
|
-
return /^Error: sendmessage failed:/i.test(details) &&
|
|
94
|
+
return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
|
|
88
95
|
!/errcode=-14\b.*session timeout/i.test(details);
|
|
89
96
|
}
|
|
90
97
|
function computeWechatSendRetryDelayMs(attempt) {
|
|
@@ -214,6 +221,14 @@ function printUsageAndExit() {
|
|
|
214
221
|
}
|
|
215
222
|
async function main() {
|
|
216
223
|
const options = parseCliArgs(process.argv.slice(2));
|
|
224
|
+
const daemonEndpoint = readDaemonEndpoint();
|
|
225
|
+
if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
|
|
226
|
+
throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
|
|
227
|
+
}
|
|
228
|
+
if (daemonEndpoint) {
|
|
229
|
+
clearDaemonEndpoint(daemonEndpoint.pid);
|
|
230
|
+
log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
|
|
231
|
+
}
|
|
217
232
|
const credentials = await ensureWechatCredentials({
|
|
218
233
|
requireUserId: true,
|
|
219
234
|
validateExisting: true,
|
|
@@ -313,6 +328,17 @@ async function main() {
|
|
|
313
328
|
return true;
|
|
314
329
|
}
|
|
315
330
|
catch (err) {
|
|
331
|
+
if (isWechatContextTokenStaleError(err)) {
|
|
332
|
+
transport.clearCachedContextToken(senderId);
|
|
333
|
+
const hint = "WeChat conversation context is stale. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
|
|
334
|
+
logError(`Failed to send WeChat ${context}: ${hint}`);
|
|
335
|
+
stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
|
|
336
|
+
context,
|
|
337
|
+
recipientId: senderId,
|
|
338
|
+
error: err,
|
|
339
|
+
}));
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
316
342
|
if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
|
|
317
343
|
const delayMs = computeWechatSendRetryDelayMs(attempt);
|
|
318
344
|
logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
|
|
@@ -837,6 +863,17 @@ function buildCompletionSummary(params) {
|
|
|
837
863
|
lines.push(`recent_output:\n${params.recentOutput}`);
|
|
838
864
|
return lines.join("\n");
|
|
839
865
|
}
|
|
866
|
+
function formatInboundMessagePreview(message) {
|
|
867
|
+
if (message.text.trim()) {
|
|
868
|
+
return message.text;
|
|
869
|
+
}
|
|
870
|
+
if (message.attachments.length > 0) {
|
|
871
|
+
return message.attachments
|
|
872
|
+
.map((attachment) => `${attachment.kind}: ${attachment.path}`)
|
|
873
|
+
.join("\n");
|
|
874
|
+
}
|
|
875
|
+
return "(empty)";
|
|
876
|
+
}
|
|
840
877
|
async function handleInboundMessage(params) {
|
|
841
878
|
const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
|
|
842
879
|
const state = stateStore.getState();
|
|
@@ -971,12 +1008,13 @@ async function handleInboundMessage(params) {
|
|
|
971
1008
|
}
|
|
972
1009
|
async function dispatchInboundWechatText(params) {
|
|
973
1010
|
const { message, options, stateStore, adapter } = params;
|
|
1011
|
+
const preview = formatInboundMessagePreview(message);
|
|
974
1012
|
const activeTask = {
|
|
975
1013
|
startedAt: Date.now(),
|
|
976
|
-
inputPreview: truncatePreview(
|
|
1014
|
+
inputPreview: truncatePreview(preview, 180),
|
|
977
1015
|
};
|
|
978
|
-
stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(
|
|
979
|
-
await adapter.sendInput(buildWechatInboundPrompt(message.text));
|
|
1016
|
+
stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
|
|
1017
|
+
await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
|
|
980
1018
|
return activeTask;
|
|
981
1019
|
}
|
|
982
1020
|
const isDirectRun = Boolean(import.meta.main);
|
|
@@ -40,7 +40,7 @@ export function parseCliArgs(argv) {
|
|
|
40
40
|
return { cwd, cliArgs };
|
|
41
41
|
}
|
|
42
42
|
export function readCodexRuntimeEndpoint(cwd) {
|
|
43
|
-
const endpoint = readLocalCompanionEndpoint(cwd);
|
|
43
|
+
const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
|
|
44
44
|
if (!endpoint || endpoint.kind !== "codex") {
|
|
45
45
|
throw new Error(`No active Codex bridge endpoint was found for ${cwd}. Start "wechat-bridge-codex" in that directory first.`);
|
|
46
46
|
}
|
|
@@ -83,14 +83,18 @@ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
|
|
|
83
83
|
updateLocalCompanionOccupancy(endpoint.cwd, {
|
|
84
84
|
companionPid: child.pid,
|
|
85
85
|
companionConnectedAt: new Date().toISOString(),
|
|
86
|
-
}, endpoint.instanceId);
|
|
86
|
+
}, endpoint.instanceId, { adapter: "codex" });
|
|
87
87
|
}
|
|
88
88
|
child.once("error", (error) => {
|
|
89
|
-
clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId
|
|
89
|
+
clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
|
|
90
|
+
adapter: "codex",
|
|
91
|
+
});
|
|
90
92
|
reject(error);
|
|
91
93
|
});
|
|
92
94
|
child.once("exit", (code, signal) => {
|
|
93
|
-
clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId
|
|
95
|
+
clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
|
|
96
|
+
adapter: "codex",
|
|
97
|
+
});
|
|
94
98
|
if (signal) {
|
|
95
99
|
process.kill(process.pid, signal);
|
|
96
100
|
return;
|