@unlinearity/cli-wechat-bridge 1.0.3 → 1.0.5

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.
@@ -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) {
@@ -207,15 +211,51 @@ export function shouldInjectWechatAttachmentPrompt(text) {
207
211
  mentionsLocalPath ||
208
212
  looksLikeShortSendCommand);
209
213
  }
210
- export function buildWechatInboundPrompt(text) {
211
- if (!shouldInjectWechatAttachmentPrompt(text)) {
212
- return text;
214
+ function formatPromptByteSize(bytes) {
215
+ if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) {
216
+ return null;
213
217
  }
214
- const normalized = normalizeOutput(text).trim();
215
- if (!normalized) {
216
- return text;
218
+ if (bytes >= 1024 * 1024) {
219
+ return `${(bytes / (1024 * 1024)).toFixed(bytes >= 100 * 1024 * 1024 ? 0 : 1)} MB`;
220
+ }
221
+ if (bytes >= 1024) {
222
+ return `${(bytes / 1024).toFixed(bytes >= 100 * 1024 ? 0 : 1)} KB`;
217
223
  }
218
- return `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${normalized}`;
224
+ return `${bytes} B`;
225
+ }
226
+ function formatWechatInboundAttachmentPrompt(attachments) {
227
+ const lines = [
228
+ "[WeChat inbound attachments]",
229
+ "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.",
230
+ ];
231
+ attachments.forEach((attachment, index) => {
232
+ const sizeText = formatPromptByteSize(attachment.sizeBytes);
233
+ const metadata = [
234
+ `kind=${attachment.kind}`,
235
+ attachment.fileName ? `name=${attachment.fileName}` : "",
236
+ sizeText ? `size=${sizeText}` : "",
237
+ ].filter(Boolean);
238
+ lines.push(`${index + 1}. ${metadata.join(" ")} path=${attachment.path}`);
239
+ });
240
+ return lines.join("\n");
241
+ }
242
+ export function buildWechatInboundPrompt(text, attachments = []) {
243
+ const trimmedAttachments = attachments.filter((attachment) => attachment.path.trim());
244
+ if (!trimmedAttachments.length) {
245
+ if (!shouldInjectWechatAttachmentPrompt(text)) {
246
+ return text;
247
+ }
248
+ const normalized = normalizeOutput(text).trim();
249
+ if (!normalized) {
250
+ return text;
251
+ }
252
+ return `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${normalized}`;
253
+ }
254
+ const baseText = normalizeOutput(text).trim() || "Received WeChat attachment(s).";
255
+ const userPrompt = shouldInjectWechatAttachmentPrompt(baseText)
256
+ ? `${WECHAT_ATTACHMENT_PROMPT_PREFIX}\n${baseText.trim()}`
257
+ : baseText;
258
+ return `${userPrompt.trim()}\n\n${formatWechatInboundAttachmentPrompt(trimmedAttachments)}`;
219
259
  }
220
260
  export function parseWechatFinalReply(text) {
221
261
  const normalized = normalizeOutput(text);
@@ -227,7 +267,11 @@ export function parseWechatFinalReply(text) {
227
267
  return extractInlineWechatAttachments(normalized);
228
268
  }
229
269
  const attachments = [];
230
- const lines = match[1]
270
+ const attachmentBlock = match[1];
271
+ if (attachmentBlock === undefined) {
272
+ return extractInlineWechatAttachments(normalized);
273
+ }
274
+ const lines = attachmentBlock
231
275
  .split("\n")
232
276
  .map((line) => line.trim())
233
277
  .filter(Boolean);
@@ -240,7 +284,11 @@ export function parseWechatFinalReply(text) {
240
284
  return extractInlineWechatAttachments(normalized);
241
285
  }
242
286
  const kind = parsed[1];
243
- const attachmentPath = resolveWechatAttachmentPath(parsed[2]);
287
+ const rawPath = parsed[2];
288
+ if (!rawPath) {
289
+ return extractInlineWechatAttachments(normalized);
290
+ }
291
+ const attachmentPath = resolveWechatAttachmentPath(rawPath);
244
292
  if (!attachmentPath) {
245
293
  return extractInlineWechatAttachments(normalized);
246
294
  }
@@ -407,7 +455,11 @@ export function getInteractiveShellCommandRejectionMessage(command) {
407
455
  if (!tokens.length) {
408
456
  return null;
409
457
  }
410
- const executable = normalizeShellExecutableToken(tokens[0]);
458
+ const firstToken = tokens[0];
459
+ if (!firstToken) {
460
+ return null;
461
+ }
462
+ const executable = normalizeShellExecutableToken(firstToken);
411
463
  const args = tokens.slice(1);
412
464
  const lowerArgs = args.map((arg) => arg.toLowerCase());
413
465
  if (!executable) {
@@ -419,7 +471,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
419
471
  switch (executable) {
420
472
  case "python":
421
473
  case "python3":
422
- case "py":
474
+ case "py": {
423
475
  if (!args.length) {
424
476
  return buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
425
477
  }
@@ -438,6 +490,7 @@ export function getInteractiveShellCommandRejectionMessage(command) {
438
490
  return hasScriptLikeArg(args)
439
491
  ? null
440
492
  : buildInteractiveShellCommandMessage(executable, 'Try "python script.py" or "python -c \\"...\\"" instead.');
493
+ }
441
494
  case "node":
442
495
  if (!args.length || lowerArgs.includes("-i") || lowerArgs.includes("--interactive")) {
443
496
  return buildInteractiveShellCommandMessage(executable, 'Try "node script.js" or "node -e \\"...\\"" instead.');
@@ -725,6 +778,9 @@ function stripInlineOpenCodeReasoningPrefix(text) {
725
778
  }
726
779
  return current;
727
780
  }
781
+ function isOpenCodeReasoningResidue(text) {
782
+ return !text.replace(/[\s"'“”‘’`.,!?;:()[\]{}<>…。!?;:()【】《》、,-]/g, "");
783
+ }
728
784
  export function sanitizeWechatFinalReplyText(adapter, text) {
729
785
  const normalized = adapter === "opencode"
730
786
  ? cleanupVisibleWechatReplyText(stripInlineOpenCodeReasoningPrefix(text))
@@ -775,7 +831,8 @@ export function sanitizeWechatFinalReplyText(adapter, text) {
775
831
  return cleaned;
776
832
  }
777
833
  const tail = cleanupVisibleWechatReplyText(keptLines.slice(tailStartIndex).join("\n"));
778
- return tail || cleaned;
834
+ const resolved = tail || cleaned;
835
+ return isOpenCodeReasoningResidue(resolved) ? "" : resolved;
779
836
  }
780
837
  function extractInlineWechatAttachments(text) {
781
838
  const sanitized = text
@@ -946,7 +1003,7 @@ function parseSingleUserInputAnswer(question, rawValue) {
946
1003
  };
947
1004
  }
948
1005
  const separatorIndex = trimmed.indexOf("|");
949
- let selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
1006
+ const selection = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex).trim() : trimmed;
950
1007
  let note = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1).trim() : "";
951
1008
  const answers = [];
952
1009
  if (selection) {
@@ -956,7 +1013,6 @@ function parseSingleUserInputAnswer(question, rawValue) {
956
1013
  }
957
1014
  else if (question.isOther) {
958
1015
  note = note ? `${selection}; ${note}` : selection;
959
- selection = "";
960
1016
  }
961
1017
  else {
962
1018
  return {
@@ -986,6 +1042,11 @@ export function parsePendingUserInputAnswerCommand(raw, pending) {
986
1042
  const answers = {};
987
1043
  if (pending.questions.length === 1) {
988
1044
  const question = pending.questions[0];
1045
+ if (!question) {
1046
+ return {
1047
+ error: "No pending user input question was found.",
1048
+ };
1049
+ }
989
1050
  const parsed = parseSingleUserInputAnswer(question, input);
990
1051
  if ("error" in parsed) {
991
1052
  return parsed;
@@ -1068,6 +1129,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1068
1129
  lines.push("");
1069
1130
  if (pending.questions.length === 1) {
1070
1131
  const question = pending.questions[0];
1132
+ if (!question) {
1133
+ return lines.join("\n");
1134
+ }
1071
1135
  if (question.options?.length) {
1072
1136
  lines.push("Reply with /answer <option number or exact label>.");
1073
1137
  if (question.isOther) {
@@ -1090,6 +1154,9 @@ export function formatUserInputRequestMessage(pending, adapterState) {
1090
1154
  export function formatPendingUserInputReminder(pending) {
1091
1155
  if (pending.questions.length === 1) {
1092
1156
  const question = pending.questions[0];
1157
+ if (!question) {
1158
+ return "Codex is waiting for user input. Reply with /answer and your response, or use /stop to interrupt.";
1159
+ }
1093
1160
  return `Codex is waiting for user input for ${question.header}. Reply with /answer and your response, or use /stop to interrupt.`;
1094
1161
  }
1095
1162
  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
  }
@@ -9,11 +9,14 @@ import { BridgeStateStore } from "./bridge-state.js";
9
9
  import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
10
10
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
11
11
  import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
12
- import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
12
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
13
13
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
14
+ import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
14
15
  const POLL_RETRY_BASE_MS = 1_000;
15
16
  const POLL_RETRY_MAX_MS = 30_000;
16
17
  const PARENT_PROCESS_POLL_MS = 5_000;
18
+ const WECHAT_SEND_MAX_ATTEMPTS = 3;
19
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
17
20
  function log(message) {
18
21
  process.stderr.write(`[wechat-bridge] ${message}\n`);
19
22
  }
@@ -73,6 +76,27 @@ export function formatUserFacingInboundError(params) {
73
76
  export function formatWechatSendFailureLogEntry(params) {
74
77
  return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
75
78
  }
79
+ export function formatWechatContextTokenStaleLogEntry(params) {
80
+ return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
81
+ }
82
+ function formatWechatSendRetryLogEntry(params) {
83
+ return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
84
+ }
85
+ export function isRetryableWechatSendError(error) {
86
+ if (isWechatContextTokenStaleError(error)) {
87
+ return false;
88
+ }
89
+ const classification = classifyWechatTransportError(error);
90
+ if (classification.retryable) {
91
+ return true;
92
+ }
93
+ const details = describeWechatTransportError(error);
94
+ return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
95
+ !/errcode=-14\b.*session timeout/i.test(details);
96
+ }
97
+ function computeWechatSendRetryDelayMs(attempt) {
98
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
99
+ }
76
100
  export function shouldWatchParentProcess(options) {
77
101
  return (options.startupParentPid > 1 &&
78
102
  (options.attachedToTerminal || options.lifecycle === "companion_bound"));
@@ -197,6 +221,14 @@ function printUsageAndExit() {
197
221
  }
198
222
  async function main() {
199
223
  const options = parseCliArgs(process.argv.slice(2));
224
+ const daemonEndpoint = readDaemonEndpoint();
225
+ if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
226
+ throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
227
+ }
228
+ if (daemonEndpoint) {
229
+ clearDaemonEndpoint(daemonEndpoint.pid);
230
+ log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
231
+ }
200
232
  const credentials = await ensureWechatCredentials({
201
233
  requireUserId: true,
202
234
  validateExisting: true,
@@ -271,6 +303,7 @@ async function main() {
271
303
  stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
272
304
  let textSendChain = Promise.resolve();
273
305
  let attachmentSendChain = Promise.resolve();
306
+ const pendingWechatForwardTasks = new Set();
274
307
  let activeTask = null;
275
308
  const deferredInboundMessages = [];
276
309
  let drainingDeferredInboundMessages = false;
@@ -288,15 +321,65 @@ async function main() {
288
321
  return run;
289
322
  };
290
323
  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
- }));
324
+ return queueWechatTextAction(async () => {
325
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
326
+ try {
327
+ await transport.sendText(senderId, text);
328
+ return true;
329
+ }
330
+ catch (err) {
331
+ if (isWechatContextTokenStaleError(err)) {
332
+ transport.clearCachedContextToken(senderId);
333
+ const hint = "WeChat conversation context is stale. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
334
+ logError(`Failed to send WeChat ${context}: ${hint}`);
335
+ stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
336
+ context,
337
+ recipientId: senderId,
338
+ error: err,
339
+ }));
340
+ return false;
341
+ }
342
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
343
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
344
+ logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
345
+ stateStore.appendLog(formatWechatSendRetryLogEntry({
346
+ context,
347
+ recipientId: senderId,
348
+ attempt,
349
+ delayMs,
350
+ error: err,
351
+ }));
352
+ await delay(delayMs);
353
+ continue;
354
+ }
355
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
356
+ stateStore.appendLog(formatWechatSendFailureLogEntry({
357
+ context,
358
+ recipientId: senderId,
359
+ error: err,
360
+ }));
361
+ return false;
362
+ }
363
+ }
364
+ return false;
298
365
  });
299
366
  };
367
+ const trackWechatForwardTask = (task) => {
368
+ const tracked = task
369
+ .catch((error) => {
370
+ logError(`WeChat forward task failed: ${describeWechatTransportError(error)}`);
371
+ stateStore.appendLog(`wechat_forward_failed: error=${truncatePreview(describeWechatTransportError(error), 400)}`);
372
+ })
373
+ .finally(() => {
374
+ pendingWechatForwardTasks.delete(tracked);
375
+ });
376
+ pendingWechatForwardTasks.add(tracked);
377
+ };
378
+ const waitForPendingWechatForwardTasks = async () => {
379
+ while (pendingWechatForwardTasks.size > 0) {
380
+ await Promise.allSettled([...pendingWechatForwardTasks]);
381
+ }
382
+ };
300
383
  const outputBatcher = new OutputBatcher(async (text) => {
301
384
  await queueWechatMessage(stateStore.getState().authorizedUserId, text);
302
385
  });
@@ -377,6 +460,7 @@ async function main() {
377
460
  }
378
461
  try {
379
462
  await outputBatcher.flushNow();
463
+ await waitForPendingWechatForwardTasks();
380
464
  }
381
465
  catch {
382
466
  // Best effort flush.
@@ -384,6 +468,7 @@ async function main() {
384
468
  try {
385
469
  await textSendChain;
386
470
  await attachmentSendChain;
471
+ await waitForPendingWechatForwardTasks();
387
472
  }
388
473
  catch {
389
474
  // Best effort flush.
@@ -458,6 +543,7 @@ async function main() {
458
543
  outputBatcher,
459
544
  queueWechatAttachmentAction,
460
545
  queueWechatMessage,
546
+ trackWechatForwardTask,
461
547
  maybeDrainDeferredInboundMessages,
462
548
  getActiveTask: () => activeTask,
463
549
  clearActiveTask: () => {
@@ -622,7 +708,7 @@ function syncSharedSessionState(stateStore, adapter) {
622
708
  }
623
709
  }
624
710
  function wireAdapterEvents(params) {
625
- const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
711
+ const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
626
712
  adapter.setEventSink((event) => {
627
713
  syncSharedSessionState();
628
714
  syncLocalClientEndpoint();
@@ -642,19 +728,28 @@ function wireAdapterEvents(params) {
642
728
  break;
643
729
  case "final_reply":
644
730
  stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
645
- void outputBatcher.flushNow().then(async () => {
731
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
646
732
  await forwardWechatFinalReply({
647
733
  adapter: options.adapter,
648
734
  rawText: event.text,
735
+ onEmptyVisibleReply: ({ rawVisibleText }) => {
736
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
737
+ },
649
738
  sender: {
650
- sendText: (text) => queueWechatMessage(authorizedUserId, text, "final_reply"),
739
+ sendText: async (text) => {
740
+ const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
741
+ if (sent) {
742
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
743
+ }
744
+ return sent;
745
+ },
651
746
  sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
652
747
  sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
653
748
  sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
654
749
  sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
655
750
  },
656
751
  });
657
- });
752
+ }));
658
753
  break;
659
754
  case "status":
660
755
  if (event.message) {
@@ -667,56 +762,56 @@ function wireAdapterEvents(params) {
667
762
  updateLastOutputAt();
668
763
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
669
764
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
670
- void outputBatcher.flushNow().then(async () => {
765
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
671
766
  await queueWechatMessage(authorizedUserId, event.text, "notice");
672
- });
767
+ }));
673
768
  }
674
769
  break;
675
770
  case "approval_required":
676
- void outputBatcher.flushNow().then(async () => {
771
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
677
772
  const pending = toPendingApproval(event.request);
678
773
  stateStore.setPendingConfirmation(pending);
679
774
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
680
775
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
681
- });
776
+ }));
682
777
  break;
683
778
  case "mirrored_user_input":
684
779
  stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
685
780
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
686
- void outputBatcher.flushNow().then(async () => {
781
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
687
782
  await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
688
- });
783
+ }));
689
784
  }
690
785
  break;
691
786
  case "session_switched":
692
787
  stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
693
788
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
694
- void outputBatcher.flushNow().then(async () => {
789
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
695
790
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
696
791
  adapter: options.adapter,
697
792
  sessionId: event.sessionId,
698
793
  source: event.source,
699
794
  reason: event.reason,
700
795
  }), "session_switched");
701
- });
796
+ }));
702
797
  }
703
798
  break;
704
799
  case "thread_switched":
705
800
  stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
706
801
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
707
- void outputBatcher.flushNow().then(async () => {
802
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
708
803
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
709
804
  adapter: options.adapter,
710
805
  sessionId: event.threadId,
711
806
  source: event.source,
712
807
  reason: event.reason,
713
808
  }), "thread_switched");
714
- });
809
+ }));
715
810
  }
