@unlinearity/cli-wechat-bridge 1.0.3 → 1.0.4

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.
@@ -8,6 +8,8 @@ import { attachLocalCompanionMessageListener, buildLocalCompanionToken, clearLoc
8
8
  import { ensureWorkspaceChannelDir } from "../wechat/channel-config.js";
9
9
  import { buildClaudeFailureMessage, buildClaudeHookSettings, buildClaudePermissionDecisionHookOutput, buildClaudePermissionApprovalRequest, extractClaudeResumeConversationId, findInjectedClaudePromptIndex, normalizeClaudeAssistantMessage, parseClaudeHookPayload, } from "./claude-hooks.js";
10
10
  import { detectCliApproval, isHighRiskShellCommand, normalizeOutput, nowIso, truncatePreview, } from "./bridge-utils.js";
11
+ import { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
12
+ export { coerceWebSocketMessageData, describeUnknownError, getCodexRpcRequestId, getLocalCompanionCommandName, getNotificationThreadId, getNotificationTurnId, getSharedSessionIdFromAdapterState, isRecentIsoTimestamp, isRecord, normalizeCodexRpcError, quotePosixCommandArg, quoteWindowsCommandArg, } from "./bridge-adapter-common.js";
11
13
  export const DEFAULT_COLS = 120;
12
14
  export const DEFAULT_ROWS = 30;
13
15
  export const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
@@ -39,101 +41,6 @@ export const OPENCODE_SERVER_READY_TIMEOUT_MS = 10_000;
39
41
  export const OPENCODE_SSE_RECONNECT_DELAY_MS = 2_000;
40
42
  export const OPENCODE_SESSION_IDLE_SETTLE_MS = 1_500;
41
43
  export const OPENCODE_WECHAT_WORKING_NOTICE_DELAY_MS = 12_000;
42
- export function isRecord(value) {
43
- return typeof value === "object" && value !== null && !Array.isArray(value);
44
- }
45
- export function getCodexRpcRequestId(value) {
46
- return typeof value === "string" || typeof value === "number" ? value : null;
47
- }
48
- export function getNotificationThreadId(params) {
49
- if (!isRecord(params)) {
50
- return null;
51
- }
52
- if (typeof params.threadId === "string") {
53
- return params.threadId;
54
- }
55
- if (isRecord(params.thread) && typeof params.thread.id === "string") {
56
- return params.thread.id;
57
- }
58
- return null;
59
- }
60
- export function getNotificationTurnId(params) {
61
- if (!isRecord(params)) {
62
- return null;
63
- }
64
- if (typeof params.turnId === "string") {
65
- return params.turnId;
66
- }
67
- if (isRecord(params.turn) && typeof params.turn.id === "string") {
68
- return params.turn.id;
69
- }
70
- return null;
71
- }
72
- export function describeUnknownError(error) {
73
- if (error instanceof Error && error.message) {
74
- return error.message;
75
- }
76
- return String(error);
77
- }
78
- export function normalizeCodexRpcError(error) {
79
- if (isRecord(error)) {
80
- const message = typeof error.message === "string"
81
- ? error.message
82
- : typeof error.code === "number"
83
- ? `RPC error ${error.code}`
84
- : "";
85
- const data = typeof error.data === "string"
86
- ? error.data
87
- : typeof error.details === "string"
88
- ? error.details
89
- : "";
90
- const combined = [message, data].filter(Boolean).join(": ");
91
- if (combined) {
92
- return combined;
93
- }
94
- }
95
- return describeUnknownError(error);
96
- }
97
- export function getLocalCompanionCommandName(kind) {
98
- switch (kind) {
99
- case "codex":
100
- return "wechat-codex";
101
- case "claude":
102
- return "wechat-claude";
103
- case "opencode":
104
- return "wechat-opencode";
105
- default:
106
- return "local companion";
107
- }
108
- }
109
- export function getSharedSessionIdFromAdapterState(state) {
110
- return state.sharedSessionId ?? state.sharedThreadId;
111
- }
112
- export function quoteWindowsCommandArg(value) {
113
- return `"${value.replace(/"/g, '""')}"`;
114
- }
115
- export function quotePosixCommandArg(value) {
116
- return `'${value.replace(/'/g, `'\\''`)}'`;
117
- }
118
- export function isRecentIsoTimestamp(timestamp, maxAgeMs) {
119
- const parsedMs = Date.parse(timestamp);
120
- if (!Number.isFinite(parsedMs)) {
121
- return false;
122
- }
123
- return parsedMs >= Date.now() - maxAgeMs;
124
- }
125
- export function coerceWebSocketMessageData(data) {
126
- if (typeof data === "string") {
127
- return data;
128
- }
129
- if (data instanceof ArrayBuffer) {
130
- return Buffer.from(data).toString("utf8");
131
- }
132
- if (ArrayBuffer.isView(data)) {
133
- return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
134
- }
135
- return null;
136
- }
137
44
  export function buildCodexCliArgs(remoteUrl, options = {}) {
138
45
  assertNoReservedExtraCliArgs(options.extraCliArgs ?? [], ["--remote", "--remote-auth-token-env"], "Codex remote connection");
139
46
  const args = [];
@@ -196,7 +103,7 @@ export function shouldIncludeClaudeNoAltScreen(command) {
196
103
  if (cached !== undefined) {
197
104
  return cached;
198
105
  }
199
- let supported = false;
106
+ let supported;
200
107
  try {
201
108
  const probe = spawnSync(spawnTarget.file, [...spawnTarget.args, "--help"], {
202
109
  cwd: process.cwd(),
@@ -660,7 +567,7 @@ export function escapePowerShellString(text) {
660
567
  return text.replace(/`/g, "``").replace(/"/g, '`"');
661
568
  }
662
569
  export function escapePosixShellString(text) {
663
- return `'${text.replace(/'/g, `'\"'\"'`)}'`;
570
+ return `'${text.replace(/'/g, `'"'"'`)}'`;
664
571
  }
665
572
  export function buildShellProfileCommand(profilePath, family) {
666
573
  const resolved = path.resolve(profilePath);
@@ -1,10 +1,25 @@
1
1
  import { formatFinalReplyMessage, parseWechatFinalReply, sanitizeWechatFinalReplyText, } from "./bridge-utils.js";
2
+ export const OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE = "OpenCode 没有产生可发送到微信的可见回复。请查看本地终端输出,或重试这条消息。";
2
3
  export async function forwardWechatFinalReply(params) {
3
- const { adapter, rawText, sender } = params;
4
+ const { adapter, rawText, sender, onEmptyVisibleReply } = params;
4
5
  const parsed = parseWechatFinalReply(rawText);
5
- const visibleText = formatFinalReplyMessage(adapter, sanitizeWechatFinalReplyText(adapter, parsed.visibleText)).trim();
6
+ const sanitizedText = sanitizeWechatFinalReplyText(adapter, parsed.visibleText);
7
+ const visibleText = formatFinalReplyMessage(adapter, sanitizedText).trim();
6
8
  if (visibleText) {
7
- await sender.sendText(visibleText);
9
+ const sent = await sender.sendText(visibleText);
10
+ if (sent === false) {
11
+ return;
12
+ }
13
+ }
14
+ else if (adapter === "opencode" && parsed.visibleText.trim()) {
15
+ onEmptyVisibleReply?.({
16
+ adapter,
17
+ rawVisibleText: parsed.visibleText,
18
+ });
19
+ const sent = await sender.sendText(OPENCODE_EMPTY_VISIBLE_REPLY_MESSAGE);
20
+ if (sent === false) {
21
+ return;
22
+ }
8
23
  }
9
24
  for (const attachment of parsed.attachments) {
10
25
  try {
@@ -3,6 +3,14 @@ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureWorkspaceChannelDir, ensureCha
3
3
  import { buildInstanceId } from "./bridge-utils.js";
4
4
  const ORPHAN_LOCK_RECLAIM_TIMEOUT_MS = 2_000;
5
5
  const ORPHAN_LOCK_RECLAIM_POLL_MS = 100;
6
+ export function resolveRestorableSharedSessionId(persisted, options) {
7
+ if (!persisted ||
8
+ persisted.cwd !== options.cwd ||
9
+ persisted.adapter !== options.adapter) {
10
+ return undefined;
11
+ }
12
+ return persisted.sharedSessionId ?? persisted.sharedThreadId;
13
+ }
6
14
  function cloneState(state) {
7
15
  return JSON.parse(JSON.stringify(state));
8
16
  }
@@ -162,9 +170,7 @@ export class BridgeStateStore {
162
170
  };
163
171
  this.acquireLock();
164
172
  const persisted = this.readStateFile();
165
- const persistedSharedSessionId = persisted?.cwd === options.cwd
166
- ? persisted.sharedSessionId ?? persisted.sharedThreadId
167
- : undefined;
173
+ const persistedSharedSessionId = resolveRestorableSharedSessionId(persisted, options);
168
174
  const persistedResumeConversationId = options.adapter === "claude" &&
169
175
  persisted?.cwd === options.cwd &&
170
176
  typeof persisted.resumeConversationId === "string"
@@ -101,6 +101,7 @@ export function stripAnsi(text) {
101
101
  }
102
102
  export function normalizeOutput(text) {
103
103
  return stripAnsi(text)
104
+ // eslint-disable-next-line no-control-regex
104
105
  .replace(/\u0000/g, "")
105
106
  .replace(/\r\n/g, "\n")
106
107
  .replace(/\r/g, "\n");
@@ -132,6 +133,9 @@ export function parseSystemCommand(text) {
132
133
  return null;
133
134
  }
134
135
  const [rawCommand, ...rest] = trimmed.split(/\s+/);
136
+ if (!rawCommand) {
137
+ return null;
138
+ }
135
139
  const command = rawCommand.toLowerCase();
136
140
  const argument = rest.join(" ").trim();
137
141
  switch (command) {
@@ -227,7 +231,11 @@ export function parseWechatFinalReply(text) {
227
231
  return extractInlineWechatAttachments(normalized);
228
232
  }
229
233
  const attachments = [];
230
- const lines = match[1]
234
+ const attachmentBlock = match[1];
235
+ if (attachmentBlock === undefined) {
236
+ return extractInlineWechatAttachments(normalized);
237
+ }
238
+ const lines = attachmentBlock
231
239
  .split("\n")
232
240
  .map((line) => line.trim())
233
241
  .filter(Boolean);
@@ -240,7 +248,11 @@ export function parseWechatFinalReply(text) {
240
248
  return extractInlineWechatAttachments(normalized);
241
249
  }
242
250
  const kind = parsed[1];
243
- const attachmentPath = resolveWechatAttachmentPath(parsed[2]);
251
+ const rawPath = parsed[2];
252
+ if (!rawPath) {
253
+ return extractInlineWechatAttachments(normalized);
254
+ }
255
+ const attachmentPath = resolveWechatAttachmentPath(rawPath);
244
256
  if (!attachmentPath) {
245
257
  return extractInlineWechatAttachments(normalized);
246
258
  }
@@ -407,7 +419,11 @@ export function getInteractiveShellCommandRejectionMessage(command) {
407
419
  if (!tokens.length) {
408
420
  return null;
409
421
  }
410
- const executable = normalizeShellExecutableToken(tokens[0]);
422
+ const firstToken = tokens[0];
423
+ if (!firstToken) {
424
+ return null;
425
+ }
426
+ const executable = normalizeShellExecutableToken(firstToken);
411
427
  const args = tokens.slice(1);
412
428
  const lowerArgs = args.map((arg) => arg.toLowerCase());
413
429
  if (!executable) {
@@ -419,7 +435,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
419
435
  switch (executable) {
420
436
  case "python":
421
437
  case "python3":
422
- case "py":
438
+ case "py": {
423
439
  if (!args.length) {
424
440
  return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
425
441
  }
@@ -438,6 +454,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
438
454
  return hasScriptLikeArg(args)
439
455
  ? null
440
456
  : buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
457
+ }
441
458
  case "node":
442
459
  if (!args.length || lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
443
460
  return buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
@@ -725,6 +742,9 @@ function stripInlineOpenCodeReasoningPrefix(text) {
725
742
  }
726
743
  return current;
727
744
  }
745
+ function isOpenCodeReasoningResidue(text) {
746
+ return !text.replace(/[\s"'“”‘’`.,!?;:()[\]{}<>…。!?;:()【】《》、,-]/g, "");
747
+ }
728
748
  export function sanitizeWechatFinalReplyText(adapter, text) {
729
749
  const normalized = adapter === "opencode"
730
750
  ? cleanupVisibleWechatReplyText(stripInlineOpenCodeReasoningPrefix(text))
@@ -775,7 +795,8 @@ export function sanitizeWechatFinalReplyText(adapter, text) {
775
795
  return cleaned;
776
796
  }
777
797
  const tail = cleanupVisibleWechatReplyText(keptLines.slice(tailStartIndex).join("\n"));
778
- return tail || cleaned;
798
+ const resolved = tail || cleaned;
799
+ return isOpenCodeReasoningResidue(resolved) ? "" : resolved;
779
800
  }
780
801
  function extractInlineWechatAttachments(text) {
781
802
  const sanitized = text
@@ -946,7 +967,7 @@ function parseSingleUserInputAnswer(question, rawValue) {
946
967
  };
947
968
  }
948
969
  const separatorIndex = trimmed.indexOf("|");
949
- let selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
970
+ const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
950
971
  let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
951
972
  const answers = [];
952
973
  if (selection) {
@@ -956,7 +977,6 @@ function parseSingleUserInputAnswer(question, rawValue) {
956
977
  }
957
978
  else if (question.isOther) {
958
979
  note = note ? `${selection}; ${note}` : selection;
959
- selection = "";
960
980
  }
961
981
  else {
962
982
  return {
@@ -986,6 +1006,11 @@ export function parsePendingUserInputAnswerCommand(raw, pending) {
986
1006
  const answers = {};
987
1007
  if (pending.questions.length === 1) {
988
1008
  const question = pending.questions[0];
1009
+ if (!question) {
1010
+ return {
1011
+ error: "No pending user input question was found.",
1012
+ };
1013
+ }
989
1014
  const parsed = parseSingleUserInputAnswer(question, input);
990
1015
  if ("error" in parsed) {
991
1016
  return parsed;
@@ -1068,6 +1093,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1068
1093
  lines.push("");
1069
1094
  if (pending.questions.length === 1) {
1070
1095
  const question = pending.questions[0];
1096
+ if (!question) {
1097
+ return lines.join("\n");
1098
+ }
1071
1099
  if (question.options?.length) {
1072
1100
  lines.push("Reply with /answer <option number or exact label>.");
1073
1101
  if (question.isOther) {
@@ -1090,6 +1118,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1090
1118
  export function formatPendingUserInputReminder(pending) {
1091
1119
  if (pending.questions.length === 1) {
1092
1120
  const question = pending.questions[0];
1121
+ if (!question) {
1122
+ return "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
1123
+ }
1093
1124
  return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1094
1125
  }
1095
1126
  return `Codex is waiting for answers to ${pending.questions.length} questions. Reply with /answer questionId=value; questionId2=value, or use /stop to interrupt.`;
@@ -108,7 +108,7 @@ function summarizeClaudePlan(plan) {
108
108
  const description = lines.find((line) => !/^#+\s+/.test(line) &&
109
109
  !/^[-*]\s+/.test(line) &&
110
110
  !/^\d+\.\s+/.test(line));
111
- return truncatePreview([heading, description].filter(Boolean).join(" - ") || lines[0], 180);
111
+ return truncatePreview([heading, description].filter(Boolean).join(" - ") || lines[0] || "(empty plan)", 180);
112
112
  }
113
113
  function summarizeClaudeToolInput(toolName, toolInput) {
114
114
  if (!toolInput) {
@@ -214,7 +214,7 @@ export function extractClaudeTranscriptFinalReply(rawTranscript) {
214
214
  if (!line) {
215
215
  continue;
216
216
  }
217
- let parsed = null;
217
+ let parsed;
218
218
  try {
219
219
  parsed = JSON.parse(line);
220
220
  }
@@ -14,6 +14,8 @@ import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.j
14
14
  const POLL_RETRY_BASE_MS = 1_000;
15
15
  const POLL_RETRY_MAX_MS = 30_000;
16
16
  const PARENT_PROCESS_POLL_MS = 5_000;
17
+ const WECHAT_SEND_MAX_ATTEMPTS = 3;
18
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
17
19
  function log(message) {
18
20
  process.stderr.write(`[wechat-bridge] ${message}\n`);
19
21
  }
@@ -73,6 +75,21 @@ export function formatUserFacingInboundError(params) {
73
75
  export function formatWechatSendFailureLogEntry(params) {
74
76
  return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
75
77
  }
78
+ function formatWechatSendRetryLogEntry(params) {
79
+ return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
80
+ }
81
+ function isRetryableWechatSendError(error) {
82
+ const classification = classifyWechatTransportError(error);
83
+ if (classification.retryable) {
84
+ return true;
85
+ }
86
+ const details = describeWechatTransportError(error);
87
+ return /^Error: sendmessage failed:/i.test(details) &&
88
+ !/errcode=-14\b.*session timeout/i.test(details);
89
+ }
90
+ function computeWechatSendRetryDelayMs(attempt) {
91
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
92
+ }
76
93
  export function shouldWatchParentProcess(options) {
77
94
  return (options.startupParentPid > 1 &&
78
95
  (options.attachedToTerminal || options.lifecycle === "companion_bound"));
@@ -271,6 +288,7 @@ async function main() {
271
288
  stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
272
289
  let textSendChain = Promise.resolve();
273
290
  let attachmentSendChain = Promise.resolve();
291
+ const pendingWechatForwardTasks = new Set();
274
292
  let activeTask = null;
275
293
  const deferredInboundMessages = [];
276
294
  let drainingDeferredInboundMessages = false;
@@ -288,14 +306,53 @@ async function main() {
288
306
  return run;
289
307
  };
290
308
  const queueWechatMessage = (senderId, text, context = "message") => {
291
- return queueWechatTextAction(() => transport.sendText(senderId, text)).catch((err) => {
292
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
293
- stateStore.appendLog(formatWechatSendFailureLogEntry({
294
- context,
295
- recipientId: senderId,
296
- error: err,
297
- }));
309
+ return queueWechatTextAction(async () => {
310
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
311
+ try {
312
+ await transport.sendText(senderId, text);
313
+ return true;
314
+ }
315
+ catch (err) {
316
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
317
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
318
+ logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
319
+ stateStore.appendLog(formatWechatSendRetryLogEntry({
320
+ context,
321
+ recipientId: senderId,
322
+ attempt,
323
+ delayMs,
324
+ error: err,
325
+ }));
326
+ await delay(delayMs);
327
+ continue;
328
+ }
329
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
330
+ stateStore.appendLog(formatWechatSendFailureLogEntry({
331
+ context,
332
+ recipientId: senderId,
333
+ error: err,
334
+ }));
335
+ return false;
336
+ }
337
+ }
338
+ return false;
339
+ });
340
+ };
341
+ const trackWechatForwardTask = (task) => {
342
+ const tracked = task
343
+ .catch((error) => {
344
+ logError(`WeChat forward task failed: ${describeWechatTransportError(error)}`);
345
+ stateStore.appendLog(`wechat_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
346
+ })
347
+ .finally(() => {
348
+ pendingWechatForwardTasks.delete(tracked);
298
349
  });
350
+ pendingWechatForwardTasks.add(tracked);
351
+ };
352
+ const waitForPendingWechatForwardTasks = async () => {
353
+ while (pendingWechatForwardTasks.size > 0) {
354
+ await Promise.allSettled([...pendingWechatForwardTasks]);
355
+ }
299
356
  };
300
357
  const outputBatcher = new OutputBatcher(async (text) => {
301
358
  await queueWechatMessage(stateStore.getState().authorizedUserId, text);
@@ -377,6 +434,7 @@ async function main() {
377
434
  }
378
435
  try {
379
436
  await outputBatcher.flushNow();
437
+ await waitForPendingWechatForwardTasks();
380
438
  }
381
439
  catch {
382
440
  // Best effort flush.
@@ -384,6 +442,7 @@ async function main() {
384
442
  try {
385
443
  await textSendChain;
386
444
  await attachmentSendChain;
445
+ await waitForPendingWechatForwardTasks();
387
446
  }
388
447
  catch {
389
448
  // Best effort flush.
@@ -458,6 +517,7 @@ async function main() {
458
517
  outputBatcher,
459
518
  queueWechatAttachmentAction,
460
519
  queueWechatMessage,
520
+ trackWechatForwardTask,
461
521
  maybeDrainDeferredInboundMessages,
462
522
  getActiveTask: () => activeTask,
463
523
  clearActiveTask: () => {
@@ -622,7 +682,7 @@ function syncSharedSessionState(stateStore, adapter) {
622
682
  }
623
683
  }
624
684
  function wireAdapterEvents(params) {
625
- const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
685
+ const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
626
686
  adapter.setEventSink((event) => {
627
687
  syncSharedSessionState();
628
688
  syncLocalClientEndpoint();
@@ -642,19 +702,28 @@ function wireAdapterEvents(params) {
642
702
  break;
643
703
  case "final_reply":
644
704
  stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
645
- void outputBatcher.flushNow().then(async () => {
705
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
646
706
  await forwardWechatFinalReply({
647
707
  adapter: options.adapter,
648
708
  rawText: event.text,
709
+ onEmptyVisibleReply: ({ rawVisibleText }) => {
710
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
711
+ },
649
712
  sender: {
650
- sendText: (text) => queueWechatMessage(authorizedUserId, text, "final_reply"),
713
+ sendText: async (text) => {
714
+ const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
715
+ if (sent) {
716
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
717
+ }
718
+ return sent;
719
+ },
651
720
  sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
652
721
  sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
653
722
  sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
654
723
  sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
655
724
  },
656
725
  });
657
- });
726
+ }));
658
727
  break;
659
728
  case "status":
660
729
  if (event.message) {
@@ -667,56 +736,56 @@ function wireAdapterEvents(params) {
667
736
  updateLastOutputAt();
668
737
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
669
738
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
670
- void outputBatcher.flushNow().then(async () => {
739
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
671
740
  await queueWechatMessage(authorizedUserId, event.text, "notice");
672
- });
741
+ }));
673
742
  }
674
743
  break;
675
744
  case "approval_required":
676
- void outputBatcher.flushNow().then(async () => {
745
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
677
746
  const pending = toPendingApproval(event.request);
678
747
  stateStore.setPendingConfirmation(pending);
679
748
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
680
749
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
681
- });
750
+ }));
682
751
  break;
683
752
  case "mirrored_user_input":
684
753
  stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
685
754
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
686
- void outputBatcher.flushNow().then(async () => {
755
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
687
756
  await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
688
- });
757
+ }));
689
758
  }
690
759
  break;
691
760
  case "session_switched":
692
761
  stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
693
762
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
694
- void outputBatcher.flushNow().then(async () => {
763
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
695
764
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
696
765
  adapter: options.adapter,
697
766
  sessionId: event.sessionId,
698
767
  source: event.source,
699
768
  reason: event.reason,
700
769
  }), "session_switched");
701
- });
770
+ }));
702
771
  }
703
772
  break;
704
773
  case "thread_switched":
705
774
  stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
706
775
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
707
- void outputBatcher.flushNow().then(async () => {
776
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
708
777
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
709
778
  adapter: options.adapter,
710
779
  sessionId: event.threadId,
711
780
  source: event.source,
712
781
  reason: event.reason,
713
782
  }), "thread_switched");
714
- });
783
+ }));
715
784
  }
716
785
  void maybeDrainDeferredInboundMessages();
717
786
  break;
718
787
  case "task_complete":
719
- void outputBatcher.flushNow().then(async () => {
788
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
720
789
  stateStore.clearPendingConfirmation();
721
790
  if (options.adapter === "shell") {
722
791
  const summary = buildCompletionSummary({
@@ -729,25 +798,25 @@ function wireAdapterEvents(params) {
729
798
  }
730
799
  clearActiveTask();
731
800
  await maybeDrainDeferredInboundMessages();
732
- });
801
+ }));
733
802
  break;
734
803
  case "task_failed":
735
- void outputBatcher.flushNow().then(async () => {
804
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
736
805
  stateStore.clearPendingConfirmation();
737
806
  clearActiveTask();
738
807
  await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
739
808
  await maybeDrainDeferredInboundMessages();
740
- });
809
+ }));
741
810
  break;
742
811
  case "fatal_error":
743
812
  logError(event.message);
744
813
  stateStore.appendLog(`fatal_error: ${event.message}`);
745
814
  stateStore.clearPendingConfirmation();
746
815
  clearActiveTask();
747
- void outputBatcher.flushNow().then(async () => {
816
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
748
817
  await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
749
818
  await maybeDrainDeferredInboundMessages();
750
- });
819
+ }));
751
820
  break;
752
821
  case "shutdown_requested":
753
822
  stateStore.appendLog(`shutdown_requested: ${event.reason}`);
@@ -774,6 +843,7 @@ async function handleInboundMessage(params) {
774
843
  const systemCommand = parseWechatControlCommand(message.text, {
775
844
  adapter: options.adapter,
776
845
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
846
+ hasPendingUserInput: Boolean(state.pendingUserInput),
777
847
  });
778
848
  if (message.senderId !== state.authorizedUserId) {
779
849
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
@@ -13,6 +13,9 @@ export function parseCliArgs(argv) {
13
13
  const cliArgs = [];
14
14
  for (let i = 0; i < argv.length; i += 1) {
15
15
  const arg = argv[i];
16
+ if (!arg) {
17
+ continue;
18
+ }
16
19
  const next = argv[i + 1];
17
20
  if (arg === "--help" || arg === "-h") {
18
21
  process.stdout.write([
@@ -87,6 +87,9 @@ export function parseCliArgs(argv) {
87
87
  const cliArgs = [];
88
88
  for (let i = 0; i < argv.length; i += 1) {
89
89
  const arg = argv[i];
90
+ if (!arg) {
91
+ continue;
92
+ }
90
93
  const next = argv[i + 1];
91
94
  if (arg === "--help" || arg === "-h") {
92
95
  process.stdout.write([
@@ -173,7 +176,9 @@ async function stopExistingBridge(lock, requestedAdapter) {
173
176
  catch (error) {
174
177
  if (isPidAlive(pid)) {
175
178
  const message = error instanceof Error ? error.message : String(error);
176
- throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}`);
179
+ throw new Error(`Failed to stop existing bridge pid=${pid}: ${message}`, {
180
+ cause: error,
181
+ });
177
182
  }
178
183
  }
179
184
  if (!(await waitForProcessExit(pid, 10_000))) {
@@ -16,6 +16,9 @@ function parseCliArgs(argv) {
16
16
  const cliArgs = [];
17
17
  for (let i = 0; i < argv.length; i += 1) {
18
18
  const arg = argv[i];
19
+ if (!arg) {
20
+ continue;
21
+ }
19
22
  const next = argv[i + 1];
20
23
  if (arg === "--help" || arg === "-h") {
21
24
  process.stdout.write([
@@ -37,7 +37,7 @@ export async function fetchLatestVersion() {
37
37
  }
38
38
  // 按版本号排序,返回最新的
39
39
  versionTags.sort((a, b) => compareVersions(b, a));
40
- return versionTags[0];
40
+ return versionTags[0] ?? null;
41
41
  }
42
42
  catch (error) {
43
43
  // 如果 git 命令失败,静默返回 null