cli-wechat-bridge 1.1.8 → 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 (76) hide show
  1. package/README.md +17 -4
  2. package/bin/_run-entry.mjs +6 -1
  3. package/bin/wechat-check-update.mjs +6 -1
  4. package/bin/wechat-claude.mjs +6 -1
  5. package/bin/wechat-codex.mjs +6 -1
  6. package/bin/wechat-daemon.mjs +6 -1
  7. package/bin/wechat-opencode.mjs +6 -1
  8. package/bin/wechat-pi.mjs +6 -1
  9. package/bin/wechat-setup.mjs +6 -1
  10. package/bin/wecom-check-update.mjs +6 -1
  11. package/bin/wecom-claude.mjs +6 -1
  12. package/bin/wecom-codex.mjs +6 -1
  13. package/bin/wecom-daemon.mjs +6 -1
  14. package/bin/wecom-opencode.mjs +6 -1
  15. package/bin/wecom-pi.mjs +6 -1
  16. package/bin/wecom-setup.mjs +6 -1
  17. package/dist/bridge/bridge-adapters.claude.js +6 -0
  18. package/dist/bridge/bridge-adapters.codex.js +6 -0
  19. package/dist/bridge/bridge-adapters.core.js +6 -0
  20. package/dist/bridge/bridge-adapters.opencode.js +6 -0
  21. package/dist/bridge/bridge-adapters.pi.js +6 -0
  22. package/dist/bridge/bridge-adapters.shared.js +6 -0
  23. package/dist/bridge/bridge-controller.js +6 -0
  24. package/dist/bridge/bridge-final-reply.js +6 -0
  25. package/dist/bridge/bridge-process-reaper.js +6 -0
  26. package/dist/bridge/bridge-state.js +6 -0
  27. package/dist/bridge/bridge-utils.js +6 -31
  28. package/dist/bridge/claude-hook.js +6 -0
  29. package/dist/bridge/native-terminal-control.js +6 -0
  30. package/dist/bridge/wechat-bridge.js +148 -241
  31. package/dist/bridge/wechat-forwarding.js +6 -0
  32. package/dist/bridge/wechat-outbound-queue.js +6 -0
  33. package/dist/channels/wechat/bridge-final-reply.js +13 -22
  34. package/dist/channels/wechat/index.js +6 -0
  35. package/dist/channels/wechat/wechat-driver.js +191 -0
  36. package/dist/channels/wechat/wechat-forwarding.js +18 -0
  37. package/dist/channels/wechat/wechat-outbound-queue.js +39 -0
  38. package/dist/channels/wecom/bridge-final-reply.js +6 -0
  39. package/dist/channels/wecom/setup-cli.js +6 -0
  40. package/dist/channels/wecom/setup.js +6 -0
  41. package/dist/channels/wecom/wecom-config.js +6 -0
  42. package/dist/channels/wecom/wecom-driver.js +106 -0
  43. package/dist/channels/wecom/wecom-media.js +6 -0
  44. package/dist/channels/wecom/wecom-transport.js +6 -0
  45. package/dist/commands/check-update.js +6 -0
  46. package/dist/companion/codex-remote-client.js +6 -0
  47. package/dist/companion/codex-visible-client-link.js +6 -0
  48. package/dist/companion/local-companion-link.js +6 -0
  49. package/dist/companion/local-companion-start.js +6 -0
  50. package/dist/companion/local-companion.js +6 -0
  51. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  52. package/dist/companion/pi-tui-bridge-extension.js +6 -0
  53. package/dist/core/bridge-control.js +2 -0
  54. package/dist/core/bridge-defer.js +25 -0
  55. package/dist/core/channel-driver.js +1 -0
  56. package/dist/core/conversation-routing.js +11 -1
  57. package/dist/core/direct-run.js +6 -0
  58. package/dist/core/inbound-message-claims.js +6 -0
  59. package/dist/core/outbound-target.js +32 -0
  60. package/dist/core/text-utils.js +6 -0
  61. package/dist/core/turn-coordinator.js +85 -0
  62. package/dist/daemon/daemon-link.js +6 -0
  63. package/dist/daemon/emoji-bindings.js +6 -0
  64. package/dist/daemon/wechat-daemon.js +451 -173
  65. package/dist/i18n/index.js +6 -0
  66. package/dist/i18n/messages-en.js +7 -1
  67. package/dist/i18n/messages-zh.js +7 -1
  68. package/dist/runtime/create-runtime-host.js +6 -0
  69. package/dist/utils/atomic-file.js +6 -0
  70. package/dist/utils/doctor.js +6 -0
  71. package/dist/utils/version-checker.js +6 -0
  72. package/dist/wechat/channel-config.js +6 -0
  73. package/dist/wechat/setup.js +6 -0
  74. package/dist/wechat/wechat-channel.js +6 -0
  75. package/dist/wechat/wechat-transport.js +73 -0
  76. package/package.json +1 -1
