@unlinearity/cli-wechat-bridge 1.1.5 → 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 (89) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +434 -427
  3. package/bin/_run-entry.mjs +47 -9
  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 +163 -5
  20. package/dist/bridge/bridge-adapters.codex.js +331 -29
  21. package/dist/bridge/bridge-adapters.core.js +27 -2
  22. package/dist/bridge/bridge-adapters.opencode.js +156 -1
  23. package/dist/bridge/bridge-adapters.pi.js +46 -1
  24. package/dist/bridge/bridge-adapters.shared.js +8 -1
  25. package/dist/bridge/bridge-controller.js +6 -0
  26. package/dist/bridge/bridge-final-reply.js +8 -65
  27. package/dist/bridge/bridge-process-reaper.js +41 -2
  28. package/dist/bridge/bridge-state.js +17 -4
  29. package/dist/bridge/bridge-utils.js +10 -127
  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 +520 -274
  34. package/dist/bridge/wechat-forwarding.js +8 -58
  35. package/dist/bridge/wechat-outbound-queue.js +8 -0
  36. package/dist/channels/wechat/bridge-final-reply.js +56 -0
  37. package/dist/channels/wechat/channel-message.js +24 -0
  38. package/dist/channels/wechat/index.js +11 -0
  39. package/dist/channels/wechat/wechat-channel-port.js +67 -0
  40. package/dist/channels/wechat/wechat-driver.js +191 -0
  41. package/dist/channels/wechat/wechat-forwarding.js +97 -0
  42. package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
  43. package/dist/channels/wecom/bridge-final-reply.js +32 -0
  44. package/dist/channels/wecom/setup-cli.js +12 -0
  45. package/dist/channels/wecom/setup.js +182 -0
  46. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  47. package/dist/channels/wecom/wecom-config.js +72 -0
  48. package/dist/channels/wecom/wecom-driver.js +106 -0
  49. package/dist/channels/wecom/wecom-media.js +147 -0
  50. package/dist/channels/wecom/wecom-message.js +149 -0
  51. package/dist/channels/wecom/wecom-transport.js +314 -0
  52. package/dist/commands/check-update.js +6 -0
  53. package/dist/companion/codex-remote-client.js +30 -3
  54. package/dist/companion/codex-visible-client-link.js +107 -0
  55. package/dist/companion/local-companion-link.js +6 -0
  56. package/dist/companion/local-companion-start.js +62 -11
  57. package/dist/companion/local-companion.js +25 -1
  58. package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
  59. package/dist/companion/pi-tui-bridge-extension.js +62 -3
  60. package/dist/core/bridge-control.js +42 -0
  61. package/dist/core/bridge-defer.js +25 -0
  62. package/dist/core/bridge-event-forwarder.js +50 -0
  63. package/dist/core/bridge-message-router.js +32 -0
  64. package/dist/core/channel-driver.js +1 -0
  65. package/dist/core/channel-types.js +3 -0
  66. package/dist/core/conversation-routing.js +58 -0
  67. package/dist/core/direct-run.js +23 -0
  68. package/dist/core/inbound-message-claims.js +67 -0
  69. package/dist/core/outbound-target.js +32 -0
  70. package/dist/core/text-utils.js +69 -0
  71. package/dist/core/turn-coordinator.js +85 -0
  72. package/dist/daemon/daemon-link.js +9 -0
  73. package/dist/daemon/emoji-bindings.js +6 -0
  74. package/dist/daemon/wechat-daemon.js +827 -209
  75. package/dist/i18n/index.js +6 -0
  76. package/dist/i18n/messages-en.js +11 -3
  77. package/dist/i18n/messages-zh.js +11 -3
  78. package/dist/runtime/create-runtime-host.js +6 -0
  79. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  80. package/dist/runtime/runtime-types.js +3 -0
  81. package/dist/utils/atomic-file.js +6 -0
  82. package/dist/utils/doctor.js +36 -10
  83. package/dist/utils/version-checker.js +17 -11
  84. package/dist/wechat/channel-config.js +6 -0
  85. package/dist/wechat/setup.js +119 -13
  86. package/dist/wechat/wechat-channel.js +6 -0
  87. package/dist/wechat/wechat-transport.js +169 -58
  88. package/package.json +14 -4
  89. 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";
@@ -8,17 +14,32 @@ import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
8
14
  import { t } from "../i18n/index.js";
9
15
  import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
10
16
  import { BridgeController } from "../bridge/bridge-controller.js";
11
- import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
12
17
  import { readBridgeLockFile, } from "../bridge/bridge-state.js";
13
18
  import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
14
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";
15
20
  import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
16
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-forwarding.js";
21
+ import { WECHAT_SEND_CONTEXTS, formatUserFacingBridgeFatalError, formatUserFacingInboundError, isWechatSendContext, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
17
22
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
18
23
  import { ensureWechatCredentials } from "../wechat/setup.js";
19
24
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
20
- 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";
26
+ import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
21
27
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
28
+ import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
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";
35
+ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
36
+ import { isDirectModuleRun } from "../core/direct-run.js";
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";
22
43
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
23
44
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
24
45
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -35,12 +56,17 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
35
56
  const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
36
57
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
37
58
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
59
+ const MAX_DAEMON_DEFERRED_INPUTS = 32;
38
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
+ }
39
65
  function log(message) {
40
- process.stderr.write(`[wechat-daemon] ${message}\n`);
66
+ process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
41
67
  }
