@unlinearity/cli-wechat-bridge 1.1.4 → 1.1.6

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.
Files changed (42) hide show
  1. package/README.md +153 -131
  2. package/bin/_run-entry.mjs +20 -17
  3. package/dist/bridge/bridge-adapters.claude.js +429 -11
  4. package/dist/bridge/bridge-adapters.codex.js +441 -22
  5. package/dist/bridge/bridge-adapters.core.js +2 -1
  6. package/dist/bridge/bridge-adapters.opencode.js +392 -53
  7. package/dist/bridge/bridge-adapters.pi.js +33 -7
  8. package/dist/bridge/bridge-adapters.shared.js +2 -1
  9. package/dist/bridge/bridge-controller.js +3 -0
  10. package/dist/bridge/bridge-final-reply.js +2 -65
  11. package/dist/bridge/bridge-session-resume.js +151 -0
  12. package/dist/bridge/bridge-state.js +1 -1
  13. package/dist/bridge/bridge-utils.js +30 -101
  14. package/dist/bridge/claude-hooks.js +1 -0
  15. package/dist/bridge/wechat-bridge.js +256 -211
  16. package/dist/bridge/wechat-forwarding.js +2 -58
  17. package/dist/bridge/wechat-outbound-queue.js +2 -0
  18. package/dist/channels/wechat/bridge-final-reply.js +65 -0
  19. package/dist/channels/wechat/channel-message.js +24 -0
  20. package/dist/channels/wechat/index.js +5 -0
  21. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  22. package/dist/channels/wechat/wechat-forwarding.js +79 -0
  23. package/dist/channels/wechat/wechat-outbound-queue.js +72 -0
  24. package/dist/companion/codex-remote-client.js +291 -32
  25. package/dist/companion/codex-visible-client-link.js +122 -0
  26. package/dist/companion/local-companion-link.js +26 -0
  27. package/dist/companion/local-companion-start.js +0 -1
  28. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  29. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  30. package/dist/core/bridge-control.js +32 -0
  31. package/dist/core/bridge-event-forwarder.js +50 -0
  32. package/dist/core/bridge-message-router.js +32 -0
  33. package/dist/core/channel-types.js +1 -0
  34. package/dist/core/text-utils.js +63 -0
  35. package/dist/daemon/wechat-daemon.js +242 -159
  36. package/dist/i18n/messages-en.js +2 -2
  37. package/dist/i18n/messages-zh.js +2 -2
  38. package/package.json +2 -6
  39. package/bin/wechat-claude-start.mjs +0 -10
  40. package/bin/wechat-codex-start.mjs +0 -10
  41. package/bin/wechat-opencode-start.mjs +0 -10
  42. package/bin/wechat-pi-start.mjs +0 -10
@@ -4,17 +4,21 @@ import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
4
4
  import { delay } from "./bridge-adapters.shared.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { BridgeController } from "./bridge-controller.js";
7
- import { forwardWechatFinalReply } from "./bridge-final-reply.js";
8
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
7
+ import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
8
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isWechatContextUnavailableError, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
9
9
  import { ensureWechatCredentials } from "../wechat/setup.js";
10
10
  import { BridgeStateStore } from "./bridge-state.js";
11
11
  import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
12
12
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
13
- import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatResumeSessionList, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
13
+ import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
14
+ import { routeBridgeMessage } from "../core/bridge-message-router.js";
15
+ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
16
+ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
17
+ 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";
14
18
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
19
+ import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
15
20
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
16
21
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
17
- import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "../daemon/emoji-bindings.js";
18
22
  const POLL_RETRY_BASE_MS = 1_000;
19
23
  const POLL_RETRY_MAX_MS = 30_000;
20
24
  const PARENT_PROCESS_POLL_MS = 5_000;
@@ -269,6 +273,11 @@ async function main() {
269
273
  initialResumeConversationId: stateStore.getState().resumeConversationId,
270
274
  initialTranscriptPath: stateStore.getState().transcriptPath,
271
275
  });
276
+ const pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(options.cwd));
277
+ const resumeCoordinator = new ResumeSessionCoordinator({
278
+ adapter: options.adapter,
279
+ runtime: adapter,
280
+ });
272
281
  const controller = new BridgeController(adapter, options.cwd);
273
282
  controller.clearLocalClientEndpoint();
274
283
  stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
@@ -290,48 +299,89 @@ async function main() {
290
299
  attachmentSendChain = run.then(() => undefined, () => undefined);
291
300
  return run;
292
301
  };
