cli-wechat-bridge 1.1.7 → 1.2.0

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 (82) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +461 -434
  3. package/bin/_run-entry.mjs +29 -6
  4. package/bin/wechat-check-update.mjs +6 -1
  5. package/bin/wechat-claude.mjs +6 -1
  6. package/bin/wechat-codex.mjs +6 -1
  7. package/bin/wechat-daemon.mjs +6 -1
  8. package/bin/wechat-opencode.mjs +6 -1
  9. package/bin/wechat-pi.mjs +6 -1
  10. package/bin/wechat-setup.mjs +6 -1
  11. package/bin/wecom-check-update.mjs +6 -1
  12. package/bin/wecom-claude.mjs +6 -1
  13. package/bin/wecom-codex.mjs +6 -1
  14. package/bin/wecom-daemon.mjs +6 -1
  15. package/bin/wecom-opencode.mjs +6 -1
  16. package/bin/wecom-pi.mjs +6 -1
  17. package/bin/wecom-setup.mjs +6 -1
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +35 -1
  20. package/dist/bridge/bridge-adapters.codex.js +118 -7
  21. package/dist/bridge/bridge-adapters.core.js +25 -1
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +45 -0
  24. package/dist/bridge/bridge-adapters.shared.js +6 -0
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +6 -0
  27. package/dist/bridge/bridge-process-reaper.js +6 -0
  28. package/dist/bridge/bridge-state.js +6 -0
  29. package/dist/bridge/bridge-utils.js +6 -31
  30. package/dist/bridge/claude-hook.js +6 -0
  31. package/dist/bridge/claude-native-controls.js +133 -0
  32. package/dist/bridge/native-terminal-control.js +259 -0
  33. package/dist/bridge/wechat-bridge.js +250 -274
  34. package/dist/bridge/wechat-forwarding.js +6 -0
  35. package/dist/bridge/wechat-outbound-queue.js +6 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +13 -22
  37. package/dist/channels/wechat/index.js +6 -0
  38. package/dist/channels/wechat/wechat-driver.js +191 -0
  39. package/dist/channels/wechat/wechat-forwarding.js +18 -0
  40. package/dist/channels/wechat/wechat-outbound-queue.js +39 -0
  41. package/dist/channels/wecom/bridge-final-reply.js +6 -0
  42. package/dist/channels/wecom/setup-cli.js +6 -0
  43. package/dist/channels/wecom/setup.js +6 -0
  44. package/dist/channels/wecom/wecom-config.js +6 -0
  45. package/dist/channels/wecom/wecom-driver.js +106 -0
  46. package/dist/channels/wecom/wecom-media.js +6 -0
  47. package/dist/channels/wecom/wecom-transport.js +6 -0
  48. package/dist/commands/check-update.js +6 -0
  49. package/dist/companion/codex-remote-client.js +28 -2
  50. package/dist/companion/codex-visible-client-link.js +107 -0
  51. package/dist/companion/local-companion-link.js +6 -0
  52. package/dist/companion/local-companion-start.js +13 -2
  53. package/dist/companion/local-companion.js +23 -0
  54. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  55. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  56. package/dist/core/bridge-control.js +10 -0
  57. package/dist/core/bridge-defer.js +25 -0
  58. package/dist/core/channel-driver.js +1 -0
  59. package/dist/core/conversation-routing.js +58 -0
  60. package/dist/core/direct-run.js +6 -0
  61. package/dist/core/inbound-message-claims.js +6 -0
  62. package/dist/core/outbound-target.js +32 -0
  63. package/dist/core/text-utils.js +6 -0
  64. package/dist/core/turn-coordinator.js +85 -0
  65. package/dist/daemon/daemon-link.js +6 -0
  66. package/dist/daemon/emoji-bindings.js +6 -0
  67. package/dist/daemon/wechat-daemon.js +529 -205
  68. package/dist/i18n/index.js +6 -0
  69. package/dist/i18n/messages-en.js +7 -1
  70. package/dist/i18n/messages-zh.js +7 -1
  71. package/dist/runtime/create-runtime-host.js +6 -0
  72. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  73. package/dist/runtime/runtime-types.js +3 -0
  74. package/dist/utils/atomic-file.js +6 -0
  75. package/dist/utils/doctor.js +6 -0
  76. package/dist/utils/version-checker.js +17 -11
  77. package/dist/wechat/channel-config.js +6 -0
  78. package/dist/wechat/setup.js +6 -0
  79. package/dist/wechat/wechat-channel.js +6 -0
  80. package/dist/wechat/wechat-transport.js +169 -58
  81. package/package.json +108 -106
  82. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env bun
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+ // Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
4
+ // Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
5
+ // This file is part of CLI-WeChat-Bridge. Modifications and derivative works
6
+ // must be released under AGPL-3.0-or-later with full source code; see
7
+ // LICENSE.txt. Network services built on it must offer source to users.
2
8
  import fs from "node:fs";
3
9
  import net from "node:net";
4
10
  import path from "node:path";
@@ -12,22 +18,28 @@ import { readBridgeLockFile, } from "../bridge/bridge-state.js";
12
18
  import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
13
19
  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";
14
20
  import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
15
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isWechatContextUnavailableError, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
21
+ import { WECHAT_SEND_CONTEXTS, formatUserFacingBridgeFatalError, formatUserFacingInboundError, isWechatSendContext, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
16
22
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
17
23
  import { ensureWechatCredentials } from "../wechat/setup.js";
18
24
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
19
- import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
25
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, WeChatTransport, } from "../wechat/wechat-transport.js";
20
26
  import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
21
27
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
22
28
  import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
23
29
  import { routeBridgeMessage } from "../core/bridge-message-router.js";
30
+ import { canDrainDeferredCodexInboundQueue, formatDeferredCodexInboundQueueMessage, isRetryableDeferredCodexDrainError, shouldDeferCodexInboundMessage, } from "../core/bridge-defer.js";
31
+ import { InboundConversationContext } from "../core/conversation-routing.js";
32
+ import { TurnCoordinator } from "../core/turn-coordinator.js";
33
+ import { resolveDaemonOutboundTarget } from "../core/outbound-target.js";
34
+ import { handleAdapterControl, invalidateModelSnapshot } from "../bridge/adapter-control.js";
24
35
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
25
36
  import { isDirectModuleRun } from "../core/direct-run.js";
26
37
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
27
38
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
28
39
  import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
29
40
  import { WecomTransport } from "../channels/wecom/wecom-transport.js";