42
68
  function logError(message) {
43
- process.stderr.write(`[wechat-daemon] ERROR: ${message}\n`);
69
+ process.stderr.write(`[${getCliChannelId()}-daemon] ERROR: ${message}\n`);
44
70
  }
45
71
  function appendDaemonLog(message) {
46
72
  ensureChannelDataDir();
@@ -51,6 +77,32 @@ function computePollRetryDelayMs(consecutiveFailures) {
51
77
  const exponent = Math.min(normalizedFailures - 1, 5);
52
78
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
53
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
+ }
54
106
  function isDaemonAdapterKind(value) {
55
107
  return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
56
108
  }
@@ -72,6 +124,7 @@ export function parseDaemonCliArgs(argv) {
72
124
  let profile;
73
125
  let initialAdapter;
74
126
  let openVisible = true;
127
+ let channelId = "wechat";
75
128
  for (let i = 0; i < argv.length; i += 1) {
76
129
  const arg = argv[i];
77
130
  if (!arg) {
@@ -80,10 +133,10 @@ export function parseDaemonCliArgs(argv) {
80
133
  const next = argv[i + 1];
81
134
  if (arg === "--help" || arg === "-h") {
82
135
  process.stdout.write([
83
- "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]`,
84
137
  "",
85
- "Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
86
- "Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
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.`,
87
140
  "",
88
141
  ].join("\n"));
89
142
  process.exit(0);
@@ -96,6 +149,14 @@ export function parseDaemonCliArgs(argv) {
96
149
  i += 1;
97
150
  continue;
98
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
+ }
99
160
  if (arg === "--adapter") {
100
161
  if (!isDaemonAdapterKind(next)) {
101
162
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -118,7 +179,13 @@ export function parseDaemonCliArgs(argv) {
118
179
  }
119
180
  throw new Error(`Unknown argument: ${arg}`);
120
181
  }
121
- return { cwd, profile, initialAdapter, openVisible };
182
+ return {
183
+ cwd,
184
+ profile,
185
+ initialAdapter,
186
+ openVisible,
187
+ ...(channelId === "wecom" ? { channelId } : {}),
188
+ };
122
189
  }
123
190
  export function parseDaemonSwitchCommand(text) {
124
191
  const normalized = text.trim().toLowerCase();
@@ -147,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
147
214
  remainder: match[2]?.trim() ?? "",
148
215
  };
149
216
  }
150
- export function defaultDaemonSessionStartMode(adapter) {
151
- 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";
152
224
  }
153
225
  export function resolveDaemonSessionStartMode(params) {
154
226
  // An explicit --session-start-mode wins over visible-client reuse: the user
@@ -161,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
161
233
  if (params.reuseExistingVisible && params.visibleConnected) {
162
234
  return "restore";
163
235
  }
164
- if (params.adapter === "codex") {
165
- return "restore";
166
- }
167
236
  if (params.slotCreated) {
168
237
  return "new";
169
238
  }
@@ -217,7 +286,7 @@ export function buildVisibleClientLaunchArgs(params) {
217
286
  export function buildWindowsVisibleClientLaunchCommand(params) {
218
287
  return [
219
288
  "start",
220
- quoteWindowsCommandArg(`wechat-${params.adapter}`),
289
+ quoteWindowsCommandArg(`${params.channelId ?? "wechat"}-${params.adapter}`),
221
290
  "/D",
222
291
  quoteWindowsCommandArg(params.cwd),
223
292
  quoteWindowsCommandArg(process.execPath),
@@ -265,6 +334,7 @@ function openVisibleClient(params) {
265
334
  adapter: params.adapter,
266
335
  cwd: params.cwd,
267
336
  args,
337
+ channelId: params.channelId,
268
338
  }),
269
339
  ];
270
340
  const child = spawn(command, launchArgs, {
@@ -285,13 +355,13 @@ function openVisibleClient(params) {
285
355
  pid: child.pid,
286
356
  };
287
357
  }
288
- const title = `wechat-${params.adapter}`;
358
+ const title = `${params.channelId ?? "wechat"}-${params.adapter}`;
289
359
  const fullArgs = [process.execPath, ...args];
290
360
  if (process.platform === "darwin") {
291
361
  const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
292
- const script = `tell application "Terminal"
293
- activate
294
- 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}"
295
365
  end tell`;
296
366
  const child = spawn("osascript", ["-e", script], {
297
367
  cwd: params.cwd,
@@ -477,11 +547,16 @@ export function formatDaemonStatus(status) {
477
547
  }
478
548
  return lines.join("\n");
479
549
  }
480
- class WechatDaemon {
550
+ export class WechatDaemon {
481
551
  cwd;
482
552
  profile;
483
553
  authorizedUserId;
484
554
  transport;
555
+ channelDriver;
556
+ channelId;
557
+ wecomTransport;
558
+ inboundConversationContext = new InboundConversationContext();
559
+ fallbackConversation;
485
560
  slots = new Map();
486
561
  // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
487
562
  slotEnsureChains = new Map();
@@ -489,10 +564,14 @@ class WechatDaemon {
489
564
  bridgeStartedAtMs = Date.now();
490
565
  backlogNoticeSent = false;
491
566
  activeAdapter = null;
567
+ activeAdapterVersion = 0;
492
568
  takenOverAdapter;
493
569
  textSendChain = Promise.resolve();
494
570
  attachmentSendChain = Promise.resolve();
495
571
  pendingWechatForwardTasks = new Set();
572
+ slotInputChains = new Map();
573
+ pendingWechatMessages;
574
+ deps;
496
575
  shutdownPromise = null;
497
576
  ipcServer = null;
498
577
  endpointToken = "";
@@ -501,6 +580,39 @@ class WechatDaemon {
501
580
  this.profile = params.profile;
502
581
  this.authorizedUserId = params.authorizedUserId;
503
582
  this.transport = params.transport;
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);
504
616
  }
505
617
  async startIpcServer() {
506
618
  this.endpointToken = buildDaemonToken();
@@ -548,6 +660,7 @@ class WechatDaemon {
548
660
  token: this.endpointToken,
549
661
  cwd: this.cwd,
550
662
  startedAt: this.startedAt,
663
+ channelId: this.channelId,
551
664
  });
552
665
  resolve();
553
666
  });
@@ -556,6 +669,7 @@ class WechatDaemon {
556
669
  getStatus() {
557
670
  return {
558
671
  cwd: this.cwd,
672
+ channelId: this.channelId,
559
673
  activeAdapter: this.activeAdapter ?? undefined,
560
674
  startedAt: this.startedAt,
561
675
  slots: Array.from(this.slots.values()).map((slot) => {
@@ -584,10 +698,49 @@ class WechatDaemon {
584
698
  }
585
699
  async runPollLoop() {
586
700
  let consecutivePollFailures = 0;
587
- 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.`);
588
741
  log(`Working directory: ${this.cwd}`);
589
- log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
590
- 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}`);
591
744
  const activeSlot = this.getActiveSlot();
592
745
  const welcomeText = t("daemon.welcome", {
593
746
  cwd: this.cwd,
@@ -595,6 +748,13 @@ class WechatDaemon {
595
748
  bindings: formatBindingsListMessage(listBindings()),
596
749
  });
597
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
+ }
598
758
  while (!this.shutdownPromise) {
599
759
  let pollResult;
600
760
  try {
@@ -624,6 +784,9 @@ class WechatDaemon {
624
784
  appendDaemonLog(`poll_recovered: failures=${consecutivePollFailures}`);
625
785
  consecutivePollFailures = 0;
626
786
  }
787
+ if (pollResult.messages.length > 0 && this.pendingWechatMessages.list().length > 0) {
788
+ await this.flushPendingWechatMessages();
789
+ }
627
790
  if (pollResult.ignoredBacklogCount > 0) {
628
791
  appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
629
792
  if (!this.backlogNoticeSent) {
@@ -685,6 +848,29 @@ class WechatDaemon {
685
848
  }
686
849
  async cleanup() {
687
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();
688
874
  for (const slot of this.slots.values()) {
689
875
  try {
690
876
  await slot.outputBatcher.flushNow();
@@ -727,7 +913,7 @@ class WechatDaemon {
727
913
  return { shuttingDown: true };
728
914
  case "ensure_slot":
729
915
  if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
730
- 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}.`);
731
917
  }
732
918
  return await this.ensureSlot(request.adapter, {
733
919
  profile: request.profile,
@@ -744,6 +930,244 @@ class WechatDaemon {
744
930
  sessionStartMode: request.sessionStartMode,
745
931
  reuseExistingVisible: request.reuseExistingVisible ?? true,
746
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;
747
1171
  }
748
1172
  }
749
1173
  async ensureSlot(adapter, options = {}) {
@@ -784,7 +1208,7 @@ class WechatDaemon {
784
1208
  visibleEndpointBeforeProbe?.companionConnectedAt ||
785
1209
  visibleEndpointBeforeProbe?.sharedThreadId ||
786
1210
  visibleEndpointBeforeProbe?.sharedSessionId);
787
- let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
1211
+ let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
788
1212
  if (shouldRestartDeadCodexVisibleRuntime({
789
1213
  adapter,
790
1214
  slotCreated: created,
@@ -813,12 +1237,17 @@ class WechatDaemon {
813
1237
  appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
814
1238
  }
815
1239
  if (options.openVisible !== false && !visibleConnected) {
1240
+ const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
816
1241
  slot.controller.syncLocalClientEndpoint();
817
- const launch = openVisibleClient({
1242
+ const launch = this.openVisibleClient({
818
1243
  adapter,
819
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.
820
1248
  sessionStartMode,
821
1249
  cliArgs: options.cliArgs,
1250
+ channelId: this.channelId,
822
1251
  onError: (error) => {
823
1252
  appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
824
1253
  },
@@ -828,14 +1257,17 @@ class WechatDaemon {
828
1257
  visibleConnected = await waitForVisibleClientConnection({
829
1258
  cwd: this.cwd,
830
1259
  adapter,
1260
+ timeoutMs: this.deps.visibleClientConnectTimeoutMs,
1261
+ }, {
1262
+ isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
831
1263
  });
832
1264
  if (visibleConnected) {
833
1265
  appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
834
1266
  }
835
1267
  else {
836
- 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}.`);
837
1269
  const cleanedLauncher = cleanupVisibleClientLauncher(launch);
838
- 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}`);
839
1271
  }
840
1272
  }
841
1273
  let visibleReady = visibleConnected;
@@ -859,7 +1291,11 @@ class WechatDaemon {
859
1291
  }
860
1292
  const activated = options.openVisible === false || (visibleConnected && visibleReady);
861
1293
  if (activated) {
1294
+ const previousSlot = this.getActiveSlot();
1295
+ if (previousSlot && this.activeAdapter !== adapter)
1296
+ invalidateModelSnapshot(previousSlot.runtime);
862
1297
  this.activeAdapter = adapter;
1298
+ this.activeAdapterVersion += 1;
863
1299
  }
864
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}`);
865
1301
  return {
@@ -869,12 +1305,13 @@ class WechatDaemon {
869
1305
  visibleConnected,
870
1306
  visibleReady,
871
1307
  activated,
1308
+ activationVersion: this.activeAdapterVersion,
872
1309
  previousActiveAdapter,
873
1310
  };
874
1311
  }
875
1312
  async createSlot(adapter, options) {
876
1313
  clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
877
- const runtime = createRuntimeHost({
1314
+ const runtime = this.createRuntime({
878
1315
  kind: adapter,
879
1316
  command: resolveDefaultAdapterCommand(adapter),
880
1317
  cwd: this.cwd,
@@ -889,7 +1326,7 @@ class WechatDaemon {
889
1326
  runtime,
890
1327
  controller,
891
1328
  outputBatcher: new OutputBatcher(async (text) => {
892
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
1329
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
893
1330
  }),
894
1331
  pendingConfirmations: [],
895
1332
  pendingUserInput: null,
@@ -897,8 +1334,14 @@ class WechatDaemon {
897
1334
  adapter,
898
1335
  runtime,
899
1336
  }),
900
- activeTask: null,
1337
+ turns: new TurnCoordinator({
1338
+ restoreLastConversationOnFailure: true,
1339
+ }),
901
1340
  lastOutputAt: 0,
1341
+ lastFinalReplyAtMs: 0,
1342
+ eventForwardChain: Promise.resolve(),
1343
+ deferredInputs: [],
1344
+ drainingDeferredInputs: false,
902
1345
  };
903
1346
  runtime.setEventSink((event) => {
904
1347
  this.handleSlotEvent(slot, event);
@@ -913,7 +1356,7 @@ class WechatDaemon {
913
1356
  slot.outputBatcher.clear();
914
1357
  slot.pendingConfirmations = [];
915
1358
  slot.pendingUserInput = null;
916
- slot.activeTask = null;
1359
+ slot.turns.complete();
917
1360
  if (slot.adapter === "codex" || slot.adapter === "claude") {
918
1361
  await slot.runtime.reset();
919
1362
  }
@@ -925,166 +1368,200 @@ class WechatDaemon {
925
1368
  }
926
1369
  slot.controller.syncLocalClientEndpoint();
927
1370
  }
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
+ }
1382
+ return new WechatChannelPort({
1383
+ sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
1384
+ sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
1385
+ sendFile: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, { recipientId })),
1386
+ sendVoice: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(filePath, recipientId)),
1387
+ sendVideo: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(filePath, { recipientId })),
1388
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1389
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
1390
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
1391
+ },
1392
+ onTextSent: (currentAdapter, text) => {
1393
+ appendDaemonLog(`final_reply_sent: adapter=${currentAdapter ?? adapter} chars=${Array.from(text).length}`);
1394
+ },
1395
+ });
1396
+ }
928
1397
  handleSlotEvent(slot, event) {
929
1398
  slot.controller.syncLocalClientEndpoint();
930
1399
  const adapterState = slot.runtime.getState();
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;
931
1405
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
932
1406
  slot.pendingConfirmations = [];
933
1407
  }
934
1408
  if (slot.pendingUserInput && !adapterState.pendingUserInput) {
935
1409
  slot.pendingUserInput = null;
936
1410
  }
937
- switch (event.type) {
938
- case "stdout":
939
- case "stderr":
1411
+ slot.eventForwardChain = slot.eventForwardChain
1412
+ .then(() => forwardBridgeEvent(event, {
1413
+ stdout: (next) => {
940
1414
  slot.lastOutputAt = Date.now();
941
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
942
- slot.outputBatcher.push(event.text);
1415
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1416
+ slot.outputBatcher.push(next.text);
943
1417
  }
944
- break;
945
- case "final_reply":
946
- appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
1418
+ },
1419
+ stderr: (next) => {
1420
+ slot.lastOutputAt = Date.now();
1421
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1422
+ slot.outputBatcher.push(next.text);
1423
+ }
1424
+ },
1425
+ finalReply: (next) => {
1426
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1427
+ slot.lastFinalReplyAtMs = Date.now();
1428
+ appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
947
1429
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
948
- await forwardWechatFinalReply({
1430
+ await channelPort.send({
1431
+ target: eventTarget,
1432
+ kind: "final_reply",
1433
+ text: next.text,
949
1434
  adapter: slot.adapter,
950
- rawText: event.text,
951
- onEmptyVisibleReply: ({ rawVisibleText }) => {
952
- appendDaemonLog(`empty_visible_final_reply: adapter=${slot.adapter} raw=${truncatePreview(rawVisibleText)}`);
953
- },
954
- sender: {
955
- sendText: async (text) => {
956
- const sent = await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, text), "final_reply");
957
- if (sent) {
958
- appendDaemonLog(`final_reply_sent: adapter=${slot.adapter} chars=${Array.from(text).length}`);
959
- }
960
- return sent;
961
- },
962
- sendImage: (imagePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(imagePath, {
963
- recipientId: this.authorizedUserId,
964
- })),
965
- sendFile: (filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, {
966
- recipientId: this.authorizedUserId,
967
- })),
968
- sendVoice: (voicePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(voicePath, this.authorizedUserId)),
969
- sendVideo: (videoPath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(videoPath, {
970
- recipientId: this.authorizedUserId,
971
- })),
972
- },
973
1435
  });
974
1436
  }));
975
- break;
976
- case "status":
977
- if (event.message) {
978
- log(`${slot.adapter} ${event.status}: ${event.message}`);
979
- appendDaemonLog(`${slot.adapter}_${event.status}: ${event.message}`);
1437
+ },
1438
+ status: (next) => {
1439
+ if (next.message) {
1440
+ log(`${slot.adapter} ${next.status}: ${next.message}`);
1441
+ appendDaemonLog(`${slot.adapter}_${next.status}: ${next.message}`);
980
1442
  }
981
- break;
982
- case "notice":
1443
+ },
1444
+ notice: (next) => {
983
1445
  slot.lastOutputAt = Date.now();
984
- appendDaemonLog(`${slot.adapter}_${event.level}_notice: ${truncatePreview(event.text)}`);
985
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
1446
+ appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
1447
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
986
1448
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
987
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, event.text), "notice");
1449
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
988
1450
  }));
989
1451
  }
990
- break;
991
- case "approval_required":
1452
+ },
1453
+ approvalRequired: (next) => {
992
1454
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
993
- const pending = toPendingApproval(event);
1455
+ const pending = toPendingApproval(next);
994
1456
  slot.pendingConfirmations.push(pending);
995
1457
  appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
996
- 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);
997
1459
  }));
998
- break;
999
- case "user_input_required": {
1000
- // Mirror the pending request synchronously, before the async WeChat
1001
- // send (which retries with backoff). Otherwise a fast user reply can
1002
- // race past the still-unwritten mirror and be dispatched as plain
1003
- // input into a CLI that is waiting for a structured form answer.
1004
- const pending = toPendingUserInput(event.request);
1460
+ },
1461
+ userInputRequired: (next) => {
1462
+ const pending = toPendingUserInput(next.request);
1005
1463
  slot.pendingUserInput = pending;
1006
1464
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
1007
1465
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1008
- 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);
1009
1467
  }));
1010
- break;
1011
- }
1012
- case "mirrored_user_input":
1013
- appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
1014
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
1468
+ },
1469
+ mirroredUserInput: (next) => {
1470
+ appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1471
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1015
1472
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1016
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, event.text)), "mirrored_user_input");
1473
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input", eventTarget);
1017
1474
  }));
1018
1475
  }
1019
- break;
1020
- case "session_switched":
1021
- if (event.source === "local") {
1476
+ },
1477
+ sessionSwitched: (next) => {
1478
+ if (next.source === "local")
1022
1479
  slot.resumeCoordinator.clear();
1023
- }
1024
- appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${event.sessionId} source=${event.source} reason=${event.reason}`);
1025
- if (shouldForwardSessionSwitchEvent(event.reason) &&
1026
- shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1480
+ appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
1481
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1027
1482
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1028
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
1029
- adapter: slot.adapter,
1030
- sessionId: event.sessionId,
1031
- source: event.source,
1032
- reason: event.reason,
1033
- })), "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);
1034
1484
  }));
1035
1485
  }
1036
- break;
1037
- case "thread_switched":
1038
- if (event.source === "local") {
1486
+ },
1487
+ threadSwitched: (next) => {
1488
+ if (next.source === "local")
1039
1489
  slot.resumeCoordinator.clear();
1490
+ appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${next.threadId} source=${next.source} reason=${next.reason}`);
1491
+ if (shouldSuppressCodexLocalThreadNotice({
1492
+ adapter: slot.adapter,
1493
+ source: next.source,
1494
+ activeTurnOrigin: slot.runtime.getState().activeTurnOrigin,
1495
+ lastFinalReplyAtMs: slot.lastFinalReplyAtMs,
1496
+ })) {
1497
+ return;
1040
1498
  }
1041
- appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${event.threadId} source=${event.source} reason=${event.reason}`);
1042
- if (shouldForwardSessionSwitchEvent(event.reason) &&
1043
- shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1499
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1044
1500
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1045
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
1046
- adapter: slot.adapter,
1047
- sessionId: event.threadId,
1048
- source: event.source,
1049
- reason: event.reason,
1050
- })), "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);
1051
1502
  }));
