cli-wechat-bridge 1.1.3 → 1.1.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.
Files changed (50) hide show
  1. package/README.md +110 -140
  2. package/bin/wechat-claude.mjs +1 -1
  3. package/bin/wechat-codex.mjs +1 -1
  4. package/bin/wechat-opencode.mjs +1 -1
  5. package/bin/wechat-pi.mjs +1 -1
  6. package/dist/bridge/bridge-adapters.claude.js +448 -11
  7. package/dist/bridge/bridge-adapters.codex.js +262 -11
  8. package/dist/bridge/bridge-adapters.core.js +76 -4
  9. package/dist/bridge/bridge-adapters.js +0 -3
  10. package/dist/bridge/bridge-adapters.opencode.js +497 -49
  11. package/dist/bridge/bridge-adapters.pi.js +44 -8
  12. package/dist/bridge/bridge-adapters.shared.js +13 -100
  13. package/dist/bridge/bridge-controller.js +3 -0
  14. package/dist/bridge/bridge-final-reply.js +17 -2
  15. package/dist/bridge/bridge-process-reaper.js +14 -4
  16. package/dist/bridge/bridge-session-resume.js +151 -0
  17. package/dist/bridge/bridge-state.js +41 -6
  18. package/dist/bridge/bridge-utils.js +63 -229
  19. package/dist/bridge/claude-hooks.js +1 -0
  20. package/dist/bridge/wechat-bridge.js +93 -140
  21. package/dist/bridge/wechat-forwarding.js +58 -0
  22. package/dist/companion/codex-remote-client.js +309 -36
  23. package/dist/companion/codex-visible-client-link.js +122 -0
  24. package/dist/companion/local-companion-link.js +33 -2
  25. package/dist/companion/local-companion-start.js +44 -13
  26. package/dist/companion/local-companion.js +6 -3
  27. package/dist/companion/opencode-tui-bridge-plugin.js +73 -0
  28. package/dist/companion/pi-tui-bridge-extension.js +42 -3
  29. package/dist/daemon/daemon-link.js +25 -3
  30. package/dist/daemon/wechat-daemon.js +168 -56
  31. package/dist/i18n/index.js +0 -3
  32. package/dist/i18n/messages-en.js +2 -3
  33. package/dist/i18n/messages-zh.js +2 -3
  34. package/dist/utils/atomic-file.js +15 -0
  35. package/dist/utils/doctor.js +23 -11
  36. package/dist/wechat/channel-config.js +0 -2
  37. package/dist/wechat/setup.js +5 -11
  38. package/dist/wechat/wechat-transport.js +14 -8
  39. package/package.json +2 -13
  40. package/bin/wechat-bridge-claude.mjs +0 -5
  41. package/bin/wechat-bridge-codex.mjs +0 -5
  42. package/bin/wechat-bridge-opencode.mjs +0 -5
  43. package/bin/wechat-bridge-pi.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. package/bin/wechat-claude-start.mjs +0 -5
  47. package/bin/wechat-codex-start.mjs +0 -5
  48. package/bin/wechat-opencode-start.mjs +0 -5
  49. package/bin/wechat-pi-start.mjs +0 -5
  50. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -5,9 +5,11 @@ import { delay } from "./bridge-adapters.shared.js";
5
5
  import { t } from "../i18n/index.js";
6
6
  import { BridgeController } from "./bridge-controller.js";
7
7
  import { forwardWechatFinalReply } from "./bridge-final-reply.js";
8
+ import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
9
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
8
10
  import { ensureWechatCredentials } from "../wechat/setup.js";
9
11
  import { BridgeStateStore } from "./bridge-state.js";
10
- import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
12
+ import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
11
13
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
12
14
  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";
13
15
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
@@ -17,8 +19,11 @@ import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix,
17
19
  const POLL_RETRY_BASE_MS = 1_000;
18
20
  const POLL_RETRY_MAX_MS = 30_000;
19
21
  const PARENT_PROCESS_POLL_MS = 5_000;