30
- import { buildWecomInboundPrompt, formatWecomVisibleText, } from "../channels/wecom/wecom-message.js";
41
+ import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
42
+ import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
31
43
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
32
44
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
33
45
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -44,6 +56,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
44
56
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
45
57
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
46
58
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
59
+ const MAX_DAEMON_DEFERRED_INPUTS = 32;
47
60
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
48
61
  function getCliChannelId(argv = process.argv.slice(2)) {
49
62
  const index = argv.indexOf("--channel");
@@ -64,22 +77,6 @@ function computePollRetryDelayMs(consecutiveFailures) {
64
77
  const exponent = Math.min(normalizedFailures - 1, 5);
65
78
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
66
79
  }
67
- function toWecomOutputKind(context) {
68
- switch (context) {
69
- case "notice":
70
- case "thinking":
71
- case "final_reply":
72
- case "approval_required":
73
- case "user_input_required":
74
- case "task_failed":
75
- case "fatal_error":
76
- return context;
77
- case "mirrored_user_input":
78
- return "mirrored_input";
79
- default:
80
- return "notice";
81
- }
82
- }
83
80
  function toWechatSendContext(kind) {
84
81
  if (kind === "mirrored_input") {
85
82
  return "mirrored_user_input";
@@ -217,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
217
214
  remainder: match[2]?.trim() ?? "",
218
215
  };
219
216
  }
220
- export function defaultDaemonSessionStartMode(adapter) {
221
- return adapter === "codex" ? "restore" : "new";
217
+ export function defaultDaemonSessionStartMode(_adapter) {
218
+ // Codex 0.155+ persists app-server threads lazily, so resuming a freshly
219
+ // prepared (not yet persisted) thread crashes the visible TUI bootstrap
220
+ // with "no rollout found". Every adapter therefore opens a fresh session
221
+ // by default; explicit --session-start-mode restore and WeChat /resume
222
+ // remain available for deliberate restores of persisted threads.
223
+ return "new";
222
224
  }
223
225
  export function resolveDaemonSessionStartMode(params) {
224
226
  // An explicit --session-start-mode wins over visible-client reuse: the user
@@ -231,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
231
233
  if (params.reuseExistingVisible && params.visibleConnected) {
232
234
  return "restore";
233
235
  }
234
- if (params.adapter === "codex") {
235
- return "restore";
236
- }
237
236
  if (params.slotCreated) {
238
237
  return "new";
239
238
  }
@@ -360,9 +359,9 @@ function openVisibleClient(params) {
360
359
  const fullArgs = [process.execPath, ...args];
361
360
  if (process.platform === "darwin") {
362
361
  const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
363
- const script = `tell application "Terminal"
364
- activate
365
- do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
362
+ const script = `tell application "Terminal"
363
+ activate
364
+ do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
366
365
  end tell`;
367
366
  const child = spawn("osascript", ["-e", script], {
368
367
  cwd: params.cwd,
@@ -548,15 +547,16 @@ export function formatDaemonStatus(status) {
548
547
  }
549
548
  return lines.join("\n");
550
549
  }
551
- class WechatDaemon {
550
+ export class WechatDaemon {
552
551
  cwd;
553
552
  profile;
554
553
  authorizedUserId;
555
554
  transport;
555
+ channelDriver;
556
556
  channelId;
557
557
  wecomTransport;
558
- currentInboundConversation = null;
559
- handlingInboundMessage = false;
558
+ inboundConversationContext = new InboundConversationContext();
559
+ fallbackConversation;
560
560
  slots = new Map();
561
561
  // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
562
562
  slotEnsureChains = new Map();
@@ -564,11 +564,14 @@ class WechatDaemon {
564
564
  bridgeStartedAtMs = Date.now();
565
565
  backlogNoticeSent = false;
566
566
  activeAdapter = null;
567
+ activeAdapterVersion = 0;
567
568
  takenOverAdapter;
568
569
  textSendChain = Promise.resolve();
569
570
  attachmentSendChain = Promise.resolve();
570
571
  pendingWechatForwardTasks = new Set();
572
+ slotInputChains = new Map();
571
573
  pendingWechatMessages;
574
+ deps;
572
575
  shutdownPromise = null;
573
576
  ipcServer = null;
574
577
  endpointToken = "";
@@ -579,17 +582,37 @@ class WechatDaemon {
579
582
  this.transport = params.transport;
580
583
  this.channelId = params.channelId ?? "wechat";
581
584
  this.wecomTransport = params.wecomTransport ?? null;
582
- if (this.channelId === "wecom") {
583
- this.currentInboundConversation = {
584
- channelId: "wecom",
585
- conversationId: params.authorizedUserId,
586
- recipientId: params.authorizedUserId,
587
- metadata: { chatType: "direct" },
588
- };
589
- }
590
- this.pendingWechatMessages = new PendingWechatMessageStore(this.channelId === "wechat"
591
- ? getPendingWechatMessagesFile(this.cwd)
592
- : getPendingChannelMessagesFile(this.cwd, this.channelId));
585
+ this.deps = params.deps ?? {};
586
+ this.channelDriver = this.wecomTransport
587
+ ? new WecomChannelDriver({
588
+ transport: this.wecomTransport,
589
+ accountId: params.accountId,
590
+ operatorId: params.authorizedUserId,
591
+ logError,
592
+ })
593
+ : new WechatChannelDriver({
594
+ transport: this.transport,
595
+ logError,
596
+ buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
597
+ });
598
+ this.fallbackConversation = this.channelDriver.defaultConversation()
599
+ ?? this.channelDriver.directConversation(params.authorizedUserId);
600
+ this.pendingWechatMessages = this.deps.pendingWechatMessages ??
601
+ new PendingWechatMessageStore(this.channelId === "wechat"
602
+ ? getPendingWechatMessagesFile(this.cwd)
603
+ : getPendingChannelMessagesFile(this.cwd, this.channelId));
604
+ }
605
+ daemonLog(message) {
606
+ (this.deps.daemonLog ?? appendDaemonLog)(message);
607
+ }
608
+ createRuntime(options) {
609
+ return (this.deps.createRuntime ?? createRuntimeHost)(options);
610
+ }
611
+ isVisibleClientAlive(cwd, adapter) {
612
+ return (this.deps.isVisibleClientAlive ?? isVisibleClientAlive)(cwd, adapter);
613
+ }
614
+ openVisibleClient(options) {
615
+ return (this.deps.openVisibleClient ?? openVisibleClient)(options);
593
616
  }
594
617
  async startIpcServer() {
595
618
  this.endpointToken = buildDaemonToken();
@@ -675,51 +698,48 @@ class WechatDaemon {
675
698
  }
676
699
  async runPollLoop() {
677
700
  let consecutivePollFailures = 0;
678
- if (this.wecomTransport) {
679
- this.wecomTransport.setHandlers({
680
- onMessage: async (channelMessage) => {
701
+ if (this.channelDriver.start) {
702
+ await this.channelDriver.start({
703
+ onInboundMessage: async (channelMessage) => {
681
704
  if (this.shutdownPromise) {
682
705
  return;
683
706
  }
684
- this.currentInboundConversation = channelMessage.conversation;
685
- this.handlingInboundMessage = true;
686
- try {
707
+ await this.inboundConversationContext.run(channelMessage.conversation, async () => {
687
708
  if (this.pendingWechatMessages.list().length > 0) {
688
709
  await this.flushPendingWechatMessages();
689
710
  }
690
711
  await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
691
- }
692
- finally {
693
- this.handlingInboundMessage = false;
694
- }
712
+ });
695
713
  },
696
- onUnauthorized: async (senderId, chatType) => {
714
+ onUnauthorizedSender: async (senderId, chatType) => {
697
715
  appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
698
716
  if (chatType === "direct") {
699
- await this.wecomTransport.sendText({
700
- channelId: "wecom",
701
- conversationId: senderId,
702
- recipientId: senderId,
703
- metadata: { chatType: "direct" },
704
- }, "Unauthorized.", "notice");
717
+ const result = await this.channelDriver.sendText({
718
+ target: this.channelDriver.directConversation(senderId),
719
+ text: "Unauthorized.",
720
+ context: "notice",
721
+ log: appendDaemonLog,
722
+ });
723
+ if (result.status !== "sent") {
724
+ logError(`Failed to reply unauthorized notice to ${senderId}`);
725
+ }
705
726
  }
706
727
  },
707
- onFatal: async (error) => {
728
+ onChannelFatal: async (error) => {
708
729
  appendDaemonLog(`wecom_fatal_error: ${error.message}`);
709
730
  await this.shutdown();
710
731
  },
711
- onConnected: async () => {
732
+ onChannelConnected: async () => {
712
733
  if (this.pendingWechatMessages.list().length > 0) {
713
734
  await this.flushPendingWechatMessages();
714
735
  }
715
736
  },
716
737
  });
717
- this.wecomTransport.start();
718
- await this.wecomTransport.waitUntilConnected();
738
+ await this.channelDriver.waitUntilReady?.();
719
739
  }
720
- log(`${this.channelId === "wecom" ? "WeCom" : "WeChat"} daemon is ready.`);
740
+ log(`${this.channelDriver.displayName} daemon is ready.`);
721
741
  log(`Working directory: ${this.cwd}`);
722
- log(`Switch from ${this.channelId === "wecom" ? "WeCom" : "WeChat"} with /codex, /claude, /opencode, or /pi.`);
742
+ log(`Switch from ${this.channelDriver.displayName} with /codex, /claude, /opencode, or /pi.`);
723
743
  appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
724
744
  const activeSlot = this.getActiveSlot();
725
745
  const welcomeText = t("daemon.welcome", {
@@ -728,7 +748,7 @@ class WechatDaemon {
728
748
  bindings: formatBindingsListMessage(listBindings()),
729
749
  });
730
750
  await this.queueWechatMessage(this.authorizedUserId, welcomeText);
731
- if (this.channelId === "wecom") {
751
+ if (this.channelDriver.capabilities.pushInbound) {
732
752
  while (!this.shutdownPromise) {
733
753
  await delay(1_000);
734
754
  }
@@ -828,6 +848,29 @@ class WechatDaemon {
828
848
  }
829
849
  async cleanup() {
830
850
  appendDaemonLog("shutdown_started");
851
+ // Stop accepting new channel work and abort an in-flight long poll before
852
+ // waiting for queued sends or adapter disposal. Otherwise Ctrl+C can leave
853
+ // the process alive until the 35-second poll timeout and retain the daemon
854
+ // endpoint for the next startup to clean up.
855
+ try {
856
+ this.transport.stop();
857
+ }
858
+ catch {
859
+ // Best effort shutdown.
860
+ }
861
+ try {
862
+ await this.channelDriver.endAllTyping?.();
863
+ }
864
+ catch {
865
+ // Best effort shutdown.
866
+ }
867
+ try {
868
+ this.wecomTransport?.stop();
869
+ }
870
+ catch {
871
+ // Best effort shutdown.
872
+ }
873
+ clearDaemonEndpoint();
831
874
  for (const slot of this.slots.values()) {
832
875
  try {
833
876
  await slot.outputBatcher.flushNow();
@@ -839,12 +882,6 @@ class WechatDaemon {
839
882
  await this.waitForPendingWechatForwardTasks();
840
883
  await this.textSendChain.catch(() => undefined);
841
884
  await this.attachmentSendChain.catch(() => undefined);
842
- try {
843
- this.wecomTransport?.stop();
844
- }
845
- catch {
846
- // Best effort shutdown.
847
- }
848
885
  for (const slot of this.slots.values()) {
849
886
  try {
850
887
  await slot.runtime.dispose();
@@ -893,6 +930,244 @@ class WechatDaemon {
893
930
  sessionStartMode: request.sessionStartMode,
894
931
  reuseExistingVisible: request.reuseExistingVisible ?? true,
895
932
  });
933
+ case "send_text":
934
+ return await this.handleDaemonSendText(request);
935
+ case "forward_input":
936
+ return await this.handleDaemonForwardInput(request);
937
+ }
938
+ }
939
+ async handleDaemonSendText(request) {
940
+ if (!request.text.trim()) {
941
+ throw new Error("send_text requires non-empty text.");
942
+ }
943
+ if (request.channel && request.channel !== this.channelId) {
944
+ throw new Error(`${this.channelId}-daemon cannot send to ${request.channel}.`);
945
+ }
946
+ const context = request.context ?? "message";
947
+ if (!isWechatSendContext(context)) {
948
+ throw new Error(`Invalid send_text context: ${JSON.stringify(request.context)}. Valid contexts: ${WECHAT_SEND_CONTEXTS.join(", ")}.`);
949
+ }
950
+ const target = this.channelDriver.capabilities.multiConversation
951
+ ? {
952
+ ...this.channelDriver.directConversation(request.recipientId),
953
+ ...(request.conversationId && request.conversationId !== request.recipientId
954
+ ? { conversationId: request.conversationId }
955
+ : {}),
956
+ ...(request.metadata ? { metadata: request.metadata } : {}),
957
+ }
958
+ : undefined;
959
+ const sent = await this.queueWechatMessage(request.recipientId, request.text, context, target);
960
+ return {
961
+ sent,
962
+ recipientId: request.recipientId,
963
+ ...(request.conversationId ? { conversationId: request.conversationId } : {}),
964
+ };
965
+ }
966
+ getSlotState(adapter) {
967
+ const slot = this.slots.get(adapter);
968
+ if (!slot)
969
+ return null;
970
+ return {
971
+ active: this.activeAdapter === adapter,
972
+ hasActiveTask: slot.turns.hasActiveTask,
973
+ ...(slot.turns.activeConversation?.conversationId
974
+ ? { activeConversationId: slot.turns.activeConversation.conversationId }
975
+ : {}),
976
+ ...(slot.turns.lastConversation?.conversationId
977
+ ? { lastConversationId: slot.turns.lastConversation.conversationId }
978
+ : {}),
979
+ };
980
+ }
981
+ async handleDaemonForwardInput(request) {
982
+ if (!request.text.trim()) {
983
+ throw new Error("forward_input requires non-empty text.");
984
+ }
985
+ if (request.cwd && !isSameWorkspaceCwd(request.cwd, this.cwd)) {
986
+ throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
987
+ }
988
+ const adapter = request.adapter ?? this.activeAdapter;
989
+ if (!adapter) {
990
+ throw new Error("No active adapter slot is available.");
991
+ }
992
+ const senderId = request.senderId ?? this.authorizedUserId;
993
+ const conversationId = request.conversationId ?? senderId;
994
+ const conversation = {
995
+ channelId: this.channelId,
996
+ conversationId,
997
+ recipientId: request.recipientId ?? senderId,
998
+ ...(request.contextToken ? { opaqueRef: request.contextToken } : {}),
999
+ ...(request.metadata ? { metadata: request.metadata } : {}),
1000
+ };
1001
+ const previousActiveAdapter = this.activeAdapter;
1002
+ const ensureResult = await this.ensureSlot(adapter, {
1003
+ openVisible: true,
1004
+ reuseExistingVisible: true,
1005
+ });
1006
+ if (!ensureResult.activated) {
1007
+ return {
1008
+ forwarded: false,
1009
+ adapter,
1010
+ conversationId,
1011
+ reason: "not_activated",
1012
+ message: formatDaemonSwitchResultDetail(ensureResult),
1013
+ };
1014
+ }
1015
+ const slot = this.slots.get(adapter);
1016
+ if (!slot) {
1017
+ throw new Error(`Adapter slot ${adapter} is not available.`);
1018
+ }
1019
+ const inboundMessage = {
1020
+ senderId,
1021
+ sender: senderId,
1022
+ sessionId: conversationId,
1023
+ text: request.text,
1024
+ attachments: [],
1025
+ contextToken: request.contextToken,
1026
+ createdAt: new Date().toISOString(),
1027
+ createdAtMs: Date.now(),
1028
+ };
1029
+ try {
1030
+ const routed = await this.serializeSlotInput(slot, () => this.inboundConversationContext.run(conversation, () => this.routeDaemonInput(slot, inboundMessage, conversation)));
1031
+ if (routed.kind === "dispatched") {
1032
+ return { forwarded: true, adapter, conversationId };
1033
+ }
1034
+ this.restoreActiveAdapterAfterRejectedInput(adapter, ensureResult.activationVersion, previousActiveAdapter);
1035
+ return {
1036
+ forwarded: false,
1037
+ ...(routed.queued ? { queued: true } : {}),
1038
+ ...(routed.queuePosition !== undefined ? { queuePosition: routed.queuePosition } : {}),
1039
+ adapter,
1040
+ conversationId,
1041
+ reason: routed.reason,
1042
+ message: routed.message,
1043
+ };
1044
+ }
1045
+ catch (error) {
1046
+ this.restoreActiveAdapterAfterRejectedInput(adapter, ensureResult.activationVersion, previousActiveAdapter);
1047
+ throw error;
1048
+ }
1049
+ }
1050
+ async routeDaemonInput(slot, inboundMessage, conversation) {
1051
+ const runtimeState = slot.runtime.getState();
1052
+ const adapterState = slot.turns.hasActiveTask && runtimeState.status === "idle"
1053
+ ? { ...runtimeState, status: "busy" }
1054
+ : runtimeState;
1055
+ let reason;
1056
+ let routeMessage;
1057
+ let queued = false;
1058
+ let queuePosition;
1059
+ const sendReminder = (text, context) => this.queueWechatMessage(inboundMessage.senderId, text, context, conversation);
1060
+ const routeResult = await routeBridgeMessage({
1061
+ message: toChannelInboundMessage(inboundMessage),
1062
+ authorized: true,
1063
+ command: null,
1064
+ adapterState,
1065
+ hasPendingApproval: slot.pendingConfirmations.length > 0,
1066
+ hasPendingUserInput: Boolean(slot.pendingUserInput),
1067
+ shouldDefer: shouldDeferCodexInboundMessage({
1068
+ adapter: slot.adapter,
1069
+ status: adapterState.status,
1070
+ activeTurnOrigin: adapterState.activeTurnOrigin,
1071
+ hasPendingConfirmation: slot.pendingConfirmations.length > 0,
1072
+ hasSystemCommand: false,
1073
+ }),
1074
+ onUnauthorized: async () => undefined,
1075
+ handleCommand: async () => false,
1076
+ remindPendingApproval: async () => {
1077
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, formatPendingApprovalReminder(slot.pendingConfirmations[0], slot.runtime.getState()));
1078
+ reason = "pending_approval";
1079
+ await sendReminder(routeMessage, "approval_required");
1080
+ },
1081
+ remindPendingUserInput: async () => {
1082
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1083
+ ? formatPendingUserInputReminder(slot.pendingUserInput)
1084
+ : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1085
+ reason = "pending_user_input";
1086
+ await sendReminder(routeMessage, "user_input_required");
1087
+ },
1088
+ remindBusy: async () => {
1089
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`);
1090
+ reason = "busy";
1091
+ await sendReminder(routeMessage, "notice");
1092
+ },
1093
+ defer: async () => {
1094
+ if (slot.deferredInputs.length >= MAX_DAEMON_DEFERRED_INPUTS) {
1095
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, `The deferred input queue is full (${MAX_DAEMON_DEFERRED_INPUTS}). Wait for the current local turn to finish before retrying.`);
1096
+ reason = "busy";
1097
+ await sendReminder(routeMessage, "notice");
1098
+ return;
1099
+ }
1100
+ slot.deferredInputs.push({ message: inboundMessage, conversation });
1101
+ queuePosition = slot.deferredInputs.length;
1102
+ queued = true;
1103
+ reason = "deferred";
1104
+ routeMessage = prefixDaemonAdapterMessage(slot.adapter, formatDeferredCodexInboundQueueMessage(queuePosition));
1105
+ await sendReminder(routeMessage, "notice");
1106
+ this.daemonLog(`deferred_inbound_input: adapter=${slot.adapter} position=${queuePosition} text=${truncatePreview(inboundMessage.text)}`);
1107
+ },
1108
+ dispatch: async () => {
1109
+ await this.dispatchInboundWechatText(inboundMessage, slot, conversation);
1110
+ },
1111
+ });
1112
+ if (routeResult.kind === "dispatched") {
1113
+ return { kind: "dispatched" };
1114
+ }
1115
+ return {
1116
+ kind: routeResult.kind,
1117
+ reason: reason ?? (routeResult.kind === "deferred" ? "deferred" : "busy"),
1118
+ message: routeMessage,
1119
+ ...(queued ? { queued: true } : {}),
1120
+ ...(queuePosition !== undefined ? { queuePosition } : {}),
1121
+ };
1122
+ }
1123
+ restoreActiveAdapterAfterRejectedInput(adapter, activationVersion, previousActiveAdapter) {
1124
+ if (this.activeAdapter === adapter && this.activeAdapterVersion === activationVersion) {
1125
+ this.activeAdapter = previousActiveAdapter;
1126
+ this.activeAdapterVersion += 1;
1127
+ }
1128
+ }
1129
+ serializeSlotInput(slot, task) {
1130
+ const tail = this.slotInputChains.get(slot.adapter) ?? Promise.resolve();
1131
+ const run = tail.then(task, task);
1132
+ this.slotInputChains.set(slot.adapter, run.then(() => undefined, () => undefined));
1133
+ return run;
1134
+ }
1135
+ async maybeDrainDeferredInputs(slot) {
1136
+ if (slot.drainingDeferredInputs)
1137
+ return;
1138
+ const state = slot.runtime.getState();
1139
+ if (!canDrainDeferredCodexInboundQueue({
1140
+ adapter: slot.adapter,
1141
+ deferredCount: slot.deferredInputs.length,
1142
+ status: state.status,
1143
+ activeTurnId: state.activeTurnId,
1144
+ hasPendingConfirmation: slot.pendingConfirmations.length > 0,
1145
+ hasPendingUserInput: Boolean(slot.pendingUserInput),
1146
+ hasPendingApproval: Boolean(state.pendingApproval),
1147
+ hasActiveTask: slot.turns.hasActiveTask,
1148
+ }))
1149
+ return;
1150
+ const next = slot.deferredInputs.shift();
1151
+ if (!next)
1152
+ return;
1153
+ slot.drainingDeferredInputs = true;
1154
+ try {
1155
+ const routed = await this.inboundConversationContext.run(next.conversation ?? this.fallbackConversation, () => this.routeDaemonInput(slot, next.message, next.conversation ?? this.fallbackConversation));
1156
+ if (routed.kind !== "dispatched") {
1157
+ slot.deferredInputs.unshift(next);
1158
+ }
1159
+ }
1160
+ catch (error) {
1161
+ const errorText = error instanceof Error ? error.message : String(error);
1162
+ if (isRetryableDeferredCodexDrainError(errorText)) {
1163
+ slot.deferredInputs.unshift(next);
1164
+ }
1165
+ else {
1166
+ await this.queueWechatMessage(next.message.senderId, formatUserFacingInboundError({ adapter: slot.adapter, cwd: this.cwd, errorText }), "inbound_error", next.conversation);
1167
+ }
1168
+ }
1169
+ finally {
1170
+ slot.drainingDeferredInputs = false;
896
1171
  }
897
1172
  }