1052
1503
  }
1053
- break;
1054
- case "task_complete":
1504
+ },
1505
+ taskComplete: () => {
1506
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1055
1507
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
1056
1508
  slot.pendingConfirmations = [];
1057
1509
  slot.pendingUserInput = null;
1058
- slot.activeTask = null;
1510
+ if (eventTask) {
1511
+ slot.turns.complete(eventTask);
1512
+ }
1513
+ void this.maybeDrainDeferredInputs(slot);
1059
1514
  }));
1060
- break;
1061
- case "task_failed":
1515
+ },
1516
+ taskFailed: (next) => {
1517
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1062
1518
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1063
1519
  slot.pendingConfirmations = [];
1064
1520
  slot.pendingUserInput = null;
1065
- slot.activeTask = null;
1066
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, event.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);
1067
1526
  }));
1068
- break;
1069
- case "fatal_error":
1070
- logError(`${slot.adapter}: ${event.message}`);
1071
- appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${event.message}`);
1527
+ },
1528
+ fatalError: (next) => {
1529
+ void this.channelDriver.endTyping?.(this.authorizedUserId);
1530
+ logError(`${slot.adapter}: ${next.message}`);
1531
+ appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
1072
1532
  slot.pendingConfirmations = [];
1073
1533
  slot.pendingUserInput = null;
1074
- slot.activeTask = null;
1534
+ if (eventTask) {
1535
+ slot.turns.complete(eventTask);
1536
+ }
1537
+ void this.maybeDrainDeferredInputs(slot);
1075
1538
  this.disposeDeadSlot(slot);
1076
1539
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1077
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
1540
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
1078
1541
  }));
1079
- break;
1080
- case "shutdown_requested":
1081
- appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${event.reason}`);
1082
- break;
1542
+ },
1543
+ shutdownRequested: (next) => {
1544
+ appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${next.reason}`);
1545
+ },
1546
+ }))
1547
+ .catch((error) => {
1548
+ const message = error instanceof Error ? error.message : String(error);
1549
+ appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
1550
+ });
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;
1083
1559
  }
1560
+ slot.turns.observeConversation(conversation);
1084
1561
  }
1085
1562
  async handleInboundMessage(message) {
1086
1563
  if (message.senderId !== this.authorizedUserId) {
1087
- 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}.`);
1088
1565
  return;