@@ -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,16 +18,19 @@ 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";
24
- import { clearTurn, InboundConversationContext, resolveConversationTarget, rollbackTurn, tryBeginTurn, } from "../core/conversation-routing.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";
25
34
  import { handleAdapterControl, invalidateModelSnapshot } from "../bridge/adapter-control.js";
26
35
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
27
36
  import { isDirectModuleRun } from "../core/direct-run.js";
@@ -29,7 +38,8 @@ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
29
38
  import { ensureWecomAccount } from "../channels/wecom/setup.js";
30
39
  import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
31
40
  import { WecomTransport } from "../channels/wecom/wecom-transport.js";
32
- 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";
33
43
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
34
44
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
35
45
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -46,6 +56,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
46
56
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
47
57
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
48
58
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
59
+ const MAX_DAEMON_DEFERRED_INPUTS = 32;
49
60
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
50
61
  function getCliChannelId(argv = process.argv.slice(2)) {
51
62
  const index = argv.indexOf("--channel");
@@ -66,22 +77,6 @@ function computePollRetryDelayMs(consecutiveFailures) {
66
77
  const exponent = Math.min(normalizedFailures - 1, 5);
67
78
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
68
79
  }
69
- function toWecomOutputKind(context) {
70
- switch (context) {
71
- case "notice":
72
- case "thinking":
73
- case "final_reply":
74
- case "approval_required":
75
- case "user_input_required":
76
- case "task_failed":
77
- case "fatal_error":
78
- return context;
79
- case "mirrored_user_input":
80
- return "mirrored_input";
81
- default:
82
- return "notice";
83
- }
84
- }
85
80
  function toWechatSendContext(kind) {
86
81
  if (kind === "mirrored_input") {
87
82
  return "mirrored_user_input";
@@ -552,11 +547,12 @@ export function formatDaemonStatus(status) {
552
547
  }
553
548
  return lines.join("\n");
554
549
  }
555
- class WechatDaemon {
550
+ export class WechatDaemon {
556
551
  cwd;
557
552
  profile;
558
553
  authorizedUserId;
559
554
  transport;
555
+ channelDriver;
560
556
  channelId;
561
557
  wecomTransport;
562
558
  inboundConversationContext = new InboundConversationContext();
@@ -568,11 +564,14 @@ class WechatDaemon {
568
564
  bridgeStartedAtMs = Date.now();
569
565
  backlogNoticeSent = false;
570
566
  activeAdapter = null;
567
+ activeAdapterVersion = 0;
571
568
  takenOverAdapter;
572
569
  textSendChain = Promise.resolve();
573
570
  attachmentSendChain = Promise.resolve();
574
571
  pendingWechatForwardTasks = new Set();
572
+ slotInputChains = new Map();
575
573
  pendingWechatMessages;
574
+ deps;
576
575
  shutdownPromise = null;
577
576
  ipcServer = null;
578
577
  endpointToken = "";
@@ -583,15 +582,37 @@ class WechatDaemon {
583
582
  this.transport = params.transport;
584
583
  this.channelId = params.channelId ?? "wechat";
585
584
  this.wecomTransport = params.wecomTransport ?? null;
586
- this.fallbackConversation = {
587
- channelId: this.channelId,
588
- conversationId: params.authorizedUserId,
589
- recipientId: params.authorizedUserId,
590
- metadata: this.channelId === "wecom" ? { chatType: "direct" } : undefined,
591
- };
592
- this.pendingWechatMessages = new PendingWechatMessageStore(this.channelId === "wechat"
593
- ? getPendingWechatMessagesFile(this.cwd)
594
- : 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);
595
616
  }
596
617
  async startIpcServer() {
597
618
  this.endpointToken = buildDaemonToken();
@@ -677,9 +698,9 @@ class WechatDaemon {
677
698
  }
678
699
  async runPollLoop() {
679
700
  let consecutivePollFailures = 0;
680
- if (this.wecomTransport) {
681
- this.wecomTransport.setHandlers({
682
- onMessage: async (channelMessage) => {
701
+ if (this.channelDriver.start) {
702
+ await this.channelDriver.start({
703
+ onInboundMessage: async (channelMessage) => {
683
704
  if (this.shutdownPromise) {
684
705
  return;
685
706
  }
@@ -690,33 +711,35 @@ class WechatDaemon {
690
711
  await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
691
712
  });
692
713
  },
693
- onUnauthorized: async (senderId, chatType) => {
714
+ onUnauthorizedSender: async (senderId, chatType) => {
694
715
  appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
695
716
  if (chatType === "direct") {
696
- await this.wecomTransport.sendText({
697
- channelId: "wecom",
698
- conversationId: senderId,
699
- recipientId: senderId,
700
- metadata: { chatType: "direct" },
701
- }, "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
+ }
702
726
  }
703
727
  },
704
- onFatal: async (error) => {
728
+ onChannelFatal: async (error) => {
705
729
  appendDaemonLog(`wecom_fatal_error: ${error.message}`);
706
730
  await this.shutdown();
707
731
  },
708
- onConnected: async () => {
732
+ onChannelConnected: async () => {
709
733
  if (this.pendingWechatMessages.list().length > 0) {
710
734
  await this.flushPendingWechatMessages();
711
735
  }
712
736
  },
713
737
  });
714
- this.wecomTransport.start();
715
- await this.wecomTransport.waitUntilConnected();
738
+ await this.channelDriver.waitUntilReady?.();
716
739
  }
717
- log(`${this.channelId === "wecom" ? "WeCom" : "WeChat"} daemon is ready.`);
740
+ log(`${this.channelDriver.displayName} daemon is ready.`);
718
741
  log(`Working directory: ${this.cwd}`);
719
- 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.`);
720
743
  appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
721
744
  const activeSlot = this.getActiveSlot();
722
745
  const welcomeText = t("daemon.welcome", {
@@ -725,7 +748,7 @@ class WechatDaemon {
725
748
  bindings: formatBindingsListMessage(listBindings()),
726
749
  });
727
750
  await this.queueWechatMessage(this.authorizedUserId, welcomeText);
728
- if (this.channelId === "wecom") {
751
+ if (this.channelDriver.capabilities.pushInbound) {
729
752
  while (!this.shutdownPromise) {
730
753
  await delay(1_000);
731
754
  }
@@ -835,6 +858,12 @@ class WechatDaemon {
835
858
  catch {
836
859
  // Best effort shutdown.
837
860
  }
861
+ try {
862
+ await this.channelDriver.endAllTyping?.();
863
+ }
864
+ catch {
865
+ // Best effort shutdown.
866
+ }
838
867
  try {
839
868
  this.wecomTransport?.stop();
840
869
  }
@@ -901,6 +930,244 @@ class WechatDaemon {
901
930
  sessionStartMode: request.sessionStartMode,
902
931
  reuseExistingVisible: request.reuseExistingVisible ?? true,
903
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;
904
1171
  }
905
1172
  }
906
1173
  async ensureSlot(adapter, options = {}) {
@@ -941,7 +1208,7 @@ class WechatDaemon {
941
1208
  visibleEndpointBeforeProbe?.companionConnectedAt ||
942
1209
  visibleEndpointBeforeProbe?.sharedThreadId ||
943
1210
  visibleEndpointBeforeProbe?.sharedSessionId);
944
- let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
1211
+ let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
945
1212
  if (shouldRestartDeadCodexVisibleRuntime({
946
1213
  adapter,
947
1214
  slotCreated: created,
@@ -970,8 +1237,9 @@ class WechatDaemon {
970
1237
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
971
1238
  }
972
1239
  if (options.openVisible !== false && !visibleConnected) {
1240
+ const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
973
1241
  slot.controller.syncLocalClientEndpoint();
974
- const launch = openVisibleClient({
1242
+ const launch = this.openVisibleClient({
975
1243
  adapter,
976
1244
  cwd: this.cwd,
977
1245
  // A freshly prepared blank thread is semantically a new session, but
@@ -989,14 +1257,17 @@ class WechatDaemon {
989
1257
  visibleConnected = await waitForVisibleClientConnection({
990
1258
  cwd: this.cwd,
991
1259
  adapter,
1260
+ timeoutMs: this.deps.visibleClientConnectTimeoutMs,
1261
+ }, {
1262
+ isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
992
1263
  });
993
1264
  if (visibleConnected) {
994
1265
  appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
995
1266
  }
996
1267
  else {
997
- 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}.`);
998
1269
  const cleanedLauncher = cleanupVisibleClientLauncher(launch);
999
- 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}`);
1000
1271
  }
1001
1272
  }
1002
1273
  let visibleReady = visibleConnected;
@@ -1024,6 +1295,7 @@ class WechatDaemon {
1024
1295
  if (previousSlot && this.activeAdapter !== adapter)
1025
1296
  invalidateModelSnapshot(previousSlot.runtime);
1026
1297
  this.activeAdapter = adapter;
1298
+ this.activeAdapterVersion += 1;
1027
1299
  }
1028
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}`);
1029
1301
  return {
@@ -1033,12 +1305,13 @@ class WechatDaemon {
1033
1305
  visibleConnected,
1034
1306
  visibleReady,
1035
1307
  activated,
1308
+ activationVersion: this.activeAdapterVersion,
1036
1309
  previousActiveAdapter,
1037
1310
  };
1038
1311
  }
1039
1312
  async createSlot(adapter, options) {
1040
1313
  clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
1041
- const runtime = createRuntimeHost({
1314
+ const runtime = this.createRuntime({
1042
1315
  kind: adapter,
1043
1316
  command: resolveDefaultAdapterCommand(adapter),
1044
1317
  cwd: this.cwd,
@@ -1061,10 +1334,14 @@ class WechatDaemon {
1061
1334
  adapter,
1062
1335
  runtime,
1063
1336
  }),
1064
- activeTask: null,
1337
+ turns: new TurnCoordinator({
1338
+ restoreLastConversationOnFailure: true,
1339
+ }),
1065
1340
  lastOutputAt: 0,
1066
1341
  lastFinalReplyAtMs: 0,
1067
1342
  eventForwardChain: Promise.resolve(),
1343
+ deferredInputs: [],
1344
+ drainingDeferredInputs: false,
1068
1345
  };
1069
1346
  runtime.setEventSink((event) => {
1070
1347
  this.handleSlotEvent(slot, event);
@@ -1079,7 +1356,7 @@ class WechatDaemon {
1079
1356
  slot.outputBatcher.clear();
1080
1357
  slot.pendingConfirmations = [];
1081
1358
  slot.pendingUserInput = null;
1082
- clearTurn(slot);
1359
+ slot.turns.complete();
1083
1360
  if (slot.adapter === "codex" || slot.adapter === "claude") {
1084
1361
  await slot.runtime.reset();
1085
1362
  }
@@ -1121,10 +1398,10 @@ class WechatDaemon {
1121
1398
  slot.controller.syncLocalClientEndpoint();
1122
1399
  const adapterState = slot.runtime.getState();
1123
1400
  const channelPort = this.createWechatChannelPort(slot.adapter);
1124
- const eventTarget = this.channelId === "wecom"
1401
+ const eventTarget = this.channelDriver.capabilities.multiConversation
1125
1402
  ? this.resolveSlotOutputTarget(slot)
1126
1403
  : this.fallbackConversation;
1127
- const eventTask = slot.activeTask;
1404
+ const eventTask = slot.turns.activeTask;
1128
1405
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
1129
1406
  slot.pendingConfirmations = [];
1130
1407
  }
@@ -1146,6 +1423,7 @@ class WechatDaemon {
1146
1423
  }
1147
1424
  },
1148
1425
  finalReply: (next) => {
1426
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1149
1427
  slot.lastFinalReplyAtMs = Date.now();
1150
1428
  appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1151
1429
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
@@ -1225,32 +1503,38 @@ class WechatDaemon {
1225
1503
  }
1226
1504
  },
1227
1505
  taskComplete: () => {
1506
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1228
1507
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
1229
1508
  slot.pendingConfirmations = [];
1230
1509
  slot.pendingUserInput = null;
1231
1510
  if (eventTask) {
1232
- clearTurn(slot, eventTask);
1511
+ slot.turns.complete(eventTask);
1233
1512
  }
1513
+ void this.maybeDrainDeferredInputs(slot);
1234
1514
  }));
1235
1515
  },
1236
1516
  taskFailed: (next) => {
1517
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1237
1518
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1238
1519
  slot.pendingConfirmations = [];
1239
1520
  slot.pendingUserInput = null;
1240
1521
  if (eventTask) {
1241
- clearTurn(slot, eventTask);
1522
+ slot.turns.complete(eventTask);
1242
1523
  }
1524
+ void this.maybeDrainDeferredInputs(slot);
1243
1525
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed", eventTarget);
1244
1526
  }));
1245
1527
  },
1246
1528
  fatalError: (next) => {
1529
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1247
1530
  logError(`${slot.adapter}: ${next.message}`);
1248
1531
  appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
1249
1532
  slot.pendingConfirmations = [];
1250
1533
  slot.pendingUserInput = null;
1251
1534
  if (eventTask) {
1252
- clearTurn(slot, eventTask);
1535
+ slot.turns.complete(eventTask);
1253
1536
  }
1537
+ void this.maybeDrainDeferredInputs(slot);
1254
1538
  this.disposeDeadSlot(slot);
1255
1539
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1256
1540
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
@@ -1266,24 +1550,18 @@ class WechatDaemon {
1266
1550
  });
1267
1551
  }
1268
1552
  resolveSlotOutputTarget(slot) {
1269
- return resolveConversationTarget({
1270
- active: slot.activeConversation,
1271
- last: slot.lastConversation,
1272
- fallback: this.fallbackConversation,
1273
- });
1553
+ return slot.turns.resolveTarget(this.fallbackConversation);
1274
1554
  }
1275
1555
  bindCurrentWecomConversation(slot) {
1276
1556
  const conversation = this.inboundConversationContext.get();
1277
- if (this.channelId !== "wecom" || !slot || !conversation) {
1557
+ if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
1278
1558
  return;
1279
1559
  }
1280
- slot.lastConversation = conversation;
1560
+ slot.turns.observeConversation(conversation);
1281
1561
  }
1282
1562
  async handleInboundMessage(message) {
1283
1563
  if (message.senderId !== this.authorizedUserId) {
1284
- await this.queueWechatMessage(message.senderId, this.channelId === "wecom"
1285
- ? "Unauthorized. This daemon only accepts messages from the paired WeCom operator."
1286
- : "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}.`);
1287
1565
  return;
1288
1566
  }
1289
1567
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1354,7 +1632,7 @@ class WechatDaemon {
1354
1632
  }
1355
1633
  this.bindCurrentWecomConversation(slot);
1356
1634
  const routeCurrentMessage = async (currentSlot, command) => {
1357
- const previousTask = currentSlot.activeTask;
1635
+ const previousTask = currentSlot.turns.activeTask;
1358
1636
  await routeBridgeMessage({
1359
1637
  message: toChannelInboundMessage(message),
1360
1638
  authorized: true,
@@ -1383,13 +1661,12 @@ class WechatDaemon {
1383
1661
  await this.dispatchInboundWechatText(message, currentSlot);
1384
1662
  },
1385
1663
  });
1386
- if (this.channelId === "wecom" &&
1664
+ if (this.channelDriver.capabilities.multiConversation &&
1387
1665
  this.inboundConversationContext.get() &&
1388
- currentSlot.activeTask &&
1389
- currentSlot.activeTask !== previousTask) {
1666
+ currentSlot.turns.activeTask &&
1667
+ currentSlot.turns.activeTask !== previousTask) {
1390
1668
  const conversation = this.inboundConversationContext.get();
1391
- currentSlot.activeConversation = conversation;
1392
- currentSlot.lastConversation = conversation;
1669
+ currentSlot.turns.bindConversation(conversation);
1393
1670
  }
1394
1671
  };
1395
1672
  const command = parseWechatControlCommand(message.text, {
@@ -1448,7 +1725,7 @@ class WechatDaemon {
1448
1725
  return;
1449
1726
  case "resume": {
1450
1727
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1451
- 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.`);
1452
1729
  return;
1453
1730
  }
1454
1731
  try {
@@ -1466,10 +1743,10 @@ class WechatDaemon {
1466
1743
  if (command.target) {
1467
1744
  await resumeSlot.outputBatcher.flushNow();
1468
1745
  }
1469
- const taskBeforeResume = resumeSlot.activeTask;
1746
+ const taskBeforeResume = resumeSlot.turns.activeTask;
1470
1747
  const result = await resumeSlot.resumeCoordinator.execute(command.target);
1471
1748
  if (result.kind === "resumed" && taskBeforeResume) {
1472
- clearTurn(resumeSlot, taskBeforeResume);
1749
+ resumeSlot.turns.complete(taskBeforeResume);
1473
1750
  }
1474
1751
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
1475
1752
  }
@@ -1491,6 +1768,9 @@ class WechatDaemon {
1491
1768
  await activeSlot.runtime.createSession();
1492
1769
  appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
1493
1770
  return;
1771
+ case "broadcast":
1772
+ await this.handleBroadcastCommand(message, command.text);
1773
+ return;
1494
1774
  case "stop": {
1495
1775
  const interrupted = await activeSlot.runtime.interrupt();
1496
1776
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
@@ -1532,10 +1812,10 @@ class WechatDaemon {
1532
1812
  }
1533
1813
  const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
1534
1814
  slot.pendingConfirmations = [];
1535
- slot.activeTask = {
1815
+ slot.turns.setActiveTask({
1536
1816
  startedAt: Date.now(),
1537
1817
  inputPreview: preview,
1538
- };
1818
+ });
1539
1819
  appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
1540
1820
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
1541
1821
  ? `${count} approvals confirmed. Continuing...`
@@ -1581,10 +1861,10 @@ class WechatDaemon {
1581
1861
  return;
1582
1862
  }
1583
1863
  slot.pendingUserInput = null;
1584
- slot.activeTask = {
1864
+ slot.turns.setActiveTask({
1585
1865
  startedAt: Date.now(),
1586
1866
  inputPreview: parsed.preview,
1587
- };
1867
+ });
1588
1868
  appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1589
1869
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1590
1870
  }
@@ -1606,30 +1886,79 @@ class WechatDaemon {
1606
1886
  }
1607
1887
  return this.slots.get(this.activeAdapter) ?? null;
1608
1888
  }
1609
- 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) {
1610
1939
  const preview = formatInboundMessagePreview(message);
1611
1940
  const nextTask = {
1612
1941
  startedAt: Date.now(),
1613
1942
  inputPreview: truncatePreview(preview, 180),
1614
1943
  };
1615
- const inboundConversation = this.channelId === "wecom"
1616
- ? this.inboundConversationContext.get()
1944
+ const inboundConversation = this.channelDriver.capabilities.multiConversation
1945
+ ? conversationOverride ?? this.inboundConversationContext.get()
1617
1946
  : undefined;
1618
- const lease = tryBeginTurn(slot, nextTask, inboundConversation);
1619
- if (!lease) {
1620
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1621
- return;
1622
- }
1623
- appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1624
- try {
1625
- await slot.runtime.sendInput(this.channelId === "wecom"
1626
- ? buildWecomInboundPrompt(message.text, message.attachments)
1627
- : buildWechatInboundPrompt(message.text, message.attachments));
1628
- }
1629
- catch (error) {
1630
- rollbackTurn(slot, lease);
1631
- throw error;
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);
1632
1960
  }
1961
+ return dispatchResult;
1633
1962
  }
1634
1963
  queueWechatTextAction(action) {
1635
1964
  const run = this.textSendChain.then(action);
@@ -1642,88 +1971,36 @@ class WechatDaemon {
1642
1971
  return run;
1643
1972
  }
1644
1973
  async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
1645
- if (this.channelId === "wecom") {
1646
- const activeSlot = this.getActiveSlot();
1647
- const target = targetOverride ??
1648
- (senderId === this.authorizedUserId
1649
- ? this.inboundConversationContext.get() ??
1650
- (activeSlot ? this.resolveSlotOutputTarget(activeSlot) : this.fallbackConversation)
1651
- : {
1652
- channelId: "wecom",
1653
- conversationId: senderId,
1654
- recipientId: senderId,
1655
- metadata: { chatType: "direct" },
1656
- });
1657
- const startedAtMs = Date.now();
1658
- appendDaemonLog(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
1659
- try {
1660
- await this.wecomTransport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
1661
- appendDaemonLog(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
1662
- return { status: "sent" };
1663
- }
1664
- catch (error) {
1665
- const message = error instanceof Error ? error.message : String(error);
1666
- appendDaemonLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
1667
- logError(`Failed to send WeCom ${context}: ${message}`);
1668
- return { status: "context_unavailable", error, target };
1669
- }
1670
- }
1671
- const startedAtMs = Date.now();
1672
- appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
1673
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1674
- try {
1675
- await this.transport.sendText(senderId, text);
1676
- appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1677
- return { status: "sent" };
1678
- }
1679
- catch (error) {
1680
- if (error instanceof Error && error.name === "AbortError") {
1681
- appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1682
- }
1683
- if (isWechatContextUnavailableError(error)) {
1684
- if (isWechatContextTokenStaleError(error)) {
1685
- this.transport.clearCachedContextToken(senderId);
1686
- }
1687
- appendDaemonLog(isWechatContextTokenStaleError(error)
1688
- ? formatWechatContextTokenStaleLogEntry({
1689
- context,
1690
- recipientId: senderId,
1691
- error,
1692
- })
1693
- : formatWechatSendFailureLogEntry({
1694
- context,
1695
- recipientId: senderId,
1696
- error,
1697
- }));
1698
- return { status: "context_unavailable", error };
1699
- }
1700
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1701
- const delayMs = computeWechatSendRetryDelayMs(attempt);
1702
- appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1703
- await delay(delayMs);
1704
- continue;
1705
- }
1706
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1707
- appendDaemonLog(formatWechatSendFailureLogEntry({
1708
- context,
1709
- recipientId: senderId,
1710
- error,
1711
- }));
1712
- return { status: "failed" };
1713
- }
1714
- }
1715
- 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
+ });
1716
1993
  }
1717
1994
  queueWechatMessage(senderId, text, context = "message", targetOverride) {
1718
1995
  const activeSlot = this.getActiveSlot();
1719
- const queuedTarget = this.channelId === "wecom" && senderId === this.authorizedUserId
1996
+ const queuedTarget = this.channelDriver.capabilities.multiConversation && senderId === this.authorizedUserId
1720
1997
  ? targetOverride ??
1721
1998
  this.inboundConversationContext.get() ??
1722
1999
  (activeSlot ? this.resolveSlotOutputTarget(activeSlot) : this.fallbackConversation)
1723
2000
  : targetOverride;
1724
2001
  return this.queueWechatTextAction(async () => {
1725
2002
  const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
1726
- if (result.status === "context_unavailable") {
2003
+ if (result.status === "target_stale") {
1727
2004
  const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
1728
2005
  if (pending) {
1729
2006
  appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
@@ -1741,7 +2018,7 @@ class WechatDaemon {
1741
2018
  appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
1742
2019
  continue;
1743
2020
  }
1744
- if (result.status === "context_unavailable") {
2021
+ if (result.status === "target_stale") {
1745
2022
  break;
1746
2023
  }
1747
2024
  appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -2085,6 +2362,7 @@ export async function runDaemon(options) {
2085
2362
  authorizedUserId: credentials.userId,
2086
2363
  transport: new WeChatTransport({ log, logError }),
2087
2364
  channelId: options.channelId,
2365
+ accountId: wecomAccount?.botId,
2088
2366
  wecomTransport: wecomAccount
2089
2367
  ? new WecomTransport({
2090
2368
  account: wecomAccount,