cli-wechat-bridge 1.1.2 → 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 (46) hide show
  1. package/README.md +129 -150
  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 +10 -0
  10. package/bin/wechat-pi.mjs +5 -0
  11. package/dist/bridge/bridge-adapter-common.js +2 -0
  12. package/dist/bridge/bridge-adapters.claude.js +21 -1
  13. package/dist/bridge/bridge-adapters.codex.js +85 -6
  14. package/dist/bridge/bridge-adapters.core.js +112 -6
  15. package/dist/bridge/bridge-adapters.js +5 -3
  16. package/dist/bridge/bridge-adapters.opencode.js +390 -52
  17. package/dist/bridge/bridge-adapters.pi.js +707 -0
  18. package/dist/bridge/bridge-adapters.shared.js +22 -102
  19. package/dist/bridge/bridge-final-reply.js +17 -2
  20. package/dist/bridge/bridge-process-reaper.js +14 -4
  21. package/dist/bridge/bridge-state.js +42 -6
  22. package/dist/bridge/bridge-utils.js +94 -237
  23. package/dist/bridge/wechat-bridge.js +77 -131
  24. package/dist/bridge/wechat-forwarding.js +58 -0
  25. package/dist/companion/codex-remote-client.js +20 -6
  26. package/dist/companion/local-companion-link.js +13 -3
  27. package/dist/companion/local-companion-start.js +64 -16
  28. package/dist/companion/local-companion.js +8 -5
  29. package/dist/companion/pi-tui-bridge-extension.js +256 -0
  30. package/dist/daemon/daemon-link.js +25 -3
  31. package/dist/daemon/wechat-daemon.js +264 -77
  32. package/dist/i18n/index.js +0 -3
  33. package/dist/i18n/messages-en.js +4 -5
  34. package/dist/i18n/messages-zh.js +4 -5
  35. package/dist/utils/atomic-file.js +15 -0
  36. package/dist/utils/doctor.js +27 -11
  37. package/dist/wechat/channel-config.js +0 -2
  38. package/dist/wechat/setup.js +5 -9
  39. package/dist/wechat/wechat-transport.js +14 -8
  40. package/package.json +113 -102
  41. package/bin/wechat-bridge-claude.mjs +0 -5
  42. package/bin/wechat-bridge-codex.mjs +0 -5
  43. package/bin/wechat-bridge-opencode.mjs +0 -5
  44. package/bin/wechat-bridge-shell.mjs +0 -5
  45. package/bin/wechat-bridge.mjs +0 -5
  46. 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") {
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 /^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", "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|shell>");
162
+ throw new Error("Missing required --adapter <codex|claude|opencode|pi>");
216
163
  }
217
164
  const defaultCommand = resolveDefaultAdapterCommand(adapter);
218
165
  return {
@@ -226,17 +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|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-shell --cmd pwsh # headless shell executor for non-interactive commands/scripts",
236
- " wechat-bridge-shell --cmd bash # headless shell executor for non-interactive commands/scripts",
237
- " wechat-bridge-codex --lifecycle companion_bound",
238
- " bun run bridge:codex # repo-local development entrypoint",
239
- " 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.",
240
180
  "",
241
181
  ].join("\n"));
242
182
  process.exit(0);
@@ -338,8 +278,6 @@ async function main() {
338
278
  let activeTask = null;
339
279
  const deferredInboundMessages = [];
340
280
  let drainingDeferredInboundMessages = false;
341
- let lastOutputAt = 0;
342
- let lastHeartbeatAt = 0;
343
281
  let consecutivePollFailures = 0;
344
282
  let backlogNoticeSent = false;
345
283
  const queueWechatTextAction = (action) => {
@@ -446,7 +384,6 @@ async function main() {
446
384
  adapter,
447
385
  });
448
386
  activeTask = nextTask;
449
- lastHeartbeatAt = 0;
450
387
  }
451
388
  catch (err) {
452
389
  const errorText = err instanceof Error ? err.message : String(err);
@@ -461,7 +398,6 @@ async function main() {
461
398
  adapter: options.adapter,
462
399
  cwd: options.cwd,
463
400
  errorText,
464
- isUserFacingShellRejection: false,
465
401
  }), "inbound_error");
466
402
  }
