cli-wechat-bridge 1.1.3 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +98 -135
  2. package/bin/_run-entry.mjs +12 -0
  3. package/bin/wechat-claude-start.mjs +7 -2
  4. package/bin/wechat-claude.mjs +1 -1
  5. package/bin/wechat-codex-start.mjs +7 -2
  6. package/bin/wechat-codex.mjs +1 -1
  7. package/bin/wechat-opencode-start.mjs +7 -2
  8. package/bin/wechat-opencode.mjs +1 -1
  9. package/bin/wechat-pi-start.mjs +7 -2
  10. package/bin/wechat-pi.mjs +1 -1
  11. package/dist/bridge/bridge-adapters.claude.js +21 -1
  12. package/dist/bridge/bridge-adapters.codex.js +27 -4
  13. package/dist/bridge/bridge-adapters.core.js +76 -4
  14. package/dist/bridge/bridge-adapters.js +0 -3
  15. package/dist/bridge/bridge-adapters.opencode.js +130 -21
  16. package/dist/bridge/bridge-adapters.pi.js +12 -2
  17. package/dist/bridge/bridge-adapters.shared.js +13 -100
  18. package/dist/bridge/bridge-final-reply.js +17 -2
  19. package/dist/bridge/bridge-process-reaper.js +14 -4
  20. package/dist/bridge/bridge-state.js +41 -6
  21. package/dist/bridge/bridge-utils.js +37 -224
  22. package/dist/bridge/wechat-bridge.js +75 -130
  23. package/dist/bridge/wechat-forwarding.js +58 -0
  24. package/dist/companion/codex-remote-client.js +20 -6
  25. package/dist/companion/local-companion-link.js +7 -2
  26. package/dist/companion/local-companion-start.js +45 -13
  27. package/dist/companion/local-companion.js +6 -3
  28. package/dist/daemon/daemon-link.js +25 -3
  29. package/dist/daemon/wechat-daemon.js +148 -54
  30. package/dist/i18n/index.js +0 -3
  31. package/dist/i18n/messages-en.js +2 -3
  32. package/dist/i18n/messages-zh.js +2 -3
  33. package/dist/utils/atomic-file.js +15 -0
  34. package/dist/utils/doctor.js +23 -11
  35. package/dist/wechat/channel-config.js +0 -2
  36. package/dist/wechat/setup.js +5 -11
  37. package/dist/wechat/wechat-transport.js +14 -8
  38. package/package.json +2 -9
  39. package/bin/wechat-bridge-claude.mjs +0 -5
  40. package/bin/wechat-bridge-codex.mjs +0 -5
  41. package/bin/wechat-bridge-opencode.mjs +0 -5
  42. package/bin/wechat-bridge-pi.mjs +0 -5
  43. package/bin/wechat-bridge-shell.mjs +0 -5
  44. package/bin/wechat-bridge.mjs +0 -5
  45. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -5,11 +5,12 @@ 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 { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
8
9
  import { ensureWechatCredentials } from "../wechat/setup.js";
9
10
  import { BridgeStateStore } from "./bridge-state.js";
10
- import { reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses } from "./bridge-process-reaper.js";
11
+ import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
11
12
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
12
- 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
+ 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
14
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
14
15
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
15
16
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
@@ -17,8 +18,11 @@ import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix,
17
18
  const POLL_RETRY_BASE_MS = 1_000;
18
19
  const POLL_RETRY_MAX_MS = 30_000;
19
20
  const PARENT_PROCESS_POLL_MS = 5_000;
20
- const WECHAT_SEND_MAX_ATTEMPTS = 3;
21
- const WECHAT_SEND_RETRY_BASE_MS = 750;
21
+ // Hard ceiling for the whole shutdown cleanup sequence before a forced exit.
22
+ const SHUTDOWN_FORCE_EXIT_TIMEOUT_MS = 10_000;
23
+ // Re-verify the parent's command line every N polls (5s each → ~60s) to keep
24
+ // the full-process probe off the hot path.
25
+ const PARENT_IDENTITY_CHECK_INTERVAL = 12;
22
26
  function log(message) {
23
27
  process.stderr.write(`[wechat-bridge] ${message}\n`);
24
28
  }
@@ -42,63 +46,6 @@ function isPidAlive(pid) {
42
46
  return false;
43
47
  }
44
48
  }
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
49
  export function shouldWatchParentProcess(options) {
103
50
  return (options.startupParentPid > 1 &&
104
51
  (options.attachedToTerminal || options.lifecycle === "companion_bound"));
@@ -159,7 +106,7 @@ export function parseCliArgs(argv) {
159
106
  const next = argv[i + 1];
160
107
  switch (arg) {
161
108
  case "--adapter":
162
- if (!next || !["codex", "claude", "opencode", "pi", "shell"].includes(next)) {
109
+ if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
163
110
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
164
111
  }
165
112
  adapter = next;
@@ -212,7 +159,7 @@ export function parseCliArgs(argv) {
212
159
  }
213
160
  }
214
161
  if (!adapter) {
215
- throw new Error("Missing required --adapter <codex|claude|opencode|pi|shell>");
162
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
216
163
  }
217
164
  const defaultCommand = resolveDefaultAdapterCommand(adapter);
218
165
  return {
@@ -226,18 +173,10 @@ export function parseCliArgs(argv) {
226
173
  }
227
174
  function printUsageAndExit() {
228
175
  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>]",
176
+ "Internal bridge runtime usage:",
177
+ " 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
178
  "",
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",
179
+ "This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
241
180
  "",
242
181
  ].join("\n"));
243
182
  process.exit(0);
@@ -339,8 +278,6 @@ async function main() {
339
278
  let activeTask = null;
340
279
  const deferredInboundMessages = [];
341
280
  let drainingDeferredInboundMessages = false;
342
- let lastOutputAt = 0;
343
- let lastHeartbeatAt = 0;
344
281
  let consecutivePollFailures = 0;
345
282
  let backlogNoticeSent = false;
346
283
  const queueWechatTextAction = (action) => {
@@ -447,7 +384,6 @@ async function main() {
447
384
  adapter,
448
385
  });
449
386
  activeTask = nextTask;
450
- lastHeartbeatAt = 0;
451
387
  }
452
388
  catch (err) {
453
389
  const errorText = err instanceof Error ? err.message : String(err);
@@ -462,7 +398,6 @@ async function main() {
462
398
  adapter: options.adapter,
463
399
  cwd: options.cwd,
464
400
  errorText,
465
- isUserFacingShellRejection: false,
466
401
  }), "inbound_error");
