@unlinearity/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 (51) 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 +5 -12
  40. package/scripts/ensure-node-pty-permissions.mjs +73 -0
  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-pi.mjs +0 -5
  45. package/bin/wechat-bridge-shell.mjs +0 -5
  46. package/bin/wechat-bridge.mjs +0 -5
  47. package/bin/wechat-claude-start.mjs +0 -5
  48. package/bin/wechat-codex-start.mjs +0 -5
  49. package/bin/wechat-opencode-start.mjs +0 -5
  50. package/bin/wechat-pi-start.mjs +0 -5
  51. package/dist/bridge/bridge-adapters.shell.js +0 -363
@@ -12,7 +12,8 @@ import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
12
12
  import { readBridgeLockFile, } from "../bridge/bridge-state.js";
13
13
  import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
14
14
  import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
15
- import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-bridge.js";
15
+ import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
16
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-forwarding.js";
16
17
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
17
18
  import { ensureWechatCredentials } from "../wechat/setup.js";
18
19
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
@@ -26,8 +27,6 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
26
27
  const DAEMON_HOST = "127.0.0.1";
27
28
  const POLL_RETRY_BASE_MS = 1_000;
28
29
  const POLL_RETRY_MAX_MS = 30_000;
29
- const WECHAT_SEND_MAX_ATTEMPTS = 3;
30
- const WECHAT_SEND_RETRY_BASE_MS = 750;
31
30
  const SINGLE_BRIDGE_STOP_TIMEOUT_MS = 10_000;
32
31
  const SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS = 3_000;
33
32
  const SINGLE_BRIDGE_STOP_POLL_MS = 250;
@@ -52,9 +51,6 @@ function computePollRetryDelayMs(consecutiveFailures) {
52
51
  const exponent = Math.min(normalizedFailures - 1, 5);
53
52
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
54
53
  }
55
- function computeWechatSendRetryDelayMs(attempt) {
56
- return WECHAT_SEND_RETRY_BASE_MS * attempt;
57
- }
58
54
  function isDaemonAdapterKind(value) {
59
55
  return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
60
56
  }
@@ -155,12 +151,16 @@ export function defaultDaemonSessionStartMode(adapter) {
155
151
  return adapter === "codex" ? "restore" : "new";
156
152
  }
157
153
  export function resolveDaemonSessionStartMode(params) {
158
- if (params.reuseExistingVisible && params.visibleConnected) {
159
- return "restore";
160
- }
154
+ // An explicit --session-start-mode wins over visible-client reuse: the user
155
+ // asked for a fresh (or restored) session on purpose, so silently degrading
156
+ // "new" back to "restore" because a window happens to be connected would
157
+ // ignore the request.
161
158
  if (params.explicitSessionStartMode) {
162
159
  return params.explicitSessionStartMode;
163
160
  }
161
+ if (params.reuseExistingVisible && params.visibleConnected) {
162
+ return "restore";
163
+ }
164
164
  if (params.adapter === "codex") {
165
165
  return "restore";
166
166
  }
@@ -483,6 +483,8 @@ class WechatDaemon {
483
483
  authorizedUserId;
484
484
  transport;
485
485
  slots = new Map();
486
+ // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
487
+ slotEnsureChains = new Map();
486
488
  startedAt = new Date().toISOString();
487
489
  bridgeStartedAtMs = Date.now();
488
490
  backlogNoticeSent = false;
@@ -638,14 +640,12 @@ class WechatDaemon {
638
640
  }
639
641
  catch (error) {
640
642
  const errorText = error instanceof Error ? error.message : String(error);
641
- const isUserFacingShellRejection = error instanceof Error && error.name === "ShellCommandRejectedError";
642
643
  logError(errorText);
643
- appendDaemonLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
644
+ appendDaemonLog(`inbound_error: ${errorText}`);
644
645
  await this.queueWechatMessage(message.senderId, formatUserFacingInboundError({
645
646
  adapter: this.activeAdapter ?? "codex",
646
647
  cwd: this.cwd,
647
648
  errorText,
648
- isUserFacingShellRejection,
649
649
  }), "inbound_error");
650
650
  }
651
651
  }
@@ -657,6 +657,32 @@ class WechatDaemon {
657
657
  }
658
658
  await this.shutdownPromise;
659
659
  }
