@unlinearity/cli-wechat-bridge 1.0.2 → 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) {
@@ -139,6 +143,9 @@ export function parseSystemCommand(text) {
139
143
  return { type: "status" };
140
144
  case "/resume":
141
145
  return argument ? { type: "resume", target: argument } : { type: "resume" };
146
+ case "/new":
147
+ case "/new-session":
148
+ return { type: "new_session" };
142
149
  case "/stop":
143
150
  return { type: "stop" };
144
151
  case "/reset":
@@ -224,7 +231,11 @@ export function parseWechatFinalReply(text) {
224
231
  return extractInlineWechatAttachments(normalized);
225
232
  }
226
233
  const attachments = [];
227
- const lines = match[1]
234
+ const attachmentBlock = match[1];
235
+ if (attachmentBlock === undefined) {
236
+ return extractInlineWechatAttachments(normalized);
237
+ }
238
+ const lines = attachmentBlock
228
239
  .split("\n")
229
240
  .map((line) => line.trim())
230
241
  .filter(Boolean);
@@ -237,7 +248,11 @@ export function parseWechatFinalReply(text) {
237
248
  return extractInlineWechatAttachments(normalized);
238
249
  }
239
250
  const kind = parsed[1];
240
- const attachmentPath = resolveWechatAttachmentPath(parsed[2]);
251
+ const rawPath = parsed[2];
252
+ if (!rawPath) {
253
+ return extractInlineWechatAttachments(normalized);
254
+ }
255
+ const attachmentPath = resolveWechatAttachmentPath(rawPath);
241
256
  if (!attachmentPath) {
242
257
  return extractInlineWechatAttachments(normalized);
243
258
  }
@@ -404,7 +419,11 @@ export function getInteractiveShellCommandRejectionMessage(command) {
404
419
  if (!tokens.length) {
405
420
  return null;
406
421
  }
407
- const executable = normalizeShellExecutableToken(tokens[0]);
422
+ const firstToken = tokens[0];
423
+ if (!firstToken) {
424
+ return null;
425
+ }
426
+ const executable = normalizeShellExecutableToken(firstToken);
408
427
  const args = tokens.slice(1);
409
428
  const lowerArgs = args.map((arg) => arg.toLowerCase());
410
429
  if (!executable) {
@@ -416,7 +435,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
416
435
  switch (executable) {
417
436
  case "python":
418
437
  case "python3":
419
- case "py":
438
+ case "py": {
420
439
  if (!args.length) {
421
440
  return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
422
441
  }
@@ -435,6 +454,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
435
454
  return hasScriptLikeArg(args)
436
455
  ? null
437
456
  : buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
457
+ }
438
458
  case "node":
439
459
  if (!args.length || lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
440
460
  return buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
@@ -694,6 +714,7 @@ const OPENCODE_REASONING_LINE_RES = [
694
714
  /\bI need to (?:respond|reply|answer|tell the user)\b/i,
695
715
  /\bWe need to (?:respond|reply|answer)\b/i,
696
716
  /\bI should\b/i,
717
+ /\bI(?:'ll| will) (?:respond|reply|answer|tell the user|provide)\b/i,
697
718
  /\bI'll provide\b/i,
698
719
  /^Let me (?:directly )?(?:answer|respond)\b/i,
699
720
  /根据系统提示/i,
@@ -704,8 +725,30 @@ const OPENCODE_REASONING_LINE_RES = [
704
725
  /^我要直接(?:回答|回复)/,
705
726
  /^用户(?:说|问)了/,
706
727
  ];
728
+ const OPENCODE_INLINE_REASONING_MARKER_RE = /\b(?:The user\b|I need to\b|I should\b|I(?:'ll| will)\b|We need to\b|Let me\b)/i;
729
+ const OPENCODE_INLINE_REASONING_SENTENCE_RE = /^(?:The user\b|I need to\b|I should\b|I(?:'ll| will)\b|We need to\b|Let me\b)[^.!?\n]*(?:[.!?]+)\s*/i;
730
+ function stripInlineOpenCodeReasoningPrefix(text) {
731
+ let current = text.trim();
732
+ const markerIndex = current.search(OPENCODE_INLINE_REASONING_MARKER_RE);
733
+ if (markerIndex > 0 && markerIndex <= 80) {
734
+ current = current.slice(markerIndex).trimStart();
735
+ }
736
+ for (let index = 0; index < 6; index += 1) {
737
+ const match = current.match(OPENCODE_INLINE_REASONING_SENTENCE_RE);
738
+ if (!match) {
739
+ break;
740
+ }
741
+ current = current.slice(match[0].length).trimStart();
742
+ }
743
+ return current;
744
+ }
745
+ function isOpenCodeReasoningResidue(text) {
746
+ return !text.replace(/[\s"'“”‘’`.,!?;:()[\]{}<>…。!?;:()【】《》、,-]/g, "");
747
+ }
707
748
  export function sanitizeWechatFinalReplyText(adapter, text) {
708
- const normalized = cleanupVisibleWechatReplyText(text);
749
+ const normalized = adapter === "opencode"
750
+ ? cleanupVisibleWechatReplyText(stripInlineOpenCodeReasoningPrefix(text))
751
+ : cleanupVisibleWechatReplyText(text);
709
752
  if (!normalized || adapter !== "opencode") {
710
753
  return normalized;
711
754
  }
@@ -752,7 +795,8 @@ export function sanitizeWechatFinalReplyText(adapter, text) {
752
795
  return cleaned;
753
796
  }
754
797
  const tail = cleanupVisibleWechatReplyText(keptLines.slice(tailStartIndex).join("\n"));
755
- return tail || cleaned;
798
+ const resolved = tail || cleaned;
799
+ return isOpenCodeReasoningResidue(resolved) ? "" : resolved;
756
800
  }
757
801
  function extractInlineWechatAttachments(text) {
758
802
  const sanitized = text
@@ -923,7 +967,7 @@ function parseSingleUserInputAnswer(question, rawValue) {
923
967
  };
924
968
  }
925
969
  const separatorIndex = trimmed.indexOf("|");
926
- let selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
970
+ const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
927
971
  let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
928
972
  const answers = [];
929
973
  if (selection) {
@@ -933,7 +977,6 @@ function parseSingleUserInputAnswer(question, rawValue) {
933
977
  }
934
978
  else if (question.isOther) {
935
979
  note = note ? `${selection}; ${note}` : selection;
936
- selection = "";
937
980
  }
938
981
  else {
939
982
  return {
@@ -963,6 +1006,11 @@ export function parsePendingUserInputAnswerCommand(raw, pending) {
963
1006
  const answers = {};
964
1007
  if (pending.questions.length === 1) {
965
1008
  const question = pending.questions[0];
1009
+ if (!question) {
1010
+ return {
1011
+ error: "No pending user input question was found.",
1012
+ };
1013
+ }
966
1014
  const parsed = parseSingleUserInputAnswer(question, input);
967
1015
  if ("error" in parsed) {
968
1016
  return parsed;
@@ -1045,6 +1093,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1045
1093
  lines.push("");
1046
1094
  if (pending.questions.length === 1) {
1047
1095
  const question = pending.questions[0];
1096
+ if (!question) {
1097
+ return lines.join("\n");
1098
+ }
1048
1099
  if (question.options?.length) {
1049
1100
  lines.push("Reply with /answer <option number or exact label>.");
1050
1101
  if (question.isOther) {
@@ -1067,6 +1118,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1067
1118
  export function formatPendingUserInputReminder(pending) {
1068
1119
  if (pending.questions.length === 1) {
1069
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
+ }
1070
1124
  return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1071
1125
  }
1072
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();
@@ -641,19 +701,29 @@ function wireAdapterEvents(params) {
641
701
  }
642
702
  break;
643
703
  case "final_reply":
644
- void outputBatcher.flushNow().then(async () => {
704
+ stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
705
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
645
706
  await forwardWechatFinalReply({
646
707
  adapter: options.adapter,
647
708
  rawText: event.text,
709
+ onEmptyVisibleReply: ({ rawVisibleText }) => {
710
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
711
+ },
648
712
  sender: {
649
- sendText: (text) => queueWechatMessage(authorizedUserId, text),
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
+ },
650
720
  sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
651
721
  sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
652
722
  sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
653
723
  sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
654
724
  },
655
725
  });
656
- });
726
+ }));
657
727
  break;
658
728
  case "status":
659
729
  if (event.message) {
@@ -666,56 +736,56 @@ function wireAdapterEvents(params) {
666
736
  updateLastOutputAt();
667
737
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
668
738
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
669
- void outputBatcher.flushNow().then(async () => {
739
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
670
740
  await queueWechatMessage(authorizedUserId, event.text, "notice");
671
- });
741
+ }));
672
742
  }
673
743
  break;
674
744
  case "approval_required":
675
- void outputBatcher.flushNow().then(async () => {
745
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
676
746
  const pending = toPendingApproval(event.request);
677
747
  stateStore.setPendingConfirmation(pending);
678
748
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
679
749
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
680
- });
750
+ }));
681
751
  break;
682
752
  case "mirrored_user_input":
683
753
  stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
684
754
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
685
- void outputBatcher.flushNow().then(async () => {
755
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
686
756
  await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
687
- });
757
+ }));
688
758
  }
689
759
  break;
690
760
  case "session_switched":
691
761
  stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
692
762
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
693
- void outputBatcher.flushNow().then(async () => {
763
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
694
764
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
695
765
  adapter: options.adapter,
696
766
  sessionId: event.sessionId,
697
767
  source: event.source,
698
768
  reason: event.reason,
699
769
  }), "session_switched");
700
- });
770
+ }));
701
771
  }
702
772
  break;
703
773
  case "thread_switched":
704
774
  stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
705
775
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
706
- void outputBatcher.flushNow().then(async () => {
776
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
707
777
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
708
778
  adapter: options.adapter,
709
779
  sessionId: event.threadId,
710
780
  source: event.source,
711
781
  reason: event.reason,
712
782
  }), "thread_switched");
713
- });
783
+ }));
714
784
  }
