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.
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  const ANSI_ESCAPE_RE =
@@ -122,6 +123,34 @@ export function truncatePreview(text, maxLength = 140) {
122
123
  }
123
124
  return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
124
125
  }
126
+ export function isThinkingForwardEnabled() {
127
+ if (process.env.CLI_BRIDGE_THINKING_FORWARD === "1") {
128
+ return true;
129
+ }
130
+ try {
131
+ const accountPath = process.env.CLI_BRIDGE_DATA_DIR
132
+ ? path.join(process.env.CLI_BRIDGE_DATA_DIR, "account.json")
133
+ : path.join(os.homedir(), ".cli-bridge", "account.json");
134
+ if (!fs.existsSync(accountPath))
135
+ return false;
136
+ const raw = fs.readFileSync(accountPath, "utf8");
137
+ const data = JSON.parse(raw);
138
+ return data.enableThinkingForward === true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ export function formatThinkingForWechat(text, maxLength = 500) {
145
+ const normalized = normalizeOutput(text).trim();
146
+ if (!normalized) {
147
+ return "";
148
+ }
149
+ if (normalized.length <= maxLength) {
150
+ return normalized;
151
+ }
152
+ return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
153
+ }
125
154
  export function buildOneTimeCode(length = 6) {
126
155
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
127
156
  let code = "";
@@ -157,8 +186,10 @@ export function parseSystemCommand(text) {
157
186
  case "/reset":
158
187
  return { type: "reset" };
159
188
  case "/confirm":
160
- return argument ? { type: "confirm", code: argument } : null;
189
+ case "/yes":
190
+ return { type: "confirm" };
161
191
  case "/deny":
192
+ case "/no":
162
193
  return { type: "deny" };
163
194
  case "/answer":
164
195
  return argument ? { type: "answer", raw: argument } : null;
@@ -171,19 +202,13 @@ export function parseWechatControlCommand(text, options) {
171
202
  if (systemCommand) {
172
203
  return systemCommand;
173
204
  }
174
- if (options.adapter !== "claude") {
205
+ if (!options.hasPendingConfirmation) {
175
206
  return null;
176
207
  }
177
208
  const normalized = text.trim().toLowerCase();
178
209
  if (!normalized) {
179
210
  return null;
180
211
  }
181
- if (normalized === "/confirm") {
182
- return { type: "confirm" };
183
- }
184
- if (!options.hasPendingConfirmation) {
185
- return null;
186
- }
187
212
  switch (normalized) {
188
213
  case "confirm":
189
214
  case "yes":
@@ -268,8 +293,8 @@ function formatPromptByteSize(bytes) {
268
293
  }
269
294
  function formatWechatInboundAttachmentPrompt(attachments) {
270
295
  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.",
296
+ "[WeChat inbound attachments — ACTION REQUIRED]",
297
+ "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
298
  ];
274
299
  attachments.forEach((attachment, index) => {
275
300
  const sizeText = formatPromptByteSize(attachment.sizeBytes);
@@ -280,6 +305,8 @@ function formatWechatInboundAttachmentPrompt(attachments) {
280
305
  ].filter(Boolean);
281
306
  lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
282
307
  });
308
+ lines.push("");
309
+ lines.push("Remember: Read each path above with the Read tool NOW. Images are viewable via Read.");
283
310
  return lines.join("\n");
284
311
  }
285
312
  export function buildWechatInboundPrompt(text, attachments = []) {
@@ -383,7 +410,7 @@ const HIGH_RISK_PATTERNS = [
383
410
  /\brmdir\b/i,
384
411
  /\bdel\b/i,
385
412
  /\berase\b/i,
386
- /\bformat\b/i,
413
+ /\bformat\b(?!-)/i,
387
414
  /\bshutdown\b/i,
388
415
  /\bstop-computer\b/i,
389
416
  /\brestart-computer\b/i,
@@ -995,10 +1022,9 @@ export function formatApprovalMessage(pending, adapterState) {
995
1022
  return [
996
1023
  `${pending.source === "shell" ? "Shell" : "CLI"} approval is required.`,
997
1024
  `adapter: ${adapterState.kind}`,
998
- `code: ${pending.code}`,
999
1025
  `summary: ${pending.summary}`,
1000
1026
  `target: ${pending.commandPreview}`,
1001
- "Reply with /confirm <code> to continue or /deny to reject.",
1027
+ "Reply with /confirm or /yes to continue, /deny or /no to reject.",
1002
1028
  ].join("\n");
1003
1029
  }
1004
1030
  export function formatPendingApprovalReminder(pending, adapterState) {
@@ -1008,7 +1034,7 @@ export function formatPendingApprovalReminder(pending, adapterState) {
1008
1034
  : pending.commandPreview;
1009
1035
  return `Approval is pending for ${truncatePreview(target, 140)}. Reply with /confirm, confirm, or yes to continue, or /deny, deny, or no to reject.`;
1010
1036
  }
1011
- return `Approval is pending for ${pending.commandPreview}. Reply with /confirm ${pending.code} or /deny.`;
1037
+ return `Approval is pending for ${pending.commandPreview}. Reply with /confirm or /deny.`;
1012
1038
  }
1013
1039
  function formatUserInputQuestionLabel(question, index) {
1014
1040
  return `${index + 1}. ${question.header} [id: ${question.id}]`;
@@ -6,14 +6,6 @@ const FILE_MUTATION_TOOL_NAMES = new Set([
6
6
  "MultiEdit",
7
7
  "NotebookEdit",
8
8
  ]);
9
- const LOW_RISK_READ_TOOL_NAMES = new Set([
10
- "Glob",
11
- "Grep",
12
- "LS",
13
- "NotebookRead",
14
- "Read",
15
- "TodoRead",
16
- ]);
17
9
  function quoteWindowsCommandArg(value) {
18
10
  return `"${value.replace(/"/g, '""')}"`;
19
11
  }
@@ -71,6 +63,11 @@ export function buildClaudeHookSettings(command) {
71
63
  ],
72
64
  Stop: [hook],
73
65
  StopFailure: [hook],
66
+ PreToolUse: [hook],
67
+ PostToolUse: [hook],
68
+ PreCompact: [hook],
69
+ PostCompact: [hook],
70
+ SubagentStop: [hook],
74
71
  },
75
72
  };
76
73
  }
@@ -85,13 +82,15 @@ export function buildClaudeHookScript(params) {
85
82
  params.runtimeExecPath,
86
83
  ...runtimeArgs,
87
84
  ].map(quoteWindowsCommandArg).join(" ");
85
+ const stderrRedirect = params.hookErrorLogPath
86
+ ? `2>>${quoteWindowsCommandArg(params.hookErrorLogPath)}`
87
+ : "2>nul";
88
88
  return [
89
89
  "@echo off",
90
90
  "setlocal",
91
91
  `set "CLAUDE_WECHAT_HOOK_PORT=${params.hookPort}"`,
92
92
  `set "CLAUDE_WECHAT_HOOK_TOKEN=${params.hookToken}"`,
93
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
94
- `${command} 2>nul`,
93
+ `${command} ${stderrRedirect}`,
95
94
  "exit /b 0",
96
95
  ].join("\r\n");
97
96
  }
@@ -99,12 +98,14 @@ export function buildClaudeHookScript(params) {
99
98
  params.runtimeExecPath,
100
99
  ...runtimeArgs,
101
100
  ].map(quotePosixCommandArg).join(" ");
101
+ const stderrRedirect = params.hookErrorLogPath
102
+ ? `2>>${quotePosixCommandArg(params.hookErrorLogPath)}`
103
+ : "2>/dev/null";
102
104
  return [
103
105
  "#!/bin/sh",
104
106
  `export CLAUDE_WECHAT_HOOK_PORT=${quotePosixCommandArg(String(params.hookPort))}`,
105
107
  `export CLAUDE_WECHAT_HOOK_TOKEN=${quotePosixCommandArg(params.hookToken)}`,
106
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
107
- `${command} 2>/dev/null || true`,
108
+ `${command} ${stderrRedirect} || true`,
108
109
  "exit 0",
109
110
  ].join("\n");
110
111
  }
@@ -209,15 +210,12 @@ export function getClaudePermissionAutoResponse(payload) {
209
210
  if (!toolName) {
210
211
  return null;
211
212
  }
212
- if (LOW_RISK_READ_TOOL_NAMES.has(toolName)) {
213
+ if (toolName !== "Bash") {
213
214
  return {
214
215
  action: "confirm",
215
216
  reason: `low-risk ${toolName} permission`,
216
217
  };
217
218
  }
218
- if (toolName !== "Bash") {
219
- return null;
220
- }
221
219
  const command = getClaudeToolInputString(payload, "command").trim();
222
220
  if (!command || isHighRiskShellCommand(command)) {
223
221
  return null;
@@ -2,16 +2,18 @@
2
2
  import path from "node:path";
3
3
  import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
4
4
  import { delay } from "./bridge-adapters.shared.js";
5
+ import { t } from "../i18n/index.js";
5
6
  import { BridgeController } from "./bridge-controller.js";
6
7
  import { forwardWechatFinalReply } from "./bridge-final-reply.js";
7
8
  import { ensureWechatCredentials } from "../wechat/setup.js";
8
9
  import { BridgeStateStore } from "./bridge-state.js";
9
10
  import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
10
11
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
11
- import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
+ import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
13
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
13
14
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
14
15
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
16
+ import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "../daemon/emoji-bindings.js";
15
17
  const POLL_RETRY_BASE_MS = 1_000;
16
18
  const POLL_RETRY_MAX_MS = 30_000;
17
19
  const PARENT_PROCESS_POLL_MS = 5_000;
@@ -608,6 +610,13 @@ async function main() {
608
610
  else if (options.adapter === "shell") {
609
611
  log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
610
612
  }
613
+ loadEmojiBindings();
614
+ const welcomeText = t("bridge.welcome", {
615
+ adapter: options.adapter,
616
+ cwd: options.cwd,
617
+ bindings: formatBindingsListMessage(listBindings()),
618
+ });
619
+ await queueWechatMessage(credentials.userId, welcomeText);
611
620
  while (true) {
612
621
  if (!ensureRuntimeOwnership()) {
613
622
  break;
@@ -792,6 +801,18 @@ function wireAdapterEvents(params) {
792
801
  }));
793
802
  }
794
803
  break;
804
+ case "thinking":
805
+ updateLastOutputAt();
806
+ if (event.text) {
807
+ const thinkingPreview = formatThinkingForWechat(event.text, 500);
808
+ if (thinkingPreview) {
809
+ stateStore.appendLog(`thinking: ${thinkingPreview}`);
810
+ trackWechatForwardTask((async () => {
811
+ await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
812
+ })());
813
+ }
814
+ }
815
+ break;
795
816
  case "approval_required":
796
817
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
797
818
  const pending = toPendingApproval(event.request);
@@ -911,17 +932,49 @@ function formatInboundMessagePreview(message) {
911
932
  return "(empty)";
912
933
  }
913
934
  async function handleInboundMessage(params) {
914
- const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
935
+ let { message, } = params;
936
+ const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
915
937
  const state = stateStore.getState();
938
+ if (message.senderId !== state.authorizedUserId) {
939
+ await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
940
+ return null;
941
+ }
942
+ // Emoji resolution: rewrite message text if it starts with a bound emoji
943
+ const emojiMatch = resolveEmojiCommand(message.text);
944
+ if (emojiMatch) {
945
+ const rewritten = emojiMatch.remainder
946
+ ? `${emojiMatch.command} ${emojiMatch.remainder}`
947
+ : emojiMatch.command;
948
+ message = { ...message, text: rewritten };
949
+ }
950
+ // Emoji binding management commands
951
+ const bindingsCmd = parseEmojiBindingsCommand(message.text);
952
+ if (bindingsCmd) {
953
+ switch (bindingsCmd.type) {
954
+ case "list":
955
+ await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
956
+ break;
957
+ case "bind":
958
+ setBinding(bindingsCmd.emoji, bindingsCmd.command);
959
+ await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
960
+ break;
961
+ case "unbind": {
962
+ const removed = removeBinding(bindingsCmd.emoji);
963
+ await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
964
+ break;
965
+ }
966
+ }
967
+ return null;
968
+ }
969
+ if (isBindCommandPrefix(message.text)) {
970
+ await queueWechatMessage(message.senderId, formatBindCommandUsage());
971
+ return null;
972
+ }
916
973
  const systemCommand = parseWechatControlCommand(message.text, {
917
974
  adapter: options.adapter,
918
975
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
919
976
  hasPendingUserInput: Boolean(state.pendingUserInput),
920
977
  });
921
- if (message.senderId !== state.authorizedUserId) {
922
- await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
923
- return null;
924
- }
925
978
  switch (systemCommand?.type) {
926
979
  case "status":
927
980
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
@@ -979,10 +1032,6 @@ async function handleInboundMessage(params) {
979
1032
  await queueWechatMessage(message.senderId, "No pending approval request.");
980
1033
  return null;
981
1034
  }
982
- if (options.adapter !== "claude" && pending.code !== systemCommand.code) {
983
- await queueWechatMessage(message.senderId, "Confirmation code did not match.");
984
- return null;
985
- }
986
1035
  const confirmed = await adapter.resolveApproval("confirm");
987
1036
  if (!confirmed) {
988
1037
  await queueWechatMessage(message.senderId, "The worker could not apply this approval request.");
@@ -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) {
@@ -127,6 +127,7 @@ export async function runLocalCompanion(options) {
127
127
  }
128
128
  socket.removeAllListeners("close");
129
129
  socket.removeAllListeners("error");
130
+ socket.on("error", () => { });
130
131
  if (destroy) {
131
132
  try {
132
133
  socket.destroy();
@@ -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,111 @@
1
+ import fs from "node:fs";
2
+ import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
3
+ import { t } from "../i18n/index.js";
4
+ export const DEFAULT_EMOJI_BINDINGS = {
5
+ "[OK]": "/confirm",
6
+ "[闭嘴]": "/stop",
7
+ "[拥抱]": "/claude",
8
+ "[强]": "/codex",
9
+ "[胜利]": "/opencode",
10
+ "[再见]": "/daemon-stop",
11
+ };
12
+ let bindingsMap = null;
13
+ function buildMap(bindings) {
14
+ const map = new Map();
15
+ for (const [emoji, command] of Object.entries(bindings)) {
16
+ map.set(emoji.toLowerCase(), command);
17
+ }
18
+ return map;
19
+ }
20
+ export function loadEmojiBindings() {
21
+ if (bindingsMap) {
22
+ return bindingsMap;
23
+ }
24
+ try {
25
+ if (fs.existsSync(EMOJI_BINDINGS_FILE)) {
26
+ const raw = JSON.parse(fs.readFileSync(EMOJI_BINDINGS_FILE, "utf-8"));
27
+ if (raw.bindings && typeof raw.bindings === "object") {
28
+ bindingsMap = buildMap(raw.bindings);
29
+ return bindingsMap;
30
+ }
31
+ }
32
+ }
33
+ catch {
34
+ // Fall through to defaults on parse error.
35
+ }
36
+ bindingsMap = buildMap(DEFAULT_EMOJI_BINDINGS);
37
+ return bindingsMap;
38
+ }
39
+ export function saveEmojiBindings(map) {
40
+ const bindings = {};
41
+ for (const [emoji, command] of map) {
42
+ bindings[emoji] = command;
43
+ }
44
+ const data = { bindings };
45
+ fs.writeFile(EMOJI_BINDINGS_FILE, JSON.stringify(data, null, 2) + "\n", () => { });
46
+ }
47
+ export function setBinding(emoji, command) {
48
+ const map = loadEmojiBindings();
49
+ map.set(emoji.toLowerCase(), command);
50
+ bindingsMap = map;
51
+ saveEmojiBindings(map);
52
+ }
53
+ export function removeBinding(emoji) {
54
+ const map = loadEmojiBindings();
55
+ const key = emoji.toLowerCase();
56
+ if (!map.has(key)) {
57
+ return false;
58
+ }
59
+ map.delete(key);
60
+ bindingsMap = map;
61
+ saveEmojiBindings(map);
62
+ return true;
63
+ }
64
+ export function listBindings() {
65
+ return loadEmojiBindings();
66
+ }
67
+ export function resolveEmojiCommand(text) {
68
+ const trimmed = text.trim();
69
+ if (!trimmed.startsWith("[")) {
70
+ return null;
71
+ }
72
+ const map = loadEmojiBindings();
73
+ const lower = trimmed.toLowerCase();
74
+ for (const [emoji, command] of map) {
75
+ if (lower.startsWith(emoji)) {
76
+ const remainder = trimmed.slice(emoji.length).trim();
77
+ return { command, remainder };
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+ export function parseEmojiBindingsCommand(text) {
83
+ const trimmed = text.trim();
84
+ const lower = trimmed.toLowerCase();
85
+ if (lower === "/bindings") {
86
+ return { type: "list" };
87
+ }
88
+ const bindMatch = trimmed.match(/^\/bind\s+(\[[^\]]+\])\s+(.+)$/i);
89
+ if (bindMatch) {
90
+ return { type: "bind", emoji: bindMatch[1], command: bindMatch[2].trim() };
91
+ }
92
+ const unbindMatch = trimmed.match(/^\/unbind\s+(\[[^\]]+\])$/i);
93
+ if (unbindMatch) {
94
+ return { type: "unbind", emoji: unbindMatch[1] };
95
+ }
96
+ return null;
97
+ }
98
+ export function isBindCommandPrefix(text) {
99
+ const lower = text.trim().toLowerCase();
100
+ return lower.startsWith("/bind") || lower.startsWith("/unbind");
101
+ }
102
+ export function formatBindCommandUsage() {
103
+ return t("binding.usage");
104
+ }
105
+ export function formatBindingsListMessage(map) {
106
+ if (map.size === 0) {
107
+ return t("binding.listEmpty");
108
+ }
109
+ const lines = Array.from(map.entries()).map(([emoji, command]) => `${emoji} → ${command}`);
110
+ return `${t("binding.listHeader")}\n${lines.join("\n")}`;
111
+ }