@unlinearity/cli-wechat-bridge 1.1.6 → 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 (85) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +467 -475
  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 +10 -0
  12. package/bin/wecom-claude.mjs +10 -0
  13. package/bin/wecom-codex.mjs +10 -0
  14. package/bin/wecom-daemon.mjs +10 -0
  15. package/bin/wecom-opencode.mjs +10 -0
  16. package/bin/wecom-pi.mjs +10 -0
  17. package/bin/wecom-setup.mjs +10 -0
  18. package/dist/bridge/adapter-control.js +58 -0
  19. package/dist/bridge/bridge-adapters.claude.js +161 -4
  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 +41 -2
  28. package/dist/bridge/bridge-state.js +16 -3
  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 +380 -171
  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 +49 -3
  41. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  42. package/dist/channels/wecom/setup-cli.js +12 -0
  43. package/dist/channels/wecom/setup.js +182 -0
  44. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  45. package/dist/channels/wecom/wecom-config.js +72 -0
  46. package/dist/channels/wecom/wecom-driver.js +106 -0
  47. package/dist/channels/wecom/wecom-media.js +147 -0
  48. package/dist/channels/wecom/wecom-message.js +149 -0
  49. package/dist/channels/wecom/wecom-transport.js +314 -0
  50. package/dist/commands/check-update.js +6 -0
  51. package/dist/companion/codex-remote-client.js +30 -3
  52. package/dist/companion/codex-visible-client-link.js +107 -0
  53. package/dist/companion/local-companion-link.js +6 -0
  54. package/dist/companion/local-companion-start.js +62 -11
  55. package/dist/companion/local-companion.js +25 -1
  56. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  57. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  58. package/dist/core/bridge-control.js +10 -0
  59. package/dist/core/bridge-defer.js +25 -0
  60. package/dist/core/channel-driver.js +1 -0
  61. package/dist/core/channel-types.js +3 -1
  62. package/dist/core/conversation-routing.js +58 -0
  63. package/dist/core/direct-run.js +23 -0
  64. package/dist/core/inbound-message-claims.js +67 -0
  65. package/dist/core/outbound-target.js +32 -0
  66. package/dist/core/text-utils.js +6 -0
  67. package/dist/core/turn-coordinator.js +85 -0
  68. package/dist/daemon/daemon-link.js +9 -0
  69. package/dist/daemon/emoji-bindings.js +6 -0
  70. package/dist/daemon/wechat-daemon.js +675 -122
  71. package/dist/i18n/index.js +6 -0
  72. package/dist/i18n/messages-en.js +9 -1
  73. package/dist/i18n/messages-zh.js +9 -1
  74. package/dist/runtime/create-runtime-host.js +6 -0
  75. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  76. package/dist/runtime/runtime-types.js +3 -0
  77. package/dist/utils/atomic-file.js +6 -0
  78. package/dist/utils/doctor.js +36 -10
  79. package/dist/utils/version-checker.js +17 -11
  80. package/dist/wechat/channel-config.js +6 -0
  81. package/dist/wechat/setup.js +119 -13
  82. package/dist/wechat/wechat-channel.js +6 -0
  83. package/dist/wechat/wechat-transport.js +169 -58
  84. package/package.json +13 -3
  85. 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,17 +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";
20
- import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
25
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, WeChatTransport, } from "../wechat/wechat-transport.js";
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";
36
+ import { isDirectModuleRun } from "../core/direct-run.js";
25
37
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
38
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
39
+ import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
40
+ import { WecomTransport } from "../channels/wecom/wecom-transport.js";
41
+ import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
42
+ import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
26
43
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
27
44
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
28
45
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -39,12 +56,17 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
39
56
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
40
57
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
41
58
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
59
+ const MAX_DAEMON_DEFERRED_INPUTS = 32;
42
60
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
61
+ function getCliChannelId(argv = process.argv.slice(2)) {
62
+ const index = argv.indexOf("--channel");
63
+ return index >= 0 && argv[index + 1] === "wecom" ? "wecom" : "wechat";
64
+ }
43
65
  function log(message) {
44
- process.stderr.write(`[wechat-daemon] ${message}\n`);
66
+ process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
45
67
  }
46
68
  function logError(message) {
47
- process.stderr.write(`[wechat-daemon] ERROR: ${message}\n`);
69
+ process.stderr.write(`[${getCliChannelId()}-daemon] ERROR: ${message}\n`);
48
70
  }