715
785
  void maybeDrainDeferredInboundMessages();
716
786
  break;
717
787
  case "task_complete":
718
- void outputBatcher.flushNow().then(async () => {
788
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
719
789
  stateStore.clearPendingConfirmation();
720
790
  if (options.adapter === "shell") {
721
791
  const summary = buildCompletionSummary({
@@ -728,25 +798,25 @@ function wireAdapterEvents(params) {
728
798
  }
729
799
  clearActiveTask();
730
800
  await maybeDrainDeferredInboundMessages();
731
- });
801
+ }));
732
802
  break;
733
803
  case "task_failed":
734
- void outputBatcher.flushNow().then(async () => {
804
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
735
805
  stateStore.clearPendingConfirmation();
736
806
  clearActiveTask();
737
807
  await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
738
808
  await maybeDrainDeferredInboundMessages();
739
- });
809
+ }));
740
810
  break;
741
811
  case "fatal_error":
742
812
  logError(event.message);
743
813
  stateStore.appendLog(`fatal_error: ${event.message}`);
744
814
  stateStore.clearPendingConfirmation();
745
815
  clearActiveTask();
746
- void outputBatcher.flushNow().then(async () => {
816
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
747
817
  await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
748
818
  await maybeDrainDeferredInboundMessages();
749
- });
819
+ }));
750
820
  break;
751
821
  case "shutdown_requested":
752
822
  stateStore.appendLog(`shutdown_requested: ${event.reason}`);
@@ -773,6 +843,7 @@ async function handleInboundMessage(params) {
773
843
  const systemCommand = parseWechatControlCommand(message.text, {
774
844
  adapter: options.adapter,
775
845
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
846
+ hasPendingUserInput: Boolean(state.pendingUserInput),
776
847
  });
777
848
  if (message.senderId !== state.authorizedUserId) {
778
849
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
@@ -798,6 +869,19 @@ async function handleInboundMessage(params) {
798
869
  await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
799
870
  return null;
800
871
  }
872
+ case "new_session": {
873
+ if (!adapter.createSession) {
874
+ await queueWechatMessage(message.senderId, `/new is not available in ${options.adapter} mode.`);
875
+ return null;
876
+ }
877
+ await outputBatcher.flushNow();
878
+ outputBatcher.clear();
879
+ stateStore.clearPendingConfirmation();
880
+ stateStore.clearSharedSessionId();
881
+ await adapter.createSession();
882
+ stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
883
+ return null;
884
+ }
801
885
  case "stop": {
802
886
  const interrupted = await adapter.interrupt();
803
887
  await queueWechatMessage(message.senderId, interrupted