898
1173
  async ensureSlot(adapter, options = {}) {
@@ -933,7 +1208,7 @@ class WechatDaemon {
933
1208
  visibleEndpointBeforeProbe?.companionConnectedAt ||
934
1209
  visibleEndpointBeforeProbe?.sharedThreadId ||
935
1210
  visibleEndpointBeforeProbe?.sharedSessionId);
936
- let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
1211
+ let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
937
1212
  if (shouldRestartDeadCodexVisibleRuntime({
938
1213
  adapter,
939
1214
  slotCreated: created,
@@ -962,10 +1237,14 @@ class WechatDaemon {
962
1237
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
963
1238
  }
964
1239
  if (options.openVisible !== false && !visibleConnected) {
1240
+ const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
965
1241
  slot.controller.syncLocalClientEndpoint();
966
- const launch = openVisibleClient({
1242
+ const launch = this.openVisibleClient({
967
1243
  adapter,
968
1244
  cwd: this.cwd,
1245
+ // A freshly prepared blank thread is semantically a new session, but
1246
+ // the visible client must resume its id from the endpoint instead of
1247
+ // creating a second thread of its own.
969
1248
  sessionStartMode,
970
1249
  cliArgs: options.cliArgs,
971
1250
  channelId: this.channelId,
@@ -978,14 +1257,17 @@ class WechatDaemon {
978
1257
  visibleConnected = await waitForVisibleClientConnection({
979
1258
  cwd: this.cwd,
980
1259
  adapter,
1260
+ timeoutMs: this.deps.visibleClientConnectTimeoutMs,
1261
+ }, {
1262
+ isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
981
1263
  });
982
1264
  if (visibleConnected) {
983
1265
  appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
984
1266
  }
985
1267
  else {
986
- log(`${adapter} visible CLI did not connect within ${formatDuration(VISIBLE_CLIENT_CONNECT_TIMEOUT_MS)}. Check ${BRIDGE_LOG_FILE}.`);
1268
+ log(`${adapter} visible CLI did not connect within ${formatDuration(visibleClientTimeoutMs)}. Check ${BRIDGE_LOG_FILE}.`);
987
1269
  const cleanedLauncher = cleanupVisibleClientLauncher(launch);
988
- appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
1270
+ appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${visibleClientTimeoutMs} cleaned_launcher=${cleanedLauncher}`);
989
1271
  }
990
1272
  }
991
1273
  let visibleReady = visibleConnected;
@@ -1009,7 +1291,11 @@ class WechatDaemon {
1009
1291
  }
1010
1292
  const activated = options.openVisible === false || (visibleConnected && visibleReady);
1011
1293
  if (activated) {
1294
+ const previousSlot = this.getActiveSlot();
1295
+ if (previousSlot && this.activeAdapter !== adapter)
1296
+ invalidateModelSnapshot(previousSlot.runtime);
1012
1297
  this.activeAdapter = adapter;
1298
+ this.activeAdapterVersion += 1;
1013
1299
  }
1014
1300
  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}`);
1015
1301
  return {
@@ -1019,12 +1305,13 @@ class WechatDaemon {
1019
1305
  visibleConnected,
1020
1306
  visibleReady,
1021
1307
  activated,
1308
+ activationVersion: this.activeAdapterVersion,
1022
1309
  previousActiveAdapter,
1023
1310
  };
1024
1311
  }
1025
1312
  async createSlot(adapter, options) {
1026
1313
  clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
1027
- const runtime = createRuntimeHost({
1314
+ const runtime = this.createRuntime({
1028
1315
  kind: adapter,
1029
1316
  command: resolveDefaultAdapterCommand(adapter),
1030
1317
  cwd: this.cwd,
@@ -1039,7 +1326,7 @@ class WechatDaemon {
1039
1326
  runtime,
1040
1327
  controller,
1041
1328
  outputBatcher: new OutputBatcher(async (text) => {
1042
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
1329
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
1043
1330
  }),
1044
1331
  pendingConfirmations: [],
1045
1332
  pendingUserInput: null,
@@ -1047,10 +1334,14 @@ class WechatDaemon {
1047
1334
  adapter,
1048
1335
  runtime,
1049
1336
  }),
1050
- activeTask: null,
1337
+ turns: new TurnCoordinator({
1338
+ restoreLastConversationOnFailure: true,
1339
+ }),
1051
1340
  lastOutputAt: 0,
1052
1341
  lastFinalReplyAtMs: 0,
1053
1342
  eventForwardChain: Promise.resolve(),
1343
+ deferredInputs: [],
1344
+ drainingDeferredInputs: false,
1054
1345
  };
1055
1346
  runtime.setEventSink((event) => {
1056
1347
  this.handleSlotEvent(slot, event);
@@ -1065,7 +1356,7 @@ class WechatDaemon {
1065
1356
  slot.outputBatcher.clear();
1066
1357
  slot.pendingConfirmations = [];
1067
1358
  slot.pendingUserInput = null;
1068
- slot.activeTask = null;
1359
+ slot.turns.complete();
1069
1360
  if (slot.adapter === "codex" || slot.adapter === "claude") {
1070
1361
  await slot.runtime.reset();
1071
1362
  }
@@ -1107,6 +1398,10 @@ class WechatDaemon {
1107
1398
  slot.controller.syncLocalClientEndpoint();
1108
1399
  const adapterState = slot.runtime.getState();
1109
1400
  const channelPort = this.createWechatChannelPort(slot.adapter);
1401
+ const eventTarget = this.channelDriver.capabilities.multiConversation
1402
+ ? this.resolveSlotOutputTarget(slot)
1403
+ : this.fallbackConversation;
1404
+ const eventTask = slot.turns.activeTask;
1110
1405
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
1111
1406
  slot.pendingConfirmations = [];
1112
1407
  }
@@ -1128,19 +1423,12 @@ class WechatDaemon {
1128
1423
  }
1129
1424
  },
1130
1425
  finalReply: (next) => {
1426
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1131
1427
  slot.lastFinalReplyAtMs = Date.now();
1132
1428
  appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1133
1429
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1134
1430
  await channelPort.send({
1135
- target: this.channelId === "wecom"
1136
- ? slot.activeConversation ??
1137
- slot.lastConversation ??
1138
- this.currentInboundConversation
1139
- : {
1140
- channelId: "wechat",
1141
- conversationId: this.authorizedUserId,
1142
- recipientId: this.authorizedUserId,
1143
- },
1431
+ target: eventTarget,
1144
1432
  kind: "final_reply",
1145
1433
  text: next.text,
1146
1434
  adapter: slot.adapter,
@@ -1158,7 +1446,7 @@ class WechatDaemon {
1158
1446
  appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
1159
1447
  if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1160
1448
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1161
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice");
1449
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
1162
1450
  }));
1163
1451
  }
1164
1452
  },
@@ -1167,7 +1455,7 @@ class WechatDaemon {
1167
1455
  const pending = toPendingApproval(next);
1168
1456
  slot.pendingConfirmations.push(pending);
1169
1457
  appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
1170
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
1458
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required", eventTarget);
1171
1459
  }));
1172
1460
  },
1173
1461
  userInputRequired: (next) => {
@@ -1175,14 +1463,14 @@ class WechatDaemon {
1175
1463
  slot.pendingUserInput = pending;
1176
1464
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
1177
1465
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1178
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
1466
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required", eventTarget);
1179
1467
  }));
1180
1468
  },
1181
1469
  mirroredUserInput: (next) => {
1182
1470
  appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1183
1471
  if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1184
1472
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1185
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input");
1473
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input", eventTarget);
1186
1474
  }));
1187
1475
  }
1188
1476
  },
@@ -1192,7 +1480,7 @@ class WechatDaemon {
1192
1480
  appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
1193
1481
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1194
1482
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1195
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason })), "session_switched");
1483
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason })), "session_switched", eventTarget);
1196
1484
  }));
