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
@@ -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;
@@ -36,7 +35,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
36
35
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
37
36
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
38
37
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
39
- const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
38
+ const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
40
39
  function log(message) {
41
40
  process.stderr.write(`[wechat-daemon] ${message}\n`);
42
41
  }
@@ -52,11 +51,8 @@ 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
- return value === "codex" || value === "claude" || value === "opencode";
55
+ return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
60
56
  }
61
57
  function isSameWorkspaceCwd(left, right) {
62
58
  const normalizedLeft = path.resolve(left);
@@ -84,10 +80,10 @@ export function parseDaemonCliArgs(argv) {
84
80
  const next = argv[i + 1];
85
81
  if (arg === "--help" || arg === "-h") {
86
82
  process.stdout.write([
87
- "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode>] [--profile <name-or-path>] [--no-open]",
83
+ "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]",
88
84
  "",
89
- "Keeps one WeChat connection alive and switches between Codex, Claude Code, and OpenCode from WeChat.",
90
- "Send /codex, /claude, or /opencode in WeChat to switch the active terminal.",
85
+ "Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
86
+ "Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
91
87
  "",
92
88
  ].join("\n"));
93
89
  process.exit(0);
@@ -133,20 +129,38 @@ export function parseDaemonSwitchCommand(text) {
133
129
  return "claude";
134
130
  case "/opencode":
135
131
  return "opencode";
132
+ case "/pi":
133
+ return "pi";
136
134
  default:
137
135
  return null;
138
136
  }
139
137
  }
138
+ export function parseDaemonSwitchDirective(text) {
139
+ const match = text
140
+ .trim()
141
+ .match(/^\/(codex|claude|opencode|pi)(?:\s+([\s\S]+))?$/i);
142
+ if (!match) {
143
+ return null;
144
+ }
145
+ return {
146
+ adapter: match[1].toLowerCase(),
147
+ remainder: match[2]?.trim() ?? "",
148
+ };
149
+ }
140
150
  export function defaultDaemonSessionStartMode(adapter) {
141
- return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
151
+ return adapter === "codex" ? "restore" : "new";
142
152
  }
143
153
  export function resolveDaemonSessionStartMode(params) {
144
- if (params.reuseExistingVisible && params.visibleConnected) {
145
- return "restore";
146
- }
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.
147
158
  if (params.explicitSessionStartMode) {
148
159
  return params.explicitSessionStartMode;
149
160
  }
161
+ if (params.reuseExistingVisible && params.visibleConnected) {
162
+ return "restore";
163
+ }
150
164
  if (params.adapter === "codex") {
151
165
  return "restore";
152
166
  }
@@ -340,6 +354,12 @@ function isVisibleClientAlive(cwd, adapter) {
340
354
  clearLocalCompanionOccupancy(cwd, endpoint.instanceId, { adapter });
341
355
  return false;
342
356
  }
357
+ export function shouldRestartDeadCodexVisibleRuntime(params) {
358
+ return (params.adapter === "codex" &&
359
+ !params.slotCreated &&
360
+ params.hadVisibleClient &&
361
+ !params.visibleConnected);
362
+ }
343
363
  function cleanupVisibleClientLauncher(launch) {
344
364
  if (!launch.pid || !isPidAlive(launch.pid)) {
345
365
  return false;
@@ -370,6 +390,24 @@ export async function waitForVisibleClientConnection(params, deps = {}) {
370
390
  await sleepFn(Math.min(pollMs, remainingMs));
371
391
  }
372
392
  }
393
+ export async function waitForCodexVisibleThread(params, deps = {}) {
394
+ const timeoutMs = params.timeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
395
+ const pollMs = params.pollMs ?? VISIBLE_CLIENT_CONNECT_POLL_MS;
396
+ const sleepFn = deps.sleep ?? sleep;
397
+ const now = deps.now ?? (() => Date.now());
398
+ const deadline = now() + timeoutMs;
399
+ while (true) {
400
+ const threadId = params.getThreadId();
401
+ if (threadId) {
402
+ return threadId;
403
+ }
404
+ const remainingMs = deadline - now();
405
+ if (remainingMs <= 0) {
406
+ return null;
407
+ }
408
+ await sleepFn(Math.min(pollMs, remainingMs));
409
+ }
410
+ }
373
411
  function formatInboundMessagePreview(message) {
374
412
  if (message.text.trim()) {
375
413
  return message.text;
@@ -384,7 +422,7 @@ function formatInboundMessagePreview(message) {
384
422
  function formatNoActiveAdapterMessage() {
385
423
  return [
386
424
  "No active terminal is selected.",
387
- "Send /codex, /claude, or /opencode to choose one.",
425
+ "Send /codex, /claude, /opencode, or /pi to choose one.",
388
426
  ].join("\n");
389
427
  }
390
428
  export function formatDaemonSwitchResultDetail(result) {
@@ -392,6 +430,9 @@ export function formatDaemonSwitchResultDetail(result) {
392
430
  const previous = result.previousActiveAdapter
393
431
  ? ` Active terminal remains ${result.previousActiveAdapter}.`
394
432
  : " No terminal is active yet.";
433
+ if (result.visibleConnected && result.visibleReady === false) {
434
+ return `The visible Codex CLI connected, but its active thread is not ready yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
435
+ }
395
436
  if (result.openedVisible) {
396
437
  return result.created
397
438
  ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
@@ -442,6 +483,8 @@ class WechatDaemon {
442
483
  authorizedUserId;
443
484
  transport;
444
485
  slots = new Map();
486
+ // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
487
+ slotEnsureChains = new Map();
445
488
  startedAt = new Date().toISOString();
446
489
  bridgeStartedAtMs = Date.now();
447
490
  backlogNoticeSent = false;
@@ -543,7 +586,7 @@ class WechatDaemon {
543
586
  let consecutivePollFailures = 0;
544
587
  log("WeChat daemon is ready.");
545
588
  log(`Working directory: ${this.cwd}`);
546
- log("Switch from WeChat with /codex, /claude, or /opencode.");
589
+ log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
547
590
  appendDaemonLog(`started: cwd=${this.cwd}`);
548
591
  const activeSlot = this.getActiveSlot();
549
592
  const welcomeText = t("daemon.welcome", {
@@ -597,14 +640,12 @@ class WechatDaemon {
597
640
  }
598
641
  catch (error) {
599
642
  const errorText = error instanceof Error ? error.message : String(error);
600
- const isUserFacingShellRejection = error instanceof Error && error.name === "ShellCommandRejectedError";
601
643
  logError(errorText);
602
- appendDaemonLog(`${isUserFacingShellRejection ? "inbound_rejected" : "inbound_error"}: ${errorText}`);
644
+ appendDaemonLog(`inbound_error: ${errorText}`);
603
645
  await this.queueWechatMessage(message.senderId, formatUserFacingInboundError({
604
646
  adapter: this.activeAdapter ?? "codex",
605
647
  cwd: this.cwd,
606
648
  errorText,
607
- isUserFacingShellRejection,
608
649
  }), "inbound_error");
609
650
  }
610
651
  }
@@ -616,6 +657,32 @@ class WechatDaemon {
616
657
  }
617
658
  await this.shutdownPromise;
618
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
+ }
619
686
  async cleanup() {
620
687
  appendDaemonLog("shutdown_started");
621
688
  for (const slot of this.slots.values()) {
@@ -680,6 +747,19 @@ class WechatDaemon {
680
747
  }
681
748
  }
682
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 = {}) {
683
763
  const previousActiveAdapter = this.activeAdapter ?? undefined;
684
764
  let slot = this.slots.get(adapter);
685
765
  let created = false;
@@ -697,7 +777,24 @@ class WechatDaemon {
697
777
  created = true;
698
778
  }
699
779
  let openedVisible = false;
780
+ const visibleEndpointBeforeProbe = readLocalCompanionEndpoint(this.cwd, {
781
+ adapter,
782
+ });
783
+ const hadVisibleClient = Boolean(visibleEndpointBeforeProbe?.companionPid ||
784
+ visibleEndpointBeforeProbe?.companionConnectedAt ||
785
+ visibleEndpointBeforeProbe?.sharedThreadId ||
786
+ visibleEndpointBeforeProbe?.sharedSessionId);
700
787
  let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
788
+ if (shouldRestartDeadCodexVisibleRuntime({
789
+ adapter,
790
+ slotCreated: created,
791
+ hadVisibleClient,
792
+ visibleConnected,
793
+ })) {
794
+ await this.startFreshSlotSession(slot);
795
+ appendDaemonLog(`dead_visible_codex_runtime_restarted: cwd=${this.cwd}`);
796
+ }
797
+ const sharedSessionBeforeVisible = getSharedSessionIdFromAdapterState(slot.runtime.getState());
701
798
  const sessionStartMode = resolveDaemonSessionStartMode({
702
799
  adapter,
703
800
  explicitSessionStartMode: options.sessionStartMode,
@@ -707,9 +804,10 @@ class WechatDaemon {
707
804
  reuseExistingVisible: options.reuseExistingVisible !== false,
708
805
  });
709
806
  if (!created &&
710
- options.reuseExistingVisible === false &&
807
+ (options.reuseExistingVisible === false ||
808
+ options.sessionStartMode === "new") &&
711
809
  sessionStartMode === "new" &&
712
- (adapter === "claude" || adapter === "opencode") &&
810
+ (adapter === "claude" || adapter === "opencode" || adapter === "pi") &&
713
811
  visibleConnected) {
714
812
  await this.startFreshSlotSession(slot);
715
813
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
@@ -740,16 +838,36 @@ class WechatDaemon {
740
838
  appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
741
839
  }
742
840
  }
743
- const activated = options.openVisible === false || visibleConnected;
841
+ let visibleReady = visibleConnected;
842
+ if (adapter === "codex" && visibleConnected && !sharedSessionBeforeVisible) {
843
+ const visibleThreadId = await waitForCodexVisibleThread({
844
+ getThreadId: () => {
845
+ const state = slot.runtime.getState();
846
+ if (state.lastThreadSwitchSource !== "local") {
847
+ return undefined;
848
+ }
849
+ return state.sharedThreadId ?? state.sharedSessionId;
850
+ },
851
+ });
852
+ visibleReady = Boolean(visibleThreadId);
853
+ if (visibleThreadId) {
854
+ appendDaemonLog(`visible_codex_thread_ready: thread=${visibleThreadId} cwd=${this.cwd}`);
855
+ }
856
+ else {
857
+ appendDaemonLog(`visible_codex_thread_timeout: cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS}`);
858
+ }
859
+ }
860
+ const activated = options.openVisible === false || (visibleConnected && visibleReady);
744
861
  if (activated) {
745
862
  this.activeAdapter = adapter;
746
863
  }
747
- appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
864
+ appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} visible_ready=${visibleReady} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
748
865
  return {
749
866
  activeAdapter: adapter,
750
867
  created,
751
868
  openedVisible,
752
869
  visibleConnected,
870
+ visibleReady,
753
871
  activated,
754
872
  previousActiveAdapter,
755
873
  };
@@ -792,12 +910,12 @@ class WechatDaemon {
792
910
  slot.pendingConfirmations = [];
793
911
  slot.pendingUserInput = null;
794
912
  slot.activeTask = null;
795
- if (slot.adapter === "claude") {
913
+ if (slot.adapter === "codex" || slot.adapter === "claude") {
796
914
  await slot.runtime.reset();
797
915
  }
798
- else if (slot.adapter === "opencode") {
916
+ else if (slot.adapter === "opencode" || slot.adapter === "pi") {
799
917
  if (!slot.runtime.createSession) {
800
- throw new Error("/new is not available in opencode mode.");
918
+ throw new Error(`/new is not available in ${slot.adapter} mode.`);
801
919
  }
802
920
  await slot.runtime.createSession();
803
921
  }
@@ -874,14 +992,19 @@ class WechatDaemon {
874
992
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
875
993
  }));
876
994
  break;
877
- 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}`);
878
1003
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
879
- const pending = toPendingUserInput(event.request);
880
- slot.pendingUserInput = pending;
881
- appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
882
1004
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
883
1005
  }));
884
1006
  break;
1007
+ }
885
1008
  case "mirrored_user_input":
886
1009
  appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
887
1010
  if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
@@ -937,6 +1060,7 @@ class WechatDaemon {
937
1060
  slot.pendingConfirmations = [];
938
1061
  slot.pendingUserInput = null;
939
1062
  slot.activeTask = null;
1063
+ this.disposeDeadSlot(slot);
940
1064
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
941
1065
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
942
1066
  }));
@@ -975,18 +1099,24 @@ class WechatDaemon {
975
1099
  message = { ...message, text: rewritten };
976
1100
  }
977
1101
  }
978
- const switchAdapter = parseDaemonSwitchCommand(message.text);
979
- if (switchAdapter) {
980
- const result = await this.ensureSlot(switchAdapter, {
1102
+ const switchDirective = parseDaemonSwitchDirective(message.text);
1103
+ if (switchDirective) {
1104
+ const result = await this.ensureSlot(switchDirective.adapter, {
981
1105
  openVisible: true,
982
1106
  reuseExistingVisible: true,
983
1107
  });
984
1108
  const detail = formatDaemonSwitchResultDetail(result);
985
- const heading = result.activated
986
- ? `Active terminal: ${switchAdapter}.`
987
- : `Could not activate terminal: ${switchAdapter}.`;
988
- await this.queueWechatMessage(message.senderId, `${heading}\n${detail}`);
989
- return;
1109
+ if (!result.activated) {
1110
+ await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1111
+ return;
1112
+ }
1113
+ if (switchDirective.remainder) {
1114
+ message = { ...message, text: switchDirective.remainder };
1115
+ }
1116
+ else {
1117
+ await this.queueWechatMessage(message.senderId, `Active terminal: ${switchDirective.adapter}.\n${detail}`);
1118
+ return;
1119
+ }
990
1120
  }
991
1121
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
992
1122
  await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
@@ -1004,7 +1134,7 @@ class WechatDaemon {
1004
1134
  await this.queueWechatMessage(message.senderId, formatBindCommandUsage());
1005
1135
  return;
1006
1136
  }
1007
- const slot = this.getActiveSlot();
1137
+ let slot = this.getActiveSlot();
1008
1138
  if (!slot) {
1009
1139
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1010
1140
  return;
@@ -1026,7 +1156,25 @@ class WechatDaemon {
1026
1156
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
1027
1157
  return;
1028
1158
  }
1159
+ const visibleResult = await this.ensureSlot(slot.adapter, {
1160
+ openVisible: true,
1161
+ reuseExistingVisible: true,
1162
+ });
1163
+ if (!visibleResult.activated) {
1164
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatDaemonSwitchResultDetail(visibleResult)));
1165
+ return;
1166
+ }
1167
+ slot = this.getActiveSlot() ?? slot;
1029
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
+ }
1030
1178
  if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1031
1179
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1032
1180
  return;
@@ -1058,9 +1206,33 @@ class WechatDaemon {
1058
1206
  case "status":
1059
1207
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1060
1208
  return;
1061
- case "resume":
1062
- 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
+ }
1063
1234
  return;
1235
+ }
1064
1236
  case "new_session":
1065
1237
  if (!activeSlot.runtime.createSession) {
1066
1238
  await this.queueWechatMessage(message.senderId, `/new is not available in ${activeSlot.adapter} mode.`);
@@ -1138,28 +1310,42 @@ class WechatDaemon {
1138
1310
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1 ? `${count} approvals denied.` : "Approval denied."));
1139
1311
  }