293
- const queueWechatMessage = (senderId, text, context = "message") => {
294
- return queueWechatTextAction(async () => {
295
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
296
- try {
297
- await transport.sendText(senderId, text);
298
- return true;
302
+ const sendWechatMessageNow = async (senderId, text, context = "message") => {
303
+ const startedAtMs = Date.now();
304
+ stateStore.appendLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
305
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
306
+ try {
307
+ await transport.sendText(senderId, text);
308
+ stateStore.appendLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
309
+ return { status: "sent" };
310
+ }
311
+ catch (err) {
312
+ if (err instanceof Error && err.name === "AbortError") {
313
+ stateStore.appendLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
299
314
  }
300
- catch (err) {
315
+ if (isWechatContextUnavailableError(err)) {
301
316
  if (isWechatContextTokenStaleError(err)) {
302
317
  transport.clearCachedContextToken(senderId);
303
- 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.";
304
- logError(`Failed to send WeChat ${context}: ${hint}`);
305
- stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
318
+ }
319
+ const hint = "WeChat conversation context is stale or unavailable. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
320
+ logError(`Failed to send WeChat ${context}: ${hint}`);
321
+ stateStore.appendLog(isWechatContextTokenStaleError(err)
322
+ ? formatWechatContextTokenStaleLogEntry({
306
323
  context,
307
324
  recipientId: senderId,
308
325
  error: err,
309
- }));
310
- return false;
311
- }
312
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
313
- const delayMs = computeWechatSendRetryDelayMs(attempt);
314
- logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
315
- stateStore.appendLog(formatWechatSendRetryLogEntry({
326
+ })
327
+ : formatWechatSendFailureLogEntry({
316
328
  context,
317
329
  recipientId: senderId,
318
- attempt,
319
- delayMs,
320
330
  error: err,
321
331
  }));
322
- await delay(delayMs);
323
- continue;
324
- }
325
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
326
- stateStore.appendLog(formatWechatSendFailureLogEntry({
332
+ return { status: "context_unavailable", error: err };
333
+ }
334
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
335
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
336
+ logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
337
+ stateStore.appendLog(formatWechatSendRetryLogEntry({
327
338
  context,
328
339
  recipientId: senderId,
340
+ attempt,
341
+ delayMs,
329
342
  error: err,
330
343
  }));
331
- return false;
344
+ await delay(delayMs);
345
+ continue;
332
346
  }
347
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
348
+ stateStore.appendLog(formatWechatSendFailureLogEntry({
349
+ context,
350
+ recipientId: senderId,
351
+ error: err,
352
+ }));
353
+ return { status: "failed" };
354
+ }
355
+ }
356
+ return { status: "failed" };
357
+ };
358
+ const queueWechatMessage = (senderId, text, context = "message") => {
359
+ return queueWechatTextAction(async () => {
360
+ const result = await sendWechatMessageNow(senderId, text, context);
361
+ if (result.status === "context_unavailable") {
362
+ const pending = pendingWechatMessages.enqueue(senderId, text, context);
363
+ if (pending) {
364
+ stateStore.appendLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${pendingWechatMessages.list().length}`);
365
+ }
366
+ }
367
+ return result.status === "sent";
368
+ });
369
+ };
370
+ const flushPendingWechatMessages = () => {
371
+ return queueWechatTextAction(async () => {
372
+ for (const pending of pendingWechatMessages.list()) {
373
+ const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
374
+ if (result.status === "sent") {
375
+ pendingWechatMessages.remove(pending.id);
376
+ stateStore.appendLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
377
+ continue;
378
+ }
379
+ if (result.status === "context_unavailable") {
380
+ break;
381
+ }
382
+ stateStore.appendLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
383
+ break;
333
384
  }
334
- return false;
335
385
  });
336
386
  };
337
387
  const trackWechatForwardTask = (task) => {
@@ -533,6 +583,7 @@ async function main() {
533
583
  try {
534
584
  wireAdapterEvents({
535
585
  adapter,
586
+ resumeCoordinator,
536
587
  options,
537
588
  transport,
538
589
  stateStore,
@@ -577,10 +628,6 @@ async function main() {
577
628
  else if (options.adapter === "pi") {
578
629
  log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
579
630
  }
580
- loadEmojiBindings();
581
- // Keep the standalone bridge's WeChat welcome brief: command and emoji
582
- // reference material belongs to /status and /bindings, which users can
583
- // request on demand. The daemon sends its own, fuller welcome.
584
631
  const welcomeText = t("bridge.welcome", {
585
632
  adapter: options.adapter,
586
633
  cwd: options.cwd,
@@ -628,6 +675,9 @@ async function main() {
628
675
  log(`WeChat long poll recovered after ${recoveredFailures} transient error(s).`);
629
676
  stateStore.appendLog(`poll_recovered: failures=${recoveredFailures}`);
630
677
  }
678
+ if (pollResult.messages.length > 0 && pendingWechatMessages.list().length > 0) {
679
+ await flushPendingWechatMessages();
680
+ }
631
681
  if (pollResult.ignoredBacklogCount > 0) {
632
682
  stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
633
683
  stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
@@ -651,8 +701,12 @@ async function main() {
651
701
  options,
652
702
  stateStore,
653
703
  adapter,
704
+ resumeCoordinator,
654
705
  queueWechatMessage,
655
706
  outputBatcher,
707
+ clearActiveTask: () => {
708
+ activeTask = null;
709
+ },
656
710
  deferInboundMessage: async (nextMessage) => {
657
711
  deferredInboundMessages.push({
658
712
  message: nextMessage,
@@ -709,7 +763,22 @@ function syncSharedSessionState(stateStore, adapter) {
709
763
  }
710
764
  }
711
765
  function wireAdapterEvents(params) {
712
- const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
766
+ const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
767
+ const channelPort = new WechatChannelPort({
768
+ sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
769
+ sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
770
+ sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
771
+ sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
772
+ sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
773
+ onEmptyVisibleReply: (adapter, rawText) => {
774
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
775
+ },
776
+ onTextSent: (_adapter, text) => {
777
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
778
+ },
779
+ });
780
+ let lastFinalReplyAtMs = 0;
781
+ let eventForwardChain = Promise.resolve();
713
782
  adapter.setEventSink((event) => {
714
783
  syncSharedSessionState();
715
784
  syncLocalClientEndpoint();
@@ -722,56 +791,52 @@ function wireAdapterEvents(params) {
722
791
  stateStore.clearPendingUserInput();
723
792
  }
724
793
  const authorizedUserId = stateStore.getState().authorizedUserId;
725
- switch (event.type) {
726
- case "stdout":
727
- case "stderr":
728
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
729
- outputBatcher.push(event.text);
794
+ eventForwardChain = eventForwardChain
795
+ .then(() => forwardBridgeEvent(event, {
796
+ stdout: (next) => {
797
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
798
+ outputBatcher.push(next.text);
730
799
  }
731
- break;
732
- case "final_reply":
733
- stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
800
+ },
801
+ stderr: (next) => {
802
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
803
+ outputBatcher.push(next.text);
804
+ }
805
+ },
806
+ finalReply: (next) => {
807
+ lastFinalReplyAtMs = Date.now();
808
+ stateStore.appendLog(`final_reply: ${truncatePreview(next.text)}`);
734
809
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
735
- await forwardWechatFinalReply({
736
- adapter: options.adapter,
737
- rawText: event.text,
738
- onEmptyVisibleReply: ({ rawVisibleText }) => {
739
- stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
740
- },
741
- sender: {
742
- sendText: async (text) => {
743
- const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
744
- if (sent) {
745
- stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
746
- }
747
- return sent;
748
- },
749
- sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
750
- sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
751
- sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
752
- sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
810
+ await channelPort.send({
811
+ target: {
812
+ channelId: "wechat",
813
+ conversationId: authorizedUserId,
814
+ recipientId: authorizedUserId,
753
815
  },
816
+ kind: "final_reply",
817
+ text: next.text,
818
+ adapter: options.adapter,
754
819
  });
755
820
  }));
756
- break;
757
- case "status":
758
- if (event.message) {
759
- log(`${event.status}: ${event.message}`);
760
- stateStore.appendLog(`${event.status}: ${event.message}`);
821
+ },
822
+ status: (next) => {
823
+ if (next.message) {
824
+ log(`${next.status}: ${next.message}`);
825
+ stateStore.appendLog(`${next.status}: ${next.message}`);
761
826
  }
762
827
  void maybeDrainDeferredInboundMessages();
763
- break;
764
- case "notice":
765
- stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
766
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
828
+ },
829
+ notice: (next) => {
830
+ stateStore.appendLog(`${next.level}_notice: ${truncatePreview(next.text)}`);
831
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
767
832
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
768
- await queueWechatMessage(authorizedUserId, event.text, "notice");
833
+ await queueWechatMessage(authorizedUserId, next.text, "notice");
769
834
  }));
770
835
  }
771
- break;
772
- case "thinking":
773
- if (event.text) {
774
- const thinkingPreview = formatThinkingForWechat(event.text, 500);
836
+ },
837
+ thinking: (next) => {
838
+ if (next.text) {
839
+ const thinkingPreview = formatThinkingForWechat(next.text, 500);
775
840
  if (thinkingPreview) {
776
841
  stateStore.appendLog(`thinking: ${thinkingPreview}`);
777
842
  trackWechatForwardTask((async () => {
@@ -779,91 +844,98 @@ function wireAdapterEvents(params) {
779
844
  })());
780
845
  }
781
846
  }
782
- break;
783
- case "approval_required":
847
+ },
848
+ approvalRequired: (next) => {
784
849
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
785
- const pending = toPendingApproval(event.request);
850
+ const pending = toPendingApproval(next.request);
786
851
  stateStore.setPendingConfirmation(pending);
787
852
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
788
853
  await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
789
854
  }));
790
- break;
791
- case "user_input_required":
855
+ },
856
+ userInputRequired: (next) => {
792
857
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
793
- const pending = toPendingUserInput(event.request);
858
+ const pending = toPendingUserInput(next.request);
794
859
  stateStore.setPendingUserInput(pending);
795
860
  stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
796
861
  await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required");
797
862
  }));
798
- break;
799
- case "mirrored_user_input":
800
- stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
801
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
863
+ },
864
+ mirroredUserInput: (next) => {
865
+ stateStore.appendLog(`mirrored_local_input: ${truncatePreview(next.text)}`);
866
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
802
867
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
803
- await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
868
+ await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, next.text), "mirrored_user_input");
804
869
  }));
805
870
  }
806
- break;
807
- case "session_switched":
808
- stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
809
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
871
+ },
872
+ sessionSwitched: (next) => {
873
+ if (next.source === "local")
874
+ resumeCoordinator.clear();
875
+ stateStore.appendLog(`session_switched: ${next.sessionId} source=${next.source} reason=${next.reason}`);
876
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
810
877
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
811
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
812
- adapter: options.adapter,
813
- sessionId: event.sessionId,
814
- source: event.source,
815
- reason: event.reason,
816
- }), "session_switched");
878
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason }), "session_switched");
817
879
  }));
818
880
  }
819
- break;
820
- case "thread_switched":
821
- stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
822
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
881
+ },
882
+ threadSwitched: (next) => {
883
+ if (next.source === "local")
884
+ resumeCoordinator.clear();
885
+ stateStore.appendLog(`thread_switched: ${next.threadId} source=${next.source} reason=${next.reason}`);
886
+ if (shouldSuppressCodexLocalThreadNotice({
887
+ adapter: options.adapter,
888
+ source: next.source,
889
+ activeTurnOrigin: adapter.getState().activeTurnOrigin,
890
+ lastFinalReplyAtMs,
891
+ })) {
892
+ return;
893
+ }
894
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
823
895
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
824
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
825
- adapter: options.adapter,
826
- sessionId: event.threadId,
827
- source: event.source,
828
- reason: event.reason,
829
- }), "thread_switched");
896
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.threadId, source: next.source, reason: next.reason }), "thread_switched");
830
897
  }));
831
898
  }
832
899
  void maybeDrainDeferredInboundMessages();
833
- break;
834
- case "task_complete":
900
+ },
901
+ taskComplete: () => {
835
902
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
836
903
  stateStore.clearPendingConfirmation();
837
904
  stateStore.clearPendingUserInput();
838
905
  clearActiveTask();
839
906
  await maybeDrainDeferredInboundMessages();
840
907
  }));
841
- break;
842
- case "task_failed":
908
+ },
909
+ taskFailed: (next) => {
843
910
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
844
911
  stateStore.clearPendingConfirmation();
845
912
  stateStore.clearPendingUserInput();
846
913
  clearActiveTask();
847
- await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
914
+ await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, next.message), "task_failed");
848
915
  await maybeDrainDeferredInboundMessages();
849
916
  }));
850
- break;
851
- case "fatal_error":
852
- logError(event.message);
853
- stateStore.appendLog(`fatal_error: ${event.message}`);
917
+ },
918
+ fatalError: (next) => {
919
+ logError(next.message);
920
+ stateStore.appendLog(`fatal_error: ${next.message}`);
854
921
  stateStore.clearPendingConfirmation();
855
922
  stateStore.clearPendingUserInput();
856
923
  clearActiveTask();
857
924
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
858
- await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
925
+ await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(next.message), "fatal_error");
859
926
  await maybeDrainDeferredInboundMessages();
860
927
  }));
861
- break;
862
- case "shutdown_requested":
863
- stateStore.appendLog(`shutdown_requested: ${event.reason}`);
864
- requestShutdown(event.message, event.exitCode ?? 0);
865
- break;
866
- }
928
+ },
929
+ shutdownRequested: (next) => {
930
+ stateStore.appendLog(`shutdown_requested: ${next.reason}`);
931
+ requestShutdown(next.message, next.exitCode ?? 0);
932
+ },
933
+ }))
934
+ .catch((error) => {
935
+ const message = error instanceof Error ? error.message : String(error);
936
+ logError(`Bridge event handling failed: ${message}`);
937
+ stateStore.appendLog(`event_forward_failed: ${message}`);
938
+ });
867
939
  });
868
940
  }
869
941
  function formatInboundMessagePreview(message) {
@@ -878,44 +950,13 @@ function formatInboundMessagePreview(message) {
878
950
  return "(empty)";
879
951
  }
880
952
  async function handleInboundMessage(params) {
881
- let { message, } = params;
882
- const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
953
+ const { message, } = params;
954
+ const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
883
955
  const state = stateStore.getState();
884
956
  if (message.senderId !== state.authorizedUserId) {
885
957
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
886
958
  return null;
887
959
  }
888
- // Emoji resolution: rewrite message text if it starts with a bound emoji
889
- const emojiMatch = resolveEmojiCommand(message.text);
890
- if (emojiMatch) {
891
- const rewritten = emojiMatch.remainder
892
- ? `${emojiMatch.command} ${emojiMatch.remainder}`
893
- : emojiMatch.command;
894
- message = { ...message, text: rewritten };
895
- }
896
- // Emoji binding management commands
897
- const bindingsCmd = parseEmojiBindingsCommand(message.text);
898
- if (bindingsCmd) {
899
- switch (bindingsCmd.type) {
900
- case "list":
901
- await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
902
- break;
903
- case "bind":
904
- setBinding(bindingsCmd.emoji, bindingsCmd.command);
905
- await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
906
- break;
907
- case "unbind": {
908
- const removed = removeBinding(bindingsCmd.emoji);
909
- await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
910
- break;
911
- }
912
- }
913
- return null;
914
- }
915
- if (isBindCommandPrefix(message.text)) {
916
- await queueWechatMessage(message.senderId, formatBindCommandUsage());
917
- return null;
918
- }
919
960
  const systemCommand = parseWechatControlCommand(message.text, {
920
961
  adapter: options.adapter,
921
962
  hasPendingConfirmation: Boolean(state.pendingConfirmation),
@@ -926,35 +967,23 @@ async function handleInboundMessage(params) {
926
967
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
927
968
  return null;
928
969
  case "resume": {
929
- if (options.adapter === "codex") {
930
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
970
+ if (!isWechatResumeEnabled(options.adapter)) {
971
+ await queueWechatMessage(message.senderId, `WeChat /resume is disabled in ${options.adapter} mode. Use /resume directly inside "wechat-${options.adapter}"; WeChat will follow the active local session.`);
931
972
  return null;
932
973
  }
933
- if (options.adapter === "claude") {
934
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
935
- return null;
936
- }
937
- if (options.adapter === "opencode") {
938
- try {
939
- if (systemCommand.target) {
940
- await adapter.resumeSession(systemCommand.target);
941
- await queueWechatMessage(message.senderId, `Resumed OpenCode session ${systemCommand.target}.`);
942
- }
943
- else {
944
- const candidates = await adapter.listResumeSessions(8);
945
- await queueWechatMessage(message.senderId, formatResumeSessionList({
946
- adapter: options.adapter,
947
- candidates,
948
- currentSessionId: adapter.getState().sharedSessionId,
949
- }));
950
- }
974
+ try {
975
+ if (systemCommand.target) {
976
+ await outputBatcher.flushNow();
951
977
  }
952
- catch (error) {
953
- await queueWechatMessage(message.senderId, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`);
978
+ const result = await resumeCoordinator.execute(systemCommand.target);
979
+ if (result.kind === "resumed") {
980
+ clearActiveTask();
954
981
  }
955
- return null;
982
+ await queueWechatMessage(message.senderId, result.message);
983
+ }
984
+ catch (error) {
985
+ await queueWechatMessage(message.senderId, error instanceof Error ? error.message : String(error));
956
986
  }
957
- await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
958
987
  return null;
959
988
  }
960
989
  case "new_session": {
@@ -967,6 +996,7 @@ async function handleInboundMessage(params) {
967
996
  stateStore.clearPendingConfirmation();
968
997
  stateStore.clearPendingUserInput();
969
998
  stateStore.clearSharedSessionId();
999
+ resumeCoordinator.clear();
970
1000
  await adapter.createSession();
971
1001
  stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
972
1002
  return null;
@@ -984,6 +1014,7 @@ async function handleInboundMessage(params) {
984
1014
  stateStore.clearPendingConfirmation();
985
1015
  stateStore.clearPendingUserInput();
986
1016
  stateStore.clearSharedSessionId();
1017
+ resumeCoordinator.clear();
987
1018
  await adapter.reset();
988
1019
  stateStore.appendLog("Worker reset by owner.");
989
1020
  await queueWechatMessage(message.senderId, "Worker session has been reset.");
@@ -1048,40 +1079,54 @@ async function handleInboundMessage(params) {
1048
1079
  };
1049
1080
  }
1050
1081
  }
1051
- if (state.pendingConfirmation) {
1052
- await queueWechatMessage(message.senderId, formatPendingApprovalReminder(state.pendingConfirmation, adapter.getState()));
1053
- return null;
1054
- }
1055
- if (state.pendingUserInput) {
1056
- await queueWechatMessage(message.senderId, formatPendingUserInputReminder(state.pendingUserInput));
1057
- return null;
1058
- }
1059
1082
  const adapterState = adapter.getState();
1060
- if (shouldDeferCodexInboundMessage({
1061
- adapter: options.adapter,
1062
- status: adapterState.status,
1063
- activeTurnOrigin: adapterState.activeTurnOrigin,
1064
- hasPendingConfirmation: Boolean(state.pendingConfirmation),
1065
- hasSystemCommand: Boolean(systemCommand),
1066
- })) {
1067
- await deferInboundMessage(message);
1068
- return null;
1069
- }
1070
- if (adapterState.status === "busy") {
1071
- if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1072
- adapterState.activeTurnOrigin === "local") {
1073
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1074
- return null;
1075
- }
1076
- await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1077
- return null;
1078
- }
1079
- return dispatchInboundWechatText({
1080
- message,
1081
- options,
1082
- stateStore,
1083
- adapter,
1083
+ const routeResult = await routeBridgeMessage({
1084
+ message: toChannelInboundMessage(message),
1085
+ authorized: true,
1086
+ command: null,
1087
+ adapterState,
1088
+ hasPendingApproval: Boolean(state.pendingConfirmation),
1089
+ hasPendingUserInput: Boolean(state.pendingUserInput),
1090
+ shouldDefer: shouldDeferCodexInboundMessage({
1091
+ adapter: options.adapter,
1092
+ status: adapterState.status,
1093
+ activeTurnOrigin: adapterState.activeTurnOrigin,
1094
+ hasPendingConfirmation: Boolean(state.pendingConfirmation),
1095
+ hasSystemCommand: Boolean(systemCommand),
1096
+ }),
1097
+ onUnauthorized: async () => undefined,
1098
+ handleCommand: async () => false,
1099
+ remindPendingApproval: async () => {
1100
+ await queueWechatMessage(message.senderId, formatPendingApprovalReminder(stateStore.getState().pendingConfirmation, adapter.getState()));
1101
+ },
1102
+ remindPendingUserInput: async () => {
1103
+ const pendingUserInput = stateStore.getState().pendingUserInput;
1104
+ await queueWechatMessage(message.senderId, pendingUserInput
1105
+ ? formatPendingUserInputReminder(pendingUserInput)
1106
+ : `${options.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1107
+ },
1108
+ remindBusy: async () => {
1109
+ const currentState = adapter.getState();
1110
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1111
+ currentState.activeTurnOrigin === "local") {
1112
+ await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1113
+ return;
1114
+ }
1115
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1116
+ },
1117
+ defer: async () => {
1118
+ await deferInboundMessage(message);
1119
+ },
1120
+ dispatch: async () => {
1121
+ return await dispatchInboundWechatText({
1122
+ message,
1123
+ options,
1124
+ stateStore,
1125
+ adapter,
1126
+ });
1127
+ },
1084
1128
  });
1129
+ return routeResult.kind === "dispatched" ? routeResult.result : null;
1085
1130
  }
1086
1131
  async function dispatchInboundWechatText(params) {
1087
1132
  const { message, options, stateStore, adapter } = params;