1197
1485
  }
1198
1486
  },
@@ -1210,37 +1498,46 @@ class WechatDaemon {
1210
1498
  }
1211
1499
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1212
1500
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1213
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.threadId, source: next.source, reason: next.reason })), "thread_switched");
1501
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.threadId, source: next.source, reason: next.reason })), "thread_switched", eventTarget);
1214
1502
  }));
1215
1503
  }
1216
1504
  },
1217
1505
  taskComplete: () => {
1506
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1218
1507
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
1219
1508
  slot.pendingConfirmations = [];
1220
1509
  slot.pendingUserInput = null;
1221
- slot.activeTask = null;
1222
- slot.activeConversation = undefined;
1510
+ if (eventTask) {
1511
+ slot.turns.complete(eventTask);
1512
+ }
1513
+ void this.maybeDrainDeferredInputs(slot);
1223
1514
  }));
1224
1515
  },
1225
1516
  taskFailed: (next) => {
1517
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1226
1518
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1227
1519
  slot.pendingConfirmations = [];
1228
1520
  slot.pendingUserInput = null;
1229
- slot.activeTask = null;
1230
- slot.activeConversation = undefined;
1231
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed");
1521
+ if (eventTask) {
1522
+ slot.turns.complete(eventTask);
1523
+ }
1524
+ void this.maybeDrainDeferredInputs(slot);
1525
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed", eventTarget);
1232
1526
  }));