660
+ /**
661
+ * Remove a slot whose runtime reported a fatal (terminal) error. The dead
662
+ * runtime would otherwise stay in the slot map forever: every later message
663
+ * would fail against it ("adapter is not running") with no path back to a
664
+ * working adapter short of a manual /reset or daemon restart. After removal,
665
+ * the next switch directive (/codex etc.) rebuilds the adapter from scratch.
666
+ */
667
+ disposeDeadSlot(slot) {
668
+ if (this.slots.get(slot.adapter) !== slot) {
669
+ return; // Already replaced by a newer ensureSlot run.
670
+ }
671
+ this.slots.delete(slot.adapter);
672
+ if (this.activeAdapter === slot.adapter) {
673
+ this.activeAdapter = null;
674
+ }
675
+ appendDaemonLog(`dead_slot_disposed: adapter=${slot.adapter}`);
676
+ void (async () => {
677
+ try {
678
+ await slot.runtime.dispose();
679
+ }
680
+ catch {
681
+ // Best effort: the runtime already reported a fatal error.
682
+ }
683
+ slot.controller.clearLocalClientEndpoint();
684
+ })();
685
+ }
660
686
  async cleanup() {
661
687
  appendDaemonLog("shutdown_started");
662
688
  for (const slot of this.slots.values()) {
@@ -721,6 +747,19 @@ class WechatDaemon {
721
747
  }
722
748
  }
723
749
  async ensureSlot(adapter, options = {}) {
750
+ // Serialize per-adapter ensureSlot runs. The IPC handler, the WeChat poll
751
+ // loop, and startup can all call this concurrently; without serialization
752
+ // two callers would both observe an empty slot map, create two runtimes,
753
+ // and the second slots.set would orphan the first (duplicate PTY child,
754
+ // double visible client, endpoint files overwriting each other).
755
+ const tail = this.slotEnsureChains.get(adapter) ?? Promise.resolve();
756
+ const run = tail.then(() => this.runEnsureSlot(adapter, options),
757
+ // A rejected predecessor must not block this run — start fresh either way.
758
+ () => this.runEnsureSlot(adapter, options));
759
+ this.slotEnsureChains.set(adapter, run.then(() => undefined, () => undefined));
760
+ return run;
761
+ }
762
+ async runEnsureSlot(adapter, options = {}) {
724
763
  const previousActiveAdapter = this.activeAdapter ?? undefined;
725
764
  let slot = this.slots.get(adapter);
726
765
  let created = false;
@@ -765,7 +804,8 @@ class WechatDaemon {
765
804
  reuseExistingVisible: options.reuseExistingVisible !== false,
766
805
  });
767
806
  if (!created &&
768
- options.reuseExistingVisible === false &&
807
+ (options.reuseExistingVisible === false ||
808
+ options.sessionStartMode === "new") &&
769
809
  sessionStartMode === "new" &&
770
810
  (adapter === "claude" || adapter === "opencode" || adapter === "pi") &&
771
811
  visibleConnected) {
@@ -853,6 +893,10 @@ class WechatDaemon {
853
893
  }),
854
894
  pendingConfirmations: [],
855
895
  pendingUserInput: null,
896
+ resumeCoordinator: new ResumeSessionCoordinator({
897
+ adapter,
898
+ runtime,
899
+ }),
856
900
  activeTask: null,
857
901
  lastOutputAt: 0,
858
902
  };
@@ -952,14 +996,19 @@ class WechatDaemon {
952
996
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
953
997
  }));
954
998
  break;
