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
@@ -16,7 +16,7 @@ const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".
16
16
  const DEFAULT_WAIT_TIMEOUT_MS = 15_000;
17
17
  const DEFAULT_ADAPTER = "codex";
18
18
  function log(adapter, message) {
19
- process.stderr.write(`[wechat-${adapter}-start] ${message}\n`);
19
+ process.stderr.write(`[wechat-${adapter}] ${message}\n`);
20
20
  }
21
21
  export function normalizeComparablePath(cwd) {
22
22
  const normalized = path.resolve(cwd);
@@ -61,7 +61,8 @@ export function decideLaunchAction(input) {
61
61
  if (input.sessionStartMode === "new" &&
62
62
  (input.requestedAdapter === "claude" ||
63
63
  input.requestedAdapter === "opencode" ||
64
- input.requestedAdapter === "pi")) {
64
+ input.requestedAdapter === "pi" ||
65
+ input.requestedAdapter === "codex")) {
65
66
  return {
66
67
  kind: "start_bridge",
67
68
  message: `Starting a fresh ${input.requestedAdapter} session for ${input.requestedCwd}...`,
@@ -106,22 +107,36 @@ export function parseCliArgs(argv) {
106
107
  }
107
108
  const next = argv[i + 1];
108
109
  if (arg === "--help" || arg === "-h") {
110
+ const helpAdapter = adapter;
111
+ const commandName = `wechat-${helpAdapter}`;
112
+ const adapterLabel = {
113
+ codex: "Codex",
114
+ claude: "Claude Code",
115
+ opencode: "OpenCode",
116
+ pi: "Pi",
117
+ }[helpAdapter];
109
118
  process.stdout.write([
110
- "Usage: wechat-codex-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...codex args]",
111
- " wechat-claude-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...claude args]",
112
- " wechat-opencode-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...opencode args]",
113
- " wechat-pi-start [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...pi args]",
114
- " local-companion-start [--adapter <codex|claude|opencode|pi>] [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [...cli args]",
119
+ `Usage: ${commandName} [--cwd <path>] [--profile <name-or-path>] [--timeout-ms <ms>] [--session-start-mode <restore|new>] [...${helpAdapter} args]`,
115
120
  "",
116
- "Starts a bridge for the current directory, waits for the local endpoint, then opens the visible companion or panel.",
117
- "Claude, OpenCode, and Pi launchers start a fresh CLI session by default.",
118
- "Use --session-start-mode restore to explicitly restore the previous session.",
119
- "All adapters are companion-bound: closing the companion/panel also stops the bridge.",
121
+ "Ensures the WeChat runtime for the current directory, then opens or reuses the visible CLI.",
122
+ "If a same-workspace daemon is running, the command delegates to that daemon.",
123
+ helpAdapter === "codex"
124
+ ? "Codex restores the current session by default."
125
+ : `${adapterLabel} starts a fresh session by default.`,
126
+ helpAdapter === "codex"
127
+ ? "Use --session-start-mode new to explicitly start a fresh session."
128
+ : "Use --session-start-mode restore to explicitly restore the previous session.",
129
+ "Use --doctor to check the selected adapter and workspace without starting it.",
130
+ "Without a daemon, the transient runtime is companion-bound and stops when the visible CLI closes.",
120
131
  "Unknown arguments are forwarded to the visible CLI client.",
132
+ `Deprecated alias: ${commandName}-start.`,
121
133
  "",
122
134
  ].join("\n"));
123
135
  process.exit(0);
124
136
  }
137
+ if (arg === "--doctor") {
138
+ continue;
139
+ }
125
140
  if (arg === "--adapter") {
126
141
  if (!next || !["codex", "claude", "opencode", "pi"].includes(next)) {
127
142
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -375,6 +390,12 @@ async function ensureBridgeReady(options) {
375
390
  await waitForEndpoint(options.cwd, options.adapter, options.timeoutMs);
376
391
  return { shouldOpenVisibleClient: true };
377
392
  }
393
+ // The daemon's ensure_slot can legitimately take a while: opening a visible
394
+ // client waits up to 15s for it to connect, and codex waits another 15s for
395
+ // the visible thread — all serialized per adapter. The default 2s IPC timeout
396
+ // would expire on nearly every cold start, so delegation uses a generous
397
+ // deadline and falls back to a local bridge when the daemon cannot deliver.
398
+ const ENSURE_SLOT_DELEGATION_TIMEOUT_MS = 45_000;
378
399
  export async function tryDelegateToDaemon(options, deps = {}) {
379
400
  const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
380
401
  const isEndpointAlive = deps.isEndpointAlive ?? isDaemonEndpointAlive;
@@ -400,9 +421,14 @@ export async function tryDelegateToDaemon(options, deps = {}) {
400
421
  openVisible: true,
401
422
  sessionStartMode: options.sessionStartMode,
402
423
  reuseExistingVisible: options.adapter !== "pi" || options.sessionStartMode !== "new",
403
- });
424
+ }, { timeoutMs: ENSURE_SLOT_DELEGATION_TIMEOUT_MS });
404
425
  if (!response.ok) {
405
- throw new Error(response.error);
426
+ // A daemon that cannot answer in time (or at all) must not abort the
427
+ // launch: fall back to a local companion-bound bridge so the user still
428
+ // gets a working terminal instead of an error for work the daemon may
429
+ // still complete.
430
+ log(options.adapter, `Daemon delegation failed (${response.error ?? "unknown error"}). Falling back to a local bridge.`);
431
+ return false;
406
432
  }
407
433
  log(options.adapter, `Delegated to running wechat-daemon for ${options.cwd}.`);
408
434
  return true;
@@ -414,6 +440,7 @@ export async function runVisibleClient(options, runners = {}) {
414
440
  return await (runners.codexRemoteClient ?? runCodexRemoteClient)({
415
441
  cwd: options.cwd,
416
442
  cliArgs: options.cliArgs,
443
+ sessionStartMode: options.sessionStartMode,
417
444
  });
418
445
  }
419
446
  return await (runners.localCompanion ?? runLocalCompanion)({
@@ -431,6 +458,11 @@ export async function ensureCompanionStartWechatCredentials(adapter, ensureCrede
431
458
  });
432
459
  }
433
460
  export async function runLocalCompanionStart(argv = process.argv.slice(2)) {
461
+ if (argv.includes("--doctor")) {
462
+ const { runDoctorCheck } = await import("../utils/doctor.js");
463
+ await runDoctorCheck(argv, { mode: "bridge" });
464
+ return 0;
465
+ }
434
466
  const options = parseCliArgs(argv);
435
467
  await ensureCompanionStartWechatCredentials(options.adapter);
436
468
  if (await tryDelegateToDaemon(options)) {
@@ -23,9 +23,9 @@ function parseCliArgs(argv) {
23
23
  const next = argv[i + 1];
24
24
  if (arg === "--help" || arg === "-h") {
25
25
  process.stdout.write([
26
- "Usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
26
+ "Internal companion usage: local-companion --adapter <codex|claude|opencode|pi> [--cwd <path>] [...cli args]",
27
27
  "",
28
- 'Starts the visible local companion and connects it to the matching running bridge for the current directory.',
28
+ "Connects a visible local CLI to the matching bridge runtime for the current directory.",
29
29
  "Unknown arguments are forwarded to the visible CLI client.",
30
30
  "",
31
31
  ].join("\n"));
@@ -73,7 +73,7 @@ function readMatchingEndpoint(options) {
73
73
  adapter: options.adapter,
74
74
  });
75
75
  if (!endpoint || endpoint.kind !== options.adapter) {
76
- throw new Error(`No active ${options.adapter} bridge endpoint was found for ${options.cwd}. Start "wechat-bridge-${options.adapter}" in that directory first.`);
76
+ throw new Error(`No active ${options.adapter} runtime was found for ${options.cwd}. Run "wechat-${options.adapter}" in that directory to recreate it.`);
77
77
  }
78
78
  return endpoint;
79
79
  }
@@ -241,6 +241,9 @@ export async function runLocalCompanion(options) {
241
241
  case "resolve_approval":
242
242
  sendResponse(socket, message.id, true, await adapter.resolveApproval(message.payload.action));
243
243
  break;
244
+ case "submit_user_input":
245
+ sendResponse(socket, message.id, true, await adapter.submitUserInput(message.payload.answers));
246
+ break;
244
247
  case "dispose":
245
248
  sendResponse(socket, message.id, true);
246
249
  await closeCompanion(0, "bridge_dispose");
@@ -65,8 +65,12 @@ export function isPidAlive(pid) {
65
65
  process.kill(pid, 0);
66
66
  return true;
67
67
  }
68
- catch {
69
- return false;
68
+ catch (error) {
69
+ // EPERM means the OS found the pid but denied access (e.g. a Windows
70
+ // process running under another privilege level). The process exists, so
71
+ // it must be treated as alive — returning false here would let callers
72
+ // delete a live daemon's endpoint file and break its singleton.
73
+ return error instanceof Error && error.code === "EPERM";
70
74
  }
71
75
  }
72
76
  export function sendDaemonResponse(socket, id, response) {
@@ -170,6 +174,11 @@ export async function sendDaemonRequest(endpoint, payload, options = {}) {
170
174
  socket.once("error", () => {
171
175
  finish({ ok: false, error: "Daemon endpoint is not reachable." });
172
176
  });
177
+ socket.once("close", () => {
178
+ // The daemon may die or be taken over mid-request; without this the
179
+ // caller waits out the full timeout for a socket that is already gone.
180
+ finish({ ok: false, error: "Daemon connection closed before a response." });
181
+ });
173
182
  });
174
183
  }
175
184
  export async function isDaemonEndpointAlive(endpoint, options = {}) {
@@ -177,5 +186,18 @@ export async function isDaemonEndpointAlive(endpoint, options = {}) {
177
186
  return false;
178
187
  }
179
188
  const response = await sendDaemonRequest(endpoint, { command: "status" }, options);
180
- return response.ok;
189
+ if (response.ok) {
190
+ return true;
191
+ }
192
+ // A momentarily busy daemon (e.g. a synchronous session-directory scan
193
+ // blocking its event loop) can miss the short status deadline. Retry once
194
+ // before declaring it dead: a false negative makes callers delete a live
195
+ // daemon's endpoint file and break its singleton.
196
+ const timedOut = (response.error ?? "").includes("Timed out");
197
+ if (!timedOut) {
198
+ return false;
199
+ }
200
+ await new Promise((resolve) => setTimeout(resolve, 250));
201
+ const retry = await sendDaemonRequest(endpoint, { command: "status" }, options);
202
+ return retry.ok;
181
203
  }
@@ -12,10 +12,11 @@ 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 { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-forwarding.js";
16
16
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
17
17
  import { ensureWechatCredentials } from "../wechat/setup.js";
18
18
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
19
+ import { formatResumeSessionList } from "../bridge/bridge-utils.js";
19
20
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
20
21
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
21
22
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.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) {
@@ -952,14 +992,19 @@ class WechatDaemon {
952
992
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
953
993
  }));
954
994
  break;
955
- case "user_input_required":
995
+ case "user_input_required": {
996
+ // Mirror the pending request synchronously, before the async WeChat
997
+ // send (which retries with backoff). Otherwise a fast user reply can
998
+ // race past the still-unwritten mirror and be dispatched as plain
999
+ // input into a CLI that is waiting for a structured form answer.
1000
+ const pending = toPendingUserInput(event.request);
1001
+ slot.pendingUserInput = pending;
1002
+ appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
956
1003
  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
1004
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
961
1005
  }));
962
1006
  break;
1007
+ }
963
1008
  case "mirrored_user_input":
964
1009
  appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
965
1010
  if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
@@ -1015,6 +1060,7 @@ class WechatDaemon {
1015
1060
  slot.pendingConfirmations = [];
1016
1061
  slot.pendingUserInput = null;
1017
1062
  slot.activeTask = null;
1063
+ this.disposeDeadSlot(slot);
1018
1064
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1019
1065
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
1020
1066
  }));
