cli-wechat-bridge 1.1.6 → 1.1.8

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 (52) hide show
  1. package/LICENSE.txt +661 -661
  2. package/README.md +454 -475
  3. package/bin/_run-entry.mjs +23 -5
  4. package/bin/wecom-check-update.mjs +5 -0
  5. package/bin/wecom-claude.mjs +5 -0
  6. package/bin/wecom-codex.mjs +5 -0
  7. package/bin/wecom-daemon.mjs +5 -0
  8. package/bin/wecom-opencode.mjs +5 -0
  9. package/bin/wecom-pi.mjs +5 -0
  10. package/bin/wecom-setup.mjs +5 -0
  11. package/dist/bridge/adapter-control.js +58 -0
  12. package/dist/bridge/bridge-adapters.claude.js +155 -4
  13. package/dist/bridge/bridge-adapters.codex.js +112 -7
  14. package/dist/bridge/bridge-adapters.core.js +19 -1
  15. package/dist/bridge/bridge-adapters.opencode.js +150 -1
  16. package/dist/bridge/bridge-adapters.pi.js +39 -0
  17. package/dist/bridge/bridge-process-reaper.js +35 -2
  18. package/dist/bridge/bridge-state.js +10 -3
  19. package/dist/bridge/claude-native-controls.js +133 -0
  20. package/dist/bridge/native-terminal-control.js +253 -0
  21. package/dist/bridge/wechat-bridge.js +389 -87
  22. package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
  23. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  24. package/dist/channels/wecom/setup-cli.js +6 -0
  25. package/dist/channels/wecom/setup.js +176 -0
  26. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  27. package/dist/channels/wecom/wecom-config.js +66 -0
  28. package/dist/channels/wecom/wecom-media.js +141 -0
  29. package/dist/channels/wecom/wecom-message.js +149 -0
  30. package/dist/channels/wecom/wecom-transport.js +308 -0
  31. package/dist/companion/codex-remote-client.js +24 -3
  32. package/dist/companion/codex-visible-client-link.js +101 -0
  33. package/dist/companion/local-companion-start.js +56 -11
  34. package/dist/companion/local-companion.js +19 -1
  35. package/dist/companion/pi-tui-bridge-extension.js +56 -3
  36. package/dist/core/bridge-control.js +8 -0
  37. package/dist/core/channel-types.js +3 -1
  38. package/dist/core/conversation-routing.js +48 -0
  39. package/dist/core/direct-run.js +17 -0
  40. package/dist/core/inbound-message-claims.js +61 -0
  41. package/dist/daemon/daemon-link.js +3 -0
  42. package/dist/daemon/wechat-daemon.js +335 -60
  43. package/dist/i18n/messages-en.js +3 -1
  44. package/dist/i18n/messages-zh.js +3 -1
  45. package/dist/runtime/legacy-adapter-runtime.js +15 -0
  46. package/dist/runtime/runtime-types.js +3 -0
  47. package/dist/utils/doctor.js +30 -10
  48. package/dist/utils/version-checker.js +11 -11
  49. package/dist/wechat/setup.js +113 -13
  50. package/dist/wechat/wechat-transport.js +96 -58
  51. package/package.json +116 -104
  52. package/scripts/ensure-node-pty-permissions.mjs +73 -73
@@ -12,11 +12,18 @@ import { getProcessRecordByPid, reapOrphanedOpencodeProcesses, reapPeerBridgePro
12
12
  import { createRuntimeHost } from "../runtime/create-runtime-host.js";
13
13
  import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
14
14
  import { routeBridgeMessage } from "../core/bridge-message-router.js";
15
+ import { clearTurn, resolveConversationTarget, rollbackTurn, tryBeginTurn, } from "../core/conversation-routing.js";
16
+ import { handleAdapterControl } from "./adapter-control.js";
15
17
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
18
+ import { isDirectModuleRun } from "../core/direct-run.js";
16
19
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
20
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
21
+ import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
22
+ import { WecomTransport } from "../channels/wecom/wecom-transport.js";
23
+ import { buildWecomInboundPrompt, formatWecomVisibleText, } from "../channels/wecom/wecom-message.js";
17
24
  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";
18
25
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, WeChatTransport, describeWechatTransportError, isWechatContextTokenStaleError, } from "../wechat/wechat-transport.js";
19
- import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
26
+ import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
20
27
  import { checkForUpdate, formatUpdateMessage, } from "../utils/version-checker.js";
21
28
  import { clearDaemonEndpoint, isDaemonEndpointAlive, readDaemonEndpoint, } from "../daemon/daemon-link.js";
22
29
  const POLL_RETRY_BASE_MS = 1_000;