1140
1312
  async answerPendingUserInput(message, activeSlot, raw) {
1141
- 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;
1142
1322
  if (!pending) {
1143
1323
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, "No pending user input request."));
1144
1324
  return;
1145
1325
  }
1146
1326
  const parsed = parsePendingUserInputAnswerCommand(raw, pending);
1147
1327
  if ("error" in parsed) {
1148
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, parsed.error));
1328
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, parsed.error));
1149
1329
  return;
1150
1330
  }
1151
- const submitted = await activeSlot.runtime.submitUserInput(parsed.answers);
1331
+ const submitted = await slot.runtime.submitUserInput(parsed.answers);
1152
1332
  if (!submitted) {
1153
- 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."));
1154
1334
  return;
1155
1335
  }
1156
- activeSlot.pendingUserInput = null;
1157
- activeSlot.activeTask = {
1336
+ slot.pendingUserInput = null;
1337
+ slot.activeTask = {
1158
1338
  startedAt: Date.now(),
1159
1339
  inputPreview: parsed.preview,
1160
1340
  };
1161
- appendDaemonLog(`user_input_answered: adapter=${activeSlot.adapter} preview=${parsed.preview}`);
1162
- 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);
1163
1349
  }
1164
1350
  resolvePendingApprovalSlot(activeSlot) {
1165
1351
  if (activeSlot.pendingConfirmations.length > 0) {
@@ -1167,13 +1353,6 @@ class WechatDaemon {
1167
1353
  }
1168
1354
  return (Array.from(this.slots.values()).find((slot) => slot.pendingConfirmations.length > 0) ?? null);
1169
1355
  }
1170
- countPendingApprovals() {
1171
- let count = 0;
1172
- for (const slot of this.slots.values()) {
1173
- count += slot.pendingConfirmations.length;
1174
- }
1175
- return count;
1176
- }
1177
1356
  getActiveSlot() {
1178
1357
  if (!this.activeAdapter) {
1179
1358
  return null;
@@ -1565,27 +1744,35 @@ export async function runDaemon(options) {
1565
1744
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
1566
1745
  }
1567
1746
  await daemon.startIpcServer();
1568
- await daemon.runInitialAdapter(options);
1569
- let shutdownInProgress = false;
1570
- const handleSignal = (signal) => {
1571
- if (shutdownInProgress) {
1572
- log(`Received ${signal} during shutdown, forcing exit.`);
1573
- process.exit(1);
1574
- }
1575
- shutdownInProgress = true;
1576
- log(`Received ${signal}. Stopping daemon.`);
1577
- void daemon.shutdown().finally(() => process.exit(0));
1578
- };
1579
- process.on("SIGINT", () => handleSignal("SIGINT"));
1580
- process.on("SIGTERM", () => handleSignal("SIGTERM"));
1581
- process.on("SIGHUP", () => handleSignal("SIGHUP"));
1582
- if (process.platform === "win32") {
1583
- 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;
1584
1775
  }
1585
- process.on("exit", () => {
1586
- clearDaemonEndpoint();
1587
- });
1588
- await daemon.runPollLoop();
1589
1776
  }
1590
1777
  export async function main(argv = process.argv.slice(2)) {
1591
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.",
@@ -11,8 +11,8 @@ export const messages = {
11
11
  "bridge.fatalError": "Bridge error: {message}",
12
12
  "bridge.backlogIgnored": "Ignored {count} backlog message(s) sent before the bridge started (they will not be processed).\nIf you actually sent them after the bridge started, your system clock is likely skewed by more than {graceSeconds}s — sync the clock and restart the bridge.",
13
13
  // === Daemon welcome & control ===
14
- "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
15
- "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, or /opencode to start one.",
14
+ "daemon.welcome": "WeChat Daemon ready.\nCWD: {cwd}\nActive: {adapter}\n\nCommands: /claude, /codex, /opencode, /pi, /stop, /confirm, /deny, /status\n{bindings}\n\nManage: /bind [emoji] cmd, /unbind [emoji], /bindings",
15
+ "daemon.noActiveAdapter": "No active terminal is selected. Send /codex, /claude, /opencode, or /pi to start one.",
16
16
  "daemon.switchResult.new": "Started a new visible CLI.",
17
17
  "daemon.switchResult.reused": "Reused the existing visible CLI.",
18
18
  // === Approval ===
@@ -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",