@unlinearity/cli-wechat-bridge 1.0.9 → 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.
@@ -2,7 +2,8 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import net from "node:net";
4
4
  import { fileURLToPath } from "node:url";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { t } from "../i18n/index.js";
6
7
  import { spawn as spawnPty } from "node-pty";
7
8
  import { WECHAT_OUTBOUND_ATTACHMENT_DENY_MESSAGE, containsWechatOutboundAttachmentPath, containsWechatOutboundAttachmentPathDeep, isWechatOutboundAttachmentWriteCommand, isHighRiskShellCommand, normalizeOutput, truncatePreview, } from "./bridge-utils.js";
8
9
  import { getNotificationThreadId, isRecord, } from "./bridge-adapter-common.js";
@@ -1214,3 +1215,102 @@ export function resolveSpawnTarget(command, kind, options = {}) {
1214
1215
  }
1215
1216
  return { file: resolved, args: [...forwardArgs] };
1216
1217
  }
1218
+ export function spawnFallbackProcess(file, args, options) {
1219
+ const child = spawn(file, args, {
1220
+ cwd: options.cwd,
1221
+ env: { ...options.env, TERM: "dumb" },
1222
+ stdio: ["pipe", "pipe", "pipe"],
1223
+ windowsHide: false,
1224
+ shell: false,
1225
+ });
1226
+ if (!child.pid) {
1227
+ throw new Error(`Failed to spawn fallback process: ${file}`);
1228
+ }
1229
+ const dataListeners = [];
1230
+ const exitListeners = [];
1231
+ child.stdout?.on("data", (chunk) => {
1232
+ const text = chunk.toString("utf8");
1233
+ for (const listener of dataListeners) {
1234
+ listener(text);
1235
+ }
1236
+ });
1237
+ child.stderr?.on("data", (chunk) => {
1238
+ const text = chunk.toString("utf8");
1239
+ for (const listener of dataListeners) {
1240
+ listener(text);
1241
+ }
1242
+ });
1243
+ child.on("exit", (code, signal) => {
1244
+ const exitCode = code ?? (signal ? 128 : 1);
1245
+ for (const listener of exitListeners) {
1246
+ listener({ exitCode });
1247
+ }
1248
+ });
1249
+ return {
1250
+ pid: child.pid,
1251
+ write(data) {
1252
+ child.stdin?.write(data);
1253
+ },
1254
+ kill(signal) {
1255
+ try {
1256
+ if (process.platform === "win32") {
1257
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
1258
+ stdio: "ignore",
1259
+ windowsHide: true,
1260
+ });
1261
+ }
1262
+ else {
1263
+ child.kill(signal);
1264
+ }
1265
+ }
1266
+ catch {
1267
+ // Best effort.
1268
+ }
1269
+ },
1270
+ onData(callback) {
1271
+ dataListeners.push(callback);
1272
+ return {
1273
+ dispose() {
1274
+ const index = dataListeners.indexOf(callback);
1275
+ if (index >= 0)
1276
+ dataListeners.splice(index, 1);
1277
+ },
1278
+ };
1279
+ },
1280
+ onExit(callback) {
1281
+ exitListeners.push(callback);
1282
+ return {
1283
+ dispose() {
1284
+ const index = exitListeners.indexOf(callback);
1285
+ if (index >= 0)
1286
+ exitListeners.splice(index, 1);
1287
+ },
1288
+ };
1289
+ },
1290
+ };
1291
+ }
1292
+ export function buildSpawnDiagnostic(error, spawnTarget, platform = process.platform) {
1293
+ const errorMessage = error instanceof Error ? error.message : String(error);
1294
+ const target = spawnTarget ? spawnTarget.file : "(unknown)";
1295
+ const lines = [
1296
+ t("spawn.diagnostic.title", { target, error: errorMessage }),
1297
+ t("spawn.diagnostic.fixesHeader"),
1298
+ ];
1299
+ if (errorMessage.includes("posix_spawnp") || errorMessage.includes("node-pty")) {
1300
+ lines.push(t("spawn.diagnostic.nodePty"));
1301
+ if (platform === "darwin") {
1302
+ lines.push(t("spawn.diagnostic.xcode"));
1303
+ }
1304
+ }
1305
+ else if (errorMessage.includes("ENOENT") || errorMessage.includes("spawn")) {
1306
+ lines.push(t("spawn.diagnostic.notFound", { target }));
1307
+ }
1308
+ else {
1309
+ lines.push(t("spawn.diagnostic.generic"));
1310
+ }
1311
+ lines.push(t("spawn.diagnostic.nodeVersion"));
1312
+ if (platform === "win32") {
1313
+ lines.push(t("spawn.diagnostic.winAdmin"));
1314
+ }
1315
+ return lines.join("\n");
1316
+ }
@@ -67,7 +67,7 @@ export class ShellAdapter extends AbstractPtyAdapter {
67
67
  throw new Error("shell is still working. Wait for the current reply or use /stop.");
68
68
  }
