cli-wechat-bridge 1.0.7 → 1.1.0

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">
@@ -3,9 +3,10 @@ import net from "node:net";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { buildLocalCompanionToken } from "../companion/local-companion-link.js";
6
+ import { t } from "../i18n/index.js";
6
7
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
7
8
  import { buildClaudeFailureMessage, buildClaudeHookScript, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeAssistantMessageText, extractClaudeResumeConversationId, extractClaudeTranscriptFinalReply, findInjectedClaudePromptIndex, getClaudePermissionAutoResponse, getClaudeWechatOutboundAttachmentDenyMessage, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
8
- import { detectCliApproval, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
9
+ import { detectCliApproval, isThinkingForwardEnabled, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
9
10
  import { AbstractPtyAdapter } from "./bridge-adapters.core.js";
10
11
  import * as shared from "./bridge-adapters.shared.js";
11
12
  const { CLAUDE_HOOK_LISTEN_HOST, CLAUDE_WECHAT_WORKING_NOTICE_DELAY_MS, DEFAULT_COLS, DEFAULT_ROWS, MODULE_DIR, buildClaudeCliArgs, delay, isClaudeInvalidResumeError, quotePosixCommandArg, quoteWindowsCommandArg, shouldIncludeClaudeNoAltScreen, } = shared;
@@ -90,6 +91,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
90
91
  localTerminalInputListener = null;
91
92
  resizeListener = null;
92
93
  settingsFilePath = null;
94
+ hookErrorLogPath = null;
95
+ hookReceivedCount = 0;
96
+ hookHealthCheckTimer = null;
93
97
  pendingHookApprovals = new Map();
94
98
  recoveringInvalidResume = false;
95
99
  workingNoticeTimer = null;
@@ -98,6 +102,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
98
102
  lastCompactCompletionAtMs = 0;
99
103
  startupOutputBuffer = "";
100
104
  hasAutoConfirmedWorkspaceTrustPrompt = false;
105
+ lastAutoApprovedPayload = null;
106
+ transcriptPollTimer = null;
107
+ transcriptTailOffset = 0;
108
+ polledTranscriptSessionId = null;
101
109
  constructor(options) {
102
110
  super(options);
103
111
  const shouldRestoreInitialSession = options.sessionStartMode !== "new";
@@ -163,7 +171,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
163
171
  throw new Error("claude is still working. Wait for the current reply or use /stop.");
164
172
  }
165
173
  if (this.pendingApproval) {
166
- throw new Error("A Claude approval request is pending. Reply with /confirm <code> or /deny.");
174
+ throw new Error("A Claude approval request is pending. Reply with /confirm or /deny.");
167
175
  }
168
176
  const normalizedText = normalizeOutput(text).trim();
169
177
  this.pendingInjectedInputs.push({
@@ -178,6 +186,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
178
186
  this.pendingCliApprovalHints = null;
179
187
  this.clearWechatWorkingNotice(true);
180
188
  this.setStatus("busy");
189
+ this.startTranscriptThinkingWatch();
181
190
  this.writeToPty(this.buildRemoteInputPayload(text));
182
191
  await delay(CLAUDE_REMOTE_ENTER_DELAY_MS);
183
192
  this.writeToPty("\r");
@@ -200,6 +209,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
200
209
  this.pendingCliApprovalHints = null;
201
210
  this.flushPendingClaudeHookApprovals();
202
211
  this.writeToPty("\u0003");
212
+ this.scheduleTaskComplete(shared.INTERRUPT_SETTLE_DELAY_MS);
203
213
  return true;
204
214
  }
205
215
  async reset() {
@@ -247,11 +257,34 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
247
257
  this.writeToPty(input);
248
258
  return true;
249
259
  }
260
+ async resolveAllApprovals(action) {
261
+ let count = 0;
262
+ for (const requestId of Array.from(this.pendingHookApprovals.keys())) {
263
+ const pending = this.pendingHookApprovals.get(requestId);
264
+ if (pending) {
265
+ this.pendingHookApprovals.delete(requestId);
266
+ this.respondToClaudeHook(pending.socket, requestId, buildClaudePermissionDecisionHookOutput(action));
267
+ count++;
268
+ }
269
+ }
270
+ if (count > 0) {
271
+ this.clearWechatWorkingNotice();
272
+ this.pendingCliApprovalHints = null;
273
+ this.pendingApproval = null;
274
+ this.state.pendingApproval = null;
275
+ this.state.pendingApprovalOrigin = undefined;
276
+ this.setStatus("busy");
277
+ return count;
278
+ }
279
+ const ok = await this.resolveApproval(action);
280
+ return ok ? 1 : 0;
281
+ }
250
282
  async dispose() {
251
283
  this.detachLocalTerminal();
252
284
  this.clearWechatWorkingNotice(true);
253
285
  this.pendingCliApprovalHints = null;
254
286
  this.flushPendingClaudeHookApprovals();
287
+ this.stopTranscriptThinkingWatch();
255
288
  await super.dispose();
256
289
  await this.stopHookServer();
257
290
  }
@@ -270,6 +303,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
270
303
  afterStart() {
271
304
  this.attachLocalTerminal();
272
305
  this.resizePtyToTerminal();
306
+ this.startHookHealthCheck();
273
307
  }
274
308
  handleData(rawText) {
275
309
  this.renderLocalOutput(rawText);
@@ -446,12 +480,15 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
446
480
  const hookEntryPath = fs.existsSync(sourceHookEntryPath)
447
481
  ? sourceHookEntryPath
448
482
  : path.join(MODULE_DIR, "claude-hook.js");
483
+ const hookErrorLogPath = path.join(runtimeDir, "hook-error.log");
484
+ this.hookErrorLogPath = hookErrorLogPath;
449
485
  fs.writeFileSync(hookScriptPath, buildClaudeHookScript({
450
486
  platform: process.platform,
451
487
  runtimeExecPath: process.execPath,
452
488
  hookEntryPath,
453
489
  hookPort: this.hookPort,
454
490
  hookToken: this.hookToken,
491
+ hookErrorLogPath,
455
492
  }), "utf8");
456
493
  if (process.platform !== "win32") {
457
494
  fs.chmodSync(hookScriptPath, 0o755);
@@ -500,7 +537,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
500
537
  return;
501
538
  }
502
539
  try {
503
- this.pty.resize(process.stdout.columns || DEFAULT_COLS, process.stdout.rows || DEFAULT_ROWS);
540
+ this.pty.resize?.(process.stdout.columns || DEFAULT_COLS, process.stdout.rows || DEFAULT_ROWS);
504
541
  }
505
542
  catch {
506
543
  // Best effort resize sync.
@@ -546,6 +583,34 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
546
583
  this.workingNoticeSent = false;
547
584
  }
548
585
  }
586
+ startHookHealthCheck() {
587
+ this.hookReceivedCount = 0;
588
+ this.clearHookHealthCheck();
589
+ this.hookHealthCheckTimer = setTimeout(() => {
590
+ this.hookHealthCheckTimer = null;
591
+ if (this.hookReceivedCount === 0 && !this.shuttingDown) {
592
+ const logHint = this.hookErrorLogPath
593
+ ? t("hook.healthCheck.logHint", { logPath: this.hookErrorLogPath })
594
+ : "";
595
+ this.emit({
596
+ type: "stdout",
597
+ text: [
598
+ t("hook.healthCheck.warning"),
599
+ logHint,
600
+ t("hook.healthCheck.fixes"),
601
+ ].filter(Boolean).join("\n"),
602
+ timestamp: nowIso(),
603
+ });
604
+ }
605
+ }, 15_000);
606
+ this.hookHealthCheckTimer.unref?.();
607
+ }
608
+ clearHookHealthCheck() {
609
+ if (this.hookHealthCheckTimer) {
610
+ clearTimeout(this.hookHealthCheckTimer);
611
+ this.hookHealthCheckTimer = null;
612
+ }
613
+ }
549
614
  emitClaudeNotice(text, level = "info") {
550
615
  const normalized = normalizeOutput(text).trim();
551
616
  if (!normalized) {
@@ -566,6 +631,9 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
566
631
  }
567
632
  return `${CLAUDE_BRACKETED_PASTE_START}${normalizedText}${CLAUDE_BRACKETED_PASTE_END}`;
568
633
  }
634
+ // Fallback heuristic for older Claude Code versions that lack PostCompact hooks.
635
+ // The structured PostCompact hook event (handled in handleClaudeHookEnvelope) is
636
+ // the reliable signal; this regex match serves as a best-effort fallback.
569
637
  shouldTreatClaudeOutputAsCompactCompletion(text) {
570
638
  if (this.state.status !== "busy" &&
571
639
  this.state.status !== "awaiting_approval" &&
@@ -664,6 +732,8 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
664
732
  this.currentPreview = "(idle)";
665
733
  }
666
734
  handleClaudeHookEnvelope(params) {
735
+ this.hookReceivedCount++;
736
+ this.clearHookHealthCheck();
667
737
  const payload = parseClaudeHookPayload(params.rawPayload);
668
738
  if (!payload?.hook_event_name) {
669
739
  this.respondToClaudeHook(params.socket, params.requestId);
@@ -682,8 +752,32 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
682
752
  this.handleClaudePermissionRequest(params.requestId, payload, params.socket);
683
753
  return;
684
754
  case "Notification":
685
- if (payload.notification_type === "permission_prompt" && this.pendingApproval) {
686
- this.setStatus("awaiting_approval", "Claude approval is required.");
755
+ if (payload.notification_type === "permission_prompt") {
756
+ if (this.pendingApproval) {
757
+ this.setStatus("awaiting_approval", "Claude approval is required.");
758
+ }
759
+ else if (this.lastAutoApprovedPayload) {
760
+ // Auto-approval response failed to reach Claude Code (socket closed before delivery).
761
+ // Fall back to forwarding the approval request to WeChat.
762
+ const fallbackPayload = this.lastAutoApprovedPayload;
763
+ this.lastAutoApprovedPayload = null;
764
+ const request = buildClaudePermissionApprovalRequest(fallbackPayload);
765
+ this.pendingApproval = {
766
+ ...request,
767
+ requestId: undefined,
768
+ confirmInput: this.pendingCliApprovalHints?.confirmInput,
769
+ denyInput: this.pendingCliApprovalHints?.denyInput,
770
+ };
771
+ this.pendingCliApprovalHints = null;
772
+ this.state.pendingApproval = this.pendingApproval;
773
+ this.state.pendingApprovalOrigin = this.state.activeTurnOrigin;
774
+ this.setStatus("awaiting_approval", "Claude auto-approval failed, forwarding to WeChat.");
775
+ this.emit({
776
+ type: "approval_required",
777
+ request: this.pendingApproval,
778
+ timestamp: nowIso(),
779
+ });
780
+ }
687
781
  }
688
782
  this.respondToClaudeHook(params.socket, params.requestId);
689
783
  return;
@@ -695,6 +789,10 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
695
789
  this.handleClaudeStopFailure(payload);
696
790
  this.respondToClaudeHook(params.socket, params.requestId);
697
791
  return;
792
+ case "PostCompact":
793
+ this.completeClaudeCompact();
794
+ this.respondToClaudeHook(params.socket, params.requestId);
795
+ return;
698
796
  default:
699
797
  this.respondToClaudeHook(params.socket, params.requestId);
700
798
  return;
@@ -732,6 +830,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
732
830
  this.state.resumeConversationId = nextResumeConversationId ?? undefined;
733
831
  this.transcriptPath = nextTranscriptPath;
734
832
  this.state.transcriptPath = nextTranscriptPath ?? undefined;
833
+ this.startTranscriptThinkingWatch();
735
834
  if (previousRuntimeSessionId === payload.session_id) {
736
835
  return;
737
836
  }
@@ -770,6 +869,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
770
869
  this.pendingCliApprovalHints = null;
771
870
  this.clearWechatWorkingNotice(true);
772
871
  this.setStatus("busy");
872
+ this.startTranscriptThinkingWatch();
773
873
  this.emit({
774
874
  type: "mirrored_user_input",
775
875
  text: prompt,
@@ -809,6 +909,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
809
909
  }
810
910
  handleClaudePermissionRequest(requestId, payload, socket) {
811
911
  this.clearWechatWorkingNotice();
912
+ this.lastAutoApprovedPayload = null;
812
913
  const denyMessage = getClaudeWechatOutboundAttachmentDenyMessage(payload);
813
914
  if (denyMessage) {
814
915
  this.pendingApproval = null;
@@ -825,6 +926,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
825
926
  if (!this.pendingApproval && !this.state.pendingApproval) {
826
927
  this.setStatus("busy", `Claude approval auto-approved: ${truncatePreview(autoResponse.reason, 180)}`);
827
928
  }
929
+ this.lastAutoApprovedPayload = payload;
828
930
  this.respondToClaudeHook(socket, requestId, buildClaudePermissionDecisionHookOutput(autoResponse.action));
829
931
  return;
830
932
  }
@@ -890,6 +992,7 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
890
992
  handleClaudeStop(payload) {
891
993
  this.clearWechatWorkingNotice(true);
892
994
  this.pendingCliApprovalHints = null;
995
+ this.lastAutoApprovedPayload = null;
893
996
  this.flushPendingClaudeHookApprovals();
894
997
  this.pendingApproval = null;
895
998
  this.state.pendingApproval = null;
@@ -907,11 +1010,97 @@ export class ClaudeCompanionAdapter extends AbstractPtyAdapter {
907
1010
  summary: this.currentPreview,
908
1011
  timestamp: nowIso(),
909
1012
  });
1013
+ this.stopTranscriptThinkingWatch();
910
1014
  this.currentPreview = "(idle)";
911
1015
  }
912
1016
  handleClaudeStopFailure(payload) {
1017
+ this.lastAutoApprovedPayload = null;
1018
+ this.stopTranscriptThinkingWatch();
913
1019
  this.failClaudeTurn(buildClaudeFailureMessage(payload));
914
1020
  }
1021
+ startTranscriptThinkingWatch() {
1022
+ this.stopTranscriptThinkingWatch();
1023
+ if (!this.transcriptPath) {
1024
+ return;
1025
+ }
1026
+ if (!isThinkingForwardEnabled()) {
1027
+ return;
1028
+ }
1029
+ this.transcriptTailOffset = 0;
1030
+ try {
1031
+ const stat = fs.statSync(this.transcriptPath);
1032
+ this.transcriptTailOffset = stat.size;
1033
+ }
1034
+ catch {
1035
+ return;
1036
+ }
1037
+ const watchPath = this.transcriptPath;
1038
+ const sessionId = this.runtimeSessionId;
1039
+ this.polledTranscriptSessionId = sessionId;
1040
+ this.transcriptPollTimer = setInterval(() => {
1041
+ if (this.shuttingDown) {
1042
+ this.stopTranscriptThinkingWatch();
1043
+ return;
1044
+ }
1045
+ if (this.polledTranscriptSessionId !== this.runtimeSessionId) {
1046
+ this.stopTranscriptThinkingWatch();
1047
+ return;
1048
+ }
1049
+ try {
1050
+ const stat = fs.statSync(watchPath);
1051
+ if (stat.size <= this.transcriptTailOffset) {
1052
+ return;
1053
+ }
1054
+ const fd = fs.openSync(watchPath, "r");
1055
+ const buf = Buffer.alloc(stat.size - this.transcriptTailOffset);
1056
+ fs.readSync(fd, buf, 0, buf.length, this.transcriptTailOffset);
1057
+ fs.closeSync(fd);
1058
+ this.transcriptTailOffset = stat.size;
1059
+ const newText = buf.toString("utf8");
1060
+ const lines = newText.split(/\r?\n/).filter(Boolean);
1061
+ for (const line of lines) {
1062
+ let parsed;
1063
+ try {
1064
+ parsed = JSON.parse(line);
1065
+ }
1066
+ catch {
1067
+ continue;
1068
+ }
1069
+ if (!parsed ||
1070
+ parsed.type !== "assistant" ||
1071
+ !Array.isArray(parsed.message?.content)) {
1072
+ continue;
1073
+ }
1074
+ for (const block of parsed.message.content) {
1075
+ if (block.type === "thinking" &&
1076
+ typeof block.thinking === "string" &&
1077
+ block.thinking.trim()) {
1078
+ const thinking = normalizeOutput(block.thinking).trim();
1079
+ if (thinking) {
1080
+ this.emit({
1081
+ type: "thinking",
1082
+ text: thinking,
1083
+ timestamp: nowIso(),
1084
+ });
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ }
1090
+ catch {
1091
+ // File may be temporarily locked or deleted; silently retry next poll.
1092
+ }
1093
+ }, 800);
1094
+ this.transcriptPollTimer.unref?.();
1095
+ }
1096
+ stopTranscriptThinkingWatch() {
1097
+ if (this.transcriptPollTimer) {
1098
+ clearInterval(this.transcriptPollTimer);
1099
+ this.transcriptPollTimer = null;
1100
+ }
1101
+ this.transcriptTailOffset = 0;
1102
+ this.polledTranscriptSessionId = null;
1103
+ }
915
1104
  respondToClaudeHook(socket, requestId, stdout) {
916
1105
  try {
917
1106
  socket.end(`${JSON.stringify({ requestId, stdout })}\n`);