20
- const WECHAT_SEND_MAX_ATTEMPTS = 3;
21
- const WECHAT_SEND_RETRY_BASE_MS = 750;
22
+ // Hard ceiling for the whole shutdown cleanup sequence before a forced exit.
23
+ const SHUTDOWN_FORCE_EXIT_TIMEOUT_MS = 10_000;
24
+ // Re-verify the parent's command line every N polls (5s each → ~60s) to keep
25
+ // the full-process probe off the hot path.
26
+ const PARENT_IDENTITY_CHECK_INTERVAL = 12;
22
27
  function log(message) {
23
28
  process.stderr.write(`[wechat-bridge] ${message}\n`);
24
29
  }
@@ -42,63 +47,6 @@ function isPidAlive(pid) {
42
47
  return false;
43
48
  }
44
49
  }
45
- export function formatUserFacingBridgeFatalError(message) {
46
- return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
47
- }
48
- export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
49
- if (adapter !== "opencode" && adapter !== "pi") {
50
- return true;
51
- }
52
- switch (eventType) {
53
- case "stdout":
54
- case "stderr":
55
- case "thread_switched":
56
- return false;
57
- case "notice":
58
- return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
59
- case "mirrored_user_input":
60
- return true;
61
- default:
62
- return true;
63
- }
64
- }
65
- export function formatUserFacingInboundError(params) {
66
- const { adapter, cwd, errorText, isUserFacingShellRejection } = params;
67
- if (isUserFacingShellRejection) {
68
- return errorText;
69
- }
70
- if (adapter === "opencode" &&
71
- /opencode companion is not connected/i.test(errorText)) {
72
- return cwd
73
- ? `OpenCode companion is not connected for bridge workspace:\n${cwd}\nRun "wechat-opencode" in that directory to reconnect the current local terminal, or run "wechat-bridge-opencode" and then "wechat-opencode" in your target project to replace this bridge.`
74
- : 'OpenCode companion is not connected. Start "wechat-opencode" in this directory to reconnect it, then retry.';
75
- }
76
- return `Bridge error: ${errorText}`;
77
- }
78
- export function formatWechatSendFailureLogEntry(params) {
79
- return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
80
- }
81
- export function formatWechatContextTokenStaleLogEntry(params) {
82
- return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
83
- }
84
- function formatWechatSendRetryLogEntry(params) {
85
- return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
86
- }
87
- export function isRetryableWechatSendError(error) {
88
- if (isWechatContextTokenStaleError(error)) {
89
- return false;
90
- }
91
- const classification = classifyWechatTransportError(error);
92
- if (classification.retryable) {
93
- return true;
94
- }
95
- const details = describeWechatTransportError(error);
96
- return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
97
- !/errcode=-14\b.*session timeout/i.test(details);
98
- }
99
- function computeWechatSendRetryDelayMs(attempt) {
100
- return WECHAT_SEND_RETRY_BASE_MS * attempt;
101
- }
102
50
  export function shouldWatchParentProcess(options) {
103
51
  return (options.startupParentPid > 1 &&
104
52
  (options.attachedToTerminal || options.lifecycle === "companion_bound"));
@@ -159,7 +107,7 @@ export function parseCliArgs(argv) {
159
107
  const next = argv[i + 1];
160
108
  switch (arg) {
161
109
  case "--adapter":
162
- if (!next || !["codex", "claude", "opencode", "pi", "shell"].includes(next)) {
110
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
163
111
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
164
112
  }
165
113
  adapter = next;
@@ -212,7 +160,7 @@ export function parseCliArgs(argv) {
212
160
  }
213
161
  }
214
162
  if (!adapter) {
215
- throw new Error("Missing required --adapter <codex|claude|opencode|pi|shell>");
163
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
216
164
  }
217
165
  const defaultCommand = resolveDefaultAdapterCommand(adapter);
218
166
  return {
@@ -226,18 +174,10 @@ export function parseCliArgs(argv) {
226
174
  }
227
175
  function printUsageAndExit() {
228
176
  process.stdout.write([
229
- "Usage: wechat-bridge --adapter <codex|claude|opencode|pi|shell> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
177
+ "Internal bridge runtime usage:",
178
+ " npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
230
179
  "",
231
- "Examples:",
232
- " wechat-bridge-codex",
233
- " wechat-bridge-claude --cwd ~/work/my-project",
234
- " wechat-bridge-opencode --cwd ~/work/my-project",
235
- " wechat-bridge-pi --cwd ~/work/my-project",
236
- " wechat-bridge-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
237
- " wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
238
- " wechat-bridge-codex --lifecycle companion_bound",
239
- " bun run bridge:codex # repo-local development entrypoint",
240
- " bun run bridge:opencode # repo-local development entrypoint",
180
+ "This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
241
181
  "",
242
182
  ].join("\n"));
243
183
  process.exit(0);
@@ -330,6 +270,10 @@ async function main() {
330
270
  initialResumeConversationId: stateStore.getState().resumeConversationId,
331
271
  initialTranscriptPath: stateStore.getState().transcriptPath,
332
272
  });
273
+ const resumeCoordinator = new ResumeSessionCoordinator({
274
+ adapter: options.adapter,
275
+ runtime: adapter,
276
+ });
333
277
  const controller = new BridgeController(adapter, options.cwd);
334
278
  controller.clearLocalClientEndpoint();
335
279
  stateStore.appendLog(`Cleared stale companion endpoint for ${options.cwd} before adapter start.`);
@@ -339,8 +283,6 @@ async function main() {
339
283
  let activeTask = null;
340
284
  const deferredInboundMessages = [];
341
285
  let drainingDeferredInboundMessages = false;
342
- let lastOutputAt = 0;
343
- let lastHeartbeatAt = 0;
344
286
  let consecutivePollFailures = 0;
345
287
  let backlogNoticeSent = false;
346
288
  const queueWechatTextAction = (action) => {
@@ -447,7 +389,6 @@ async function main() {
447
389
  adapter,
448
390
  });
449
391
  activeTask = nextTask;
450
- lastHeartbeatAt = 0;
451
392
  }
452
393
  catch (err) {
453
394
  const errorText = err instanceof Error ? err.message : String(err);
@@ -462,7 +403,6 @@ async function main() {
462
403
  adapter: options.adapter,
463
404
  cwd: options.cwd,
464
405
  errorText,
465
- isUserFacingShellRejection: false,
466
406
  }), "inbound_error");
467
407
  }
468
408
  finally {
@@ -474,6 +414,10 @@ async function main() {
474
414
  let shutdownPromise = null;
475
415
  let requestedExitCode = 0;
476
416
  let stdinDetached = false;
417
+ // Snapshot of the parent process command line at bridge startup (null when
418
+ // unresolvable). Compared against periodic re-probes to detect OS pid reuse.
419
+ const parentStartupCommandLine = getProcessRecordByPid(startupParentPid)?.commandLine ?? null;
420
+ let parentWatchPollCount = 0;
477
421
  const parentWatchTimer = shouldWatchParentProcess({
478
422
  startupParentPid,
479
423
  attachedToTerminal,
@@ -481,6 +425,19 @@ async function main() {
481
425
  })
482
426
  ? setInterval(() => {
483
427
  if (shutdownPromise || isPidAlive(startupParentPid)) {
428
+ // Periodically re-verify the parent's command line so an OS pid
429
+ // reuse (dead parent's pid handed to an unrelated long-running
430
+ // process) cannot keep this companion-bound bridge alive forever.
431
+ parentWatchPollCount += 1;
432
+ if (parentWatchPollCount % PARENT_IDENTITY_CHECK_INTERVAL === 0) {
433
+ const record = getProcessRecordByPid(startupParentPid);
434
+ if (parentStartupCommandLine !== null &&
435
+ record !== null &&
436
+ record.commandLine !== parentStartupCommandLine) {
437
+ log(`Parent pid ${startupParentPid} was reused by another process. Stopping bridge.`);
438
+ void shutdown(0);
439
+ }
440
+ }
484
441
  return;
485
442
  }
486
443
  log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
@@ -530,7 +487,17 @@ async function main() {
530
487
  return;
531
488
  }
532
489
  log(message);
533
- void shutdown(exitCode).finally(() => process.exit(requestedExitCode));
490
+ // Bound the whole cleanup: a dispose that hangs (child process refusing
491
+ // to exit) would otherwise keep the process alive indefinitely after the
492
+ // one-shot signal handlers are consumed.
493
+ const forceExitTimer = setTimeout(() => {
494
+ logError(`Shutdown cleanup exceeded ${formatDuration(SHUTDOWN_FORCE_EXIT_TIMEOUT_MS)}; forcing exit.`);
495
+ process.exit(requestedExitCode);
496
+ }, SHUTDOWN_FORCE_EXIT_TIMEOUT_MS);
497
+ void shutdown(exitCode).finally(() => {
498
+ clearTimeout(forceExitTimer);
499
+ process.exit(requestedExitCode);
500
+ });
534
501
  };
535
502
  process.once("SIGINT", () => {
536
503
  requestShutdown("Received SIGINT. Stopping bridge.");
@@ -571,6 +538,7 @@ async function main() {
571
538
  try {
572
539
  wireAdapterEvents({
573
540
  adapter,
541
+ resumeCoordinator,
574
542
  options,
575
543
  transport,
576
544
  stateStore,
@@ -579,13 +547,8 @@ async function main() {
579
547
  queueWechatMessage,
580
548
  trackWechatForwardTask,
581
549
  maybeDrainDeferredInboundMessages,
582
- getActiveTask: () => activeTask,
583
550
  clearActiveTask: () => {
584
551
  activeTask = null;
585
- lastHeartbeatAt = 0;
586
- },
587
- updateLastOutputAt: () => {
588
- lastOutputAt = Date.now();
589
552
  },
590
553
  syncSharedSessionState: () => {
591
554
  syncSharedSessionState(stateStore, adapter);
@@ -609,25 +572,33 @@ async function main() {
609
572
  }
610
573
  log(`Authorized WeChat user: ${credentials.userId}`);
611
574
  if (options.adapter === "codex") {
612
- log('Start the visible Codex panel in a second terminal with: wechat-codex');
575
+ log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
613
576
  }
614
577
  else if (options.adapter === "opencode") {
615
- log('Start the visible OpenCode companion in a second terminal with: wechat-opencode');
578
+ log("For source-mode debugging, open the visible OpenCode client with: npm run opencode:panel");
616
579
  }
617
580
  else if (options.adapter === "claude") {
618
- log('Start the visible Claude companion in a second terminal with: wechat-claude');
581
+ log("For source-mode debugging, open the visible Claude client with: npm run claude:companion");
619
582
  }
620
- else if (options.adapter === "shell") {
621
- log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
583
+ else if (options.adapter === "pi") {
584
+ log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
622
585
  }
623
586
  loadEmojiBindings();
587
+ // Keep the standalone bridge's WeChat welcome brief: command and emoji
588
+ // reference material belongs to /status and /bindings, which users can
589
+ // request on demand. The daemon sends its own, fuller welcome.
624
590
  const welcomeText = t("bridge.welcome", {
625
591
  adapter: options.adapter,
626
592
  cwd: options.cwd,
627
- bindings: formatBindingsListMessage(listBindings()),
628
593
  });
629
594
  await queueWechatMessage(credentials.userId, welcomeText);
630
595
  while (true) {
596
+ if (shutdownPromise) {
597
+ // Shutdown (e.g. SIGINT) was requested while we were awaiting the
598
+ // previous poll: stop consuming inbound messages instead of racing
599
+ // the concurrent cleanup/dispose with new sends.
600
+ break;
601
+ }
631
602
  if (!ensureRuntimeOwnership()) {
632
603
  break;
633
604
  }
@@ -654,7 +625,7 @@ async function main() {
654
625
  await delay(delayMs);
655
626
  continue;
656
627
  }
657
- if (!ensureRuntimeOwnership()) {
628
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
658
629
  break;
659
630
  }
660
631
  if (consecutivePollFailures > 0) {
@@ -686,8 +657,12 @@ async function main() {
686
657
  options,
687
658
  stateStore,
688
659
  adapter,
660
+ resumeCoordinator,
689
661
  queueWechatMessage,
690
662
  outputBatcher,
663
+ clearActiveTask: () => {
664
+ activeTask = null;
665
+ },
691
666
  deferInboundMessage: async (nextMessage) => {
692
667
  deferredInboundMessages.push({
693
668
  message: nextMessage,
@@ -699,32 +674,20 @@ async function main() {
699
674
  }
700
675
  catch (err) {
701
676
  const errorText = err instanceof Error ? err.message : String(err);
702
- const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
703
677
  logError(errorText);
704
- stateStore.appendLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
678
+ stateStore.appendLog(`inbound_error: ${errorText}`);
705
679
  await queueWechatMessage(message.senderId, formatUserFacingInboundError({
706
680
  adapter: options.adapter,
707
681
  cwd: options.cwd,
708
682
  errorText,
709
- isUserFacingShellRejection,
710
683
  }), "inbound_error");
711
684
  }
712
685
  if (nextTask) {
713
686
  activeTask = nextTask;
714
- lastHeartbeatAt = 0;
715
687
  }
716
688
  syncSharedSessionState(stateStore, adapter);
717
689
  await maybeDrainDeferredInboundMessages();
718
690
  }
719
- const adapterState = adapter.getState();
720
- const lastSignalAt = Math.max(lastHeartbeatAt, lastOutputAt || activeTask?.startedAt || 0);
721
- if (activeTask &&
722
- options.adapter === "shell" &&
723
- adapterState.status === "busy" &&
724
- Date.now() - lastSignalAt >= 30_000) {
725
- lastHeartbeatAt = Date.now();
726
- await queueWechatMessage(stateStore.getState().authorizedUserId, `${options.adapter} is still running. Waiting for more output...`);
727
- }
728
691
  }
729
692
  }
730
693
  finally {
@@ -756,7 +719,7 @@ function syncSharedSessionState(stateStore, adapter) {
756
719
  }
757
720
  }
758
721
  function wireAdapterEvents(params) {
759
- const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
722
+ const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
760
723
  adapter.setEventSink((event) => {
761
724
  syncSharedSessionState();
762
725
  syncLocalClientEndpoint();
@@ -772,7 +735,6 @@ function wireAdapterEvents(params) {
772
735
  switch (event.type) {
773
736
  case "stdout":
774
737
  case "stderr":
775
- updateLastOutputAt();
776
738
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
777
739
  outputBatcher.push(event.text);
778
740
  }
@@ -810,7 +772,6 @@ function wireAdapterEvents(params) {
810
772
  void maybeDrainDeferredInboundMessages();
811
773
  break;
812
774
  case "notice":
813
- updateLastOutputAt();
814
775
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
815
776
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
816
777
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
@@ -819,7 +780,6 @@ function wireAdapterEvents(params) {
819
780
  }
820
781
  break;
821
782
  case "thinking":
822
- updateLastOutputAt();
823
783
  if (event.text) {
824
784
  const thinkingPreview = formatThinkingForWechat(event.text, 500);
825
785
  if (thinkingPreview) {
@@ -855,8 +815,12 @@ function wireAdapterEvents(params) {
855
815
  }
856
816
  break;
857
817
  case "session_switched":
818
+ if (event.source === "local") {
819
+ resumeCoordinator.clear();
820
+ }
858
821
  stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
859
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
822
+ if (shouldForwardSessionSwitchEvent(event.reason) &&
823
+ shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
860
824
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
861
825
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
862
826
  adapter: options.adapter,
@@ -868,8 +832,12 @@ function wireAdapterEvents(params) {
868
832
  }
869
833
  break;
870
834
  case "thread_switched":
835
+ if (event.source === "local") {
836
+ resumeCoordinator.clear();
837
+ }
871
838
  stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
872
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
839
+ if (shouldForwardSessionSwitchEvent(event.reason) &&
840
+ shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
873
841
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
874
842
  await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
875
843
  adapter: options.adapter,
@@ -885,15 +853,6 @@ function wireAdapterEvents(params) {
885
853
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
886
854
  stateStore.clearPendingConfirmation();
887
855
  stateStore.clearPendingUserInput();
888
- if (options.adapter === "shell") {
889
- const summary = buildCompletionSummary({
890
- adapter: options.adapter,
891
- activeTask: getActiveTask(),
892
- exitCode: event.exitCode,
893
- recentOutput: outputBatcher.getRecentSummary(),
894
- });
895
- await queueWechatMessage(authorizedUserId, summary);
896
- }
897
856
  clearActiveTask();
898
857
  await maybeDrainDeferredInboundMessages();
899
858
  }));
@@ -925,18 +884,6 @@ function wireAdapterEvents(params) {
925
884
  }
926
885
  });
927
886
  }
928
- function buildCompletionSummary(params) {
929
- const lines = [`${params.adapter} task complete.`];
930
- if (params.activeTask) {
931
- lines.push(`duration: ${formatDuration(Date.now() - params.activeTask.startedAt)}`);
932
- lines.push(`input: ${params.activeTask.inputPreview}`);
933
- }
934
- if (typeof params.exitCode === "number") {
935
- lines.push(`exit_code: ${params.exitCode}`);
936
- }
937
- lines.push(`recent_output:\n${params.recentOutput}`);
938
- return lines.join("\n");
939
- }
940
887
  function formatInboundMessagePreview(message) {
941
888
  if (message.text.trim()) {
942
889
  return message.text;
@@ -950,7 +897,7 @@ function formatInboundMessagePreview(message) {
950
897
  }
951
898
  async function handleInboundMessage(params) {
952
899
  let { message, } = params;
953
- const { options, stateStore, adapter, queueWechatMessage, outputBatcher, deferInboundMessage, } = params;
900
+ const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
954
901
  const state = stateStore.getState();
955
902
  if (message.senderId !== state.authorizedUserId) {
956
903
  await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
@@ -997,19 +944,23 @@ async function handleInboundMessage(params) {
997
944
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
998
945
  return null;
999
946
  case "resume": {
1000
- if (options.adapter === "codex") {
1001
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in codex mode. Use /resume directly inside "wechat-codex"; WeChat will follow the active local thread.');
947
+ if (!isWechatResumeEnabled(options.adapter)) {
948
+ 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.`);
1002
949
  return null;
1003
950
  }
1004
- if (options.adapter === "claude") {
1005
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in claude mode. Use /resume directly inside "wechat-claude"; WeChat will follow the active local session.');
1006
- return null;
951
+ try {
952
+ if (systemCommand.target) {
953
+ await outputBatcher.flushNow();
954
+ }
955
+ const result = await resumeCoordinator.execute(systemCommand.target);
956
+ if (result.kind === "resumed") {
957
+ clearActiveTask();
958
+ }
959
+ await queueWechatMessage(message.senderId, result.message);
1007
960
  }
1008
- if (options.adapter === "opencode") {
1009
- await queueWechatMessage(message.senderId, 'WeChat /resume is disabled in opencode mode. Use /resume directly inside "wechat-opencode"; WeChat will follow the active local session.');
1010
- return null;
961
+ catch (error) {
962
+ await queueWechatMessage(message.senderId, error instanceof Error ? error.message : String(error));
1011
963
  }
1012
- await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
1013
964
  return null;
1014
965
  }
1015
966
  case "new_session": {
@@ -1022,6 +973,7 @@ async function handleInboundMessage(params) {
1022
973
  stateStore.clearPendingConfirmation();
1023
974
  stateStore.clearPendingUserInput();
1024
975
  stateStore.clearSharedSessionId();
976
+ resumeCoordinator.clear();
1025
977
  await adapter.createSession();
1026
978
  stateStore.appendLog(`New ${options.adapter} session requested by owner.`);
1027
979
  return null;
@@ -1039,6 +991,7 @@ async function handleInboundMessage(params) {
1039
991
  stateStore.clearPendingConfirmation();
1040
992
  stateStore.clearPendingUserInput();
1041
993
  stateStore.clearSharedSessionId();
994
+ resumeCoordinator.clear();
1042
995
  await adapter.reset();
1043
996
  stateStore.appendLog("Worker reset by owner.");
1044
997
  await queueWechatMessage(message.senderId, "Worker session has been reset.");
@@ -0,0 +1,58 @@
1
+ import { truncatePreview } from "./bridge-utils.js";
2
+ import { classifyWechatTransportError, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
3
+ export const WECHAT_SEND_MAX_ATTEMPTS = 3;
4
+ const WECHAT_SEND_RETRY_BASE_MS = 750;
5
+ export function formatUserFacingBridgeFatalError(message) {
6
+ return `Bridge error: ${message.replace(/\s+Recent app-server log:.*$/s, "").trim()}`;
7
+ }
8
+ export function shouldForwardBridgeEventToWechat(adapter, eventType, options = {}) {
9
+ if (adapter !== "opencode" && adapter !== "pi") {
10
+ return true;
11
+ }
12
+ switch (eventType) {
13
+ case "stdout":
14
+ case "stderr":
15
+ case "thread_switched":
16
+ return false;
17
+ case "notice":
18
+ return adapter === "pi" || /^OpenCode local draft:\s*/i.test(options.text ?? "");
19
+ case "mirrored_user_input":
20
+ return true;
21
+ default:
22
+ return true;
23
+ }
24
+ }
25
+ export function formatUserFacingInboundError(params) {
26
+ const { adapter, cwd, errorText } = params;
27
+ if (adapter === "opencode" &&
28
+ /opencode companion is not connected/i.test(errorText)) {
29
+ return cwd
30
+ ? `OpenCode is not connected for workspace:\n${cwd}\nRun "wechat-opencode" in that directory to recreate or reconnect the visible terminal.`
31
+ : 'OpenCode is not connected. Run "wechat-opencode" in this directory, then retry.';
32
+ }
33
+ return `Bridge error: ${errorText}`;
34
+ }
35
+ export function formatWechatSendFailureLogEntry(params) {
36
+ return `wechat_send_failed: context=${params.context} recipient=${params.recipientId} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
37
+ }
38
+ export function formatWechatContextTokenStaleLogEntry(params) {
39
+ return `wechat_context_token_stale: context=${params.context} recipient=${params.recipientId} action=wechat_message_required error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
40
+ }
41
+ export function formatWechatSendRetryLogEntry(params) {
42
+ return `wechat_send_retry: context=${params.context} recipient=${params.recipientId} attempt=${params.attempt} delay_ms=${params.delayMs} error=${truncatePreview(describeWechatTransportError(params.error), 400)}`;
43
+ }
44
+ export function isRetryableWechatSendError(error) {
45
+ if (isWechatContextTokenStaleError(error)) {
46
+ return false;
47
+ }
48
+ const classification = classifyWechatTransportError(error);
49
+ if (classification.retryable) {
50
+ return true;
51
+ }
52
+ const details = describeWechatTransportError(error);
53
+ return /^(?:Error|WechatApiResponseError): sendmessage failed:/i.test(details) &&
54
+ !/errcode=-14\b.*session timeout/i.test(details);
55
+ }
56
+ export function computeWechatSendRetryDelayMs(attempt) {
57
+ return WECHAT_SEND_RETRY_BASE_MS * attempt;
58
+ }