716
811
  void maybeDrainDeferredInboundMessages();
717
812
  break;
718
813
  case "task_complete":
719
- void outputBatcher.flushNow().then(async () => {
814
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
720
815
  stateStore.clearPendingConfirmation();
721
816
  if (options.adapter === "shell") {
722
817
  const summary = buildCompletionSummary({
@@ -729,25 +824,25 @@ function wireAdapterEvents(params) {
729
824
  }
730
825
  clearActiveTask();
731
826
  await maybeDrainDeferredInboundMessages();
732
- });
827
+ }));
733
828
  break;
734
829
  case "task_failed":
735
- void outputBatcher.flushNow().then(async () => {
830
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
736
831
  stateStore.clearPendingConfirmation();
737
832
  clearActiveTask();
738
833
  await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
739
834
  await maybeDrainDeferredInboundMessages();
740
- });
835
+ }));
741
836
  break;
742
837
  case "fatal_error":
743
838
  logError(event.message);
744
839
  stateStore.appendLog(`fatal_error: ${event.message}`);
745
840
  stateStore.clearPendingConfirmation();
746
841
  clearActiveTask();
747
- void outputBatcher.flushNow().then(async () => {
842
+ trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
748
843
  await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
749
844
  await maybeDrainDeferredInboundMessages();
750
- });
845
+ }));
751
846
  break;