1089
1566
  }
1090
1567
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1096,6 +1573,7 @@ class WechatDaemon {
1096
1573
  reuseExistingVisible: true,
1097
1574
  });
1098
1575
  if (result.activated) {
1576
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1099
1577
  message = { ...message, text: emojiMatch.remainder };
1100
1578
  }
1101
1579
  else {
@@ -1122,6 +1600,7 @@ class WechatDaemon {
1122
1600
  await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1123
1601
  return;
1124
1602
  }
1603
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1125
1604
  if (switchDirective.remainder) {
1126
1605
  message = { ...message, text: switchDirective.remainder };
1127
1606
  }
@@ -1131,7 +1610,7 @@ class WechatDaemon {
1131
1610
  }
1132
1611
  }
1133
1612
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
1134
- await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
1613
+ await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
1135
1614
  setTimeout(() => {
1136
1615
  void this.shutdown().finally(() => process.exit(0));
1137
1616
  }, 0);
@@ -1151,21 +1630,56 @@ class WechatDaemon {
1151
1630
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1152
1631
  return;
1153
1632
  }
1633
+ this.bindCurrentWecomConversation(slot);
1634
+ const routeCurrentMessage = async (currentSlot, command) => {
1635
+ const previousTask = currentSlot.turns.activeTask;
1636
+ await routeBridgeMessage({
1637
+ message: toChannelInboundMessage(message),
1638
+ authorized: true,
1639
+ command,
1640
+ adapterState: currentSlot.runtime.getState(),
1641
+ hasPendingApproval: currentSlot.pendingConfirmations.length > 0,
1642
+ hasPendingUserInput: Boolean(currentSlot.pendingUserInput),
1643
+ onUnauthorized: async () => undefined,
1644
+ handleCommand: async (nextCommand) => {
1645
+ await this.handleSystemCommand(message, currentSlot, nextCommand);
1646
+ return true;
1647
+ },
1648
+ remindPendingApproval: async () => {
1649
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, formatPendingApprovalReminder(currentSlot.pendingConfirmations[0], currentSlot.runtime.getState())));
1650
+ },
1651
+ remindPendingUserInput: async () => {
1652
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, currentSlot.pendingUserInput
1653
+ ? formatPendingUserInputReminder(currentSlot.pendingUserInput)
1654
+ : `${currentSlot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1655
+ },
1656
+ remindBusy: async () => {
1657
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, `${currentSlot.adapter} is still working. Wait for the current reply or use /stop.`));
1658
+ },
1659
+ defer: async () => undefined,
1660
+ dispatch: async () => {
1661
+ await this.dispatchInboundWechatText(message, currentSlot);
1662
+ },
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
+ }
1671
+ };
1154
1672
  const command = parseWechatControlCommand(message.text, {
1155
1673
  adapter: slot.adapter,
1156
1674
  hasPendingConfirmation: slot.pendingConfirmations.length > 0,
1157
1675
  hasPendingUserInput: Boolean(slot.pendingUserInput),
1158
1676
  });
1159
- if (command) {
1160
- await this.handleSystemCommand(message, slot, command);
1161
- return;
1162
- }
1163
- if (slot.pendingConfirmations.length > 0) {
1164
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingApprovalReminder(slot.pendingConfirmations[0], slot.runtime.getState())));
1165
- return;
1166
- }
1167
- if (slot.pendingUserInput) {
1168
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
1677
+ const slotState = slot.runtime.getState();
1678
+ if (command ||
1679
+ slot.pendingConfirmations.length > 0 ||
1680
+ slot.pendingUserInput ||
1681
+ slotState.status === "awaiting_input") {
1682
+ await routeCurrentMessage(slot, command);
1169
1683
  return;
1170
1684
  }
1171
1685
  const visibleResult = await this.ensureSlot(slot.adapter, {
@@ -1177,21 +1691,8 @@ class WechatDaemon {
1177
1691
  return;
1178
1692
  }
1179
1693
  slot = this.getActiveSlot() ?? slot;
1180
- const adapterState = slot.runtime.getState();
1181
- if (adapterState.status === "awaiting_input") {
1182
- // Belt-and-braces with the pendingUserInput mirror above: the adapter
1183
- // state is authoritative even when the mirror was cleared or the WeChat
1184
- // notification is still queued.
1185
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1186
- ? formatPendingUserInputReminder(slot.pendingUserInput)
1187
- : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1188
- return;
1189
- }
1190
- if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1191
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1192
- return;
1193
- }
1194
- await this.dispatchInboundWechatText(message, slot);
1694
+ this.bindCurrentWecomConversation(slot);
1695
+ await routeCurrentMessage(slot, null);
1195
1696
  }
1196
1697
  async handleEmojiBindingsCommand(senderId, cmd) {
1197
1698
  switch (cmd.type) {
@@ -1215,12 +1716,16 @@ class WechatDaemon {
1215
1716
  }
1216
1717
  async handleSystemCommand(message, activeSlot, command) {
1217
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;
1218
1723
  case "status":
1219
1724
  await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
1220
1725
  return;
1221
1726
  case "resume": {
1222
1727
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1223
- 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.`);
1224
1729
  return;
1225
1730
  }