467
403
  finally {
@@ -473,6 +409,10 @@ async function main() {
473
409
  let shutdownPromise = null;
474
410
  let requestedExitCode = 0;
475
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;
476
416
  const parentWatchTimer = shouldWatchParentProcess({
477
417
  startupParentPid,
478
418
  attachedToTerminal,
@@ -480,6 +420,19 @@ async function main() {
480
420
  })
481
421
  ? setInterval(() => {
482
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
+ }
483
436
  return;
484
437
  }
485
438
  log(`Parent process ${startupParentPid} exited. Stopping bridge.`);
@@ -529,7 +482,17 @@ async function main() {
529
482
  return;
530
483
  }
531
484
  log(message);
532
- 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
+ });
533
496
  };
534
497
  process.once("SIGINT", () => {
535
498
  requestShutdown("Received SIGINT. Stopping bridge.");
@@ -578,13 +541,8 @@ async function main() {
578
541
  queueWechatMessage,
579
542
  trackWechatForwardTask,
580
543
  maybeDrainDeferredInboundMessages,
581
- getActiveTask: () => activeTask,
582
544
  clearActiveTask: () => {
583
545
  activeTask = null;
584
- lastHeartbeatAt = 0;
585
- },
586
- updateLastOutputAt: () => {
587
- lastOutputAt = Date.now();
588
546
  },
589
547
  syncSharedSessionState: () => {
590
548
  syncSharedSessionState(stateStore, adapter);
@@ -608,25 +566,33 @@ async function main() {
608
566
  }
609
567
  log(`Authorized WeChat user: ${credentials.userId}`);
610
568
  if (options.adapter === "codex") {
611
- 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");
612
570
  }
613
571
  else if (options.adapter === "opencode") {
614
- 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");
615
573
  }
616
574
  else if (options.adapter === "claude") {
617
- 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");
618
576
  }
619
- else if (options.adapter === "shell") {
620
- 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");
621
579
  }
622
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.
623
584
  const welcomeText = t("bridge.welcome", {
624
585
  adapter: options.adapter,
625
586
  cwd: options.cwd,
626
- bindings: formatBindingsListMessage(listBindings()),
627
587
  });
628
588
  await queueWechatMessage(credentials.userId, welcomeText);
629
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
+ }
630
596
  if (!ensureRuntimeOwnership()) {
631
597
  break;
632
598
  }
@@ -653,7 +619,7 @@ async function main() {
653
619
  await delay(delayMs);
654
620
  continue;
655
621
  }
656
- if (!ensureRuntimeOwnership()) {
622
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
657
623
  break;
658
624
  }
659
625
  if (consecutivePollFailures > 0) {
@@ -698,32 +664,20 @@ async function main() {
698
664
  }
699
665
  catch (err) {
700
666
  const errorText = err instanceof Error ? err.message : String(err);
701
- const isUserFacingShellRejection = err instanceof Error && err.name === "ShellCommandRejectedError";
702
667
  logError(errorText);
703
- stateStore.appendLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
668
+ stateStore.appendLog(`inbound_error: ${errorText}`);
704
669
  await queueWechatMessage(message.senderId, formatUserFacingInboundError({
705
670
  adapter: options.adapter,
706
671
  cwd: options.cwd,
707
672
  errorText,
708
- isUserFacingShellRejection,
709
673
  }), "inbound_error");
710
674
  }
711
675
  if (nextTask) {
712
676
  activeTask = nextTask;
713
- lastHeartbeatAt = 0;
714
677
  }
715
678
  syncSharedSessionState(stateStore, adapter);
716
679
  await maybeDrainDeferredInboundMessages();
717
680
  }
718
- const adapterState = adapter.getState();
719
- const lastSignalAt = Math.max(lastHeartbeatAt, lastOutputAt || activeTask?.startedAt || 0);
720
- if (activeTask &&
721
- options.adapter === "shell" &&
722
- adapterState.status === "busy" &&
723
- Date.now() - lastSignalAt >= 30_000) {
724
- lastHeartbeatAt = Date.now();
725
- await queueWechatMessage(stateStore.getState().authorizedUserId, `${options.adapter} is still running. Waiting for more output...`);
726
- }
727
681
  }
728
682
  }
729
683
  finally {
@@ -755,7 +709,7 @@ function syncSharedSessionState(stateStore, adapter) {
755
709
  }
756
710
  }
757
711
  function wireAdapterEvents(params) {
758
- 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;
759
713
  adapter.setEventSink((event) => {
760
714
  syncSharedSessionState();
761
715
  syncLocalClientEndpoint();
@@ -771,7 +725,6 @@ function wireAdapterEvents(params) {
771
725
  switch (event.type) {
772
726
  case "stdout":
773
727
  case "stderr":
774
- updateLastOutputAt();
775
728
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
776
729
  outputBatcher.push(event.text);
777
730
  }
@@ -809,7 +762,6 @@ function wireAdapterEvents(params) {
809
762
  void maybeDrainDeferredInboundMessages();
810
763
  break;
811
764
  case "notice":
812
- updateLastOutputAt();
813
765
  stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
814
766
  if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
815
767
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
@@ -818,7 +770,6 @@ function wireAdapterEvents(params) {
818
770
  }
819
771
  break;
820
772
  case "thinking":
821
- updateLastOutputAt();
822
773
  if (event.text) {
823
774
  const thinkingPreview = formatThinkingForWechat(event.text, 500);
824
775
  if (thinkingPreview) {
@@ -884,15 +835,6 @@ function wireAdapterEvents(params) {
884
835
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
885
836
  stateStore.clearPendingConfirmation();
886
837
  stateStore.clearPendingUserInput();
887
- if (options.adapter === "shell") {
888
- const summary = buildCompletionSummary({
889
- adapter: options.adapter,
890
- activeTask: getActiveTask(),
891
- exitCode: event.exitCode,
892
- recentOutput: outputBatcher.getRecentSummary(),
893
- });
894
- await queueWechatMessage(authorizedUserId, summary);
895
- }
896
838
  clearActiveTask();
897
839
  await maybeDrainDeferredInboundMessages();
898
840
  }));
@@ -924,18 +866,6 @@ function wireAdapterEvents(params) {
924
866
  }
925
867
  });
926
868
  }
927
- function buildCompletionSummary(params) {
928
- const lines = [`${params.adapter} task complete.`];
929
- if (params.activeTask) {
930
- lines.push(`duration: ${formatDuration(Date.now() - params.activeTask.startedAt)}`);
931
- lines.push(`input: ${params.activeTask.inputPreview}`);
932
- }
933
- if (typeof params.exitCode === "number") {
934
- lines.push(`exit_code: ${params.exitCode}`);
935
- }
936
- lines.push(`recent_output:\n${params.recentOutput}`);
937
- return lines.join("\n");
938
- }
939
869
  function formatInboundMessagePreview(message) {
940
870
  if (message.text.trim()) {
941
871
  return message.text;
@@ -1005,7 +935,23 @@ async function handleInboundMessage(params) {
1005
935
  return null;
1006
936
  }
1007
937
  if (options.adapter === "opencode") {
1008
- 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
+ }
1009
955
  return null;
1010
956
  }
1011
957
  await queueWechatMessage(message.senderId, `/resume is not available in ${options.adapter} mode.`);
@@ -1122,9 +1068,9 @@ async function handleInboundMessage(params) {
1122
1068
  return null;
1123
1069
  }
1124
1070
  if (adapterState.status === "busy") {
1125
- if ((options.adapter === "codex" || options.adapter === "opencode") &&
1071
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1126
1072
  adapterState.activeTurnOrigin === "local") {
1127
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
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.`);
1128
1074
  return null;
1129
1075
  }
1130
1076
  await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
@@ -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,13 +1,18 @@
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)) {
7
8
  return null;
8
9
  }
9
10
  const record = value;
10
- const kind = record.kind === "codex" || record.kind === "claude" || record.kind === "opencode" || record.kind === "shell"
11
+ const kind = record.kind === "codex" ||
12
+ record.kind === "claude" ||
13
+ record.kind === "opencode" ||
14
+ record.kind === "pi" ||
15
+ record.kind === "shell"
11
16
  ? record.kind
12
17
  : "codex";
13
18
  const sharedSessionId = typeof record.sharedSessionId === "string"
@@ -86,9 +91,13 @@ export function writeLocalCompanionEndpoint(endpoint, options = {}) {
86
91
  const payload = {
87
92
  ...serializeEndpoint(endpoint),
88
93
  };
89
- 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);
90
99
  if (options.writeLegacy !== false) {
91
- fs.writeFileSync(endpointFile, JSON.stringify(payload, null, 2), "utf8");
100
+ writeJsonFileAtomic(endpointFile, payload);
92
101
  }
93
102
  }
94
103
  export function readLocalCompanionEndpoint(cwd, options = {}) {
@@ -118,6 +127,7 @@ export function clearLocalCompanionEndpoint(cwd, instanceId, options = {}) {
118
127
  getWorkspaceAdapterEndpointFile(cwd, "codex"),
119
128
  getWorkspaceAdapterEndpointFile(cwd, "claude"),
120
129
  getWorkspaceAdapterEndpointFile(cwd, "opencode"),
130
+ getWorkspaceAdapterEndpointFile(cwd, "pi"),
121
131
  getWorkspaceAdapterEndpointFile(cwd, "shell"),
122
132
  ];
123
133
  for (const filePath of files) {