752
847
  case "shutdown_requested":
753
848
  stateStore.appendLog(`shutdown_requested: ${event.reason}`);
@@ -768,12 +863,24 @@ function buildCompletionSummary(params) {
768
863
  lines.push(`recent_output:\n${params.recentOutput}`);
769
864
  return lines.join("\n");
770
865
  }
866
+ function formatInboundMessagePreview(message) {
867
+ if (message.text.trim()) {
868
+ return message.text;
869
+ }
870
+ if (message.attachments.length > 0) {
871
+ return message.attachments
872
+ .map((attachment) => `${attachment.kind}: ${attachment.path}`)
873
+ .join("\n");
874
+ }
875
+ return "(empty)";
876
+ }
771
877
  async function handleInboundMessage(params) {
772
878
  const { message, options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
773
879
  const state = stateStore.getState();
774
880
  const systemCommand = parseWechatControlCommand(message.text, {
775
881
  adapter: options.adapter,
776
882
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
883
+ hasPendingUserInput: Boolean(state.pendingUserInput),
777
884
  });
778
885
  if (message.senderId !== state.authorizedUserId) {
779
886
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
@@ -901,12 +1008,13 @@ async function handleInboundMessage(params) {
901
1008
  }
902
1009
  async function dispatchInboundWechatText(params) {
903
1010
  const { message, options, stateStore, adapter } = params;
1011
+ const preview = formatInboundMessagePreview(message);
904
1012
  const activeTask = {
905
1013
  startedAt: Date.now(),
906
- inputPreview: truncatePreview(message.text, 180),
1014
+ inputPreview: truncatePreview(preview, 180),
907
1015
  };
908
- stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(message.text)}`);
909
- await adapter.sendInput(buildWechatInboundPrompt(message.text));
1016
+ stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
1017
+ await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
910
1018
  return activeTask;
911
1019
  }
912
1020
  const isDirectRun = Boolean(import.meta.main);
@@ -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([
@@ -37,7 +40,7 @@ export function parseCliArgs(argv) {
37
40
  return { cwd, cliArgs };
38
41
  }
39
42
  export function readCodexRuntimeEndpoint(cwd) {
40
- const endpoint = readLocalCompanionEndpoint(cwd);
43
+ const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
41
44
  if (!endpoint || endpoint.kind !== "codex") {
42
45
  throw new Error(`No active Codex bridge endpoint was found for ${cwd}. Start "wechat-bridge-codex" in that directory first.`);
43
46
  }
@@ -80,14 +83,18 @@ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
80
83
  updateLocalCompanionOccupancy(endpoint.cwd, {
81
84
  companionPid: child.pid,
82
85
  companionConnectedAt: new Date().toISOString(),
83
- }, endpoint.instanceId);
86
+ }, endpoint.instanceId, { adapter: "codex" });
84
87
  }
85
88
  child.once("error", (error) => {
86
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
89
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
90
+ adapter: "codex",
91
+ });
87
92
  reject(error);
88
93
  });
89
94
  child.once("exit", (code, signal) => {
90
- clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId);
95
+ clearLocalCompanionOccupancy(endpoint.cwd, endpoint.instanceId, {
96
+ adapter: "codex",
97
+ });
91
98
  if (signal) {
92
99
  process.kill(process.pid, signal);
93
100
  return;