@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,21 +1,40 @@
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 path from "node:path";
3
9
  import { resolveDefaultAdapterCommand, } from "./bridge-adapters.js";
4
10
  import { delay } from "./bridge-adapters.shared.js";
5
11
  import { t } from "../i18n/index.js";
6
12
  import { BridgeController } from "./bridge-controller.js";
7
- import { forwardWechatFinalReply } from "./bridge-final-reply.js";
8
13
  import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "./bridge-session-resume.js";
9
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, formatWechatSendRetryLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "./wechat-forwarding.js";
14
+ import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
10
15
  import { ensureWechatCredentials } from "../wechat/setup.js";
11
16
  import { BridgeStateStore } from "./bridge-state.js";
12
17
  import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "./bridge-process-reaper.js";
13
18
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
19
+ import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
20
+ import { routeBridgeMessage } from "../core/bridge-message-router.js";
21
+ import { canDrainDeferredCodexInboundQueue, formatDeferredCodexInboundQueueMessage, isRetryableDeferredCodexDrainError, shouldDeferCodexInboundMessage, } from "../core/bridge-defer.js";
22
+ import { TurnCoordinator } from "../core/turn-coordinator.js";
23
+ import { resolveOutboundConversationTarget } from "../core/outbound-target.js";
24
+ import { handleAdapterControl } from "./adapter-control.js";
25
+ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
26
+ import { isDirectModuleRun } from "../core/direct-run.js";
27
+ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
28
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
29
+ import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
30
+ import { WecomTransport } from "../channels/wecom/wecom-transport.js";
31
+ import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
32
+ import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
14
33
  import { buildWechatInboundPrompt, buildOneTimeCode, formatApprovalMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatDuration, formatMirroredUserInputMessage, formatSessionSwitchMessage, formatStatusReport, formatTaskFailedMessage, formatThinkingForWechat, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "./bridge-utils.js";
15
- import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
34
+ import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, } from "../wechat/wechat-transport.js";
35
+ import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
16
36
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
17
37
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
18
- import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "../daemon/emoji-bindings.js";
19
38
  const POLL_RETRY_BASE_MS = 1_000;
20
39
  const POLL_RETRY_MAX_MS = 30_000;
21
40
  const PARENT_PROCESS_POLL_MS = 5_000;
@@ -70,31 +89,7 @@ function toPendingUserInput(request) {
70
89
  createdAt: nowIso(),
71
90
  };
72
91
  }