1233
1527
  },
1234
1528
  fatalError: (next) => {
1529
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1235
1530
  logError(`${slot.adapter}: ${next.message}`);
1236
1531
  appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
1237
1532
  slot.pendingConfirmations = [];
1238
1533
  slot.pendingUserInput = null;
1239
- slot.activeTask = null;
1240
- slot.activeConversation = undefined;
1534
+ if (eventTask) {
1535
+ slot.turns.complete(eventTask);
1536
+ }
1537
+ void this.maybeDrainDeferredInputs(slot);
1241
1538
  this.disposeDeadSlot(slot);
1242
1539
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1243
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error");
1540
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
1244
1541
  }));
1245
1542
  },
1246
1543
  shutdownRequested: (next) => {
@@ -1252,17 +1549,19 @@ class WechatDaemon {
1252
1549
  appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
1253
1550
  });
1254
1551
  }
1552
+ resolveSlotOutputTarget(slot) {
1553
+ return slot.turns.resolveTarget(this.fallbackConversation);
1554
+ }
1255
1555
  bindCurrentWecomConversation(slot) {
1256
- if (this.channelId !== "wecom" || !slot || !this.currentInboundConversation) {
1556
+ const conversation = this.inboundConversationContext.get();
1557
+ if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
1257
1558
  return;
1258
1559
  }
1259
- slot.lastConversation = this.currentInboundConversation;
1560
+ slot.turns.observeConversation(conversation);
1260
1561
  }