@@ -1120,6 +1166,15 @@ class WechatDaemon {
1120
1166
  }
1121
1167
  slot = this.getActiveSlot() ?? slot;
1122
1168
  const adapterState = slot.runtime.getState();
1169
+ if (adapterState.status === "awaiting_input") {
1170
+ // Belt-and-braces with the pendingUserInput mirror above: the adapter
1171
+ // state is authoritative even when the mirror was cleared or the WeChat
1172
+ // notification is still queued.
1173
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1174
+ ? formatPendingUserInputReminder(slot.pendingUserInput)
1175
+ : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1176
+ return;
1177
+ }
1123
1178
  if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1124
1179
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1125
1180
  return;
@@ -1151,9 +1206,33 @@ class WechatDaemon {
1151
1206
  case "status":
1152
1207
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1153
1208
  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.`);
1209
+ case "resume": {
1210
+ if (activeSlot.adapter !== "opencode") {
1211
+ 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.`);
1212
+ return;
1213
+ }
1214
+ if (command.type !== "resume") {
1215
+ return;
1216
+ }
1217
+ try {
1218
+ if (command.target) {
1219
+ await activeSlot.runtime.resumeSession(command.target);
1220
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, `Resumed OpenCode session ${command.target}.`));
1221
+ }
1222
+ else {
1223
+ const candidates = await activeSlot.runtime.listResumeSessions(8);
1224
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, formatResumeSessionList({
1225
+ adapter: activeSlot.adapter,
1226
+ candidates,
1227
+ currentSessionId: activeSlot.runtime.getState().sharedSessionId,
1228
+ })));
1229
+ }
1230
+ }
1231
+ catch (error) {
1232
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, `Failed to resume OpenCode session: ${error instanceof Error ? error.message : String(error)}`));
1233
+ }
1156
1234
  return;