1226
1731
  try {
@@ -1238,9 +1743,10 @@ class WechatDaemon {
1238
1743
  if (command.target) {
1239
1744
  await resumeSlot.outputBatcher.flushNow();
1240
1745
  }
1746
+ const taskBeforeResume = resumeSlot.turns.activeTask;
1241
1747
  const result = await resumeSlot.resumeCoordinator.execute(command.target);
1242
- if (result.kind === "resumed") {
1243
- resumeSlot.activeTask = null;
1748
+ if (result.kind === "resumed" && taskBeforeResume) {
1749
+ resumeSlot.turns.complete(taskBeforeResume);
1244
1750
  }
1245
1751
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
1246
1752
  }
@@ -1262,6 +1768,9 @@ class WechatDaemon {
1262
1768
  await activeSlot.runtime.createSession();
1263
1769
  appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
1264
1770
  return;
1771
+ case "broadcast":
1772
+ await this.handleBroadcastCommand(message, command.text);
1773
+ return;
1265
1774
  case "stop": {
1266
1775
  const interrupted = await activeSlot.runtime.interrupt();
1267
1776
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
@@ -1303,10 +1812,10 @@ class WechatDaemon {
1303
1812
  }
1304
1813
  const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
1305
1814
  slot.pendingConfirmations = [];
1306
- slot.activeTask = {
1815
+ slot.turns.setActiveTask({
1307
1816
  startedAt: Date.now(),
1308
1817
  inputPreview: preview,
1309
- };
1818
+ });
1310
1819
  appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
1311
1820
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
1312
1821
  ? `${count} approvals confirmed. Continuing...`
@@ -1352,10 +1861,10 @@ class WechatDaemon {
1352
1861
  return;
1353
1862
  }
1354
1863
  slot.pendingUserInput = null;
1355
- slot.activeTask = {
1864
+ slot.turns.setActiveTask({
1356
1865
  startedAt: Date.now(),
1357
1866
  inputPreview: parsed.preview,
1358
- };
1867
+ });
1359
1868
  appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
1360
1869
  await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
1361
1870
  }
@@ -1377,14 +1886,79 @@ class WechatDaemon {
1377
1886
  }
1378
1887
  return this.slots.get(this.activeAdapter) ?? null;
1379
1888
  }
1380
- 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) {
1381
1939
  const preview = formatInboundMessagePreview(message);
1382
- slot.activeTask = {
1940
+ const nextTask = {
1383
1941
  startedAt: Date.now(),
1384
1942
  inputPreview: truncatePreview(preview, 180),
1385
1943
  };
1386
- appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1387
- 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;
1388
1962
  }
1389
1963
  queueWechatTextAction(action) {
1390
1964
  const run = this.textSendChain.then(action);
@@ -1396,39 +1970,60 @@ class WechatDaemon {
1396
1970
  this.attachmentSendChain = run.then(() => undefined, () => undefined);
1397
1971
  return run;
1398
1972
  }
1399
- queueWechatMessage(senderId, text, context = "message") {
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
+ });
1993
+ }
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;
1400
2001
  return this.queueWechatTextAction(async () => {
1401
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1402
- try {
1403
- await this.transport.sendText(senderId, text);
1404
- return true;
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);
2005
+ if (pending) {
2006
+ appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
1405
2007
  }
1406
- catch (error) {
1407
- if (isWechatContextTokenStaleError(error)) {
1408
- this.transport.clearCachedContextToken(senderId);
1409
- appendDaemonLog(formatWechatContextTokenStaleLogEntry({
1410
- context,
1411
- recipientId: senderId,
1412
- error,
1413
- }));
1414
- return false;
1415
- }
1416
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1417
- const delayMs = computeWechatSendRetryDelayMs(attempt);
1418
- appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1419
- await delay(delayMs);
1420
- continue;
1421
- }
1422
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1423
- appendDaemonLog(formatWechatSendFailureLogEntry({
1424
- context,
1425
- recipientId: senderId,
1426
- error,
1427
- }));
1428
- return false;
2008
+ }
2009
+ return result.status === "sent";
2010
+ });
2011
+ }
2012
+ flushPendingWechatMessages() {
2013
+ return this.queueWechatTextAction(async () => {
2014
+ for (const pending of this.pendingWechatMessages.list()) {
2015
+ const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
2016
+ if (result.status === "sent") {
2017
+ this.pendingWechatMessages.remove(pending.id);
2018
+ appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
2019
+ continue;
2020
+ }
2021
+ if (result.status === "target_stale") {
2022
+ break;
1429
2023
  }
2024
+ appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
2025
+ break;
1430
2026
  }
1431
- return false;
1432
2027
  });
1433
2028
  }