@@ -105,6 +112,7 @@ export function parseCliArgs(argv) {
105
112
  let profile;
106
113
  let lifecycle = "persistent";
107
114
  let sessionStartMode = "restore";
115
+ let channelId = "wechat";
108
116
  for (let i = 0; i < argv.length; i += 1) {
109
117
  const arg = argv[i];
110
118
  const next = argv[i + 1];
@@ -151,6 +159,13 @@ export function parseCliArgs(argv) {
151
159
  sessionStartMode = next;
152
160
  i += 1;
153
161
  break;
162
+ case "--channel":
163
+ if (!next || (next !== "wechat" && next !== "wecom")) {
164
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
165
+ }
166
+ channelId = next;
167
+ i += 1;
168
+ break;
154
169
  case "--shutdown-on-parent-exit":
155
170
  lifecycle = "companion_bound";
156
171
  break;
@@ -173,6 +188,51 @@ export function parseCliArgs(argv) {
173
188
  profile,
174
189
  lifecycle,
175
190
  sessionStartMode,
191
+ channelId,
192
+ };
193
+ }
194
+ function toWecomOutputKind(context) {
195
+ const known = new Set([
196
+ "status",
197
+ "notice",
198
+ "thinking",
199
+ "final_reply",
200
+ "approval_required",
201
+ "user_input_required",
202
+ "mirrored_input",
203
+ "task_failed",
204
+ "fatal_error",
205
+ ]);
206
+ return known.has(context)
207
+ ? context
208
+ : context === "mirrored_user_input"
209
+ ? "mirrored_input"
210
+ : "notice";
211
+ }
212
+ function toWechatSendContext(kind) {
213
+ if (kind === "mirrored_input") {
214
+ return "mirrored_user_input";
215
+ }
216
+ if (kind === "status") {
217
+ return "message";
218
+ }
219
+ return kind;
220
+ }
221
+ function toLegacyInboundWechatMessage(message) {
222
+ return {
223
+ senderId: message.senderId,
224
+ sender: message.senderId,
225
+ sessionId: message.conversation.conversationId,
226
+ text: message.text,
227
+ attachments: message.attachments.map((attachment) => ({
228
+ kind: attachment.kind === "image" ? "image" : "file",
229
+ path: attachment.path,
230
+ fileName: attachment.fileName || path.basename(attachment.path),
231
+ sizeBytes: attachment.sizeBytes ?? 0,
232
+ })),
233
+ contextToken: message.conversation.opaqueRef,
234
+ createdAt: message.createdAt,
235
+ createdAtMs: Date.parse(message.createdAt),
176
236
  };
177
237
  }
178
238
  function printUsageAndExit() {
@@ -180,7 +240,7 @@ function printUsageAndExit() {
180
240
  "Internal bridge runtime usage:",
181
241
  " 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>]",
182
242
  "",
183
- "This entry is internal. Users should run wechat-codex, wechat-claude, wechat-opencode, wechat-pi, or wechat-daemon.",
243
+ "This entry is internal. Users should run a wechat-* or wecom-* launcher.",
184
244
  "",
185
245
  ].join("\n"));
186
246
  process.exit(0);
@@ -194,21 +254,37 @@ async function main() {
194
254
  const options = parseCliArgs(process.argv.slice(2));
195
255
  const daemonEndpoint = readDaemonEndpoint();
196
256
  if (daemonEndpoint && await isDaemonEndpointAlive(daemonEndpoint, { timeoutMs: 500 })) {
197
- throw new Error(`wechat-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
257
+ throw new Error(`${daemonEndpoint.channelId ?? "wechat"}-daemon is already running (pid=${daemonEndpoint.pid}, cwd=${daemonEndpoint.cwd}). Stop it before starting a standalone bridge.`);
198
258
  }
199
259
  if (daemonEndpoint) {
200
260
  clearDaemonEndpoint(daemonEndpoint.pid);
201
261
  log(`Cleared stale wechat-daemon endpoint for pid=${daemonEndpoint.pid}.`);
202
262
  }
203
- const credentials = await ensureWechatCredentials({
204
- requireUserId: true,
205
- validateExisting: true,
206
- log,
207
- });
263
+ const wecomAccount = options.channelId === "wecom"
264
+ ? await ensureWecomAccount({ log })
265
+ : null;
266
+ const credentials = wecomAccount
267
+ ? { userId: wecomAccount.operatorUserId }
268
+ : await ensureWechatCredentials({
269
+ requireUserId: true,
270
+ validateExisting: true,
271
+ log,
272
+ });
208
273
  if (!credentials.userId) {
209
- throw new Error("Saved WeChat credentials are missing userId.");
274
+ throw new Error(options.channelId === "wecom"
275
+ ? "Saved WeCom credentials are missing operatorUserId."
276
+ : "Saved WeChat credentials are missing userId.");
210
277
  }
211
278
  const transport = new WeChatTransport({ log, logError });
279
+ const wecomTransport = wecomAccount
280
+ ? new WecomTransport({
281
+ account: wecomAccount,
282
+ logger: {
283
+ log: (message) => log(message),
284
+ error: (message) => logError(message),
285
+ },
286
+ })
287
+ : null;
212
288
  // 非阻塞地检查更新(不影响启动速度,也避免首次登录时打断二维码输出)
213
289
  // unref:不能让这个延迟检查把 event loop 挂活(如 --doctor 或快速退出场景),
214
290
  // 否则会与强制退出 teardown 竞态。
@@ -227,6 +303,7 @@ async function main() {
227
303
  const stateStore = new BridgeStateStore({
228
304
  ...options,
229
305
  authorizedUserId: credentials.userId,
306
+ channelId: options.channelId,
230
307
  });
231
308
  const reapedPeerPids = await reapPeerBridgeProcesses({
232
309
  logger: (message) => stateStore.appendLog(message),
@@ -273,7 +350,9 @@ async function main() {
273
350
  initialResumeConversationId: stateStore.getState().resumeConversationId,
274
351
  initialTranscriptPath: stateStore.getState().transcriptPath,
275
352
  });
276
- const pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(options.cwd));
353
+ const pendingWechatMessages = new PendingWechatMessageStore(options.channelId === "wechat"
354
+ ? getPendingWechatMessagesFile(options.cwd)
355
+ : getPendingChannelMessagesFile(options.cwd, options.channelId));
277
356
  const resumeCoordinator = new ResumeSessionCoordinator({
278
357
  adapter: options.adapter,
279
358
  runtime: adapter,
@@ -284,7 +363,17 @@ async function main() {
284
363
  let textSendChain = Promise.resolve();
285
364
  let attachmentSendChain = Promise.resolve();
286
365
  const pendingWechatForwardTasks = new Set();
287
- let activeTask = null;
366
+ const defaultWecomConversation = {
367
+ channelId: "wecom",
368
+ accountId: wecomAccount?.botId,
369
+ conversationId: credentials.userId,
370
+ recipientId: credentials.userId,
371
+ metadata: { chatType: "direct" },
372
+ };
373
+ const turnState = {
374
+ activeTask: null,
375
+ lastConversation: defaultWecomConversation,
376
+ };
288
377
  const deferredInboundMessages = [];
289
378
  let drainingDeferredInboundMessages = false;
290
379
  let consecutivePollFailures = 0;
@@ -299,7 +388,36 @@ async function main() {
299
388
  attachmentSendChain = run.then(() => undefined, () => undefined);
300
389
  return run;
301
390
  };
302
- const sendWechatMessageNow = async (senderId, text, context = "message") => {
391
+ const sendWechatMessageNow = async (senderId, text, context = "message", targetOverride) => {
392
+ if (options.channelId === "wecom") {
393
+ const target = targetOverride ??
394
+ (senderId === credentials.userId
395
+ ? resolveConversationTarget({
396
+ active: turnState.activeConversation,
397
+ last: turnState.lastConversation,
398
+ fallback: defaultWecomConversation,
399
+ })
400
+ : {
401
+ channelId: "wecom",
402
+ accountId: wecomAccount?.botId,
403
+ conversationId: senderId,
404
+ recipientId: senderId,
405
+ metadata: { chatType: "direct" },
406
+ });
407
+ const startedAtMs = Date.now();
408
+ stateStore.appendLog(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
409
+ try {
410
+ await wecomTransport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
411
+ stateStore.appendLog(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
412
+ return { status: "sent" };
413
+ }
414
+ catch (error) {
415
+ const message = error instanceof Error ? error.message : String(error);
416
+ stateStore.appendLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
417
+ logError(`Failed to send WeCom ${context}: ${message}`);
418
+ return { status: "context_unavailable", error, target };
419
+ }
420
+ }
303
421
  const startedAtMs = Date.now();
304
422
  stateStore.appendLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
305
423
  for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
@@ -355,11 +473,18 @@ async function main() {
355
473
  }
356
474
  return { status: "failed" };
357
475
  };
358
- const queueWechatMessage = (senderId, text, context = "message") => {
476
+ const queueWechatMessage = (senderId, text, context = "message", targetOverride) => {
477
+ const queuedTarget = options.channelId === "wecom" && senderId === credentials.userId
478
+ ? targetOverride ?? resolveConversationTarget({
479
+ active: turnState.activeConversation,
480
+ last: turnState.lastConversation,
481
+ fallback: defaultWecomConversation,
482
+ })
483
+ : targetOverride;
359
484
  return queueWechatTextAction(async () => {
360
- const result = await sendWechatMessageNow(senderId, text, context);
485
+ const result = await sendWechatMessageNow(senderId, text, context, queuedTarget);
361
486
  if (result.status === "context_unavailable") {
362
- const pending = pendingWechatMessages.enqueue(senderId, text, context);
487
+ const pending = pendingWechatMessages.enqueue(senderId, text, context, result.target);
363
488
  if (pending) {
364
489
  stateStore.appendLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${pendingWechatMessages.list().length}`);
365
490
  }
@@ -370,7 +495,7 @@ async function main() {
370
495
  const flushPendingWechatMessages = () => {
371
496
  return queueWechatTextAction(async () => {
372
497
  for (const pending of pendingWechatMessages.list()) {
373
- const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
498
+ const result = await sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
374
499
  if (result.status === "sent") {
375
500
  pendingWechatMessages.remove(pending.id);
376
501
  stateStore.appendLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -401,7 +526,13 @@ async function main() {
401
526
  }
402
527
  };
403
528
  const outputBatcher = new OutputBatcher(async (text) => {
404
- await queueWechatMessage(stateStore.getState().authorizedUserId, text);
529
+ await queueWechatMessage(stateStore.getState().authorizedUserId, text, "message", options.channelId === "wecom"
530
+ ? resolveConversationTarget({
531
+ active: turnState.activeConversation,
532
+ last: turnState.lastConversation,
533
+ fallback: defaultWecomConversation,
534
+ })
535
+ : undefined);
405
536
  });
406
537
  const maybeDrainDeferredInboundMessages = async () => {
407
538
  if (drainingDeferredInboundMessages || !ensureRuntimeOwnership()) {
@@ -416,7 +547,7 @@ async function main() {
416
547
  hasPendingConfirmation: Boolean(stateStore.getState().pendingConfirmation),
417
548
  hasPendingUserInput: Boolean(stateStore.getState().pendingUserInput),
418
549
  hasPendingApproval: Boolean(adapterState.pendingApproval),
419
- hasActiveTask: Boolean(activeTask),
550
+ hasActiveTask: Boolean(turnState.activeTask),
420
551
  })) {
421
552
  return;
422
553
  }
@@ -427,13 +558,25 @@ async function main() {
427
558
  drainingDeferredInboundMessages = true;
428
559
  try {
429
560
  stateStore.appendLog(`draining_deferred_inbound_input: remaining=${deferredInboundMessages.length} text=${truncatePreview(nextDeferred.message.text)}`);
430
- const nextTask = await dispatchInboundWechatText({
431
- message: nextDeferred.message,
432
- options,
433
- stateStore,
434
- adapter,
435
- });
436
- activeTask = nextTask;
561
+ const nextTask = createActiveTask(nextDeferred.message);
562
+ const lease = tryBeginTurn(turnState, nextTask, nextDeferred.channelMessage?.conversation);
563
+ if (!lease) {
564
+ deferredInboundMessages.unshift(nextDeferred);
565
+ return;
566
+ }
567
+ try {
568
+ await dispatchInboundWechatText({
569
+ message: nextDeferred.message,
570
+ options,
571
+ stateStore,
572
+ adapter,
573
+ activeTask: nextTask,
574
+ });
575
+ }
576
+ catch (error) {
577
+ rollbackTurn(turnState, lease);
578
+ throw error;
579
+ }
437
580
  }
438
581
  catch (err) {
439
582
  const errorText = err instanceof Error ? err.message : String(err);
@@ -448,7 +591,7 @@ async function main() {
448
591
  adapter: options.adapter,
449
592
  cwd: options.cwd,
450
593
  errorText,
451
- }), "inbound_error");
594
+ }), "inbound_error", nextDeferred.channelMessage?.conversation);
452
595
  }
453
596
  finally {
454
597
  drainingDeferredInboundMessages = false;
@@ -515,6 +658,12 @@ async function main() {
515
658
  catch {
516
659
  // Best effort shutdown.
517
660
  }
661
+ try {
662
+ wecomTransport?.stop();
663
+ }
664
+ catch {
665
+ // Best effort shutdown.
666
+ }
518
667
  controller.clearLocalClientEndpoint();
519
668
  stateStore.releaseLock();
520
669
  };
@@ -592,8 +741,11 @@ async function main() {
592
741
  queueWechatMessage,
593
742
  trackWechatForwardTask,
594
743
  maybeDrainDeferredInboundMessages,
595
- clearActiveTask: () => {
596
- activeTask = null;
744
+ getActiveTask: () => turnState.activeTask,
745
+ clearActiveTask: (expectedTask) => {
746
+ if (expectedTask) {
747
+ clearTurn(turnState, expectedTask);
748
+ }
597
749
  },
598
750
  syncSharedSessionState: () => {
599
751
  syncSharedSessionState(stateStore, adapter);
@@ -602,6 +754,12 @@ async function main() {
602
754
  controller.syncLocalClientEndpoint();
603
755
  },
604
756
  requestShutdown,
757
+ wecomTransport,
758
+ getWecomTarget: () => resolveConversationTarget({
759
+ active: turnState.activeConversation,
760
+ last: turnState.lastConversation,
761
+ fallback: defaultWecomConversation,
762
+ }),
605
763
  });
606
764
  await adapter.start();
607
765
  if (!ensureRuntimeOwnership()) {
@@ -609,13 +767,112 @@ async function main() {
609
767
  }
610
768
  syncSharedSessionState(stateStore, adapter);
611
769
  controller.syncLocalClientEndpoint();
612
- stateStore.appendLog(`Bridge started with adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
613
- log(`WeChat bridge is ready for adapter "${options.adapter}".`);
770
+ stateStore.appendLog(`Bridge started with channel=${options.channelId} adapter=${options.adapter} command=${options.command} cwd=${options.cwd}`);
771
+ if (wecomTransport) {
772
+ wecomTransport.setHandlers({
773
+ onMessage: async (channelMessage) => {
774
+ if (shutdownPromise || !ensureRuntimeOwnership()) {
775
+ return;
776
+ }
777
+ turnState.lastConversation = channelMessage.conversation;
778
+ const queueInboundReply = (senderId, text, context) => queueWechatMessage(senderId, text, context, channelMessage.conversation);
779
+ if (pendingWechatMessages.list().length > 0) {
780
+ await flushPendingWechatMessages();
781
+ }
782
+ const message = toLegacyInboundWechatMessage(channelMessage);
783
+ const taskAtMessageStart = turnState.activeTask;
784
+ stateStore.touchActivity(message.createdAt);
785
+ try {
786
+ await handleInboundMessage({
787
+ message,
788
+ channelMessage,
789
+ options,
790
+ stateStore,
791
+ adapter,
792
+ resumeCoordinator,
793
+ queueWechatMessage: queueInboundReply,
794
+ outputBatcher,
795
+ clearActiveTask: () => {
796
+ if (taskAtMessageStart) {
797
+ clearTurn(turnState, taskAtMessageStart);
798
+ }
799
+ },
800
+ dispatchInboundText: async () => {
801
+ const nextActiveTask = createActiveTask(message);
802
+ const lease = tryBeginTurn(turnState, nextActiveTask, channelMessage.conversation);
803
+ if (!lease) {
804
+ await queueInboundReply(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
805
+ return null;
806
+ }
807
+ try {
808
+ await dispatchInboundWechatText({
809
+ message,
810
+ options,
811
+ stateStore,
812
+ adapter,
813
+ activeTask: nextActiveTask,
814
+ });
815
+ return nextActiveTask;
816
+ }
817
+ catch (error) {
818
+ rollbackTurn(turnState, lease);
819
+ throw error;
820
+ }
821
+ },
822
+ deferInboundMessage: async (nextMessage) => {
823
+ deferredInboundMessages.push({
824
+ message: nextMessage,
825
+ channelMessage,
826
+ });
827
+ stateStore.appendLog(`deferred_inbound_input: position=${deferredInboundMessages.length} text=${truncatePreview(nextMessage.text)}`);
828
+ await queueInboundReply(nextMessage.senderId, formatDeferredCodexInboundQueueMessage(deferredInboundMessages.length));
829
+ },
830
+ });
831
+ }
832
+ catch (error) {
833
+ const errorText = error instanceof Error ? error.message : String(error);
834
+ logError(errorText);
835
+ stateStore.appendLog(`inbound_error: ${errorText}`);
836
+ await queueInboundReply(message.senderId, formatUserFacingInboundError({
837
+ adapter: options.adapter,
838
+ cwd: options.cwd,
839
+ errorText,
840
+ }), "inbound_error");
841
+ }
842
+ syncSharedSessionState(stateStore, adapter);
843
+ await maybeDrainDeferredInboundMessages();
844
+ },
845
+ onUnauthorized: async (senderId, chatType) => {
846
+ stateStore.appendLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
847
+ if (chatType === "direct") {
848
+ await wecomTransport.sendText({
849
+ channelId: "wecom",
850
+ accountId: wecomAccount.botId,
851
+ conversationId: senderId,
852
+ recipientId: senderId,
853
+ metadata: { chatType: "direct" },
854
+ }, "Unauthorized.", "notice");
855
+ }
856
+ },
857
+ onFatal: async (error) => {
858
+ stateStore.appendLog(`wecom_fatal_error: ${error.message}`);
859
+ requestShutdown(error.message, 1);
860
+ },
861
+ onConnected: async () => {
862
+ if (pendingWechatMessages.list().length > 0) {
863
+ await flushPendingWechatMessages();
864
+ }
865
+ },
866
+ });
867
+ wecomTransport.start();
868
+ await wecomTransport.waitUntilConnected();
869
+ }
870
+ log(`${options.channelId === "wecom" ? "WeCom" : "WeChat"} bridge is ready for adapter "${options.adapter}".`);
614
871
  log(`Working directory: ${options.cwd}`);
615
872
  if (options.profile) {
616
873
  log(`Profile: ${options.profile}`);
617
874
  }
618
- log(`Authorized WeChat user: ${credentials.userId}`);
875
+ log(`Authorized ${options.channelId === "wecom" ? "WeCom" : "WeChat"} user: ${credentials.userId}`);
619
876
  if (options.adapter === "codex") {
620
877
  log("For source-mode debugging, open the visible Codex client with: npm run codex:panel");
621
878
  }
@@ -633,6 +890,12 @@ async function main() {
633
890
  cwd: options.cwd,
634
891
  });
635
892
  await queueWechatMessage(credentials.userId, welcomeText);
893
+ if (options.channelId === "wecom") {
894
+ while (!shutdownPromise && ensureRuntimeOwnership()) {
895
+ await delay(1_000);
896
+ }
897
+ return;
898
+ }
636
899
  while (true) {
637
900
  if (shutdownPromise) {
638
901
  // Shutdown (e.g. SIGINT) was requested while we were awaiting the
@@ -693,10 +956,10 @@ async function main() {
693
956
  if (!ensureRuntimeOwnership()) {
694
957
  break;
695
958
  }
959
+ const taskAtMessageStart = turnState.activeTask;
696
960
  stateStore.touchActivity(message.createdAt);
697
- let nextTask = null;
698
961
  try {
699
- nextTask = await handleInboundMessage({
962
+ await handleInboundMessage({
700
963
  message,
701
964
  options,
702
965
  stateStore,
@@ -705,7 +968,31 @@ async function main() {
705
968
  queueWechatMessage,
706
969
  outputBatcher,
707
970
  clearActiveTask: () => {
708
- activeTask = null;
971
+ if (taskAtMessageStart) {
972
+ clearTurn(turnState, taskAtMessageStart);
973
+ }
974
+ },
975
+ dispatchInboundText: async () => {
976
+ const nextActiveTask = createActiveTask(message);
977
+ const lease = tryBeginTurn(turnState, nextActiveTask);
978
+ if (!lease) {
979
+ await queueWechatMessage(message.senderId, `${options.adapter} is still working. Wait for the current reply or use /stop.`);
980
+ return null;
981
+ }
982
+ try {
983
+ await dispatchInboundWechatText({
984
+ message,
985
+ options,
986
+ stateStore,
987
+ adapter,
988
+ activeTask: nextActiveTask,
989
+ });
990
+ return nextActiveTask;
991
+ }
992
+ catch (error) {
993
+ rollbackTurn(turnState, lease);
994
+ throw error;
995
+ }
709
996
  },
710
997
  deferInboundMessage: async (nextMessage) => {
711
998
  deferredInboundMessages.push({
@@ -726,9 +1013,6 @@ async function main() {
726
1013
  errorText,
727
1014
  }), "inbound_error");
728
1015
  }
729
- if (nextTask) {
730
- activeTask = nextTask;
731
- }
732
1016
  syncSharedSessionState(stateStore, adapter);
733
1017
  await maybeDrainDeferredInboundMessages();
734
1018
  }
@@ -763,20 +1047,28 @@ function syncSharedSessionState(stateStore, adapter) {
763
1047
  }
764
1048
  }
765
1049
  function wireAdapterEvents(params) {
766
- const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, } = params;
767
- const channelPort = new WechatChannelPort({
768
- sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
769
- sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
770
- sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
771
- sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
772
- sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
773
- onEmptyVisibleReply: (adapter, rawText) => {
774
- stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
775
- },
776
- onTextSent: (_adapter, text) => {
777
- stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
778
- },
779
- });
1050
+ const { adapter, resumeCoordinator, options, transport, stateStore, outputBatcher, queueWechatAttachmentAction, queueWechatMessage, trackWechatForwardTask, maybeDrainDeferredInboundMessages, getActiveTask, clearActiveTask, syncSharedSessionState, syncLocalClientEndpoint, requestShutdown, wecomTransport, getWecomTarget, } = params;
1051
+ const channelPort = options.channelId === "wecom"
1052
+ ? new WecomChannelPort({
1053
+ transport: wecomTransport,
1054
+ sendText: (target, text, kind) => queueWechatMessage(stateStore.getState().authorizedUserId, text, toWechatSendContext(kind), target),
1055
+ onEmptyVisibleReply: (adapter, rawText) => {
1056
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
1057
+ },
1058
+ })
1059
+ : new WechatChannelPort({
1060
+ sendText: (recipientId, text, context) => queueWechatMessage(recipientId, text, context),
1061
+ sendImage: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendImage(filePath, { recipientId })),
1062
+ sendFile: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendFile(filePath, { recipientId })),
1063
+ sendVoice: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVoice(filePath, recipientId)),
1064
+ sendVideo: (recipientId, filePath) => queueWechatAttachmentAction(() => transport.sendVideo(filePath, { recipientId })),
1065
+ onEmptyVisibleReply: (adapter, rawText) => {
1066
+ stateStore.appendLog(`empty_visible_final_reply: adapter=${adapter ?? options.adapter} raw=${truncatePreview(rawText)}`);
1067
+ },
1068
+ onTextSent: (_adapter, text) => {
1069
+ stateStore.appendLog(`final_reply_sent: chars=${Array.from(text).length}`);
1070
+ },
1071
+ });
780
1072
  let lastFinalReplyAtMs = 0;
781
1073
  let eventForwardChain = Promise.resolve();
782
1074
  adapter.setEventSink((event) => {
@@ -791,6 +1083,14 @@ function wireAdapterEvents(params) {
791
1083
  stateStore.clearPendingUserInput();
792
1084
  }
793
1085
  const authorizedUserId = stateStore.getState().authorizedUserId;
1086
+ const eventTarget = options.channelId === "wecom"
1087
+ ? getWecomTarget()
1088
+ : {
1089
+ channelId: "wechat",
1090
+ conversationId: authorizedUserId,
1091
+ recipientId: authorizedUserId,
1092
+ };
1093
+ const eventTask = getActiveTask();
794
1094
  eventForwardChain = eventForwardChain
795
1095
  .then(() => forwardBridgeEvent(event, {
796
1096
  stdout: (next) => {
@@ -808,11 +1108,7 @@ function wireAdapterEvents(params) {
808
1108
  stateStore.appendLog(`final_reply: ${truncatePreview(next.text)}`);
809
1109
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
810
1110
  await channelPort.send({
811
- target: {
812
- channelId: "wechat",
813
- conversationId: authorizedUserId,
814
- recipientId: authorizedUserId,
815
- },
1111
+ target: eventTarget,
816
1112
  kind: "final_reply",
817
1113
  text: next.text,
818
1114
  adapter: options.adapter,
@@ -830,7 +1126,7 @@ function wireAdapterEvents(params) {
830
1126
  stateStore.appendLog(`${next.level}_notice: ${truncatePreview(next.text)}`);
831
1127
  if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
832
1128
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
833
- await queueWechatMessage(authorizedUserId, next.text, "notice");
1129
+ await queueWechatMessage(authorizedUserId, next.text, "notice", eventTarget);
834
1130
  }));
835
1131
  }
836
1132
  },
@@ -840,7 +1136,9 @@ function wireAdapterEvents(params) {
840
1136
  if (thinkingPreview) {
841
1137
  stateStore.appendLog(`thinking: ${thinkingPreview}`);
842
1138
  trackWechatForwardTask((async () => {
843
- await queueWechatMessage(authorizedUserId, `思考: ${thinkingPreview}`, "thinking");
1139
+ await queueWechatMessage(authorizedUserId, options.channelId === "wecom"
1140
+ ? `Processing: ${thinkingPreview}`
1141
+ : `思考: ${thinkingPreview}`, "thinking", eventTarget);
844
1142
  })());
845
1143
  }
846
1144
  }
@@ -850,7 +1148,7 @@ function wireAdapterEvents(params) {
850
1148
  const pending = toPendingApproval(next.request);
851
1149
  stateStore.setPendingConfirmation(pending);
852
1150
  stateStore.appendLog(`Approval requested (${pending.source}): ${pending.commandPreview}`);
853
- await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required");
1151
+ await queueWechatMessage(authorizedUserId, formatApprovalMessage(pending, adapterState), "approval_required", eventTarget);
854
1152
  }));
855
1153
  },
856
1154
  userInputRequired: (next) => {
@@ -858,14 +1156,14 @@ function wireAdapterEvents(params) {
858
1156
  const pending = toPendingUserInput(next.request);
859
1157
  stateStore.setPendingUserInput(pending);
860
1158
  stateStore.appendLog(`User input requested: questions=${pending.questions.length}`);
861
- await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required");
1159
+ await queueWechatMessage(authorizedUserId, formatUserInputRequestMessage(pending, adapterState), "user_input_required", eventTarget);
862
1160
  }));
863
1161
  },
864
1162
  mirroredUserInput: (next) => {
865
1163
  stateStore.appendLog(`mirrored_local_input: ${truncatePreview(next.text)}`);
866
1164
  if (shouldForwardBridgeEventToWechat(options.adapter, next.type, { text: next.text })) {
867
1165
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
868
- await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, next.text), "mirrored_user_input");
1166
+ await queueWechatMessage(authorizedUserId, formatMirroredUserInputMessage(options.adapter, next.text), "mirrored_user_input", eventTarget);
869
1167
  }));
870
1168
  }
871
1169
  },
@@ -875,7 +1173,7 @@ function wireAdapterEvents(params) {
875
1173
  stateStore.appendLog(`session_switched: ${next.sessionId} source=${next.source} reason=${next.reason}`);
876
1174
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
877
1175
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
878
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason }), "session_switched");
1176
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason }), "session_switched", eventTarget);
879
1177
  }));
880
1178
  }
881
1179
  },
@@ -893,7 +1191,7 @@ function wireAdapterEvents(params) {
893
1191
  }
894
1192
  if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(options.adapter, next.type)) {
895
1193
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
896
- await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.threadId, source: next.source, reason: next.reason }), "thread_switched");
1194
+ await queueWechatMessage(authorizedUserId, formatSessionSwitchMessage({ adapter: options.adapter, sessionId: next.threadId, source: next.source, reason: next.reason }), "thread_switched", eventTarget);
897
1195
  }));
898
1196
  }
899
1197
  void maybeDrainDeferredInboundMessages();
@@ -902,7 +1200,7 @@ function wireAdapterEvents(params) {
902
1200
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
903
1201
  stateStore.clearPendingConfirmation();
904
1202
  stateStore.clearPendingUserInput();
905
- clearActiveTask();
1203
+ clearActiveTask(eventTask);
906
1204
  await maybeDrainDeferredInboundMessages();
907
1205
  }));
908
1206
  },
@@ -910,8 +1208,8 @@ function wireAdapterEvents(params) {
910
1208
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
911
1209
  stateStore.clearPendingConfirmation();
912
1210
  stateStore.clearPendingUserInput();
913
- clearActiveTask();
914
- await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, next.message), "task_failed");
1211
+ clearActiveTask(eventTask);
1212
+ await queueWechatMessage(authorizedUserId, formatTaskFailedMessage(options.adapter, next.message), "task_failed", eventTarget);
915
1213
  await maybeDrainDeferredInboundMessages();
916
1214
  }));
917
1215
  },
@@ -920,9 +1218,9 @@ function wireAdapterEvents(params) {
920
1218
  stateStore.appendLog(`fatal_error: ${next.message}`);
921
1219
  stateStore.clearPendingConfirmation();
922
1220
  stateStore.clearPendingUserInput();
923
- clearActiveTask();
1221
+ clearActiveTask(eventTask);
924
1222
  trackWechatForwardTask(outputBatcher.flushNow().then(async () => {
925
- await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(next.message), "fatal_error");
1223
+ await queueWechatMessage(authorizedUserId, formatUserFacingBridgeFatalError(next.message), "fatal_error", eventTarget);
926
1224
  await maybeDrainDeferredInboundMessages();
927
1225
  }));
928
1226
  },
@@ -950,11 +1248,13 @@ function formatInboundMessagePreview(message) {
950
1248
  return "(empty)";
951
1249
  }
952
1250
  async function handleInboundMessage(params) {
953
- const { message, } = params;
954
- const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, deferInboundMessage, } = params;
1251
+ const { message, channelMessage, } = params;
1252
+ const { options, stateStore, adapter, resumeCoordinator, queueWechatMessage, outputBatcher, clearActiveTask, dispatchInboundText, deferInboundMessage, } = params;
955
1253
  const state = stateStore.getState();
956
1254
  if (message.senderId !== state.authorizedUserId) {
957
- await queueWechatMessage(message.senderId, "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
1255
+ await queueWechatMessage(message.senderId, options.channelId === "wecom"
1256
+ ? "Unauthorized. This bridge only accepts messages from the paired WeCom operator."
1257
+ : "Unauthorized. This bridge only accepts messages from the configured WeChat owner.");
958
1258
  return null;
959
1259
  }
960
1260
  const systemCommand = parseWechatControlCommand(message.text, {
@@ -963,12 +1263,16 @@ async function handleInboundMessage(params) {
963
1263
  hasPendingUserInput: Boolean(state.pendingUserInput),
964
1264
  });
965
1265
  switch (systemCommand?.type) {
1266
+ case "model":
1267
+ case "plan":
1268
+ await queueWechatMessage(message.senderId, await handleAdapterControl(adapter, message.senderId, systemCommand));
1269
+ return null;
966
1270
  case "status":
967
1271
  await queueWechatMessage(message.senderId, formatStatusReport(stateStore.getState(), adapter.getState()));
968
1272
  return null;
969
1273
  case "resume": {
970
1274
  if (!isWechatResumeEnabled(options.adapter)) {
971
- 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.`);
1275
+ await queueWechatMessage(message.senderId, `${options.channelId === "wecom" ? "WeCom" : "WeChat"} /resume is disabled in ${options.adapter} mode. Use /resume directly inside "${options.channelId}-${options.adapter}"; the remote channel will follow the active local session.`);
972
1276
  return null;
973
1277
  }
974
1278
  try {
@@ -1081,7 +1385,7 @@ async function handleInboundMessage(params) {
1081
1385
  }
1082
1386
  const adapterState = adapter.getState();
1083
1387
  const routeResult = await routeBridgeMessage({
1084
- message: toChannelInboundMessage(message),
1388
+ message: channelMessage ?? toChannelInboundMessage(message),
1085
1389
  authorized: true,
1086
1390
  command: null,
1087
1391
  adapterState,
@@ -1117,29 +1421,27 @@ async function handleInboundMessage(params) {
1117
1421
  defer: async () => {
1118
1422
  await deferInboundMessage(message);
1119
1423
  },
1120
- dispatch: async () => {
1121
- return await dispatchInboundWechatText({
1122
- message,
1123
- options,
1124
- stateStore,
1125
- adapter,
1126
- });
1127
- },
1424
+ dispatch: dispatchInboundText,
1128
1425
  });
1129
1426
  return routeResult.kind === "dispatched" ? routeResult.result : null;
1130
1427
  }
1428
+ function createActiveTask(message) {
1429
+ return {
1430
+ startedAt: Date.now(),
1431
+ inputPreview: truncatePreview(formatInboundMessagePreview(message), 180),
1432
+ };
1433
+ }
1131
1434
  async function dispatchInboundWechatText(params) {
1132
1435
  const { message, options, stateStore, adapter } = params;
1133
1436
  const preview = formatInboundMessagePreview(message);
1134
- const activeTask = {
1135
- startedAt: Date.now(),
1136
- inputPreview: truncatePreview(preview, 180),
1137
- };
1437
+ const activeTask = params.activeTask ?? createActiveTask(message);
1138
1438
  stateStore.appendLog(`Forwarded input to ${options.adapter}: ${truncatePreview(preview)}`);
1139
- await adapter.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
1439
+ await adapter.sendInput(options.channelId === "wecom"
1440
+ ? buildWecomInboundPrompt(message.text, message.attachments)
1441
+ : buildWechatInboundPrompt(message.text, message.attachments));
1140
1442
  return activeTask;
1141
1443
  }
1142
- const isDirectRun = Boolean(import.meta.main);
1444
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1143
1445
  if (isDirectRun) {
1144
1446
  main().catch((err) => {
1145
1447
  logError(describeWechatTransportError(err));