1235
+ }
1157
1236
  case "new_session":
1158
1237
  if (!activeSlot.runtime.createSession) {
1159
1238
  await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
@@ -1231,28 +1310,42 @@ class WechatDaemon {
1231
1310
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
1232
1311
  }
1233
1312
  async answerPendingUserInput(message, activeSlot, raw) {
1234
- const pending = activeSlot.pendingUserInput;
1313
+ // Mirror the /confirm and /deny behavior: search every slot for a pending
1314
+ // user-input request so the answer reaches the adapter that asked, even
1315
+ // when the user has since switched the active adapter.
1316
+ const slot = this.resolvePendingUserInputSlot(activeSlot);
1317
+ if (!slot) {
1318
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1319
+ return;
1320
+ }
1321
+ const pending = slot.pendingUserInput;
1235
1322
  if (!pending) {
1236
1323
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1237
1324
  return;
1238
1325
  }
1239
1326
  const parsed = parsePendingUserInputAnswerCommand(raw, pending);
1240
1327
  if ("error" in parsed) {
1241
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, parsed.error));
1328
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
1242
1329
  return;
1243
1330
  }
1244
- const submitted = await activeSlot.runtime.submitUserInput(parsed.answers);
1331
+ const submitted = await slot.runtime.submitUserInput(parsed.answers);
1245
1332
  if (!submitted) {
1246
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "The worker could not apply this answer."));
1333
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "The worker could not apply this answer."));
1247
1334
  return;