1261
1562
  async handleInboundMessage(message) {
1262
1563
  if (message.senderId !== this.authorizedUserId) {
1263
- await this.queueWechatMessage(message.senderId, this.channelId === "wecom"
1264
- ? "Unauthorized. This daemon only accepts messages from the paired WeCom operator."
1265
- : "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
1564
+ await this.queueWechatMessage(message.senderId, `Unauthorized. This daemon only accepts messages from ${this.channelDriver.operatorDescription}.`);
1266
1565
  return;
1267
1566
  }
1268
1567
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1333,7 +1632,7 @@ class WechatDaemon {
1333
1632
  }
1334
1633
  this.bindCurrentWecomConversation(slot);
1335
1634
  const routeCurrentMessage = async (currentSlot, command) => {
1336
- const previousTask = currentSlot.activeTask;
1635
+ const previousTask = currentSlot.turns.activeTask;
1337
1636
  await routeBridgeMessage({
1338
1637
  message: toChannelInboundMessage(message),
1339
1638
  authorized: true,
@@ -1362,12 +1661,12 @@ class WechatDaemon {
1362
1661
  await this.dispatchInboundWechatText(message, currentSlot);
1363
1662
  },
1364
1663
  });
1365
- if (this.channelId === "wecom" &&
1366
- this.currentInboundConversation &&
1367
- currentSlot.activeTask &&
1368
- currentSlot.activeTask !== previousTask) {
1369
- currentSlot.activeConversation = this.currentInboundConversation;
1370
- currentSlot.lastConversation = this.currentInboundConversation;
1664
+ if (this.channelDriver.capabilities.multiConversation &&
1665
+ this.inboundConversationContext.get() &&
1666
+ currentSlot.turns.activeTask &&
1667
+ currentSlot.turns.activeTask !== previousTask) {
1668
+ const conversation = this.inboundConversationContext.get();
1669
+ currentSlot.turns.bindConversation(conversation);
1371
1670
  }
1372
1671
  };
1373
1672
  const command = parseWechatControlCommand(message.text, {
@@ -1417,12 +1716,16 @@ class WechatDaemon {
1417
1716
  }
1418
1717
  async handleSystemCommand(message, activeSlot, command) {
1419
1718
  switch (command.type) {
1719
+ case "model":
1720
+ case "plan":
1721
+ await this.queueWechatMessage(message.senderId, await handleAdapterControl(activeSlot.runtime, message.senderId, command));
1722
+ return;
1420
1723
  case "status":
1421
1724
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1422
1725
  return;
1423
1726
  case "resume": {
1424
1727
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1425
- await this.queueWechatMessage(message.senderId, `${this.channelId === "wecom" ? "WeCom" : "WeChat"} /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; the remote channel will follow that local session.`);
1728
+ await this.queueWechatMessage(message.senderId, `${this.channelDriver.displayName} /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; the remote channel will follow that local session.`);
1426
1729
  return;
1427
1730
  }
1428
1731
  try {
@@ -1440,9 +1743,10 @@ class WechatDaemon {
1440
1743
  if (command.target) {
1441
1744
  await resumeSlot.outputBatcher.flushNow();
1442
1745
  }
1746
+ const taskBeforeResume = resumeSlot.turns.activeTask;
1443
1747
  const result = await resumeSlot.resumeCoordinator.execute(command.target);
1444
- if (result.kind === "resumed") {
1445
- resumeSlot.activeTask = null;
1748
+ if (result.kind === "resumed" && taskBeforeResume) {
1749
+ resumeSlot.turns.complete(taskBeforeResume);
1446
1750
  }
1447
1751
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
1448
1752
  }
@@ -1464,6 +1768,9 @@ class WechatDaemon {
1464
1768
  await activeSlot.runtime.createSession();
1465
1769
  appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
1466
1770
  return;
1771
+ case "broadcast":
1772
+ await this.handleBroadcastCommand(message, command.text);
1773
+ return;
1467
1774
  case "stop": {
1468
1775
  const interrupted = await activeSlot.runtime.interrupt();
1469
1776
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
@@ -1505,10 +1812,10 @@ class WechatDaemon {
1505
1812
  }
1506
1813
  const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
1507
1814
  slot.pendingConfirmations = [];
1508
- slot.activeTask = {
1815
+ slot.turns.setActiveTask({
1509
1816
  startedAt: Date.now(),
1510
1817
  inputPreview: preview,
1511
- };
1818
+ });
1512
1819
  appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
1513
1820
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
1514
1821
  ? `${count} approvals confirmed. Continuing...`
@@ -1554,10 +1861,10 @@ class WechatDaemon {
1554
1861
  return;
1555
1862
  }
1556
1863
  slot.pendingUserInput = null;
1557
- slot.activeTask = {
1864
+ slot.turns.setActiveTask({
1558
1865
  startedAt: Date.now(),
1559
1866
  inputPreview: parsed.preview,
1560
- };
1867
+ });
1561
1868
  appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1562
1869
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1563
1870
  }
@@ -1579,16 +1886,79 @@ class WechatDaemon {
1579
1886
  }
1580
1887
  return this.slots.get(this.activeAdapter) ?? null;
1581
1888
  }
1582
- async dispatchInboundWechatText(message, slot) {
1889
+ isSlotBusyForBroadcast(slot) {
1890
+ const state = slot.runtime.getState();
1891
+ if (slot.turns.hasActiveTask)
1892
+ return true;
1893
+ if (slot.pendingConfirmations.length > 0 || slot.pendingUserInput)
1894
+ return true;
1895
+ if (slot.deferredInputs.length > 0)
1896
+ return true;
1897
+ if (state.status === "busy" || state.status === "awaiting_approval" || state.status === "awaiting_input") {
1898
+ return true;
1899
+ }
1900
+ return false;
1901
+ }
1902
+ async handleBroadcastCommand(message, text) {
1903
+ const startedSlots = DAEMON_ADAPTERS
1904
+ .map((adapter) => this.slots.get(adapter))
1905
+ .filter((slot) => Boolean(slot));
1906
+ if (startedSlots.length === 0) {
1907
+ await this.queueWechatMessage(message.senderId, "/all needs at least one running adapter. Open one first with /codex, /claude, /opencode, or /pi.");
1908
+ return;
1909
+ }
1910
+ const busySlots = startedSlots.filter((slot) => this.isSlotBusyForBroadcast(slot));
1911
+ if (busySlots.length > 0) {
1912
+ const names = busySlots.map((slot) => slot.adapter).join(", ");
1913
+ appendDaemonLog(`broadcast_canceled_busy: busy=[${names}]`);
1914
+ await this.queueWechatMessage(message.senderId, `/all canceled: ${busySlots.length > 1 ? "these workers are" : "this worker is"} still busy — ${names}. Wait for the current replies or use /stop first.`);
1915
+ return;
1916
+ }
1917
+ const broadcastMessage = { ...message, text };
1918
+ const dispatched = [];
1919
+ const skipped = [];
1920
+ for (const adapter of DAEMON_ADAPTERS) {
1921
+ const slot = this.slots.get(adapter);
1922
+ if (!slot) {
1923
+ skipped.push(adapter);
1924
+ continue;
1925
+ }
1926
+ appendDaemonLog(`broadcast_input: adapter=${adapter} text=${truncatePreview(text)}`);
1927
+ await this.dispatchInboundWechatText(broadcastMessage, slot);
1928
+ dispatched.push(adapter);
1929
+ }
1930
+ const lines = [
1931
+ `Broadcast dispatched to ${dispatched.length} ${dispatched.length > 1 ? "workers" : "worker"}: ${dispatched.join(", ")}. Each reply arrives prefixed with its adapter name.`,
1932
+ ];
1933
+ if (skipped.length > 0) {
1934
+ lines.push(`Not started (skipped): ${skipped.join(", ")}. Start them with their /command to include them next time.`);
1935
+ }
1936
+ await this.queueWechatMessage(message.senderId, lines.join("\n"));
1937
+ }
1938
+ async dispatchInboundWechatText(message, slot, conversationOverride) {
1583
1939
  const preview = formatInboundMessagePreview(message);
1584
- slot.activeTask = {
1940
+ const nextTask = {
1585
1941
  startedAt: Date.now(),
1586
1942
  inputPreview: truncatePreview(preview, 180),
1587
1943
  };
1588
- appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1589
- await slot.runtime.sendInput(this.channelId === "wecom"
1590
- ? buildWecomInboundPrompt(message.text, message.attachments)
1591
- : buildWechatInboundPrompt(message.text, message.attachments));
1944
+ const inboundConversation = this.channelDriver.capabilities.multiConversation
1945
+ ? conversationOverride ?? this.inboundConversationContext.get()
1946
+ : undefined;
1947
+ const dispatchResult = await slot.turns.dispatch({
1948
+ task: nextTask,
1949
+ conversation: inboundConversation,
1950
+ onBusy: async () => {
1951
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`), "notice", conversationOverride);
1952
+ },
1953
+ forward: async () => {
1954
+ appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1955
+ await slot.runtime.sendInput(this.channelDriver.buildInboundPrompt(message.text, message.attachments));
1956
+ },
1957
+ });
1958
+ if (dispatchResult.status === "dispatched") {
1959
+ void this.channelDriver.beginTyping?.(message.senderId);
1960
+ }
1961
+ return dispatchResult;
1592
1962
  }
1593
1963
  queueWechatTextAction(action) {
1594
1964
  const run = this.textSendChain.then(action);
@@ -1601,86 +1971,36 @@ class WechatDaemon {
1601
1971
  return run;
1602
1972
  }
1603
1973
  async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
1604
- if (this.channelId === "wecom") {
1605
- const activeSlot = this.getActiveSlot();
1606
- const target = targetOverride ??
1607
- (senderId === this.authorizedUserId
1608
- ? (this.handlingInboundMessage
1609
- ? this.currentInboundConversation
1610
- : null) ??
1611
- activeSlot?.activeConversation ??
1612
- activeSlot?.lastConversation ??
1613
- this.currentInboundConversation
1614
- : {
1615
- channelId: "wecom",
1616
- conversationId: senderId,
1617
- recipientId: senderId,
1618
- metadata: { chatType: "direct" },
1619
- });
1620
- const startedAtMs = Date.now();
1621
- appendDaemonLog(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
1622
- try {
1623
- await this.wecomTransport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
1624
- appendDaemonLog(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
1625
- return { status: "sent" };
1626
- }
1627
- catch (error) {
1628
- const message = error instanceof Error ? error.message : String(error);
1629
- appendDaemonLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
1630
- logError(`Failed to send WeCom ${context}: ${message}`);
1631
- return { status: "context_unavailable", error, target };
1632
- }
1633
- }
1634
- const startedAtMs = Date.now();
1635
- appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
1636
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1637
- try {
1638
- await this.transport.sendText(senderId, text);
1639
- appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1640
- return { status: "sent" };
1641
- }
1642
- catch (error) {
1643
- if (error instanceof Error && error.name === "AbortError") {
1644
- appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1645
- }
1646
- if (isWechatContextUnavailableError(error)) {
1647
- if (isWechatContextTokenStaleError(error)) {
1648
- this.transport.clearCachedContextToken(senderId);
1649
- }
1650
- appendDaemonLog(isWechatContextTokenStaleError(error)
1651
- ? formatWechatContextTokenStaleLogEntry({
1652
- context,
1653
- recipientId: senderId,
1654
- error,
1655
- })
1656
- : formatWechatSendFailureLogEntry({
1657
- context,
1658
- recipientId: senderId,
1659
- error,
1660
- }));
1661
- return { status: "context_unavailable", error };
1662
- }
1663
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1664
- const delayMs = computeWechatSendRetryDelayMs(attempt);
1665
- appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1666
- await delay(delayMs);
1667
- continue;
1668
- }
1669
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1670
- appendDaemonLog(formatWechatSendFailureLogEntry({
1671
- context,
1672
- recipientId: senderId,
1673
- error,
1674
- }));
1675
- return { status: "failed" };
1676
- }
1677
- }
1678
- return { status: "failed" };
1974
+ const activeSlot = this.getActiveSlot();
1975
+ const target = resolveDaemonOutboundTarget({
1976
+ senderId,
1977
+ operatorId: this.authorizedUserId,
1978
+ override: targetOverride,
1979
+ multiConversation: this.channelDriver.capabilities.multiConversation,
1980
+ inboundConversation: this.channelDriver.capabilities.multiConversation
1981
+ ? this.inboundConversationContext.get()
1982
+ : null,
1983
+ activeSlotTarget: activeSlot ? this.resolveSlotOutputTarget(activeSlot) : null,
1984
+ fallbackConversation: this.fallbackConversation,
1985
+ directConversation: (id) => this.channelDriver.directConversation(id),
1986
+ });
1987
+ return this.channelDriver.sendText({
1988
+ target,
1989
+ text,
1990
+ context,
1991
+ log: appendDaemonLog,
1992
+ });
1679
1993
  }
1680
1994
  queueWechatMessage(senderId, text, context = "message", targetOverride) {
1995
+ const activeSlot = this.getActiveSlot();
1996
+ const queuedTarget = this.channelDriver.capabilities.multiConversation && senderId === this.authorizedUserId
1997
+ ? targetOverride ??
1998
+ this.inboundConversationContext.get() ??
1999
+ (activeSlot ? this.resolveSlotOutputTarget(activeSlot) : this.fallbackConversation)
2000
+ : targetOverride;
1681
2001
  return this.queueWechatTextAction(async () => {
1682
- const result = await this.sendWechatMessageNow(senderId, text, context, targetOverride);
1683
- if (result.status === "context_unavailable") {
2002
+ const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
2003
+ if (result.status === "target_stale") {
1684
2004
  const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
1685
2005
  if (pending) {
1686
2006
  appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
@@ -1698,7 +2018,7 @@ class WechatDaemon {
1698
2018
  appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
1699
2019
  continue;
1700
2020
  }
1701
- if (result.status === "context_unavailable") {
2021
+ if (result.status === "target_stale") {
1702
2022
  break;
1703
2023
  }
1704
2024
  appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -2042,6 +2362,7 @@ export async function runDaemon(options) {
2042
2362
  authorizedUserId: credentials.userId,
2043
2363
  transport: new WeChatTransport({ log, logError }),
2044
2364
  channelId: options.channelId,
2365
+ accountId: wecomAccount?.botId,
2045
2366
  wecomTransport: wecomAccount
2046
2367
  ? new WecomTransport({
2047
2368
  account: wecomAccount,
@@ -2066,6 +2387,9 @@ export async function runDaemon(options) {
2066
2387
  }
2067
2388
  shutdownInProgress = true;
2068
2389
  log(`Received ${signal}. Stopping daemon.`);
2390
+ // Remove this process's endpoint synchronously so a new terminal can be
2391
+ // started immediately even if asynchronous adapter cleanup takes time.
2392
+ clearDaemonEndpoint();
2069
2393
  void daemon.shutdown().finally(() => process.exit(0));
2070
2394
  };
2071
2395
  process.on("SIGINT", () => handleSignal("SIGINT"));