73
- export function shouldDeferCodexInboundMessage(params) {
74
- return (params.adapter === "codex" &&
75
- !params.hasPendingConfirmation &&
76
- !params.hasSystemCommand &&
77
- params.activeTurnOrigin === "local" &&
78
- (params.status === "busy" || params.status === "awaiting_approval"));
79
- }
80
- export function canDrainDeferredCodexInboundQueue(params) {
81
- return (params.adapter === "codex" &&
82
- params.deferredCount > 0 &&
83
- !params.hasPendingConfirmation &&
84
- !params.hasPendingUserInput &&
85
- !params.hasPendingApproval &&
86
- !params.hasActiveTask &&
87
- !params.activeTurnId &&
88
- params.status !== "busy" &&
89
- params.status !== "awaiting_approval" &&
90
- params.status !== "awaiting_input");
91
- }
92
- export function formatDeferredCodexInboundQueueMessage(queuePosition) {
93
- return `Queued for delivery after the current local Codex turn finishes. Queue position: ${queuePosition}.`;
94
- }
95
- export function isRetryableDeferredCodexDrainError(errorText) {
96
- return /still working|approval request is pending|waiting for local terminal input/i.test(errorText);
97
- }
92
+ export { canDrainDeferredCodexInboundQueue, formatDeferredCodexInboundQueueMessage, isRetryableDeferredCodexDrainError, shouldDeferCodexInboundMessage, } from "../core/bridge-defer.js";
98
93
  export function parseCliArgs(argv) {
99
94
  let adapter = null;
100
95
  let commandOverride;
@@ -102,6 +97,7 @@ export function parseCliArgs(argv) {
102
97
  let profile;
103
98
  let lifecycle = "persistent";
104
99
  let sessionStartMode = "restore";
100
+ let channelId = "wechat";
105
101
  for (let i = 0; i < argv.length; i += 1) {
106
102
  const arg = argv[i];
107
103
  const next = argv[i + 1];
@@ -148,6 +144,13 @@ export function parseCliArgs(argv) {
148
144
  sessionStartMode = next;
149
145
  i += 1;
150
146
  break;
147
+ case "--channel":
148
+ if (!next || (next !== "wechat" && next !== "wecom")) {
149
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
150
+ }
151
+ channelId = next;
152
+ i += 1;
153
+ break;
151
154
  case "--shutdown-on-parent-exit":
152
155
  lifecycle = "companion_bound";
153
156
  break;
@@ -170,6 +173,33 @@ export function parseCliArgs(argv) {
170
173
  profile,
171
174
  lifecycle,
172
175
  sessionStartMode,
176
+ channelId,
177
+ };
178
+ }
179
+ function toWechatSendContext(kind) {
180
+ if (kind === "mirrored_input") {
181
+ return "mirrored_user_input";
182
+ }
183
+ if (kind === "status") {
184
+ return "message";
185
+ }
186
+ return kind;
187
+ }
188
+ function toLegacyInboundWechatMessage(message) {
189
+ return {
190
+ senderId: message.senderId,
191
+ sender: message.senderId,
192
+ sessionId: message.conversation.conversationId,
193
+ text: message.text,
194
+ attachments: message.attachments.map((attachment) => ({
195
+ kind: attachment.kind === "image" ? "image" : "file",
196
+ path: attachment.path,
197
+ fileName: attachment.fileName || path.basename(attachment.path),
198
+ sizeBytes: attachment.sizeBytes ?? 0,
199
+ })),
200
+ contextToken: message.conversation.opaqueRef,
201
+ createdAt: message.createdAt,
202
+ createdAtMs: Date.parse(message.createdAt),
173
203
  };
174
204
  }
175
205
  function printUsageAndExit() {
@@ -177,7 +207,7 @@ function printUsageAndExit() {
177
207
  "Internal bridge runtime usage:",
178
208
  " npm run bridge -- --adapter <codex|claude|opencode|pi> [--cmd <executable>] [--cwd <path>] [--profile <name-or-path>] [--lifecycle <persistent|companion_bound>] [--session-start-mode <restore|new>]",
179
209
  "",
180
- "This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
210
+ "This entry is internal. Users should run a wechat-* or wecom-* launcher.",
181
211
  "",
182
212
  ].join("\n"));
183
213
  process.exit(0);
@@ -191,21 +221,49 @@ async function main() {
191
221
  const options = parseCliArgs(process.argv.slice(2));
192
222
  const daemonEndpoint = readDaemonEndpoint();
193
223
  if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
194
- throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
224
+ throw new Error(`${daemonEndpoint.channelId ?? "wechat"}-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
195
225
  }
196
226
  if (daemonEndpoint) {
197
227
  clearDaemonEndpoint(daemonEndpoint.pid);
198
228
  log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
199
229
  }
200
- const credentials = await ensureWechatCredentials({
201
- requireUserId: true,
202
- validateExisting: true,
203
- log,
204
- });
230
+ const wecomAccount = options.channelId === "wecom"
231
+ ? await ensureWecomAccount({ log })
232
+ : null;
233
+ const credentials = wecomAccount
234
+ ? { userId: wecomAccount.operatorUserId }
235
+ : await ensureWechatCredentials({
236
+ requireUserId: true,
237
+ validateExisting: true,
238
+ log,
239
+ });
205
240
  if (!credentials.userId) {
206
- throw new Error("Saved WeChat credentials are missing userId.");
241
+ throw new Error(options.channelId === "wecom"
242
+ ? "Saved WeCom credentials are missing operatorUserId."
243
+ : "Saved WeChat credentials are missing userId.");
207
244
  }
208
245
  const transport = new WeChatTransport({ log, logError });
246
+ const wecomTransport = wecomAccount
247
+ ? new WecomTransport({
248
+ account: wecomAccount,
249
+ logger: {
250
+ log: (message) => log(message),
251
+ error: (message) => logError(message),
252
+ },
253
+ })
254
+ : null;
255
+ const channelDriver = wecomTransport
256
+ ? new WecomChannelDriver({
257
+ transport: wecomTransport,
258
+ accountId: wecomAccount?.botId,
259
+ operatorId: credentials.userId,
260
+ logError,
261
+ })
262
+ : new WechatChannelDriver({
263
+ transport,
264
+ logError,
265
+ buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
266
+ });
209
267
  // 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
210
268
  // unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
211
269
  // 否则会与强制退出 teardown 竞态。
@@ -224,6 +282,7 @@ async function main() {
224
282
  const stateStore = new BridgeStateStore({
225
283
  ...options,
226
284
  authorizedUserId: credentials.userId,
285
+ channelId: options.channelId,
227
286
  });
228
287
  const reapedPeerPids = await reapPeerBridgeProcesses({
229
288
  logger: (message) => stateStore.appendLog(message),
@@ -270,6 +329,9 @@ async function main() {
270
329
  initialResumeConversationId: stateStore.getState().resumeConversationId,
271
330
  initialTranscriptPath: stateStore.getState().transcriptPath,
272
331
  });
332
+ const pendingWechatMessages = new PendingWechatMessageStore(options.channelId === "wechat"
333
+ ? getPendingWechatMessagesFile(options.cwd)
334
+ : getPendingChannelMessagesFile(options.cwd, options.channelId));
273
335
  const resumeCoordinator = new ResumeSessionCoordinator({
274
336
  adapter: options.adapter,
275
337
  runtime: adapter,
@@ -280,7 +342,13 @@ async function main() {
280
342
  let textSendChain = Promise.resolve();
281
343
  let attachmentSendChain = Promise.resolve();
282
344
  const pendingWechatForwardTasks = new Set();
283
- let activeTask = null;
345
+ const defaultConversation = channelDriver.defaultConversation();
346
+ const operatorId = credentials.userId;
347
+ const operatorConversation = defaultConversation
348
+ ?? channelDriver.directConversation(operatorId);
349
+ const turns = new TurnCoordinator({
350
+ initialLastConversation: defaultConversation,
351
+ });
284
352
  const deferredInboundMessages = [];
285
353
  let drainingDeferredInboundMessages = false;
286
354
  let consecutivePollFailures = 0;
@@ -295,48 +363,52 @@ async function main() {
295
363
  attachmentSendChain = run.then(() => undefined, () => undefined);
296
364
  return run;
297
365
  };
298
- const queueWechatMessage = (senderId, text, context = "message") => {
366
+ const sendWechatMessageNow = async (senderId, text, context = "message", targetOverride) => {
367
+ const target = resolveOutboundConversationTarget({
368
+ senderId,
369
+ operatorId,
370
+ override: targetOverride,
371
+ multiConversation: channelDriver.capabilities.multiConversation,
372
+ operatorTarget: turns.resolveTarget(operatorConversation),
373
+ directConversation: (id) => channelDriver.directConversation(id),
374
+ });
375
+ return channelDriver.sendText({
376
+ target,
377
+ text,
378
+ context,
379
+ log: (entry) => stateStore.appendLog(entry),
380
+ });
381
+ };
382
+ const queueWechatMessage = (senderId, text, context = "message", targetOverride) => {
383
+ const queuedTarget = senderId === operatorId && channelDriver.capabilities.multiConversation
384
+ ? targetOverride ?? turns.resolveTarget(operatorConversation)
385
+ : targetOverride;
299
386
  return queueWechatTextAction(async () => {
300
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
301
- try {
302
- await transport.sendText(senderId, text);
303
- return true;
387
+ const result = await sendWechatMessageNow(senderId, text, context, queuedTarget);
388
+ if (result.status === "target_stale") {
389
+ const pending = pendingWechatMessages.enqueue(senderId, text, context, result.target);
390
+ if (pending) {
391
+ stateStore.appendLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${pendingWechatMessages.list().length}`);
304
392
  }
305
- catch (err) {
306
- if (isWechatContextTokenStaleError(err)) {
307
- transport.clearCachedContextToken(senderId);
308
- const hint = "WeChat conversation context is stale. Ask the WeChat owner to send any message first, then local terminal replies can sync back to WeChat.";
309
- logError(`Failed to send WeChat ${context}: ${hint}`);
310
- stateStore.appendLog(formatWechatContextTokenStaleLogEntry({
311
- context,
312
- recipientId: senderId,
313
- error: err,
314
- }));
315
- return false;
316
- }
317
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(err)) {
318
- const delayMs = computeWechatSendRetryDelayMs(attempt);
319
- logError(`Failed to send WeChat ${context} (attempt ${attempt}). Retrying in ${formatDuration(delayMs)}. ${describeWechatTransportError(err)}`);
320
- stateStore.appendLog(formatWechatSendRetryLogEntry({
321
- context,
322
- recipientId: senderId,
323
- attempt,
324
- delayMs,
325
- error: err,
326
- }));
327
- await delay(delayMs);
328
- continue;
329
- }
330
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(err)}`);
331
- stateStore.appendLog(formatWechatSendFailureLogEntry({
332
- context,
333
- recipientId: senderId,
334
- error: err,
335
- }));
336
- return false;
393
+ }
394
+ return result.status === "sent";
395
+ });
396
+ };
397
+ const flushPendingWechatMessages = () => {
398
+ return queueWechatTextAction(async () => {
399
+ for (const pending of pendingWechatMessages.list()) {
400
+ const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
401
+ if (result.status === "sent") {
402
+ pendingWechatMessages.remove(pending.id);
403
+ stateStore.appendLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
404
+ continue;
405
+ }
406
+ if (result.status === "target_stale") {
407
+ break;
337
408
  }
409
+ stateStore.appendLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
410
+ break;
338
411
  }
339
- return false;
340
412
  });
341
413
  };
342
414
  const trackWechatForwardTask = (task) => {
@@ -356,7 +428,9 @@ async function main() {
356
428
  }
357
429
  };
358
430
  const outputBatcher = new OutputBatcher(async (text) => {
359
- await queueWechatMessage(stateStore.getState().authorizedUserId, text);
431
+ await queueWechatMessage(stateStore.getState().authorizedUserId, text, "message", channelDriver.capabilities.multiConversation
432
+ ? turns.resolveTarget(operatorConversation)
433
+ : undefined);
360
434
  });
361
435
  const maybeDrainDeferredInboundMessages = async () => {
362
436
  if (drainingDeferredInboundMessages || !ensureRuntimeOwnership()) {
@@ -371,7 +445,7 @@ async function main() {
371
445
  hasPendingConfirmation: Boolean(stateStore.getState().pendingConfirmation),
372
446
  hasPendingUserInput: Boolean(stateStore.getState().pendingUserInput),
373
447
  hasPendingApproval: Boolean(adapterState.pendingApproval),
374
- hasActiveTask: Boolean(activeTask),
448
+ hasActiveTask: turns.hasActiveTask,
375
449
  })) {
376
450
  return;
377
451
  }
@@ -382,13 +456,26 @@ async function main() {
382
456
  drainingDeferredInboundMessages = true;
383
457
  try {
384
458
  stateStore.appendLog(`draining_deferred_inbound_input: remaining=${deferredInboundMessages.length} text=${truncatePreview(nextDeferred.message.text)}`);
385
- const nextTask = await dispatchInboundWechatText({
386
- message: nextDeferred.message,
387
- options,
388
- stateStore,
389
- adapter,
390
- });
391
- activeTask = nextTask;
459
+ const nextTask = createActiveTask(nextDeferred.message);
460
+ const lease = turns.beginTurn(nextTask, nextDeferred.channelMessage?.conversation);
461
+ if (!lease) {
462
+ deferredInboundMessages.unshift(nextDeferred);
463
+ return;
464
+ }
465
+ try {
466
+ await dispatchInboundWechatText({
467
+ message: nextDeferred.message,
468
+ channelDriver,
469
+ options,
470
+ stateStore,
471
+ adapter,
472
+ activeTask: nextTask,
473
+ });
474
+ }
475
+ catch (error) {
476
+ turns.rollback(lease);
477
+ throw error;
478
+ }
392
479
  }
393
480
  catch (err) {
394
481
  const errorText = err instanceof Error ? err.message : String(err);
@@ -403,7 +490,7 @@ async function main() {
403
490
  adapter: options.adapter,
404
491
  cwd: options.cwd,
405
492
  errorText,
406
- }), "inbound_error");
493
+ }), "inbound_error", nextDeferred.channelMessage?.conversation);
407
494
  }
408
495
  finally {
409
496
  drainingDeferredInboundMessages = false;
@@ -470,6 +557,18 @@ async function main() {
470
557
  catch {
471
558
  // Best effort shutdown.
472
559
  }
560
+ try {
561
+ wecomTransport?.stop();
562
+ }
563
+ catch {
564
+ // Best effort shutdown.
565
+ }
566
+ try {
567
+ await channelDriver.endAllTyping?.();
568
+ }
569
+ catch {
570
+ // Best effort shutdown.
571
+ }
473
572
  controller.clearLocalClientEndpoint();
474
573
  stateStore.releaseLock();
475
574
  };
@@ -538,6 +637,7 @@ async function main() {
538
637
  try {
539
638
  wireAdapterEvents({
540
639
  adapter,
640
+ channelDriver,
541
641
  resumeCoordinator,
542
642
  options,
543
643
  transport,
@@ -547,8 +647,11 @@ async function main() {
547
647
  queueWechatMessage,
548
648
  trackWechatForwardTask,
549
649
  maybeDrainDeferredInboundMessages,
550
- clearActiveTask: () => {
551
- activeTask = null;
650
+ getActiveTask: () => turns.activeTask,
651
+ clearActiveTask: (expectedTask) => {
652
+ if (expectedTask) {
653
+ turns.complete(expectedTask);
654
+ }
552
655
  },
553
656
  syncSharedSessionState: () => {
554
657
  syncSharedSessionState(stateStore, adapter);
@@ -557,6 +660,8 @@ async function main() {
557
660
  controller.syncLocalClientEndpoint();
558
661
  },
559
662
  requestShutdown,
663
+ wecomTransport,
664
+ getWecomTarget: () => turns.resolveTarget(operatorConversation),
560
665
  });
561
666
  await adapter.start();
562
667
  if (!ensureRuntimeOwnership()) {
@@ -564,13 +669,116 @@ async function main() {
564
669
  }
565
670
  syncSharedSessionState(stateStore, adapter);
566
671
  controller.syncLocalClientEndpoint();
567
- stateStore.appendLog(`Bridge started with adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
568
- log(`WeChat bridge is ready for adapter "${options.adapter}".`);
672
+ stateStore.appendLog(`Bridge started with channel=${options.channelId} adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
673
+ if (channelDriver.start) {
674
+ await channelDriver.start({
675
+ onInboundMessage: async (channelMessage) => {
676
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
677
+ return;
678
+ }
679
+ turns.observeConversation(channelMessage.conversation);
680
+ const queueInboundReply = (senderId, text, context) => queueWechatMessage(senderId, text, context, channelMessage.conversation);
681
+ if (pendingWechatMessages.list().length > 0) {
682
+ await flushPendingWechatMessages();
683
+ }
684
+ const message = toLegacyInboundWechatMessage(channelMessage);
685
+ const taskAtMessageStart = turns.activeTask;
686
+ stateStore.touchActivity(message.createdAt);
687
+ try {
688
+ await handleInboundMessage({
689
+ message,
690
+ channelMessage,
691
+ channelDriver,
692
+ options,
693
+ stateStore,
694
+ adapter,
695
+ resumeCoordinator,
696
+ queueWechatMessage: queueInboundReply,
697
+ outputBatcher,
698
+ clearActiveTask: () => {
699
+ if (taskAtMessageStart) {
700
+ turns.complete(taskAtMessageStart);
701
+ }
702
+ },
703
+ dispatchInboundText: async () => {
704
+ const nextActiveTask = createActiveTask(message);
705
+ const result = await turns.dispatch({
706
+ task: nextActiveTask,
707
+ conversation: channelMessage.conversation,
708
+ onBusy: async () => {
709
+ await queueInboundReply(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
710
+ },
711
+ forward: async () => {
712
+ await dispatchInboundWechatText({
713
+ message,
714
+ channelDriver,
715
+ options,
716
+ stateStore,
717
+ adapter,
718
+ activeTask: nextActiveTask,
719
+ });
720
+ },
721
+ });
722
+ if (result.status === "dispatched") {
723
+ void channelDriver.beginTyping?.(message.senderId);
724
+ }
725
+ return result.status === "dispatched" ? nextActiveTask : null;
726
+ },
727
+ deferInboundMessage: async (nextMessage) => {
728
+ deferredInboundMessages.push({
729
+ message: nextMessage,
730
+ channelMessage,
731
+ });
732
+ stateStore.appendLog(`deferred_inbound_input: position=${deferredInboundMessages.length} text=${truncatePreview(nextMessage.text)}`);
733
+ await queueInboundReply(nextMessage.senderId, formatDeferredCodexInboundQueueMessage(deferredInboundMessages.length));
734
+ },
735
+ });
736
+ }
737
+ catch (error) {
738
+ const errorText = error instanceof Error ? error.message : String(error);
739
+ logError(errorText);
740
+ stateStore.appendLog(`inbound_error: ${errorText}`);
741
+ await queueInboundReply(message.senderId, formatUserFacingInboundError({
742
+ adapter: options.adapter,
743
+ cwd: options.cwd,
744
+ errorText,
745
+ }), "inbound_error");
746
+ }
747
+ syncSharedSessionState(stateStore, adapter);
748
+ await maybeDrainDeferredInboundMessages();
749
+ },
750
+ onUnauthorizedSender: async (senderId, chatType) => {
751
+ stateStore.appendLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
752
+ if (chatType === "direct") {
753
+ const result = await channelDriver.sendText({
754
+ target: channelDriver.directConversation(senderId),
755
+ text: "Unauthorized.",
756
+ context: "notice",
757
+ log: (entry) => stateStore.appendLog(entry),
758
+ });
759
+ if (result.status !== "sent") {
760
+ logError(`Failed to reply unauthorized notice to ${senderId}`);
761
+ }
762
+ }
763
+ },
764
+ onChannelFatal: async (error) => {
765
+ stateStore.appendLog(`wecom_fatal_error: ${error.message}`);
766
+ requestShutdown(error.message, 1);
767
+ },
768
+ onChannelConnected: async () => {
769
+ if (pendingWechatMessages.list().length > 0) {
770
+ await flushPendingWechatMessages();
771
+ }
772
+ },
773
+ });
774
+ await channelDriver.waitUntilReady?.();
775
+ }
776
+ log(`${channelDriver.displayName} bridge is ready for adapter "${options.adapter}".`);
569
777
  log(`Working directory: ${options.cwd}`);
570
778
  if (options.profile) {
571
779
  log(`Profile: ${options.profile}`);
572
780
  }
573
- log(`Authorized WeChat user: ${credentials.userId}`);
781
+ log(`Authorized ${channelDriver.displayName} user: ${credentials.userId}`);
574
782
  if (options.adapter === "codex") {
575
783
  log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
576
784
  }
@@ -583,15 +791,17 @@ async function main() {
583
791
  else if (options.adapter === "pi") {
584
792
  log("For source-mode debugging, open the visible Pi client with: npm run pi:companion");
585
793
  }
586
- loadEmojiBindings();
587
- // Keep the standalone bridge's WeChat welcome brief: command and emoji
588
- // reference material belongs to /status and /bindings, which users can
589
- // request on demand. The daemon sends its own, fuller welcome.
590
794
  const welcomeText = t("bridge.welcome", {
591
795
  adapter: options.adapter,
592
796
  cwd: options.cwd,
593
797
  });
594
798
  await queueWechatMessage(credentials.userId, welcomeText);
799
+ if (channelDriver.capabilities.pushInbound) {
800
+ while (!shutdownPromise && ensureRuntimeOwnership()) {
801
+ await delay(1_000);
802
+ }
803
+ return;
804
+ }
595
805
  while (true) {
596
806
  if (shutdownPromise) {
597
807
  // Shutdown (e.g. SIGINT) was requested while we were awaiting the
@@ -634,6 +844,9 @@ async function main() {
634
844
  log(`WeChat long poll recovered after ${recoveredFailures} transient error(s).`);
635
845
  stateStore.appendLog(`poll_recovered: failures=${recoveredFailures}`);
636
846
  }
847
+ if (pollResult.messages.length > 0 && pendingWechatMessages.list().length > 0) {
848
+ await flushPendingWechatMessages();
849
+ }
637
850
  if (pollResult.ignoredBacklogCount > 0) {
638
851
  stateStore.incrementIgnoredBacklog(pollResult.ignoredBacklogCount);
639
852
  stateStore.appendLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
@@ -649,11 +862,12 @@ async function main() {
649
862
  if (!ensureRuntimeOwnership()) {
650
863
  break;
651
864
  }
865
+ const taskAtMessageStart = turns.activeTask;
652
866
  stateStore.touchActivity(message.createdAt);
653
- let nextTask = null;
654
867
  try {
655
- nextTask = await handleInboundMessage({
868
+ await handleInboundMessage({
656
869
  message,
870
+ channelDriver,
657
871
  options,
658
872
  stateStore,
659
873
  adapter,
@@ -661,7 +875,32 @@ async function main() {
661
875
  queueWechatMessage,
662
876
  outputBatcher,
663
877
  clearActiveTask: () => {
664
- activeTask = null;
878
+ if (taskAtMessageStart) {
879
+ turns.complete(taskAtMessageStart);
880
+ }
881
+ },
882
+ dispatchInboundText: async () => {
883
+ const nextActiveTask = createActiveTask(message);
884
+ const result = await turns.dispatch({
885
+ task: nextActiveTask,
886
+ onBusy: async () => {
887
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
888
+ },
889
+ forward: async () => {
890
+ await dispatchInboundWechatText({
891
+ message,
892
+ channelDriver,
893
+ options,
894
+ stateStore,
895
+ adapter,
896
+ activeTask: nextActiveTask,
897
+ });
898
+ },
899
+ });
900
+ if (result.status === "dispatched") {
901
+ void channelDriver.beginTyping?.(message.senderId);
902
+ }
903
+ return result.status === "dispatched" ? nextActiveTask : null;
665
904
  },
666
905
  deferInboundMessage: async (nextMessage) => {
667
906
  deferredInboundMessages.push({
@@ -682,9 +921,6 @@ async function main() {
682
921
  errorText,
683
922
  }), "inbound_error");
684
923
  }
685
- if (nextTask) {
686
- activeTask = nextTask;
687
- }
688
924
  syncSharedSessionState(stateStore, adapter);
689
925
  await maybeDrainDeferredInboundMessages();
690
926
  }
@@ -719,7 +955,30 @@ function syncSharedSessionState(stateStore, adapter) {
719
955
  }
720
956
  }
721
957
  function wireAdapterEvents(params) {
722
- const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
958
+ const { adapter, channelDriver, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, getWecomTarget, } = params;
959
+ const channelPort = options.channelId === "wecom"
960
+ ? new WecomChannelPort({
961
+ transport: wecomTransport,
962
+ sendText: (target, text, kind) => queueWechatMessage(stateStore.getState().authorizedUserId, text, toWechatSendContext(kind), target),
963
+ onEmptyVisibleReply: (adapter, rawText) => {
964
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
965
+ },
966
+ })
967
+ : new WechatChannelPort({
968
+ sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
969
+ sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
970
+ sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
971
+ sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
972
+ sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
973
+ onEmptyVisibleReply: (adapter, rawText) => {
974
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
975
+ },
976
+ onTextSent: (_adapter, text) => {
977
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
978
+ },
979
+ });
980
+ let lastFinalReplyAtMs = 0;
981
+ let eventForwardChain = Promise.resolve();
723
982
  adapter.setEventSink((event) => {
724
983
  syncSharedSessionState();
725
984
  syncLocalClientEndpoint();
@@ -732,156 +991,157 @@ function wireAdapterEvents(params) {
732
991
  stateStore.clearPendingUserInput();
733
992
  }
734
993
  const authorizedUserId = stateStore.getState().authorizedUserId;
735
- switch (event.type) {
736
- case "stdout":
737
- case "stderr":
738
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
739
- outputBatcher.push(event.text);
994
+ const eventTarget = channelDriver.capabilities.multiConversation
995
+ ? getWecomTarget()
996
+ : channelDriver.directConversation(authorizedUserId);
997
+ const eventTask = getActiveTask();
998
+ eventForwardChain = eventForwardChain
999
+ .then(() => forwardBridgeEvent(event, {
1000
+ stdout: (next) => {
1001
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
1002
+ outputBatcher.push(next.text);
740
1003
  }
741
- break;
742
- case "final_reply":
743
- stateStore.appendLog(`final_reply: ${truncatePreview(event.text)}`);
1004
+ },
1005
+ stderr: (next) => {
1006
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
1007
+ outputBatcher.push(next.text);
1008
+ }
1009
+ },
1010
+ finalReply: (next) => {
1011
+ void channelDriver.endTyping?.(authorizedUserId);
1012
+ lastFinalReplyAtMs = Date.now();
1013
+ stateStore.appendLog(`final_reply: ${truncatePreview(next.text)}`);
744
1014
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
745
- await forwardWechatFinalReply({
1015
+ await channelPort.send({
1016
+ target: eventTarget,
1017
+ kind: "final_reply",
1018
+ text: next.text,
746
1019
  adapter: options.adapter,
747
- rawText: event.text,
748
- onEmptyVisibleReply: ({ rawVisibleText }) => {
749
- stateStore.appendLog(`empty_visible_final_reply: adapter=${options.adapter} raw=${truncatePreview(rawVisibleText)}`);
750
- },
751
- sender: {
752
- sendText: async (text) => {
753
- const sent = await queueWechatMessage(authorizedUserId, text, "final_reply");
754
- if (sent) {
755
- stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
756
- }
757
- return sent;
758
- },
759
- sendImage: (imagePath) => queueWechatAttachmentAction(() => transport.sendImage(imagePath, { recipientId: authorizedUserId })),
760
- sendFile: (filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId: authorizedUserId })),
761
- sendVoice: (voicePath) => queueWechatAttachmentAction(() => transport.sendVoice(voicePath, authorizedUserId)),
762
- sendVideo: (videoPath) => queueWechatAttachmentAction(() => transport.sendVideo(videoPath, { recipientId: authorizedUserId })),
763
- },
764
1020
  });
765
1021
  }));
766
- break;
767
- case "status":
768
- if (event.message) {
769
- log(`${event.status}: ${event.message}`);
770
- stateStore.appendLog(`${event.status}: ${event.message}`);
1022
+ },
1023
+ status: (next) => {
1024
+ if (next.message) {
1025
+ log(`${next.status}: ${next.message}`);
1026
+ stateStore.appendLog(`${next.status}: ${next.message}`);
771
1027
  }
772
1028
  void maybeDrainDeferredInboundMessages();
773
- break;
774
- case "notice":
775
- stateStore.appendLog(`${event.level}_notice: ${truncatePreview(event.text)}`);
776
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
1029
+ },
1030
+ notice: (next) => {
1031
+ stateStore.appendLog(`${next.level}_notice: ${truncatePreview(next.text)}`);
1032
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
777
1033
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
778
- await queueWechatMessage(authorizedUserId, event.text, "notice");
1034
+ await queueWechatMessage(authorizedUserId, next.text, "notice", eventTarget);
779
1035
  }));
780
1036
  }
781
- break;
782
- case "thinking":
783
- if (event.text) {
784
- const thinkingPreview = formatThinkingForWechat(event.text, 500);
1037
+ },
1038
+ thinking: (next) => {
1039
+ if (next.text) {
1040
+ const thinkingPreview = formatThinkingForWechat(next.text, 500);
785
1041
  if (thinkingPreview) {
786
1042
  stateStore.appendLog(`thinking: ${thinkingPreview}`);
787
1043
  trackWechatForwardTask((async () => {
788
- await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
1044
+ await queueWechatMessage(authorizedUserId, channelDriver.id === "wecom"
1045
+ ? `Processing: ${thinkingPreview}`
1046
+ : `思考: ${thinkingPreview}`, "thinking", eventTarget);
789
1047
  })());
790
1048
  }
791
1049
  }
792
- break;
793
- case "approval_required":
1050
+ },
1051
+ approvalRequired: (next) => {
794
1052
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
795
- const pending = toPendingApproval(event.request);
1053
+ const pending = toPendingApproval(next.request);
796
1054
  stateStore.setPendingConfirmation(pending);
797
1055
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
798
- await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
1056
+ await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required", eventTarget);
799
1057
  }));
800
- break;
801
- case "user_input_required":
1058
+ },
1059
+ userInputRequired: (next) => {
802
1060
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
803
- const pending = toPendingUserInput(event.request);
1061
+ const pending = toPendingUserInput(next.request);
804
1062
  stateStore.setPendingUserInput(pending);
805
1063
  stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
806
- await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required");
1064
+ await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required", eventTarget);
807
1065
  }));
808
- break;
809
- case "mirrored_user_input":
810
- stateStore.appendLog(`mirrored_local_input: ${truncatePreview(event.text)}`);
811
- if (shouldForwardBridgeEventToWechat(options.adapter, event.type, { text: event.text })) {
1066
+ },
1067
+ mirroredUserInput: (next) => {
1068
+ stateStore.appendLog(`mirrored_local_input: ${truncatePreview(next.text)}`);
1069
+ if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
812
1070
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
813
- await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, event.text), "mirrored_user_input");
1071
+ await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, next.text), "mirrored_user_input", eventTarget);
814
1072
  }));
815
1073
  }
816
- break;
817
- case "session_switched":
818
- if (event.source === "local") {
1074
+ },
1075
+ sessionSwitched: (next) => {
1076
+ if (next.source === "local")
819
1077
  resumeCoordinator.clear();
820
- }
821
- stateStore.appendLog(`session_switched: ${event.sessionId} source=${event.source} reason=${event.reason}`);
822
- if (shouldForwardSessionSwitchEvent(event.reason) &&
823
- shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
1078
+ stateStore.appendLog(`session_switched: ${next.sessionId} source=${next.source} reason=${next.reason}`);
1079
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
824
1080
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
825
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
826
- adapter: options.adapter,
827
- sessionId: event.sessionId,
828
- source: event.source,
829
- reason: event.reason,
830
- }), "session_switched");
1081
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason }), "session_switched", eventTarget);
831
1082
  }));
832
1083
  }
833
- break;
834
- case "thread_switched":
835
- if (event.source === "local") {
1084
+ },
1085
+ threadSwitched: (next) => {
1086
+ if (next.source === "local")
836
1087
  resumeCoordinator.clear();
1088
+ stateStore.appendLog(`thread_switched: ${next.threadId} source=${next.source} reason=${next.reason}`);
1089
+ if (shouldSuppressCodexLocalThreadNotice({
1090
+ adapter: options.adapter,
1091
+ source: next.source,
1092
+ activeTurnOrigin: adapter.getState().activeTurnOrigin,
1093
+ lastFinalReplyAtMs,
1094
+ })) {
1095
+ return;
837
1096
  }
838
- stateStore.appendLog(`thread_switched: ${event.threadId} source=${event.source} reason=${event.reason}`);
839
- if (shouldForwardSessionSwitchEvent(event.reason) &&
840
- shouldForwardBridgeEventToWechat(options.adapter, event.type)) {
1097
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
841
1098
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
842
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({
843
- adapter: options.adapter,
844
- sessionId: event.threadId,
845
- source: event.source,
846
- reason: event.reason,
847
- }), "thread_switched");
1099
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.threadId, source: next.source, reason: next.reason }), "thread_switched", eventTarget);
848
1100
  }));
849
1101
  }
850
1102
  void maybeDrainDeferredInboundMessages();
851
- break;
852
- case "task_complete":
1103
+ },
1104
+ taskComplete: () => {
1105
+ void channelDriver.endTyping?.(authorizedUserId);
853
1106
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
854
1107
  stateStore.clearPendingConfirmation();
855
1108
  stateStore.clearPendingUserInput();
856
- clearActiveTask();
1109
+ clearActiveTask(eventTask);
857
1110
  await maybeDrainDeferredInboundMessages();
858
1111
  }));
859
- break;
860
- case "task_failed":
1112
+ },
1113
+ taskFailed: (next) => {
1114
+ void channelDriver.endTyping?.(authorizedUserId);
861
1115
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
862
1116
  stateStore.clearPendingConfirmation();
863
1117
  stateStore.clearPendingUserInput();
864
- clearActiveTask();
865
- await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, event.message), "task_failed");
1118
+ clearActiveTask(eventTask);
1119
+ await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, next.message), "task_failed", eventTarget);
866
1120
  await maybeDrainDeferredInboundMessages();
867
1121
  }));
868
- break;
869
- case "fatal_error":
870
- logError(event.message);
871
- stateStore.appendLog(`fatal_error: ${event.message}`);
1122
+ },
1123
+ fatalError: (next) => {
1124
+ void channelDriver.endTyping?.(authorizedUserId);
1125
+ logError(next.message);
1126
+ stateStore.appendLog(`fatal_error: ${next.message}`);
872
1127
  stateStore.clearPendingConfirmation();
873
1128
  stateStore.clearPendingUserInput();
874
- clearActiveTask();
1129
+ clearActiveTask(eventTask);
875
1130
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
876
- await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(event.message), "fatal_error");
1131
+ await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(next.message), "fatal_error", eventTarget);
877
1132
  await maybeDrainDeferredInboundMessages();
878
1133
  }));
879
- break;
880
- case "shutdown_requested":
881
- stateStore.appendLog(`shutdown_requested: ${event.reason}`);
882
- requestShutdown(event.message, event.exitCode ?? 0);
883
- break;
884
- }
1134
+ },
1135
+ shutdownRequested: (next) => {
1136
+ stateStore.appendLog(`shutdown_requested: ${next.reason}`);
1137
+ requestShutdown(next.message, next.exitCode ?? 0);
1138
+ },
1139
+ }))
1140
+ .catch((error) => {
1141
+ const message = error instanceof Error ? error.message : String(error);
1142
+ logError(`Bridge event handling failed: ${message}`);
1143
+ stateStore.appendLog(`event_forward_failed: ${message}`);
1144
+ });
885
1145
  });
886
1146
  }
887
1147
  function formatInboundMessagePreview(message) {
@@ -896,42 +1156,11 @@ function formatInboundMessagePreview(message) {
896
1156
  return "(empty)";
897
1157
  }
898
1158
  async function handleInboundMessage(params) {
899
- let { message, } = params;
900
- const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
1159
+ const { message, channelMessage, channelDriver, } = params;
1160
+ const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, dispatchInboundText, deferInboundMessage, } = params;
901
1161
  const state = stateStore.getState();
902
1162
  if (message.senderId !== state.authorizedUserId) {
903
- await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
904
- return null;
905
- }
906
- // Emoji resolution: rewrite message text if it starts with a bound emoji
907
- const emojiMatch = resolveEmojiCommand(message.text);
908
- if (emojiMatch) {
909
- const rewritten = emojiMatch.remainder
910
- ? `${emojiMatch.command} ${emojiMatch.remainder}`
911
- : emojiMatch.command;
912
- message = { ...message, text: rewritten };
913
- }
914
- // Emoji binding management commands
915
- const bindingsCmd = parseEmojiBindingsCommand(message.text);
916
- if (bindingsCmd) {
917
- switch (bindingsCmd.type) {
918
- case "list":
919
- await queueWechatMessage(message.senderId, formatBindingsListMessage(listBindings()));
920
- break;
921
- case "bind":
922
- setBinding(bindingsCmd.emoji, bindingsCmd.command);
923
- await queueWechatMessage(message.senderId, `Bound ${bindingsCmd.emoji} → ${bindingsCmd.command}`);
924
- break;
925
- case "unbind": {
926
- const removed = removeBinding(bindingsCmd.emoji);
927
- await queueWechatMessage(message.senderId, removed ? `Unbound ${bindingsCmd.emoji}` : `No binding found for ${bindingsCmd.emoji}`);
928
- break;
929
- }
930
- }
931
- return null;
932
- }
933
- if (isBindCommandPrefix(message.text)) {
934
- await queueWechatMessage(message.senderId, formatBindCommandUsage());
1163
+ await queueWechatMessage(message.senderId, `Unauthorized. This bridge only accepts messages from ${channelDriver.operatorDescription}.`);
935
1164
  return null;
936
1165
  }
937
1166
  const systemCommand = parseWechatControlCommand(message.text, {
@@ -940,12 +1169,19 @@ async function handleInboundMessage(params) {
940
1169
  hasPendingUserInput: Boolean(state.pendingUserInput),
941
1170
  });
942
1171
  switch (systemCommand?.type) {
1172
+ case "model":
1173
+ case "plan":
1174
+ await queueWechatMessage(message.senderId, await handleAdapterControl(adapter, message.senderId, systemCommand));
1175
+ return null;
1176
+ case "broadcast":
1177
+ await queueWechatMessage(message.senderId, "/all is only available in daemon mode. Start the daemon with wechat-daemon or wecom-daemon to broadcast to all workers.");
1178
+ return null;
943
1179
  case "status":
944
1180
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
945
1181
  return null;
946
1182
  case "resume": {
947
1183
  if (!isWechatResumeEnabled(options.adapter)) {
948
- await queueWechatMessage(message.senderId, `WeChat /resume is disabled in ${options.adapter} mode. Use /resume directly inside "wechat-${options.adapter}"; WeChat will follow the active local session.`);
1184
+ await queueWechatMessage(message.senderId, `${channelDriver.displayName} /resume is disabled in ${options.adapter} mode. Use /resume directly inside "${channelDriver.id}-${options.adapter}"; the remote channel will follow the active local session.`);
949
1185
  return null;
950
1186
  }
951
1187
  try {
@@ -1056,53 +1292,63 @@ async function handleInboundMessage(params) {
1056
1292
  };
1057
1293
  }
1058
1294
  }
1059
- if (state.pendingConfirmation) {
1060
- await queueWechatMessage(message.senderId, formatPendingApprovalReminder(state.pendingConfirmation, adapter.getState()));
1061
- return null;
1062
- }
1063
- if (state.pendingUserInput) {
1064
- await queueWechatMessage(message.senderId, formatPendingUserInputReminder(state.pendingUserInput));
1065
- return null;
1066
- }
1067
1295
  const adapterState = adapter.getState();
1068
- if (shouldDeferCodexInboundMessage({
1069
- adapter: options.adapter,
1070
- status: adapterState.status,
1071
- activeTurnOrigin: adapterState.activeTurnOrigin,
1072
- hasPendingConfirmation: Boolean(state.pendingConfirmation),
1073
- hasSystemCommand: Boolean(systemCommand),
1074
- })) {
1075
- await deferInboundMessage(message);
1076
- return null;
1077
- }
1078
- if (adapterState.status === "busy") {
1079
- if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1080
- adapterState.activeTurnOrigin === "local") {
1081
- await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1082
- return null;
1083
- }
1084
- await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1085
- return null;
1086
- }
1087
- return dispatchInboundWechatText({
1088
- message,
1089
- options,
1090
- stateStore,
1091
- adapter,
1296
+ const routeResult = await routeBridgeMessage({
1297
+ message: channelMessage ?? toChannelInboundMessage(message),
1298
+ authorized: true,
1299
+ command: null,
1300
+ adapterState,
1301
+ hasPendingApproval: Boolean(state.pendingConfirmation),
1302
+ hasPendingUserInput: Boolean(state.pendingUserInput),
1303
+ shouldDefer: shouldDeferCodexInboundMessage({
1304
+ adapter: options.adapter,
1305
+ status: adapterState.status,
1306
+ activeTurnOrigin: adapterState.activeTurnOrigin,
1307
+ hasPendingConfirmation: Boolean(state.pendingConfirmation),
1308
+ hasSystemCommand: Boolean(systemCommand),
1309
+ }),
1310
+ onUnauthorized: async () => undefined,
1311
+ handleCommand: async () => false,
1312
+ remindPendingApproval: async () => {
1313
+ await queueWechatMessage(message.senderId, formatPendingApprovalReminder(stateStore.getState().pendingConfirmation, adapter.getState()));
1314
+ },
1315
+ remindPendingUserInput: async () => {
1316
+ const pendingUserInput = stateStore.getState().pendingUserInput;
1317
+ await queueWechatMessage(message.senderId, pendingUserInput
1318
+ ? formatPendingUserInputReminder(pendingUserInput)
1319
+ : `${options.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
1320
+ },
1321
+ remindBusy: async () => {
1322
+ const currentState = adapter.getState();
1323
+ if ((options.adapter === "codex" || options.adapter === "opencode" || options.adapter === "pi") &&
1324
+ currentState.activeTurnOrigin === "local") {
1325
+ await queueWechatMessage(message.senderId, `${options.adapter === "opencode" ? "OpenCode" : options.adapter === "pi" ? "Pi" : "codex"} is currently busy with a local terminal turn. Wait for it to finish or use /stop.`);
1326
+ return;
1327
+ }
1328
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
1329
+ },
1330
+ defer: async () => {
1331
+ await deferInboundMessage(message);
1332
+ },
1333
+ dispatch: dispatchInboundText,
1092
1334
  });
1335
+ return routeResult.kind === "dispatched" ? routeResult.result : null;
1093
1336
  }
1094
- async function dispatchInboundWechatText(params) {
1095
- const { message, options, stateStore, adapter } = params;
1096
- const preview = formatInboundMessagePreview(message);
1097
- const activeTask = {
1337
+ function createActiveTask(message) {
1338
+ return {
1098
1339
  startedAt: Date.now(),
1099
- inputPreview: truncatePreview(preview, 180),
1340
+ inputPreview: truncatePreview(formatInboundMessagePreview(message), 180),
1100
1341
  };
1342
+ }
1343
+ async function dispatchInboundWechatText(params) {
1344
+ const { message, options, channelDriver, stateStore, adapter } = params;
1345
+ const preview = formatInboundMessagePreview(message);
1346
+ const activeTask = params.activeTask ?? createActiveTask(message);
1101
1347
  stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
1102
- await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
1348
+ await adapter.sendInput(channelDriver.buildInboundPrompt(message.text, message.attachments));
1103
1349
  return activeTask;
1104
1350
  }
1105
- const isDirectRun = Boolean(import.meta.main);
1351
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1106
1352
  if (isDirectRun) {
1107
1353
  main().catch((err) => {
1108
1354
  logError(describeWechatTransportError(err));