467
402
  }
468
403
  finally {
@@ -474,6 +409,10 @@ async function main() {
474
409
  let shutdownPromise = null;
475
410
  let requestedExitCode = 0;
476
411
  let stdinDetached = false;
412
+ // Snapshot of the parent process command line at bridge startup (null when
413
+ // unresolvable). Compared against periodic re-probes to detect OS pid reuse.
414
+ const parentStartupCommandLine = getProcessRecordByPid(startupParentPid)?.commandLine ?? null;
415
+ let parentWatchPollCount = 0;
477
416
  const parentWatchTimer = shouldWatchParentProcess({
478
417
  startupParentPid,
479
418
  attachedToTerminal,
@@ -481,6 +420,19 @@ async function main() {
481
420
  })
482
421
  ? setInterval(() => {
483
422
  if (shutdownPromise || isPidAlive(startupParentPid)) {
423
+ // Periodically re-verify the parent's command line so an OS pid
424
+ // reuse (dead parent's pid handed to an unrelated long-running
425
+ // process) cannot keep this companion-bound bridge alive forever.
426
+ parentWatchPollCount += 1;
427
+ if (parentWatchPollCount % PARENT_IDENTITY_CHECK_INTERVAL === 0) {
428
+ const record = getProcessRecordByPid(startupParentPid);
429
+ if (parentStartupCommandLine !== null &&
430
+ record !== null &&
431
+ record.commandLine !== parentStartupCommandLine) {
432
+ log(`Parent pid ${startupParentPid} was reused by another process. Stopping bridge.`);
433
+ void shutdown(0);
434
+ }
435
+ }
484
436
  return;
485
437
  }
486
438
  log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
@@ -530,7 +482,17 @@ async function main() {
530
482
  return;
531
483
  }
532
484
  log(message);
533
- void shutdown(exitCode).finally(() => process.exit(requestedExitCode));
485
+ // Bound the whole cleanup: a dispose that hangs (child process refusing
486
+ // to exit) would otherwise keep the process alive indefinitely after the
487
+ // one-shot signal handlers are consumed.
488
+ const forceExitTimer = setTimeout(() => {
489
+ logError(`Shutdown cleanup exceeded ${formatDuration(SHUTDOWN_FORCE_EXIT_TIMEOUT_MS)}; forcing exit.`);
490
+ process.exit(requestedExitCode);
491
+ }, SHUTDOWN_FORCE_EXIT_TIMEOUT_MS);
492
+ void shutdown(exitCode).finally(() => {
493
+ clearTimeout(forceExitTimer);
494
+ process.exit(requestedExitCode);
495
+ });
534
496
  };
535
497
  process.once("SIGINT", () => {
536
498
  requestShutdown("Received SIGINT. Stopping bridge.");
@@ -579,13 +541,8 @@ async function main() {
579
541
  queueWechatMessage,
580
542
  trackWechatForwardTask,
581
543
  maybeDrainDeferredInboundMessages,
582
- getActiveTask: () => activeTask,
583
544
  clearActiveTask: () => {
584
545
  activeTask = null;
585
- lastHeartbeatAt = 0;
586
- },
587
- updateLastOutputAt: () => {
588
- lastOutputAt = Date.now();
589
546
  },
590
547
  syncSharedSessionState: () => {
591
548
  syncSharedSessionState(stateStore, adapter);
@@ -609,25 +566,33 @@ async function main() {
609
566
  }
610
567
  log(`Authorized WeChat user: ${credentials.userId}`);
611
568
  if (options.adapter === "codex") {
612
- log('Start the visible Codex panel in a second terminal with: wechat-codex');
569
+ log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
613
570
  }
614
571
  else if (options.adapter === "opencode") {
615
- log('Start the visible OpenCode companion in a second terminal with: wechat-opencode');
572
+ log("For source-mode debugging, open the visible OpenCode client with: npm run opencode:panel");
616
573
  }
617
574
  else if (options.adapter === "claude") {
618
- log('Start the visible Claude companion in a second terminal with: wechat-claude');
575
+ log("For source-mode debugging, open the visible Claude client with: npm run claude:companion");
619
576
  }
620
- else if (options.adapter === "shell") {
621
- log("Shell mode runs as a headless remote executor for non-interactive commands and scripts.");
577
+ else if (options.adapter === "pi") {
578
+ log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
622
579
  }
623
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.
624
584
  const welcomeText = t("bridge.welcome", {
625
585
  adapter: options.adapter,
626
586
  cwd: options.cwd,
627
- bindings: formatBindingsListMessage(listBindings()),
628
587
  });
629
588
  await queueWechatMessage(credentials.userId, welcomeText);
630
589
  while (true) {
590
+ if (shutdownPromise) {
591
+ // Shutdown (e.g. SIGINT) was requested while we were awaiting the
592
+ // previous poll: stop consuming inbound messages instead of racing
593
+ // the concurrent cleanup/dispose with new sends.
594
+ break;
595
+ }
631
596
  if (!ensureRuntimeOwnership()) {
632
597
  break;
633
598
  }
@@ -654,7 +619,7 @@ async function main() {
654
619
  await delay(delayMs);
655
620
  continue;
656
621
  }
657
- if (!ensureRuntimeOwnership()) {
622
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
658
623
  break;
659
624
  }
660
625
  if (consecutivePollFailures > 0) {
@@ -699,32 +664,20 @@ async function main() {
699
664
  }
700
665
  catch (err) {
701
666
  const errorText = err instanceof Error ? err.message : String(err);
702
- const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
703
667
  logError(errorText);
704
- stateStore.appendLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
668
+ stateStore.appendLog(`inbound_error: ${errorText}`);
705
669
  await queueWechatMessage(message.senderId, formatUserFacingInboundError({
706
670
  adapter: options.adapter,
707
671
  cwd: options.cwd,
708
672
  errorText,
709
- isUserFacingShellRejection,
710
673
  }), "inbound_error");
711
674
  }
712
675
  if (nextTask) {
713
676
  activeTask = nextTask;
714
- lastHeartbeatAt = 0;
715
677
  }
716
678
  syncSharedSessionState(stateStore, adapter);
717
679
  await maybeDrainDeferredInboundMessages();
718
680
  }
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
681
  }
729
682
  }
730
683
  finally {
@@ -756,7 +709,7 @@ function syncSharedSessionState(stateStore, adapter) {
756
709
  }
757
710
  }
758
711
  function wireAdapterEvents(params) {
759
- const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, updateLastOutputAt, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
712
+ const { adapter, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
760
713
  adapter.setEventSink((event) => {
761
714
  syncSharedSessionState();
762
715
  syncLocalClientEndpoint();
@@ -772,7 +725,6 @@ function wireAdapterEvents(params) {
772
725
  switch (event.type) {
773
726
  case "stdout":
774
727
  case "stderr":
775
- updateLastOutputAt();
776
728
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
777
729
  outputBatcher.push(event.text);
778
730
  }
@@ -810,7 +762,6 @@ function wireAdapterEvents(params) {
810
762
  void maybeDrainDeferredInboundMessages();
811
763
  break;
812
764
  case "notice":
813
- updateLastOutputAt();
814
765
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
815
766
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
816
767
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
@@ -819,7 +770,6 @@ function wireAdapterEvents(params) {
819
770
  }
820
771
  break;
821
772
  case "thinking":
822
- updateLastOutputAt();
823
773
  if (event.text) {
824
774
  const thinkingPreview = formatThinkingForWechat(event.text, 500);
825
775
  if (thinkingPreview) {
@@ -885,15 +835,6 @@ function wireAdapterEvents(params) {
885
835
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
886
836
  stateStore.clearPendingConfirmation();
887
837
  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
838
  clearActiveTask();
898
839
  await maybeDrainDeferredInboundMessages();
899
840
  }));
@@ -925,18 +866,6 @@ function wireAdapterEvents(params) {
925
866
  }
926
867
  });
927
868
  }
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
869
  function formatInboundMessagePreview(message) {
941
870
  if (message.text.trim()) {
942
871
  return message.text;
@@ -1006,7 +935,23 @@ async function handleInboundMessage(params) {
1006
935
  return null;
1007
936
  }
1008
937
  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.');
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
+ }
951
+ }
952
+ catch (error) {
953
+ await queueWechatMessage(message.senderId, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`);
954
+ }
1010
955
  return null;
1011
956
  }
1012
957
  await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
@@ -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
+ }
@@ -10,6 +10,7 @@ function log(message) {
10
10
  }
11
11
  export function parseCliArgs(argv) {
12
12
  let cwd = process.cwd();
13
+ let sessionStartMode;
13
14
  const cliArgs = [];
14
15
  for (let i = 0; i < argv.length; i += 1) {
15
16
  const arg = argv[i];
@@ -19,9 +20,10 @@ export function parseCliArgs(argv) {
19
20
  const next = argv[i + 1];
20
21
  if (arg === "--help" || arg === "-h") {
21
22
  process.stdout.write([
22
- "Usage: wechat-codex [--cwd <path>] [...codex args]",
23
+ "Internal Codex client usage: npm run codex:panel -- [--cwd <path>] [--session-start-mode <restore|new>] [...codex args]",
23
24
  "",
24
- 'Starts the visible native Codex client and connects it to the running "wechat-bridge-codex" instance for the current directory.',
25
+ "Connects the visible native Codex client to the bridge runtime for the current directory.",
26
+ "Use --session-start-mode new to open the client without resuming the shared thread.",
25
27
  "Unknown arguments are forwarded to the Codex client.",
26
28
  "",
27
29
  ].join("\n"));
@@ -35,17 +37,25 @@ export function parseCliArgs(argv) {
35
37
  i += 1;
36
38
  continue;
37
39
  }
40
+ if (arg === "--session-start-mode") {
41
+ if (next !== "restore" && next !== "new") {
42
+ throw new Error(`Invalid --session-start-mode value: ${next ?? "(missing)"}`);
43
+ }
44
+ sessionStartMode = next;
45
+ i += 1;
46
+ continue;
47
+ }
38
48
  cliArgs.push(arg);
39
49
  }
40
- return { cwd, cliArgs };
50
+ return { cwd, cliArgs, sessionStartMode };
41
51
  }
42
52
  export function readCodexRuntimeEndpoint(cwd) {
43
53
  const endpoint = readLocalCompanionEndpoint(cwd, { adapter: "codex" });
44
54
  if (!endpoint || endpoint.kind !== "codex") {
45
- throw new Error(`No active Codex bridge endpoint was found for ${cwd}. Start "wechat-bridge-codex" in that directory first.`);
55
+ throw new Error(`No active Codex runtime was found for ${cwd}. Run "wechat-codex" in that directory to recreate it.`);
46
56
  }
47
57
  if (endpoint.runtimeKind !== "codex_runtime_host" || (!endpoint.serverUrl && !endpoint.serverPort)) {
48
- throw new Error(`The running Codex bridge for ${cwd} is using an older local companion protocol. Restart "wechat-bridge-codex" in that directory first.`);
58
+ throw new Error(`The running Codex runtime for ${cwd} uses an older local client protocol. Run "wechat-codex" in that directory to replace it.`);
49
59
  }
50
60
  return endpoint;
51
61
  }
@@ -55,7 +65,9 @@ export function buildRemoteCodexClientArgs(endpoint, options = {}) {
55
65
  const remoteUrl = endpoint.serverUrl ?? `ws://127.0.0.1:${endpoint.serverPort ?? endpoint.port}`;
56
66
  const args = buildCodexCliArgs(remoteUrl, {
57
67
  profile: endpoint.profile,
58
- resumeThreadId: endpoint.sharedThreadId,
68
+ // "new" skips resume so the visible client opens a fresh Codex session
69
+ // instead of rejoining the shared thread.
70
+ resumeThreadId: options.sessionStartMode === "new" ? undefined : endpoint.sharedThreadId,
59
71
  });
60
72
  const tokenEnvName = endpoint.remoteAuthTokenEnv ?? CODEX_REMOTE_AUTH_TOKEN_ENV;
61
73
  return [...args, "--remote-auth-token-env", tokenEnvName, ...extraCliArgs];
@@ -70,6 +82,7 @@ export async function runCodexRemoteClientFromEndpoint(endpoint, options = {}) {
70
82
  const spawnTarget = resolveSpawnTarget(endpoint.command, "codex");
71
83
  const args = buildRemoteCodexClientArgs(endpoint, {
72
84
  extraCliArgs: options.extraCliArgs,
85
+ sessionStartMode: options.sessionStartMode,
73
86
  });
74
87
  const env = buildRemoteCodexClientEnv(endpoint);
75
88
  return await new Promise((resolve, reject) => {
@@ -107,6 +120,7 @@ export async function runCodexRemoteClient(options) {
107
120
  const endpoint = readCodexRuntimeEndpoint(options.cwd);
108
121
  return await runCodexRemoteClientFromEndpoint(endpoint, {
109
122
  extraCliArgs: options.cliArgs,
123
+ sessionStartMode: options.sessionStartMode,
110
124
  });
111
125
  }
112
126
  async function main() {
@@ -1,6 +1,7 @@
1
1
  import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { ensureWorkspaceChannelDir, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
4
+ import { writeJsonFileAtomic } from "../utils/atomic-file.js";
4
5
  import { LOCAL_CLIENT_PROTOCOL_VERSION, } from "../runtime/runtime-types.js";
5
6
  function normalizeEndpoint(value) {
6
7
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -90,9 +91,13 @@ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
90
91
  const payload = {
91
92
  ...serializeEndpoint(endpoint),
92
93
  };
93
- fs.writeFileSync(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), JSON.stringify(payload, null, 2), "utf8");
94
+ // Atomic writes: the endpoint files are rewritten on every adapter event
95
+ // while companions poll them concurrently; a torn write reads as JSON.parse
96
+ // failure → "no endpoint" → healthy bridges get restarted or duplicate
97
+ // clients launched.
98
+ writeJsonFileAtomic(getWorkspaceAdapterEndpointFile(endpoint.cwd, endpoint.kind), payload);
94
99
  if (options.writeLegacy !== false) {
95
- fs.writeFileSync(endpointFile, JSON.stringify(payload, null, 2), "utf8");
100
+ writeJsonFileAtomic(endpointFile, payload);
96
101
  }
97
102
  }
98
103
  export function readLocalCompanionEndpoint(cwd, options = {}) {