49
71
  function appendDaemonLog(message) {
50
72
  ensureChannelDataDir();
@@ -55,6 +77,32 @@ function computePollRetryDelayMs(consecutiveFailures) {
55
77
  const exponent = Math.min(normalizedFailures - 1, 5);
56
78
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
57
79
  }
80
+ function toWechatSendContext(kind) {
81
+ if (kind === "mirrored_input") {
82
+ return "mirrored_user_input";
83
+ }
84
+ if (kind === "status") {
85
+ return "message";
86
+ }
87
+ return kind;
88
+ }
89
+ function toLegacyInboundWechatMessage(message) {
90
+ return {
91
+ senderId: message.senderId,
92
+ sender: message.senderId,
93
+ sessionId: message.conversation.conversationId,
94
+ text: message.text,
95
+ attachments: message.attachments.map((attachment) => ({
96
+ kind: attachment.kind === "image" ? "image" : "file",
97
+ path: attachment.path,
98
+ fileName: attachment.fileName || path.basename(attachment.path),
99
+ sizeBytes: attachment.sizeBytes ?? 0,
100
+ })),
101
+ contextToken: message.conversation.opaqueRef,
102
+ createdAt: message.createdAt,
103
+ createdAtMs: Date.parse(message.createdAt),
104
+ };
105
+ }
58
106
  function isDaemonAdapterKind(value) {
59
107
  return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
60
108
  }
@@ -76,6 +124,7 @@ export function parseDaemonCliArgs(argv) {
76
124
  let profile;
77
125
  let initialAdapter;
78
126
  let openVisible = true;
127
+ let channelId = "wechat";
79
128
  for (let i = 0; i < argv.length; i += 1) {
80
129
  const arg = argv[i];
81
130
  if (!arg) {
@@ -84,10 +133,10 @@ export function parseDaemonCliArgs(argv) {
84
133
  const next = argv[i + 1];
85
134
  if (arg === "--help" || arg === "-h") {
86
135
  process.stdout.write([
87
- "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]",
136
+ `Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
88
137
  "",
89
- "Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
90
- "Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
138
+ `Keeps one ${channelId === "wecom" ? "WeCom" : "WeChat"} connection alive and switches between Codex, Claude Code, OpenCode, and Pi.`,
139
+ `Send /codex, /claude, /opencode, or /pi in ${channelId === "wecom" ? "WeCom" : "WeChat"} to switch the active terminal.`,
91
140
  "",
92
141
  ].join("\n"));
93
142
  process.exit(0);
@@ -100,6 +149,14 @@ export function parseDaemonCliArgs(argv) {
100
149
  i += 1;
101
150
  continue;
102
151
  }
152
+ if (arg === "--channel") {
153
+ if (!next || (next !== "wechat" && next !== "wecom")) {
154
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
155
+ }
156
+ channelId = next;
157
+ i += 1;
158
+ continue;
159
+ }
103
160
  if (arg === "--adapter") {
104
161
  if (!isDaemonAdapterKind(next)) {
105
162
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -122,7 +179,13 @@ export function parseDaemonCliArgs(argv) {
122
179
  }
123
180
  throw new Error(`Unknown argument: ${arg}`);
124
181
  }
125
- return { cwd, profile, initialAdapter, openVisible };
182
+ return {
183
+ cwd,
184
+ profile,
185
+ initialAdapter,
186
+ openVisible,
187
+ ...(channelId === "wecom" ? { channelId } : {}),
188
+ };
126
189
  }
127
190
  export function parseDaemonSwitchCommand(text) {
128
191
  const normalized = text.trim().toLowerCase();
@@ -151,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
151
214
  remainder: match[2]?.trim() ?? "",
152
215
  };
153
216
  }
154
- export function defaultDaemonSessionStartMode(adapter) {
155
- 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";
156
224
  }
157
225
  export function resolveDaemonSessionStartMode(params) {
158
226
  // An explicit --session-start-mode wins over visible-client reuse: the user
@@ -165,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
165
233
  if (params.reuseExistingVisible && params.visibleConnected) {
166
234
  return "restore";
167
235
  }
168
- if (params.adapter === "codex") {
169
- return "restore";
170
- }
171
236
  if (params.slotCreated) {
172
237
  return "new";
173
238
  }
@@ -221,7 +286,7 @@ export function buildVisibleClientLaunchArgs(params) {
221
286
  export function buildWindowsVisibleClientLaunchCommand(params) {
222
287
  return [
223
288
  "start",
224
- quoteWindowsCommandArg(`wechat-${params.adapter}`),
289
+ quoteWindowsCommandArg(`${params.channelId ?? "wechat"}-${params.adapter}`),
225
290
  "/D",
226
291
  quoteWindowsCommandArg(params.cwd),
227
292
  quoteWindowsCommandArg(process.execPath),
@@ -269,6 +334,7 @@ function openVisibleClient(params) {
269
334
  adapter: params.adapter,
270
335
  cwd: params.cwd,
271
336
  args,
337
+ channelId: params.channelId,
272
338
  }),
273
339
  ];
274
340
  const child = spawn(command, launchArgs, {
@@ -289,13 +355,13 @@ function openVisibleClient(params) {
289
355
  pid: child.pid,
290
356
  };
291
357
  }
292
- const title = `wechat-${params.adapter}`;
358
+ const title = `${params.channelId ?? "wechat"}-${params.adapter}`;
293
359
  const fullArgs = [process.execPath, ...args];
294
360
  if (process.platform === "darwin") {
295
361
  const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
296
- const script = `tell application "Terminal"
297
- activate
298
- 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}"
299
365
  end tell`;
300
366
  const child = spawn("osascript", ["-e", script], {
301
367
  cwd: params.cwd,
@@ -481,11 +547,16 @@ export function formatDaemonStatus(status) {
481
547
  }
482
548
  return lines.join("\n");
483
549
  }
484
- class WechatDaemon {
550
+ export class WechatDaemon {
485
551
  cwd;
486
552
  profile;
487
553
  authorizedUserId;
488
554
  transport;
555
+ channelDriver;
556
+ channelId;
557
+ wecomTransport;
558
+ inboundConversationContext = new InboundConversationContext();
559
+ fallbackConversation;
489
560
  slots = new Map();
490
561
  // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
491
562
  slotEnsureChains = new Map();
@@ -493,11 +564,14 @@ class WechatDaemon {
493
564
  bridgeStartedAtMs = Date.now();
494
565
  backlogNoticeSent = false;
495
566
  activeAdapter = null;
567
+ activeAdapterVersion = 0;
496
568
  takenOverAdapter;
497
569
  textSendChain = Promise.resolve();
498
570
  attachmentSendChain = Promise.resolve();
499
571
  pendingWechatForwardTasks = new Set();
572
+ slotInputChains = new Map();
500
573
  pendingWechatMessages;
574
+ deps;
501
575
  shutdownPromise = null;
502
576
  ipcServer = null;
503
577
  endpointToken = "";
@@ -506,7 +580,39 @@ class WechatDaemon {
506
580
  this.profile = params.profile;
507
581
  this.authorizedUserId = params.authorizedUserId;
508
582
  this.transport = params.transport;
509
- this.pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(this.cwd));
583
+ this.channelId = params.channelId ?? "wechat";
584
+ this.wecomTransport = params.wecomTransport ?? null;
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);
510
616
  }
511
617
  async startIpcServer() {
512
618
  this.endpointToken = buildDaemonToken();
@@ -554,6 +660,7 @@ class WechatDaemon {
554
660
  token: this.endpointToken,
555
661
  cwd: this.cwd,
556
662
  startedAt: this.startedAt,
663
+ channelId: this.channelId,
557
664
  });
558
665
  resolve();
559
666
  });
@@ -562,6 +669,7 @@ class WechatDaemon {
562
669
  getStatus() {
563
670
  return {
564
671
  cwd: this.cwd,
672
+ channelId: this.channelId,
565
673
  activeAdapter: this.activeAdapter ?? undefined,
566
674
  startedAt: this.startedAt,
567
675
  slots: Array.from(this.slots.values()).map((slot) => {
@@ -590,10 +698,49 @@ class WechatDaemon {
590
698
  }
591
699
  async runPollLoop() {
592
700
  let consecutivePollFailures = 0;
593
- log("WeChat daemon is ready.");
701
+ if (this.channelDriver.start) {
702
+ await this.channelDriver.start({
703
+ onInboundMessage: async (channelMessage) => {
704
+ if (this.shutdownPromise) {
705
+ return;
706
+ }
707
+ await this.inboundConversationContext.run(channelMessage.conversation, async () => {
708
+ if (this.pendingWechatMessages.list().length > 0) {
709
+ await this.flushPendingWechatMessages();
710
+ }
711
+ await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
712
+ });
713
+ },
714
+ onUnauthorizedSender: async (senderId, chatType) => {
715
+ appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
716
+ if (chatType === "direct") {
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
+ }
726
+ }
727
+ },
728
+ onChannelFatal: async (error) => {
729
+ appendDaemonLog(`wecom_fatal_error: ${error.message}`);
730
+ await this.shutdown();
731
+ },
732
+ onChannelConnected: async () => {
733
+ if (this.pendingWechatMessages.list().length > 0) {
734
+ await this.flushPendingWechatMessages();
735
+ }
736
+ },
737
+ });
738
+ await this.channelDriver.waitUntilReady?.();
739
+ }
740
+ log(`${this.channelDriver.displayName} daemon is ready.`);
594
741
  log(`Working directory: ${this.cwd}`);
595
- log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
596
- appendDaemonLog(`started: cwd=${this.cwd}`);
742
+ log(`Switch from ${this.channelDriver.displayName} with /codex, /claude, /opencode, or /pi.`);
743
+ appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
597
744
  const activeSlot = this.getActiveSlot();
598
745
  const welcomeText = t("daemon.welcome", {
599
746
  cwd: this.cwd,
@@ -601,6 +748,13 @@ class WechatDaemon {
601
748
  bindings: formatBindingsListMessage(listBindings()),
602
749
  });
603
750
  await this.queueWechatMessage(this.authorizedUserId, welcomeText);
751
+ if (this.channelDriver.capabilities.pushInbound) {
752
+ while (!this.shutdownPromise) {
753
+ await delay(1_000);
754
+ }
755
+ await this.shutdownPromise;
756
+ return;
757
+ }
604
758
  while (!this.shutdownPromise) {
605
759
  let pollResult;
606
760
  try {
@@ -694,6 +848,29 @@ class WechatDaemon {
694
848
  }
695
849
  async cleanup() {
696
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();
697
874
  for (const slot of this.slots.values()) {
698
875
  try {
699
876
  await slot.outputBatcher.flushNow();
@@ -736,7 +913,7 @@ class WechatDaemon {
736
913
  return { shuttingDown: true };
737
914
  case "ensure_slot":
738
915
  if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
739
- throw new Error(`wechat-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
916
+ throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
740
917
  }
741
918
  return await this.ensureSlot(request.adapter, {
742
919
  profile: request.profile,
@@ -753,6 +930,244 @@ class WechatDaemon {
753
930
  sessionStartMode: request.sessionStartMode,
754
931
  reuseExistingVisible: request.reuseExistingVisible ?? true,
755
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;
756
1171
  }
757
1172
  }
758
1173
  async ensureSlot(adapter, options = {}) {
@@ -793,7 +1208,7 @@ class WechatDaemon {
793
1208
  visibleEndpointBeforeProbe?.companionConnectedAt ||
794
1209
  visibleEndpointBeforeProbe?.sharedThreadId ||
795
1210
  visibleEndpointBeforeProbe?.sharedSessionId);
796
- let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
1211
+ let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
797
1212
  if (shouldRestartDeadCodexVisibleRuntime({
798
1213
  adapter,
799
1214
  slotCreated: created,
@@ -822,12 +1237,17 @@ class WechatDaemon {
822
1237
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
823
1238
  }
824
1239
  if (options.openVisible !== false && !visibleConnected) {
1240
+ const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
825
1241
  slot.controller.syncLocalClientEndpoint();
826
- const launch = openVisibleClient({
1242
+ const launch = this.openVisibleClient({
827
1243
  adapter,
828
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.
829
1248
  sessionStartMode,
830
1249
  cliArgs: options.cliArgs,
1250
+ channelId: this.channelId,
831
1251
  onError: (error) => {
832
1252
  appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
833
1253
  },
@@ -837,14 +1257,17 @@ class WechatDaemon {
837
1257
  visibleConnected = await waitForVisibleClientConnection({
838
1258
  cwd: this.cwd,
839
1259
  adapter,
1260
+ timeoutMs: this.deps.visibleClientConnectTimeoutMs,
1261
+ }, {
1262
+ isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
840
1263
  });
841
1264
  if (visibleConnected) {
842
1265
  appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
843
1266
  }
844
1267
  else {
845
- 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}.`);
846
1269
  const cleanedLauncher = cleanupVisibleClientLauncher(launch);
847
- 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}`);
848
1271
  }
849
1272
  }
850
1273
  let visibleReady = visibleConnected;
@@ -868,7 +1291,11 @@ class WechatDaemon {
868
1291
  }
869
1292
  const activated = options.openVisible === false || (visibleConnected && visibleReady);
870
1293
  if (activated) {
1294
+ const previousSlot = this.getActiveSlot();
1295
+ if (previousSlot && this.activeAdapter !== adapter)
1296
+ invalidateModelSnapshot(previousSlot.runtime);
871
1297
  this.activeAdapter = adapter;
1298
+ this.activeAdapterVersion += 1;
872
1299
  }
873
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}`);
874
1301
  return {
@@ -878,12 +1305,13 @@ class WechatDaemon {
878
1305
  visibleConnected,
879
1306
  visibleReady,
880
1307
  activated,
1308
+ activationVersion: this.activeAdapterVersion,
881
1309
  previousActiveAdapter,
882
1310
  };
883
1311
  }
884
1312
  async createSlot(adapter, options) {
885
1313
  clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
886
- const runtime = createRuntimeHost({
1314
+ const runtime = this.createRuntime({
887
1315
  kind: adapter,
888
1316
  command: resolveDefaultAdapterCommand(adapter),
889
1317
  cwd: this.cwd,
@@ -898,7 +1326,7 @@ class WechatDaemon {
898
1326
  runtime,
899
1327
  controller,
900
1328
  outputBatcher: new OutputBatcher(async (text) => {
901
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
1329
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
902
1330
  }),
903
1331
  pendingConfirmations: [],
904
1332
  pendingUserInput: null,
@@ -906,10 +1334,14 @@ class WechatDaemon {
906
1334
  adapter,
907
1335
  runtime,
908
1336
  }),
909
- activeTask: null,
1337
+ turns: new TurnCoordinator({
1338
+ restoreLastConversationOnFailure: true,
1339
+ }),
910
1340
  lastOutputAt: 0,
911
1341
  lastFinalReplyAtMs: 0,
912
1342
  eventForwardChain: Promise.resolve(),
1343
+ deferredInputs: [],
1344
+ drainingDeferredInputs: false,
913
1345
  };
914
1346
  runtime.setEventSink((event) => {
915
1347
  this.handleSlotEvent(slot, event);
@@ -924,7 +1356,7 @@ class WechatDaemon {
924
1356
  slot.outputBatcher.clear();
925
1357
  slot.pendingConfirmations = [];
926
1358
  slot.pendingUserInput = null;
927
- slot.activeTask = null;
1359
+ slot.turns.complete();
928
1360
  if (slot.adapter === "codex" || slot.adapter === "claude") {
929
1361
  await slot.runtime.reset();
930
1362
  }
@@ -937,6 +1369,16 @@ class WechatDaemon {
937
1369
  slot.controller.syncLocalClientEndpoint();
938
1370
  }
939
1371
  createWechatChannelPort(adapter) {
1372
+ if (this.channelId === "wecom") {
1373
+ return new WecomChannelPort({
1374
+ transport: this.wecomTransport,
1375
+ sendText: (target, text, kind) => this.queueWechatMessage(this.authorizedUserId, text, toWechatSendContext(kind), target),
1376
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1377
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
1378
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
1379
+ },
1380
+ });
1381
+ }
940
1382
  return new WechatChannelPort({
941
1383
  sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
942
1384
  sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
@@ -956,6 +1398,10 @@ class WechatDaemon {
956
1398
  slot.controller.syncLocalClientEndpoint();
957
1399
  const adapterState = slot.runtime.getState();
958
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;
959
1405
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
960
1406
  slot.pendingConfirmations = [];
961
1407
  }
@@ -977,15 +1423,12 @@ class WechatDaemon {
977
1423
  }
978
1424
  },
979
1425
  finalReply: (next) => {
1426
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
980
1427
  slot.lastFinalReplyAtMs = Date.now();
981
1428
  appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
982
1429
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
983
1430
  await channelPort.send({
984
- target: {
985
- channelId: "wechat",
986
- conversationId: this.authorizedUserId,
987
- recipientId: this.authorizedUserId,
988
- },
1431
+ target: eventTarget,
989
1432
  kind: "final_reply",
990
1433
  text: next.text,
991
1434
  adapter: slot.adapter,
@@ -1003,7 +1446,7 @@ class WechatDaemon {
1003
1446
  appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
1004
1447
  if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1005
1448
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1006
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice");
1449
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
1007
1450
  }));
1008
1451
  }
1009
1452
  },
@@ -1012,7 +1455,7 @@ class WechatDaemon {
1012
1455
  const pending = toPendingApproval(next);
1013
1456
  slot.pendingConfirmations.push(pending);
1014
1457
  appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
1015
- 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);
1016
1459
  }));
1017
1460
  },
1018
1461
  userInputRequired: (next) => {
@@ -1020,14 +1463,14 @@ class WechatDaemon {
1020
1463
  slot.pendingUserInput = pending;
1021
1464
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
1022
1465
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1023
- 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);
1024
1467
  }));
1025
1468
  },
1026
1469
  mirroredUserInput: (next) => {
1027
1470
  appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1028
1471
  if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1029
1472
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1030
- 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);
1031
1474
  }));
1032
1475
  }
1033
1476
  },
@@ -1037,7 +1480,7 @@ class WechatDaemon {
1037
1480
  appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
1038
1481
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1039
1482
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1040
- 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);
1041
1484
  }));
1042
1485
  }
1043
1486
  },
@@ -1055,34 +1498,46 @@ class WechatDaemon {
1055
1498
  }
1056
1499
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1057
1500
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1058
- 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);
1059
1502
  }));
1060
1503
  }
1061
1504
  },
1062
1505
  taskComplete: () => {
1506
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1063
1507
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
1064
1508
  slot.pendingConfirmations = [];
1065
1509
  slot.pendingUserInput = null;
1066
- slot.activeTask = null;
1510
+ if (eventTask) {
1511
+ slot.turns.complete(eventTask);
1512
+ }
1513
+ void this.maybeDrainDeferredInputs(slot);
1067
1514
  }));
1068
1515
  },
1069
1516
  taskFailed: (next) => {
1517
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1070
1518
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1071
1519
  slot.pendingConfirmations = [];
1072
1520
  slot.pendingUserInput = null;
1073
- slot.activeTask = null;
1074
- 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);
1075
1526
  }));
1076
1527
  },
1077
1528
  fatalError: (next) => {
1529
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1078
1530
  logError(`${slot.adapter}: ${next.message}`);
1079
1531
  appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
1080
1532
  slot.pendingConfirmations = [];
1081
1533
  slot.pendingUserInput = null;
1082
- slot.activeTask = null;
1534
+ if (eventTask) {
1535
+ slot.turns.complete(eventTask);
1536
+ }
1537
+ void this.maybeDrainDeferredInputs(slot);
1083
1538
  this.disposeDeadSlot(slot);
1084
1539
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1085
- 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);
1086
1541
  }));
1087
1542
  },
1088
1543
  shutdownRequested: (next) => {
@@ -1094,9 +1549,19 @@ class WechatDaemon {
1094
1549
  appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
1095
1550
  });
1096
1551
  }
1552
+ resolveSlotOutputTarget(slot) {
1553
+ return slot.turns.resolveTarget(this.fallbackConversation);
1554
+ }
1555
+ bindCurrentWecomConversation(slot) {
1556
+ const conversation = this.inboundConversationContext.get();
1557
+ if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
1558
+ return;
1559
+ }
1560
+ slot.turns.observeConversation(conversation);
1561
+ }
1097
1562
  async handleInboundMessage(message) {
1098
1563
  if (message.senderId !== this.authorizedUserId) {
1099
- await this.queueWechatMessage(message.senderId, "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}.`);
1100
1565
  return;
1101
1566
  }
1102
1567
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1108,6 +1573,7 @@ class WechatDaemon {
1108
1573
  reuseExistingVisible: true,
1109
1574
  });
1110
1575
  if (result.activated) {
1576
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1111
1577
  message = { ...message, text: emojiMatch.remainder };
1112
1578
  }
1113
1579
  else {
@@ -1134,6 +1600,7 @@ class WechatDaemon {
1134
1600
  await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1135
1601
  return;
1136
1602
  }
1603
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1137
1604
  if (switchDirective.remainder) {
1138
1605
  message = { ...message, text: switchDirective.remainder };
1139
1606
  }
@@ -1143,7 +1610,7 @@ class WechatDaemon {
1143
1610
  }
1144
1611
  }
1145
1612
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
1146
- await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
1613
+ await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
1147
1614
  setTimeout(() => {
1148
1615
  void this.shutdown().finally(() => process.exit(0));
1149
1616
  }, 0);
@@ -1163,7 +1630,9 @@ class WechatDaemon {
1163
1630
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1164
1631
  return;
1165
1632
  }
1633
+ this.bindCurrentWecomConversation(slot);
1166
1634
  const routeCurrentMessage = async (currentSlot, command) => {
1635
+ const previousTask = currentSlot.turns.activeTask;
1167
1636
  await routeBridgeMessage({
1168
1637
  message: toChannelInboundMessage(message),
1169
1638
  authorized: true,
@@ -1192,6 +1661,13 @@ class WechatDaemon {
1192
1661
  await this.dispatchInboundWechatText(message, currentSlot);
1193
1662
  },
1194
1663
  });
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);
1670
+ }
1195
1671
  };
1196
1672
  const command = parseWechatControlCommand(message.text, {
1197
1673
  adapter: slot.adapter,
@@ -1215,6 +1691,7 @@ class WechatDaemon {
1215
1691
  return;
1216
1692
  }
1217
1693
  slot = this.getActiveSlot() ?? slot;
1694
+ this.bindCurrentWecomConversation(slot);
1218
1695
  await routeCurrentMessage(slot, null);
1219
1696
  }
1220
1697
  async handleEmojiBindingsCommand(senderId, cmd) {
@@ -1239,12 +1716,16 @@ class WechatDaemon {
1239
1716
  }
1240
1717
  async handleSystemCommand(message, activeSlot, command) {
1241
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;
1242
1723
  case "status":
1243
1724
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1244
1725
  return;
1245
1726
  case "resume": {
1246
1727
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1247
- await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; WeChat will follow that local session.`);
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.`);
1248
1729
  return;
1249
1730
  }
1250
1731
  try {
@@ -1262,9 +1743,10 @@ class WechatDaemon {
1262
1743
  if (command.target) {
1263
1744
  await resumeSlot.outputBatcher.flushNow();
1264
1745
  }
1746
+ const taskBeforeResume = resumeSlot.turns.activeTask;
1265
1747
  const result = await resumeSlot.resumeCoordinator.execute(command.target);
1266
- if (result.kind === "resumed") {
1267
- resumeSlot.activeTask = null;
1748
+ if (result.kind === "resumed" && taskBeforeResume) {
1749
+ resumeSlot.turns.complete(taskBeforeResume);
1268
1750
  }
1269
1751
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
1270
1752
  }
@@ -1286,6 +1768,9 @@ class WechatDaemon {
1286
1768
  await activeSlot.runtime.createSession();
1287
1769
  appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
1288
1770
  return;
1771
+ case "broadcast":
1772
+ await this.handleBroadcastCommand(message, command.text);
1773
+ return;
1289
1774
  case "stop": {
1290
1775
  const interrupted = await activeSlot.runtime.interrupt();
1291
1776
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
@@ -1327,10 +1812,10 @@ class WechatDaemon {
1327
1812
  }
1328
1813
  const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
1329
1814
  slot.pendingConfirmations = [];
1330
- slot.activeTask = {
1815
+ slot.turns.setActiveTask({
1331
1816
  startedAt: Date.now(),
1332
1817
  inputPreview: preview,
1333
- };
1818
+ });
1334
1819
  appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
1335
1820
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
1336
1821
  ? `${count} approvals confirmed. Continuing...`
@@ -1376,10 +1861,10 @@ class WechatDaemon {
1376
1861
  return;
1377
1862
  }
1378
1863
  slot.pendingUserInput = null;
1379
- slot.activeTask = {
1864
+ slot.turns.setActiveTask({
1380
1865
  startedAt: Date.now(),
1381
1866
  inputPreview: parsed.preview,
1382
- };
1867
+ });
1383
1868
  appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1384
1869
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1385
1870
  }
@@ -1401,14 +1886,79 @@ class WechatDaemon {
1401
1886
  }
1402
1887
  return this.slots.get(this.activeAdapter) ?? null;
1403
1888
  }
1404
- 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) {
1405
1939
  const preview = formatInboundMessagePreview(message);
1406
- slot.activeTask = {
1940
+ const nextTask = {
1407
1941
  startedAt: Date.now(),
1408
1942
  inputPreview: truncatePreview(preview, 180),
1409
1943
  };
1410
- appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1411
- await slot.runtime.sendInput(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;
1412
1962
  }
1413
1963
  queueWechatTextAction(action) {
1414
1964
  const run = this.textSendChain.then(action);
@@ -1420,58 +1970,38 @@ class WechatDaemon {
1420
1970
  this.attachmentSendChain = run.then(() => undefined, () => undefined);
1421
1971
  return run;
1422
1972
  }
1423
- async sendWechatMessageNow(senderId, text, context = "message") {
1424
- const startedAtMs = Date.now();
1425
- appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
1426
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1427
- try {
1428
- await this.transport.sendText(senderId, text);
1429
- appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1430
- return { status: "sent" };
1431
- }
1432
- catch (error) {
1433
- if (error instanceof Error && error.name === "AbortError") {
1434
- appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1435
- }
1436
- if (isWechatContextUnavailableError(error)) {
1437
- if (isWechatContextTokenStaleError(error)) {
1438
- this.transport.clearCachedContextToken(senderId);
1439
- }
1440
- appendDaemonLog(isWechatContextTokenStaleError(error)
1441
- ? formatWechatContextTokenStaleLogEntry({
1442
- context,
1443
- recipientId: senderId,
1444
- error,
1445
- })
1446
- : formatWechatSendFailureLogEntry({
1447
- context,
1448
- recipientId: senderId,
1449
- error,
1450
- }));
1451
- return { status: "context_unavailable", error };
1452
- }
1453
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1454
- const delayMs = computeWechatSendRetryDelayMs(attempt);
1455
- appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1456
- await delay(delayMs);
1457
- continue;
1458
- }
1459
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1460
- appendDaemonLog(formatWechatSendFailureLogEntry({
1461
- context,
1462
- recipientId: senderId,
1463
- error,
1464
- }));
1465
- return { status: "failed" };
1466
- }
1467
- }
1468
- return { status: "failed" };
1973
+ async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
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
+ });
1469
1993
  }
1470
- queueWechatMessage(senderId, text, context = "message") {
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;
1471
2001
  return this.queueWechatTextAction(async () => {
1472
- const result = await this.sendWechatMessageNow(senderId, text, context);
1473
- if (result.status === "context_unavailable") {
1474
- const pending = this.pendingWechatMessages.enqueue(senderId, text, context);
2002
+ const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
2003
+ if (result.status === "target_stale") {
2004
+ const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
1475
2005
  if (pending) {
1476
2006
  appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
1477
2007
  }
@@ -1482,13 +2012,13 @@ class WechatDaemon {
1482
2012
  flushPendingWechatMessages() {
1483
2013
  return this.queueWechatTextAction(async () => {
1484
2014
  for (const pending of this.pendingWechatMessages.list()) {
1485
- const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
2015
+ const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
1486
2016
  if (result.status === "sent") {
1487
2017
  this.pendingWechatMessages.remove(pending.id);
1488
2018
  appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
1489
2019
  continue;
1490
2020
  }
1491
- if (result.status === "context_unavailable") {
2021
+ if (result.status === "target_stale") {
1492
2022
  break;
1493
2023
  }
1494
2024
  appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -1793,7 +2323,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1793
2323
  return { action: "stopped", lock, forced };
1794
2324
  }
1795
2325
  export async function runDaemon(options) {
1796
- migrateLegacyChannelFiles((message) => log(message));
2326
+ if ((options.channelId ?? "wechat") === "wechat") {
2327
+ migrateLegacyChannelFiles((message) => log(message));
2328
+ }
1797
2329
  loadEmojiBindings();
1798
2330
  await cleanupDaemonBeforeStart({ cwd: options.cwd });
1799
2331
  const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
@@ -1809,19 +2341,37 @@ export async function runDaemon(options) {
1809
2341
  if (reapedOpencodePids.length > 0) {
1810
2342
  log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
1811
2343
  }
1812
- const credentials = await ensureWechatCredentials({
1813
- requireUserId: true,
1814
- validateExisting: true,
1815
- log,
1816
- });
2344
+ const wecomAccount = options.channelId === "wecom"
2345
+ ? await ensureWecomAccount({ log })
2346
+ : null;
2347
+ const credentials = wecomAccount
2348
+ ? { userId: wecomAccount.operatorUserId }
2349
+ : await ensureWechatCredentials({
2350
+ requireUserId: true,
2351
+ validateExisting: true,
2352
+ log,
2353
+ });
1817
2354
  if (!credentials.userId) {
1818
- throw new Error("Saved WeChat credentials are missing userId.");
2355
+ throw new Error(options.channelId === "wecom"
2356
+ ? "Saved WeCom credentials are missing operatorUserId."
2357
+ : "Saved WeChat credentials are missing userId.");
1819
2358
  }
1820
2359
  const daemon = new WechatDaemon({
1821
2360
  cwd: options.cwd,
1822
2361
  profile: options.profile,
1823
2362
  authorizedUserId: credentials.userId,
1824
2363
  transport: new WeChatTransport({ log, logError }),
2364
+ channelId: options.channelId,
2365
+ accountId: wecomAccount?.botId,
2366
+ wecomTransport: wecomAccount
2367
+ ? new WecomTransport({
2368
+ account: wecomAccount,
2369
+ logger: {
2370
+ log: (message) => log(message),
2371
+ error: (message) => logError(message),
2372
+ },
2373
+ })
2374
+ : null,
1825
2375
  });
1826
2376
  if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
1827
2377
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
@@ -1837,6 +2387,9 @@ export async function runDaemon(options) {
1837
2387
  }
1838
2388
  shutdownInProgress = true;
1839
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();
1840
2393
  void daemon.shutdown().finally(() => process.exit(0));
1841
2394
  };
1842
2395
  process.on("SIGINT", () => handleSignal("SIGINT"));
@@ -1871,7 +2424,7 @@ export async function main(argv = process.argv.slice(2)) {
1871
2424
  process.exit(1);
1872
2425
  }
1873
2426
  }
1874
- const isDirectRun = Boolean(import.meta.main);
2427
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1875
2428
  if (isDirectRun) {
1876
2429
  void main();
1877
2430
  }