955
- case "user_input_required":
999
+ case "user_input_required": {
1000
+ // Mirror the pending request synchronously, before the async WeChat
1001
+ // send (which retries with backoff). Otherwise a fast user reply can
1002
+ // race past the still-unwritten mirror and be dispatched as plain
1003
+ // input into a CLI that is waiting for a structured form answer.
1004
+ const pending = toPendingUserInput(event.request);
1005
+ slot.pendingUserInput = pending;
1006
+ appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
956
1007
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
957
- const pending = toPendingUserInput(event.request);
958
- slot.pendingUserInput = pending;
959
- appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
960
1008
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
961
1009
  }));
962
1010
  break;
1011
+ }
963
1012
  case "mirrored_user_input":
964
1013
  appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
965
1014
  if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
@@ -969,8 +1018,12 @@ class WechatDaemon {
969
1018
  }
970
1019
  break;
971
1020
  case "session_switched":
1021
+ if (event.source === "local") {
1022
+ slot.resumeCoordinator.clear();
1023
+ }
972
1024
  appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${event.sessionId} source=${event.source} reason=${event.reason}`);
973
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1025
+ if (shouldForwardSessionSwitchEvent(event.reason) &&
1026
+ shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
974
1027
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
975
1028
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
976
1029
  adapter: slot.adapter,
@@ -982,8 +1035,12 @@ class WechatDaemon {
982
1035
  }
983
1036
  break;
984
1037
  case "thread_switched":
1038
+ if (event.source === "local") {
1039
+ slot.resumeCoordinator.clear();
1040
+ }
985
1041
  appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${event.threadId} source=${event.source} reason=${event.reason}`);
986
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1042
+ if (shouldForwardSessionSwitchEvent(event.reason) &&
1043
+ shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
987
1044
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
988
1045
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
989
1046
  adapter: slot.adapter,
@@ -1015,6 +1072,7 @@ class WechatDaemon {
1015
1072
  slot.pendingConfirmations = [];
1016
1073
  slot.pendingUserInput = null;
1017
1074
  slot.activeTask = null;
1075
+ this.disposeDeadSlot(slot);
1018
1076
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1019
1077
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
1020
1078
  }));
@@ -1120,6 +1178,15 @@ class WechatDaemon {
1120
1178
  }
1121
1179
  slot = this.getActiveSlot() ?? slot;
1122
1180
  const adapterState = slot.runtime.getState();
1181
+ if (adapterState.status === "awaiting_input") {
1182
+ // Belt-and-braces with the pendingUserInput mirror above: the adapter
1183
+ // state is authoritative even when the mirror was cleared or the WeChat
1184
+ // notification is still queued.
1185
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1186
+ ? formatPendingUserInputReminder(slot.pendingUserInput)
1187
+ : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1188
+ return;
1189
+ }
1123
1190
  if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1124
1191
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1125
1192
  return;
@@ -1151,9 +1218,37 @@ class WechatDaemon {
1151
1218
  case "status":
1152
1219
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1153
1220
  return;
1154
- case "resume":
1155
- await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled in daemon mode. Use /resume directly inside the visible ${activeSlot.adapter} terminal; WeChat will follow that local session.`);
1221
+ case "resume": {
1222
+ if (!isWechatResumeEnabled(activeSlot.adapter)) {
1223
+ await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; WeChat will follow that local session.`);
1224
+ return;
1225
+ }
1226
+ try {
1227
+ let resumeSlot = activeSlot;
1228
+ if (activeSlot.adapter === "codex" && command.target) {
1229
+ const visible = await this.ensureSlot("codex", {
1230
+ openVisible: true,
1231
+ reuseExistingVisible: true,
1232
+ });
1233
+ if (!visible.activated) {
1234
+ throw new Error(formatDaemonSwitchResultDetail(visible));
1235
+ }
1236
+ resumeSlot = this.slots.get("codex") ?? activeSlot;
1237
+ }
1238
+ if (command.target) {
1239
+ await resumeSlot.outputBatcher.flushNow();
1240
+ }
1241
+ const result = await resumeSlot.resumeCoordinator.execute(command.target);
1242
+ if (result.kind === "resumed") {
1243
+ resumeSlot.activeTask = null;
1244
+ }
1245
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
1246
+ }
1247
+ catch (error) {
1248
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, error instanceof Error ? error.message : String(error)));
1249
+ }
1156
1250
  return;