1248
1335
  }
1249
- activeSlot.pendingUserInput = null;
1250
- activeSlot.activeTask = {
1336
+ slot.pendingUserInput = null;
1337
+ slot.activeTask = {
1251
1338
  startedAt: Date.now(),
1252
1339
  inputPreview: parsed.preview,
1253
1340
  };
1254
- appendDaemonLog(`user_input_answered: adapter=${activeSlot.adapter} preview=${parsed.preview}`);
1255
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "Answer submitted. Continuing..."));
1341
+ appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1342
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1343
+ }
1344
+ resolvePendingUserInputSlot(activeSlot) {
1345
+ if (activeSlot.pendingUserInput) {
1346
+ return activeSlot;
1347
+ }
1348
+ return (Array.from(this.slots.values()).find((slot) => slot.pendingUserInput !== null) ?? null);
1256
1349
  }
1257
1350
  resolvePendingApprovalSlot(activeSlot) {
1258
1351
  if (activeSlot.pendingConfirmations.length > 0) {
@@ -1260,13 +1353,6 @@ class WechatDaemon {
1260
1353
  }
1261
1354
  return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmations.length > 0) ?? null);
1262
1355
  }
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
1356
  getActiveSlot() {
1271
1357
  if (!this.activeAdapter) {
1272
1358
  return null;
@@ -1658,27 +1744,35 @@ export async function runDaemon(options) {
1658
1744
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
1659
1745
  }
1660
1746
  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"));
1747
+ try {
1748
+ await daemon.runInitialAdapter(options);
1749
+ let shutdownInProgress = false;
1750
+ const handleSignal = (signal) => {
1751
+ if (shutdownInProgress) {
1752
+ log(`Received ${signal} during shutdown, forcing exit.`);
1753
+ process.exit(1);
1754
+ }
1755
+ shutdownInProgress = true;
1756
+ log(`Received ${signal}. Stopping daemon.`);
1757
+ void daemon.shutdown().finally(() => process.exit(0));
1758
+ };
1759
+ process.on("SIGINT", () => handleSignal("SIGINT"));
1760
+ process.on("SIGTERM", () => handleSignal("SIGTERM"));
1761
+ process.on("SIGHUP", () => handleSignal("SIGHUP"));
1762
+ if (process.platform === "win32") {
1763
+ process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
1764
+ }
1765
+ process.on("exit", () => {
1766
+ clearDaemonEndpoint();
1767
+ });
1768
+ await daemon.runPollLoop();
1769
+ }
1770
+ catch (error) {
1771
+ // Abnormal exit (poll loop failure, initial adapter failure): dispose the
1772
+ // slot runtimes so adapter child processes are not stranded as orphans.
1773
+ await daemon.shutdown().catch(() => undefined);
1774
+ throw error;
1677
1775
  }
1678
- process.on("exit", () => {
1679
- clearDaemonEndpoint();
1680
- });
1681
- await daemon.runPollLoop();
1682
1776
  }
1683
1777
  export async function main(argv = process.argv.slice(2)) {
1684
1778
  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
+ }