1434
2029
  trackWechatForwardTask(task) {
@@ -1728,7 +2323,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1728
2323
  return { action: "stopped", lock, forced };
1729
2324
  }
1730
2325
  export async function runDaemon(options) {
1731
- migrateLegacyChannelFiles((message) => log(message));
2326
+ if ((options.channelId ?? "wechat") === "wechat") {
2327
+ migrateLegacyChannelFiles((message) => log(message));
2328
+ }
1732
2329
  loadEmojiBindings();
1733
2330
  await cleanupDaemonBeforeStart({ cwd: options.cwd });
1734
2331
  const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
@@ -1744,19 +2341,37 @@ export async function runDaemon(options) {
1744
2341
  if (reapedOpencodePids.length > 0) {
1745
2342
  log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
1746
2343
  }
1747
- const credentials = await ensureWechatCredentials({
1748
- requireUserId: true,
1749
- validateExisting: true,
1750
- log,
1751
- });
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
+ });
1752
2354
  if (!credentials.userId) {
1753
- 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.");
1754
2358
  }
1755
2359
  const daemon = new WechatDaemon({
1756
2360
  cwd: options.cwd,
1757
2361
  profile: options.profile,
1758
2362
  authorizedUserId: credentials.userId,
1759
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,
1760
2375
  });
1761
2376
  if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
1762
2377
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
@@ -1772,6 +2387,9 @@ export async function runDaemon(options) {
1772
2387
  }
1773
2388
  shutdownInProgress = true;
1774
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();
1775
2393
  void daemon.shutdown().finally(() => process.exit(0));
1776
2394
  };
1777
2395
  process.on("SIGINT", () => handleSignal("SIGINT"));
@@ -1806,7 +2424,7 @@ export async function main(argv = process.argv.slice(2)) {
1806
2424
  process.exit(1);
1807
2425
  }
1808
2426
  }
1809
- const isDirectRun = Boolean(import.meta.main);
2427
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1810
2428
  if (isDirectRun) {
1811
2429
  void main();
1812
2430
  }