1251
+ }
1157
1252
  case "new_session":
1158
1253
  if (!activeSlot.runtime.createSession) {
1159
1254
  await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
@@ -1163,6 +1258,7 @@ class WechatDaemon {
1163
1258
  activeSlot.outputBatcher.clear();
1164
1259
  activeSlot.pendingConfirmations = [];
1165
1260
  activeSlot.pendingUserInput = null;
1261
+ activeSlot.resumeCoordinator.clear();
1166
1262
  await activeSlot.runtime.createSession();
1167
1263
  appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
1168
1264
  return;
@@ -1178,6 +1274,7 @@ class WechatDaemon {
1178
1274
  activeSlot.outputBatcher.clear();
1179
1275
  activeSlot.pendingConfirmations = [];
1180
1276
  activeSlot.pendingUserInput = null;
1277
+ activeSlot.resumeCoordinator.clear();
1181
1278
  await activeSlot.runtime.reset();
1182
1279
  appendDaemonLog(`reset: adapter=${activeSlot.adapter}`);
1183
1280
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Worker session has been reset."));
@@ -1231,28 +1328,42 @@ class WechatDaemon {
1231
1328
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
1232
1329
  }
1233
1330
  async answerPendingUserInput(message, activeSlot, raw) {
1234
- const pending = activeSlot.pendingUserInput;
1331
+ // Mirror the /confirm and /deny behavior: search every slot for a pending
1332
+ // user-input request so the answer reaches the adapter that asked, even
1333
+ // when the user has since switched the active adapter.
1334
+ const slot = this.resolvePendingUserInputSlot(activeSlot);
1335
+ if (!slot) {
1336
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1337
+ return;
1338
+ }
1339
+ const pending = slot.pendingUserInput;
1235
1340
  if (!pending) {
1236
1341
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1237
1342
  return;
1238
1343
  }
1239
1344
  const parsed = parsePendingUserInputAnswerCommand(raw, pending);
1240
1345
  if ("error" in parsed) {
1241
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, parsed.error));
1346
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
1242
1347
  return;
1243
1348
  }
1244
- const submitted = await activeSlot.runtime.submitUserInput(parsed.answers);
1349
+ const submitted = await slot.runtime.submitUserInput(parsed.answers);
1245
1350
  if (!submitted) {
1246
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "The worker could not apply this answer."));
1351
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this answer."));
1247
1352
  return;
1248
1353
  }
1249
- activeSlot.pendingUserInput = null;
1250
- activeSlot.activeTask = {
1354
+ slot.pendingUserInput = null;
1355
+ slot.activeTask = {
1251
1356
  startedAt: Date.now(),
1252
1357
  inputPreview: parsed.preview,
1253
1358
  };
1254
- appendDaemonLog(`user_input_answered: adapter=${activeSlot.adapter} preview=${parsed.preview}`);
1255
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Answer submitted. Continuing..."));
1359
+ appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1360
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1361
+ }
1362
+ resolvePendingUserInputSlot(activeSlot) {
1363
+ if (activeSlot.pendingUserInput) {
1364
+ return activeSlot;
1365
+ }
1366
+ return (Array.from(this.slots.values()).find((slot) => slot.pendingUserInput !== null) ?? null);
1256
1367
  }
1257
1368
  resolvePendingApprovalSlot(activeSlot) {
1258
1369
  if (activeSlot.pendingConfirmations.length > 0) {
@@ -1260,13 +1371,6 @@ class WechatDaemon {
1260
1371
  }
1261
1372
  return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmations.length > 0) ?? null);
1262
1373
  }
