@unlinearity/cli-wechat-bridge 1.0.7 → 1.0.9

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 CHANGED
@@ -10,7 +10,7 @@
10
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
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>
12
12
  <a href="https://www.npmjs.com/package/cli-wechat-bridge">
13
- <img alt="License" src="https://img.shields.io/npm/l/cli-wechat-bridge?label=License&style=for-the-badge&color=7c3aed&labelColor=1c1917">
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
15
  </p>
16
16
 
@@ -305,9 +305,44 @@ wechat-claude-start --model sonnet --dangerously-skip-permissions
305
305
  | `/new` 或 `/new-session` | OpenCode 模式下新建 session |
306
306
  | `/confirm` / `/deny` | 处理 CLI 权限请求;需要一次性 code 的请求会在消息中提示具体确认格式 |
307
307
  | `/daemon-stop` | daemon 模式下停止常驻进程 |
308
+ | `/bindings` | 查看当前所有表情绑定 |
309
+ | `/bind [表情] /命令` | 绑定表情到指定命令 |
310
+ | `/unbind [表情]` | 解除指定表情的绑定 |
308
311
 
309
312
  说明:微信侧 `/resume` 目前暂时保持禁用;需要切换 Codex / Claude / OpenCode 会话时,优先在本地 companion 中使用原生 `/resume`、`/new` 或对应 CLI 命令,微信会跟随本地活动会话。
310
313
 
314
+ ### 表情绑定
315
+
316
+ Daemon 模式支持将微信表情映射为命令,在微信中发送表情即可快速触发操作。(不过注意,第一次启动相关cli的时候,最好不要带消息,等启动完成再“表情+文本”快速给指定 cli 发送消息。
317
+
318
+ ![表情触发](docs/images/image-10.png)
319
+
320
+ **默认绑定:**
321
+
322
+ | 表情 | 命令 | 说明 |
323
+ | --- | --- | --- |
324
+ | `[OK]` | `/confirm` | 批准权限请求 |
325
+ | `[闭嘴]` | `/stop` | 中断当前任务 |
326
+ | `[拥抱]` | `/claude` | 切换到 Claude Code |
327
+ | `[强]` | `/codex` | 切换到 Codex |
328
+ | `[胜利]` | `/opencode` | 切换到 OpenCode |
329
+ | `[再见]` | `/daemon-stop` | 停止 daemon |
330
+
331
+ **管理命令:**
332
+
333
+ ```
334
+ /bindings 查看当前所有绑定
335
+ /bind [表情] /命令 绑定表情到命令,如 /bind [微笑] /status
336
+ /unbind [表情] 解除绑定,如 /unbind [微笑]
337
+ ```
338
+
339
+ **触发规则:**
340
+
341
+ - 表情必须出现在消息开头才会触发(如 `[OK]` 触发,`你好[OK]` 不触发);
342
+ - 如果表情后面还有文本(如 `[拥抱]帮我写个脚本`),会先执行命令再将剩余文本作为消息转发;
343
+ - 大小写不敏感(`[OK]` 和 `[ok]` 等价);
344
+ - 修改后立即生效并持久化到 `~/.cli-bridge/emoji-bindings.json`,重启 daemon 后保留。
345
+
311
346
  ## 工作区模型
312
347
 
313
348
  本项目采用“当前目录即当前工作区”的模型:
@@ -390,7 +425,19 @@ npm install -g cli-wechat-bridge@latest
390
425
 
391
426
  ## License
392
427
 
393
- [MIT](LICENSE.txt)
428
+ 本项目采用**双协议**授权:
429
+
430
+ **开源协议:[AGPL-3.0](LICENSE.txt)**
431
+
432
+ - 个人使用、学习、研究:完全免费
433
+ - 修改和衍生作品必须以相同协议(AGPL-3.0)开源
434
+ - 通过网络提供基于本项目的服务,也必须公开完整源代码
435
+
436
+ **商业授权**
437
+
438
+ 如果你希望在闭源商业产品中使用本项目(不公开你的源代码),需要获得商业许可。请联系作者获取商业授权方案:
439
+
440
+ - GitHub: [@UNLINEARITY](https://github.com/UNLINEARITY)
394
441
 
395
442
  <p align="center">
396
443
  <img alt="Star History Chart" src="https://api.star-history.com/chart?repos=UNLINEARITY/CLI-WeChat-Bridge&type=timeline&legend=top-left">
@@ -200,6 +200,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
200
200
  this.pendingCliApprovalHints = null;
201
201
  this.flushPendingClaudeHookApprovals();
202
202
  this.writeToPty("\u0003");
203
+ this.scheduleTaskComplete(shared.INTERRUPT_SETTLE_DELAY_MS);
203
204
  return true;
204
205
  }
205
206
  async reset() {
@@ -566,6 +567,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
566
567
  }
567
568
  return `${CLAUDE_BRACKETED_PASTE_START}${normalizedText}${CLAUDE_BRACKETED_PASTE_END}`;
568
569
  }