69
69
  if (this.pendingApproval || this.state.status === "awaiting_approval") {
70
- throw new Error("A shell approval request is pending. Reply with /confirm <code> or /deny.");
70
+ throw new Error("A shell approval request is pending. Reply with /confirm or /deny.");
71
71
  }
72
72
  const rejectionMessage = getInteractiveShellCommandRejectionMessage(text);
73
73
  if (rejectionMessage) {
@@ -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":
@@ -385,7 +410,7 @@ const HIGH_RISK_PATTERNS = [
385
410
  /\brmdir\b/i,
386
411
  /\bdel\b/i,
387
412
  /\berase\b/i,
388
- /\bformat\b/i,
413
+ /\bformat\b(?!-)/i,
389
414
  /\bshutdown\b/i,
390
415
  /\bstop-computer\b/i,
391
416
  /\brestart-computer\b/i,
@@ -997,10 +1022,9 @@ export function formatApprovalMessage(pending, adapterState) {
997
1022
  return [
998
1023
  `${pending.source === "shell" ? "Shell" : "CLI"} approval is required.`,
999
1024
  `adapter: ${adapterState.kind}`,
1000
- `code: ${pending.code}`,
1001
1025
  `summary: ${pending.summary}`,
1002
1026
  `target: ${pending.commandPreview}`,
1003
- "Reply with /confirm <code> to continue or /deny to reject.",
1027
+ "Reply with /confirm or /yes to continue, /deny or /no to reject.",
1004
1028
  ].join("\n");
1005
1029
  }
1006
1030
  export function formatPendingApprovalReminder(pending, adapterState) {
@@ -1010,7 +1034,7 @@ export function formatPendingApprovalReminder(pending, adapterState) {
1010
1034
  : pending.commandPreview;
1011
1035
  return `Approval is pending for ${truncatePreview(target, 140)}. Reply with /confirm, confirm, or yes to continue, or /deny, deny, or no to reject.`;
1012
1036
  }
1013
- 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.`;
1014
1038
  }
1015
1039
  function formatUserInputQuestionLabel(question, index) {
1016
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
  }
@@ -90,13 +82,15 @@ export function buildClaudeHookScript(params) {
90
82
  params.runtimeExecPath,
91
83
  ...runtimeArgs,
92
84
  ].map(quoteWindowsCommandArg).join(" ");
85
+ const stderrRedirect = params.hookErrorLogPath
86
+ ? `2>>${quoteWindowsCommandArg(params.hookErrorLogPath)}`
87
+ : "2>nul";
93
88
  return [
94
89
  "@echo off",
95
90
  "setlocal",
96
91
  `set "CLAUDE_WECHAT_HOOK_PORT=${params.hookPort}"`,
97
92
  `set "CLAUDE_WECHAT_HOOK_TOKEN=${params.hookToken}"`,
98
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
99
- `${command} 2>nul`,
93
+ `${command} ${stderrRedirect}`,
100
94
  "exit /b 0",
101
95
  ].join("\r\n");
102
96
  }
@@ -104,12 +98,14 @@ export function buildClaudeHookScript(params) {
104
98
  params.runtimeExecPath,
105
99
  ...runtimeArgs,
106
100
  ].map(quotePosixCommandArg).join(" ");
101
+ const stderrRedirect = params.hookErrorLogPath
102
+ ? `2>>${quotePosixCommandArg(params.hookErrorLogPath)}`
103
+ : "2>/dev/null";
107
104
  return [
108
105
  "#!/bin/sh",
109
106
  `export CLAUDE_WECHAT_HOOK_PORT=${quotePosixCommandArg(String(params.hookPort))}`,
110
107
  `export CLAUDE_WECHAT_HOOK_TOKEN=${quotePosixCommandArg(params.hookToken)}`,
111
- // Claude reads hook decisions from stdout, so only stderr can be discarded here.
112
- `${command} 2>/dev/null || true`,
108
+ `${command} ${stderrRedirect} || true`,
113
109
  "exit 0",
114
110
  ].join("\n");
115
111
  }
@@ -214,15 +210,12 @@ export function getClaudePermissionAutoResponse(payload) {
214
210
  if (!toolName) {
215
211
  return null;
216
212
  }
217
- if (LOW_RISK_READ_TOOL_NAMES.has(toolName)) {
213
+ if (toolName !== "Bash") {
218
214
  return {
219
215
  action: "confirm",
220
216
  reason: `low-risk ${toolName} permission`,
221
217
  };
222
218
  }
223
- if (toolName !== "Bash") {
224
- return null;
225
- }
226
219
  const command = getClaudeToolInputString(payload, "command").trim();
227
220
  if (!command || isHighRiskShellCommand(command)) {
228
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.");
@@ -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();
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { EMOJI_BINDINGS_FILE } from "../wechat/channel-config.js";
3
+ import { t } from "../i18n/index.js";
3
4
  export const DEFAULT_EMOJI_BINDINGS = {
4
5
  "[OK]": "/confirm",
5
6
  "[闭嘴]": "/stop",
@@ -78,3 +79,33 @@ export function resolveEmojiCommand(text) {
78
79
  }
79
80
  return null;
80
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
+ }