1263
- countPendingApprovals() {
1264
- let count = 0;
1265
- for (const slot of this.slots.values()) {
1266
- count += slot.pendingConfirmations.length;
1267
- }
1268
- return count;
1269
- }
1270
1374
  getActiveSlot() {
1271
1375
  if (!this.activeAdapter) {
1272
1376
  return null;
@@ -1658,27 +1762,35 @@ export async function runDaemon(options) {
1658
1762
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
1659
1763
  }
1660
1764
  await daemon.startIpcServer();
1661
- await daemon.runInitialAdapter(options);
1662
- let shutdownInProgress = false;
1663
- const handleSignal = (signal) => {
1664
- if (shutdownInProgress) {
1665
- log(`Received ${signal} during shutdown, forcing exit.`);
1666
- process.exit(1);
1667
- }
1668
- shutdownInProgress = true;
1669
- log(`Received ${signal}. Stopping daemon.`);
1670
- void daemon.shutdown().finally(() => process.exit(0));
1671
- };
1672
- process.on("SIGINT", () => handleSignal("SIGINT"));
1673
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
1674
- process.on("SIGHUP", () => handleSignal("SIGHUP"));
1675
- if (process.platform === "win32") {
1676
- process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
1765
+ try {
1766
+ await daemon.runInitialAdapter(options);
1767
+ let shutdownInProgress = false;
1768
+ const handleSignal = (signal) => {
1769
+ if (shutdownInProgress) {
1770
+ log(`Received ${signal} during shutdown, forcing exit.`);
1771
+ process.exit(1);
1772
+ }
1773
+ shutdownInProgress = true;
1774
+ log(`Received ${signal}. Stopping daemon.`);
1775
+ void daemon.shutdown().finally(() => process.exit(0));
1776
+ };
1777
+ process.on("SIGINT", () => handleSignal("SIGINT"));
1778
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
1779
+ process.on("SIGHUP", () => handleSignal("SIGHUP"));
1780
+ if (process.platform === "win32") {
1781
+ process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
1782
+ }
1783
+ process.on("exit", () => {
1784
+ clearDaemonEndpoint();
1785
+ });
1786
+ await daemon.runPollLoop();
1787
+ }
1788
+ catch (error) {
1789
+ // Abnormal exit (poll loop failure, initial adapter failure): dispose the
1790
+ // slot runtimes so adapter child processes are not stranded as orphans.
1791
+ await daemon.shutdown().catch(() => undefined);
1792
+ throw error;
1677
1793
  }
1678
- process.on("exit", () => {
1679
- clearDaemonEndpoint();
1680
- });
1681
- await daemon.runPollLoop();
1682
1794
  }
1683
1795
  export async function main(argv = process.argv.slice(2)) {
1684
1796
  if (argv.includes("--doctor")) {
@@ -5,9 +5,6 @@ const catalogs = {
5
5
  en: enMessages,
6
6
  };
7
7
  let currentLocale = "zh";
8
- export function getLocale() {
9
- return currentLocale;
10
- }
11
8
  export function setLocale(locale) {
12
9
  currentLocale = locale;
13
10
  }
@@ -1,6 +1,6 @@
1
1
  export const messages = {
2
2
  // === Bridge welcome & control ===
3
- "bridge.welcome": "WeChat Bridge ready ({adapter}).\nCWD: {cwd}\n\nCommands: /stop, /confirm, /deny, /status, /new\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
3
+ "bridge.welcome": "WeChat Bridge ready ({adapter}).\nCWD: {cwd}",
4
4
  "bridge.stopped": "Bridge stopped.",
5
5
  "bridge.interrupt.sent": "Interrupt signal sent.",
6
6
  "bridge.interrupt.notBusy": "No active task to interrupt.",
@@ -66,7 +66,6 @@ export const messages = {
66
66
  "doctor.clock.ok": "~{skewSeconds}s skew vs server time",
67
67
  "doctor.clock.skewWarning": "~{skewSeconds}s skew vs server time (large skew makes the bridge treat fresh messages as pre-start backlog — sync the system clock)",
68
68
  "doctor.label.nodePty": "node-pty",
69
- "doctor.label.shell": "shell",
70
69
  "doctor.label.dataDir": "Data dir",
71
70
  "doctor.label.credentials": "Credentials",
72
71
  "doctor.label.mode": "Mode",
@@ -79,7 +78,6 @@ export const messages = {
79
78
  "doctor.winBuildConpty": "(ConPTY requires build 18309+)",
80
79
  "doctor.nodePty.loaded": "loaded",
81
80
  "doctor.nodePty.fix": "Fix: {fix}",
82
- "doctor.adapterCli.shellNative": "uses the local shell; no other adapter CLI is checked.",
83
81
  "doctor.adapterCli.notFound": "not found{optional}",
84
82
  "doctor.dataDirMissing": "(will be created on first run)",
85
83
  "doctor.credentialsFound": "found",
@@ -88,6 +86,7 @@ export const messages = {
88
86
  "doctor.mode.generic": "generic",
89
87
  "doctor.notSpecified": "(not specified)",
90
88
  "doctor.none": "none",
89
+ "doctor.endpoint.corrupted": "endpoint file exists but cannot be parsed; it will be replaced on the next successful write",
91
90
  "doctor.unknown": "unknown",
92
91
  "doctor.yes": "yes",
93
92
  "doctor.no": "no",
@@ -1,6 +1,6 @@
1
1
  export const messages = {
2
2
  // === 桥接欢迎 & 控制 ===
3
- "bridge.welcome": "微信桥接就绪 ({adapter})。\n工作目录: {cwd}\n\n命令: /stop, /confirm, /deny, /status, /new\n{bindings}\n\n管理: /bind [表情] 命令, /unbind [表情], /bindings",
3
+ "bridge.welcome": "微信桥接就绪 ({adapter})。\n工作目录: {cwd}",
4
4
  "bridge.stopped": "桥接已停止。",
5
5
  "bridge.interrupt.sent": "已发送中断信号。",
6
6
  "bridge.interrupt.notBusy": "没有正在执行的任务可中断。",
@@ -66,7 +66,6 @@ export const messages = {
66
66
  "doctor.clock.ok": "与服务器时间偏差约 {skewSeconds}s",
67
67
  "doctor.clock.skewWarning": "与服务器时间偏差约 {skewSeconds}s(偏差过大会导致桥接启动后的消息被当作积压忽略,请校准系统时间)",
68
68
  "doctor.label.nodePty": "node-pty",
69
- "doctor.label.shell": "shell",
70
69
  "doctor.label.dataDir": "数据目录",
71
70
  "doctor.label.credentials": "凭据",
72
71
  "doctor.label.mode": "模式",
@@ -79,7 +78,6 @@ export const messages = {
79
78
  "doctor.winBuildConpty": "(ConPTY 需要 build 18309+)",
80
79
  "doctor.nodePty.loaded": "已加载",
81
80
  "doctor.nodePty.fix": "修复: {fix}",
82
- "doctor.adapterCli.shellNative": "使用本机 shell,无需检查其它适配器 CLI。",
83
81
  "doctor.adapterCli.notFound": "未找到{optional}",
84
82
  "doctor.dataDirMissing": "(首次运行会创建)",
85
83
  "doctor.credentialsFound": "已找到",
@@ -88,6 +86,7 @@ export const messages = {
88
86
  "doctor.mode.generic": "通用",
89
87
  "doctor.notSpecified": "(未指定)",
90
88
  "doctor.none": "无",
89
+ "doctor.endpoint.corrupted": "endpoint 文件存在但无法解析;下次成功写入时将被替换",
91
90
  "doctor.unknown": "未知",
92
91
  "doctor.yes": "是",
93
92
  "doctor.no": "否",
@@ -0,0 +1,15 @@
1
+ import fs from "node:fs";
2
+ import { ensureChannelDataDir } from "../wechat/channel-config.js";
3
+ /**
4
+ * Write JSON atomically via temp file + rename, so a crash mid-write cannot
5
+ * leave a truncated or half-written file behind. Cross-process readers (the
6
+ * daemon, launchers, companions) either see the previous complete file or the
7
+ * new complete file — never a torn write.
8
+ */
9
+ export function writeJsonFileAtomic(filePath, value) {
10
+ ensureChannelDataDir();
11
+ const data = JSON.stringify(value, null, 2);
12
+ const tempPath = `${filePath}.${process.pid}.tmp`;
13
+ fs.writeFileSync(tempPath, data, "utf-8");
14
+ fs.renameSync(tempPath, filePath);
15
+ }
@@ -7,7 +7,7 @@ import { normalizeBridgeLockPayload, shouldAutoReclaimBridgeLock, } from "../bri
7
7
  import { readLocalCompanionEndpoint as readWorkspaceEndpoint, } from "../companion/local-companion-link.js";
8
8
  import { isDaemonEndpointAlive, } from "../daemon/daemon-link.js";
9
9
  import { t } from "../i18n/index.js";
10
- import { DEFAULT_BASE_URL, resolveChannelDataDir } from "../wechat/channel-config.js";
10
+ import { DEFAULT_BASE_URL, getWorkspaceAdapterEndpointFile, getWorkspaceChannelPaths, resolveChannelDataDir, } from "../wechat/channel-config.js";
11
11
  const STATE_OK = "[ok]";
12
12
  const STATE_WARN = "[warn]";
13
13
  const STATE_FAIL = "[fail]";
@@ -174,8 +174,7 @@ function isBridgeAdapterKind(value) {
174
174
  return (value === "codex" ||
175
175
  value === "claude" ||
176
176
  value === "opencode" ||
177
- value === "pi" ||
178
- value === "shell");
177
+ value === "pi");
179
178
  }
180
179
  export function parseDoctorCliArgs(argv, mode = "generic", fallbackCwd = process.cwd()) {
181
180
  let cwd = fallbackCwd;
@@ -417,8 +416,6 @@ function chooseCliChecks(options) {
417
416
  return [{ name: "opencode", label: "OpenCode", optional: false }];
418
417
  case "pi":
419
418
  return [{ name: "pi", label: "Pi", optional: false }];
420
- case "shell":
421
- return [];
422
419
  default:
423
420
  return [];
424
421
  }
@@ -432,11 +429,6 @@ function chooseCliChecks(options) {
432
429
  }
433
430
  function appendCliLines(lines, options, deps) {
434
431
  const clis = chooseCliChecks(options);
435
- if (options.mode === "bridge" && options.adapter === "shell") {
436
- section(lines, msg("section.adapterCli"));
437
- lines.push(row(STATE_OK, msg("label.shell"), msg("adapterCli.shellNative")));
438
- return;
439
- }
440
432
  if (clis.length === 0) {
441
433
  return;
442
434
  }
@@ -519,6 +511,18 @@ async function appendEndpointSummary(lines, label, endpoint, options, lock, deps
519
511
  lines.push(detail(msg("issue", { issue })));
520
512
  }
521
513
  }
514
+ function endpointFileState(filePath) {
515
+ if (!fs.existsSync(filePath)) {
516
+ return "missing";
517
+ }
518
+ try {
519
+ JSON.parse(fs.readFileSync(filePath, "utf8"));
520
+ return "readable";
521
+ }
522
+ catch {
523
+ return "corrupted";
524
+ }
525
+ }
522
526
  async function appendWorkspaceEndpointLines(lines, options, lock, deps) {
523
527
  const adapters = chooseEndpointAdapters(options, lock);
524
528
  const seenEndpoints = new Set();
@@ -527,12 +531,20 @@ async function appendWorkspaceEndpointLines(lines, options, lock, deps) {
527
531
  if (endpoint) {
528
532
  seenEndpoints.add(`${endpoint.kind}:${endpoint.instanceId}:${endpoint.port}`);
529
533
  }
534
+ else if (endpointFileState(getWorkspaceAdapterEndpointFile(options.cwd, adapter)) ===
535
+ "corrupted") {
536
+ // A torn or otherwise unparsable endpoint file must not read as a
537
+ // healthy "none" — surface it so the operator knows to clear it.
538
+ lines.push(row(STATE_WARN, msg("label.workspaceEndpoint", { adapter }), msg("endpoint.corrupted")));
539
+ continue;
540
+ }
530
541
  await appendEndpointSummary(lines, msg("label.workspaceEndpoint", { adapter }), endpoint, { ...options, adapter }, lock, deps);
531
542
  }
532
543
  const legacyEndpoint = deps.readLocalCompanionEndpoint(options.cwd);
533
544
  const legacyLabel = msg("label.legacyEndpoint");
534
545
  if (!legacyEndpoint) {
535
- lines.push(row(STATE_OK, legacyLabel, msg("none")));
546
+ const legacyFileState = endpointFileState(getWorkspaceChannelPaths(options.cwd).endpointFile);
547
+ lines.push(row(legacyFileState === "corrupted" ? STATE_WARN : STATE_OK, legacyLabel, legacyFileState === "corrupted" ? msg("endpoint.corrupted") : msg("none")));
536
548
  return;
537
549
  }
538
550
  const legacyKey = `${legacyEndpoint.kind}:${legacyEndpoint.instanceId}:${legacyEndpoint.port}`;
@@ -16,7 +16,6 @@ export const CHANNEL_DATA_DIR = resolveChannelDataDir();
16
16
  export const CREDENTIALS_FILE = path.join(CHANNEL_DATA_DIR, "account.json");
17
17
  export const SYNC_BUF_FILE = path.join(CHANNEL_DATA_DIR, "sync_buf.txt");
18
18
  export const CONTEXT_CACHE_FILE = path.join(CHANNEL_DATA_DIR, "context_tokens.json");
19
- export const BRIDGE_STATE_FILE = path.join(CHANNEL_DATA_DIR, "bridge-state.json");
20
19
  export const BRIDGE_LOG_FILE = path.join(CHANNEL_DATA_DIR, "bridge.log");
21
20
  // Hard cap for bridge.log. Long-running daemons otherwise grow it without bound.
22
21
  export const BRIDGE_LOG_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
@@ -48,7 +47,6 @@ export function appendBoundedLog(filePath, line) {
48
47
  }
49
48
  export const BRIDGE_LOCK_FILE = path.join(CHANNEL_DATA_DIR, "bridge.lock.json");
50
49
  export const DAEMON_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "daemon-endpoint.json");
51
- export const CODEX_PANEL_ENDPOINT_FILE = path.join(CHANNEL_DATA_DIR, "codex-panel-endpoint.json");
52
50
  export const WORKSPACES_DIR = path.join(CHANNEL_DATA_DIR, "workspaces");
53
51
  export const INBOUND_MESSAGE_CLAIMS_DIR = path.join(CHANNEL_DATA_DIR, "inbound-message-claims");
54
52
  export const INBOUND_ATTACHMENTS_DIR = path.join(CHANNEL_DATA_DIR, "inbound-attachments");
@@ -166,17 +166,11 @@ function printPostLoginHelp(log) {
166
166
  log("This WeChat account is now authorized for the bridge.");
167
167
  log("");
168
168
  log("Start from any project directory with one of:");
169
- log(" wechat-codex-start");
170
- log(" wechat-claude-start");
171
- log(" wechat-opencode-start");
172
- log(" wechat-pi-start");
173
- log(" wechat-bridge-shell");
174
- log("");
175
- log("Manual two-terminal mode is also available:");
176
- log(" wechat-bridge-codex + wechat-codex");
177
- log(" wechat-bridge-claude + wechat-claude");
178
- log(" wechat-bridge-opencode + wechat-opencode");
179
- log(" wechat-bridge-pi + wechat-pi");
169
+ log(" wechat-codex");
170
+ log(" wechat-claude");
171
+ log(" wechat-opencode");
172
+ log(" wechat-pi");
173
+ log(" wechat-daemon");
180
174
  log("");
181
175
  log("Run wechat-setup again any time you need to refresh the login.");
182
176
  }