570
+ // Fallback heuristic for older Claude Code versions that lack PostCompact hooks.
571
+ // The structured PostCompact hook event (handled in handleClaudeHookEnvelope) is
572
+ // the reliable signal; this regex match serves as a best-effort fallback.
569
573
  shouldTreatClaudeOutputAsCompactCompletion(text) {
570
574
  if (this.state.status !== "busy" &&
571
575
  this.state.status !== "awaiting_approval" &&
@@ -695,6 +699,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
695
699
  this.handleClaudeStopFailure(payload);
696
700
  this.respondToClaudeHook(params.socket, params.requestId);
697
701
  return;
702
+ case "PostCompact":
703
+ this.completeClaudeCompact();
704
+ this.respondToClaudeHook(params.socket, params.requestId);
705
+ return;
698
706
  default:
699
707
  this.respondToClaudeHook(params.socket, params.requestId);
700
708
  return;
@@ -495,9 +495,27 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
495
495
  this.sessionPartialLine = "";
496
496
  this.seedSessionReplayCutoff(startedAtMs);
497
497
  }
498
- let content;
498
+ let chunk;
499
499
  try {
500
- content = fs.readFileSync(this.sessionFilePath, "utf8");
500
+ const stat = fs.statSync(this.sessionFilePath);
501
+ if (stat.size < this.sessionReadOffset) {
502
+ this.sessionReadOffset = 0;
503
+ this.sessionPartialLine = "";
504
+ }
505
+ if (stat.size === this.sessionReadOffset) {
506
+ return;
507
+ }
508
+ const fd = fs.openSync(this.sessionFilePath, "r");
509
+ try {
510
+ const bytesToRead = stat.size - this.sessionReadOffset;
511
+ const buf = Buffer.alloc(bytesToRead);
512
+ fs.readSync(fd, buf, 0, bytesToRead, this.sessionReadOffset);
513
+ chunk = buf.toString("utf8");
514
+ this.sessionReadOffset = stat.size;
515
+ }
516
+ finally {
517
+ fs.closeSync(fd);
518
+ }
501
519
  }
502
520
  catch {
503
521
  this.sessionFilePath = null;
@@ -505,15 +523,6 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
505
523
  this.sessionPartialLine = "";
506
524
  return;
507
525
  }
508
- if (content.length < this.sessionReadOffset) {
509
- this.sessionReadOffset = 0;
510
- this.sessionPartialLine = "";
511
- }
512
- if (content.length === this.sessionReadOffset) {
513
- return;
514
- }
515
- const chunk = content.slice(this.sessionReadOffset);
516
- this.sessionReadOffset = content.length;
517
526
  const lines = `${this.sessionPartialLine}${chunk}`.split(/\r?\n/);
518
527
  this.sessionPartialLine = lines.pop() ?? "";
519
528
  for (const line of lines) {
@@ -1627,10 +1636,14 @@ export class CodexPtyAdapter extends AbstractPtyAdapter {
1627
1636
  const requestId = ++this.rpcRequestCounter;
1628
1637
  const requestKey = this.rpcRequestKey(requestId);
1629
1638
  const responsePromise = new Promise((resolve, reject) => {
1639
+ const timer = setTimeout(() => {
1640
+ this.pendingRpcRequests.delete(requestKey);
1641
+ reject(new Error(`Codex RPC request timed out after 30s (method: ${method})`));
1642
+ }, 30_000);
1630
1643
  this.pendingRpcRequests.set(requestKey, {
1631
1644
  method,
1632
- resolve,
1633
- reject,
1645
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
1646
+ reject: (err) => { clearTimeout(timer); reject(err); },
1634
1647
  });
1635
1648
  });
1636
1649
  try {
@@ -294,6 +294,7 @@ export class LocalCompanionProxyAdapter {
294
294
  }
295
295
  }
296
296
  detachPanelSocket() {
297
+ this.rejectPendingRequests("Companion socket disconnected unexpectedly.");
297
298
  this.detachMessageListener?.();
298
299
  this.detachMessageListener = null;
299
300
  if (this.socket) {
@@ -393,7 +394,14 @@ export class LocalCompanionProxyAdapter {
393
394
  }
394
395
  const id = `${++this.requestCounter}`;
395
396
  const response = new Promise((resolve, reject) => {
396
- this.pendingRequests.set(id, { resolve, reject });
397
+ const timer = setTimeout(() => {
398
+ this.pendingRequests.delete(id);
399
+ reject(new Error(`${this.options.kind} companion request timed out after 30s (command: ${payload.command})`));
400
+ }, 30_000);
401
+ this.pendingRequests.set(id, {
402
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
403
+ reject: (err) => { clearTimeout(timer); reject(err); },
404
+ });
397
405
  });
398
406
  sendLocalCompanionMessage(socket, {
399
407
  type: "request",
@@ -588,15 +596,21 @@ export class AbstractPtyAdapter {
588
596
  return;
589
597
  }
590
598
  this.shuttingDown = true;
599
+ const pty = this.pty;
600
+ this.pty = null;
601
+ this.state.status = "stopped";
602
+ this.state.pid = undefined;
591
603
  try {
592
- this.pty.kill();
604
+ const exitPromise = new Promise((resolve) => {
605
+ const timeout = setTimeout(resolve, 3_000);
606
+ pty.onExit(() => { clearTimeout(timeout); resolve(); });
607
+ });
608
+ pty.kill();
609
+ await exitPromise;
593
610
  }
594
611
  catch {
595
612
  // Best effort shutdown.
596
613
  }
597
- this.pty = null;
598
- this.state.status = "stopped";
599
- this.state.pid = undefined;
600
614
  }
601
615
  getState() {
602
616
  return JSON.parse(JSON.stringify(this.state));
@@ -285,21 +285,25 @@ export class OpenCodeServerAdapter {
285
285
  if (this.nativeProcess) {
286
286
  const proc = this.nativeProcess;
287
287
  this.nativeProcess = null;
288
- try {
289
- killProcessTreeSync(proc.pid);
290
- }
291
- catch {
292
- // Best effort.
288
+ if (proc.pid != null) {
289
+ try {
290
+ killProcessTreeSync(proc.pid);
291
+ }
292
+ catch {
293
+ // Best effort.
294
+ }
293
295
  }
294
296
  }
295
297
  if (this.serverProcess) {
296
298
  const proc = this.serverProcess;
297
299
  this.serverProcess = null;
298
- try {
299
- killProcessTreeSync(proc.pid);
300
- }
301
- catch {
302
- // Best effort.
300
+ if (proc.pid != null) {
301
+ try {
302
+ killProcessTreeSync(proc.pid);
303
+ }
304
+ catch {
305
+ // Best effort.
306
+ }
303
307
  }
304
308
  }
305
309
  this.client = null;
@@ -328,9 +328,14 @@ export function killProcessTreeSync(pid) {
328
328
  }
329
329
  else {
330
330
  try {
331
- process.kill(pid);
331
+ process.kill(-pid);
332
+ }
333
+ catch {
334
+ try {
335
+ process.kill(pid);
336
+ }
337
+ catch { /* best effort */ }
332
338
  }
333
- catch { /* best effort */ }
334
339
  }
335
340
  }
336
341
  export async function reapPeerBridgeProcesses(params = {}) {
@@ -268,8 +268,8 @@ function formatPromptByteSize(bytes) {
268
268
  }
269
269
  function formatWechatInboundAttachmentPrompt(attachments) {
270
270
  const lines = [
271
- "[WeChat inbound attachments]",
272
- "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.",
271
+ "[WeChat inbound attachments — ACTION REQUIRED]",
272
+ "The user sent the following files through WeChat. You MUST use the Read tool to read each file path below BEFORE responding. Do NOT skip reading these files — the user expects you to see their content (including images).",
273
273
  ];
274
274
  attachments.forEach((attachment, index) => {
275
275
  const sizeText = formatPromptByteSize(attachment.sizeBytes);
@@ -280,6 +280,8 @@ function formatWechatInboundAttachmentPrompt(attachments) {
280
280
  ].filter(Boolean);
281
281
  lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
282
282
  });
283
+ lines.push("");
284
+ lines.push("Remember: Read each path above with the Read tool NOW. Images are viewable via Read.");
283
285
  return lines.join("\n");
284
286
  }
285
287
  export function buildWechatInboundPrompt(text, attachments = []) {
@@ -71,6 +71,11 @@ export function buildClaudeHookSettings(command) {
71
71
  ],
72
72
  Stop: [hook],
73
73
  StopFailure: [hook],
74
+ PreToolUse: [hook],
75
+ PostToolUse: [hook],
76
+ PreCompact: [hook],
77
+ PostCompact: [hook],
78
+ SubagentStop: [hook],
74
79
  },
75
80
  };
76
81
  }
@@ -210,10 +210,16 @@ export function sendLocalCompanionMessage(socket, message) {
210
210
  return false;
211
211
  }
212
212
  }
213
+ const MAX_IPC_BUFFER_SIZE = 1_048_576; // 1MB
213
214
  export function attachLocalCompanionMessageListener(socket, onMessage) {
214
215
  let buffer = "";
215
216
  const onData = (chunk) => {
216
217
  buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
218
+ if (buffer.length > MAX_IPC_BUFFER_SIZE) {
219
+ socket.destroy(new Error("IPC buffer overflow: exceeded 1MB without complete frame"));
220
+ buffer = "";
221
+ return;
222
+ }
217
223
  while (true) {
218
224
  const newlineIndex = buffer.indexOf("\n");
219
225
  if (newlineIndex < 0) {
@@ -80,10 +80,16 @@ export function sendDaemonResponse(socket, id, response) {
80
80
  return false;
81
81
  }
82
82
  }
83
+ const MAX_IPC_BUFFER_SIZE = 1_048_576; // 1MB
83
84
  export function attachDaemonRequestListener(socket, onRequest) {
84
85
  let buffer = "";
85
86
  const onData = (chunk) => {
86
87
  buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
88
+ if (buffer.length > MAX_IPC_BUFFER_SIZE) {
89
+ socket.destroy(new Error("IPC buffer overflow: exceeded 1MB without complete frame"));
90
+ buffer = "";
91
+ return;
92
+ }
87
93
  while (true) {
88
94
  const newlineIndex = buffer.indexOf("\n");
89
95
  if (newlineIndex < 0) {
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs";
2
+ import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
3
+ export const DEFAULT_EMOJI_BINDINGS = {
4
+ "[OK]": "/confirm",
5
+ "[闭嘴]": "/stop",
6
+ "[拥抱]": "/claude",
7
+ "[强]": "/codex",
8
+ "[胜利]": "/opencode",
9
+ "[再见]": "/daemon-stop",
10
+ };
11
+ let bindingsMap = null;
12
+ function buildMap(bindings) {
13
+ const map = new Map();
14
+ for (const [emoji, command] of Object.entries(bindings)) {
15
+ map.set(emoji.toLowerCase(), command);
16
+ }
17
+ return map;
18
+ }
19
+ export function loadEmojiBindings() {
20
+ if (bindingsMap) {
21
+ return bindingsMap;
22
+ }
23
+ try {
24
+ if (fs.existsSync(EMOJI_BINDINGS_FILE)) {
25
+ const raw = JSON.parse(fs.readFileSync(EMOJI_BINDINGS_FILE, "utf-8"));
26
+ if (raw.bindings && typeof raw.bindings === "object") {
27
+ bindingsMap = buildMap(raw.bindings);
28
+ return bindingsMap;
29
+ }
30
+ }
31
+ }
32
+ catch {
33
+ // Fall through to defaults on parse error.
34
+ }
35
+ bindingsMap = buildMap(DEFAULT_EMOJI_BINDINGS);
36
+ return bindingsMap;
37
+ }
38
+ export function saveEmojiBindings(map) {
39
+ const bindings = {};
40
+ for (const [emoji, command] of map) {
41
+ bindings[emoji] = command;
42
+ }
43
+ const data = { bindings };
44
+ fs.writeFile(EMOJI_BINDINGS_FILE, JSON.stringify(data, null, 2) + "\n", () => { });
45
+ }
46
+ export function setBinding(emoji, command) {
47
+ const map = loadEmojiBindings();
48
+ map.set(emoji.toLowerCase(), command);
49
+ bindingsMap = map;
50
+ saveEmojiBindings(map);
51
+ }
52
+ export function removeBinding(emoji) {
53
+ const map = loadEmojiBindings();
54
+ const key = emoji.toLowerCase();
55
+ if (!map.has(key)) {
56
+ return false;
57
+ }
58
+ map.delete(key);
59
+ bindingsMap = map;
60
+ saveEmojiBindings(map);
61
+ return true;
62
+ }
63
+ export function listBindings() {
64
+ return loadEmojiBindings();
65
+ }
66
+ export function resolveEmojiCommand(text) {
67
+ const trimmed = text.trim();
68
+ if (!trimmed.startsWith("[")) {
69
+ return null;
70
+ }
71
+ const map = loadEmojiBindings();
72
+ const lower = trimmed.toLowerCase();
73
+ for (const [emoji, command] of map) {
74
+ if (lower.startsWith(emoji)) {
75
+ const remainder = trimmed.slice(emoji.length).trim();
76
+ return { command, remainder };
77
+ }
78
+ }
79
+ return null;
80
+ }