cli-wechat-bridge 1.1.6 → 1.1.7

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 (34) hide show
  1. package/README.md +163 -198
  2. package/bin/wecom-check-update.mjs +5 -0
  3. package/bin/wecom-claude.mjs +5 -0
  4. package/bin/wecom-codex.mjs +5 -0
  5. package/bin/wecom-daemon.mjs +5 -0
  6. package/bin/wecom-opencode.mjs +5 -0
  7. package/bin/wecom-pi.mjs +5 -0
  8. package/bin/wecom-setup.mjs +5 -0
  9. package/dist/bridge/bridge-adapters.claude.js +126 -3
  10. package/dist/bridge/bridge-process-reaper.js +35 -2
  11. package/dist/bridge/bridge-state.js +10 -3
  12. package/dist/bridge/wechat-bridge.js +277 -44
  13. package/dist/channels/wechat/wechat-outbound-queue.js +10 -3
  14. package/dist/channels/wecom/bridge-final-reply.js +26 -0
  15. package/dist/channels/wecom/setup-cli.js +6 -0
  16. package/dist/channels/wecom/setup.js +176 -0
  17. package/dist/channels/wecom/wecom-channel-port.js +45 -0
  18. package/dist/channels/wecom/wecom-config.js +66 -0
  19. package/dist/channels/wecom/wecom-media.js +141 -0
  20. package/dist/channels/wecom/wecom-message.js +149 -0
  21. package/dist/channels/wecom/wecom-transport.js +308 -0
  22. package/dist/companion/codex-remote-client.js +2 -1
  23. package/dist/companion/local-companion-start.js +49 -9
  24. package/dist/companion/local-companion.js +2 -1
  25. package/dist/core/channel-types.js +3 -1
  26. package/dist/core/direct-run.js +17 -0
  27. package/dist/core/inbound-message-claims.js +61 -0
  28. package/dist/daemon/daemon-link.js +3 -0
  29. package/dist/daemon/wechat-daemon.js +265 -36
  30. package/dist/i18n/messages-en.js +2 -0
  31. package/dist/i18n/messages-zh.js +2 -0
  32. package/dist/utils/doctor.js +30 -10
  33. package/dist/wechat/setup.js +113 -13
  34. package/package.json +20 -10
@@ -17,12 +17,17 @@ import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataD
17
17
  import { ensureWechatCredentials } from "../wechat/setup.js";
18
18
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
19
19
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
20
- import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
20
+ import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
21
21
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
22
22
  import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
23
23
  import { routeBridgeMessage } from "../core/bridge-message-router.js";
24
24
  import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
25
+ import { isDirectModuleRun } from "../core/direct-run.js";
25
26
  import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
27
+ import { ensureWecomAccount } from "../channels/wecom/setup.js";
28
+ import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
29
+ import { WecomTransport } from "../channels/wecom/wecom-transport.js";
30
+ import { buildWecomInboundPrompt, formatWecomVisibleText, } from "../channels/wecom/wecom-message.js";
26
31
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
27
32
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
28
33
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -40,11 +45,15 @@ const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
40
45
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
41
46
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
42
47
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
48
+ function getCliChannelId(argv = process.argv.slice(2)) {
49
+ const index = argv.indexOf("--channel");
50
+ return index >= 0 && argv[index + 1] === "wecom" ? "wecom" : "wechat";
51
+ }
43
52
  function log(message) {
44
- process.stderr.write(`[wechat-daemon] ${message}\n`);
53
+ process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
45
54
  }
46
55
  function logError(message) {
47
- process.stderr.write(`[wechat-daemon] ERROR: ${message}\n`);
56
+ process.stderr.write(`[${getCliChannelId()}-daemon] ERROR: ${message}\n`);
48
57
  }
49
58
  function appendDaemonLog(message) {
50
59
  ensureChannelDataDir();
@@ -55,6 +64,48 @@ function computePollRetryDelayMs(consecutiveFailures) {
55
64
  const exponent = Math.min(normalizedFailures - 1, 5);
56
65
  return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
57
66
  }
67
+ function toWecomOutputKind(context) {
68
+ switch (context) {
69
+ case "notice":
70
+ case "thinking":
71
+ case "final_reply":
72
+ case "approval_required":
73
+ case "user_input_required":
74
+ case "task_failed":
75
+ case "fatal_error":
76
+ return context;
77
+ case "mirrored_user_input":
78
+ return "mirrored_input";
79
+ default:
80
+ return "notice";
81
+ }
82
+ }
83
+ function toWechatSendContext(kind) {
84
+ if (kind === "mirrored_input") {
85
+ return "mirrored_user_input";
86
+ }
87
+ if (kind === "status") {
88
+ return "message";
89
+ }
90
+ return kind;
91
+ }
92
+ function toLegacyInboundWechatMessage(message) {
93
+ return {
94
+ senderId: message.senderId,
95
+ sender: message.senderId,
96
+ sessionId: message.conversation.conversationId,
97
+ text: message.text,
98
+ attachments: message.attachments.map((attachment) => ({
99
+ kind: attachment.kind === "image" ? "image" : "file",
100
+ path: attachment.path,
101
+ fileName: attachment.fileName || path.basename(attachment.path),
102
+ sizeBytes: attachment.sizeBytes ?? 0,
103
+ })),
104
+ contextToken: message.conversation.opaqueRef,
105
+ createdAt: message.createdAt,
106
+ createdAtMs: Date.parse(message.createdAt),
107
+ };
108
+ }
58
109
  function isDaemonAdapterKind(value) {
59
110
  return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
60
111
  }
@@ -76,6 +127,7 @@ export function parseDaemonCliArgs(argv) {
76
127
  let profile;
77
128
  let initialAdapter;
78
129
  let openVisible = true;
130
+ let channelId = "wechat";
79
131
  for (let i = 0; i < argv.length; i += 1) {
80
132
  const arg = argv[i];
81
133
  if (!arg) {
@@ -84,10 +136,10 @@ export function parseDaemonCliArgs(argv) {
84
136
  const next = argv[i + 1];
85
137
  if (arg === "--help" || arg === "-h") {
86
138
  process.stdout.write([
87
- "Usage: wechat-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]",
139
+ `Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
88
140
  "",
89
- "Keeps one WeChat connection alive and switches between Codex, Claude Code, OpenCode, and Pi from WeChat.",
90
- "Send /codex, /claude, /opencode, or /pi in WeChat to switch the active terminal.",
141
+ `Keeps one ${channelId === "wecom" ? "WeCom" : "WeChat"} connection alive and switches between Codex, Claude Code, OpenCode, and Pi.`,
142
+ `Send /codex, /claude, /opencode, or /pi in ${channelId === "wecom" ? "WeCom" : "WeChat"} to switch the active terminal.`,
91
143
  "",
92
144
  ].join("\n"));
93
145
  process.exit(0);
@@ -100,6 +152,14 @@ export function parseDaemonCliArgs(argv) {
100
152
  i += 1;
101
153
  continue;
102
154
  }
155
+ if (arg === "--channel") {
156
+ if (!next || (next !== "wechat" && next !== "wecom")) {
157
+ throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
158
+ }
159
+ channelId = next;
160
+ i += 1;
161
+ continue;
162
+ }
103
163
  if (arg === "--adapter") {
104
164
  if (!isDaemonAdapterKind(next)) {
105
165
  throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
@@ -122,7 +182,13 @@ export function parseDaemonCliArgs(argv) {
122
182
  }
123
183
  throw new Error(`Unknown argument: ${arg}`);
124
184
  }
125
- return { cwd, profile, initialAdapter, openVisible };
185
+ return {
186
+ cwd,
187
+ profile,
188
+ initialAdapter,
189
+ openVisible,
190
+ ...(channelId === "wecom" ? { channelId } : {}),
191
+ };
126
192
  }
127
193
  export function parseDaemonSwitchCommand(text) {
128
194
  const normalized = text.trim().toLowerCase();
@@ -221,7 +287,7 @@ export function buildVisibleClientLaunchArgs(params) {
221
287
  export function buildWindowsVisibleClientLaunchCommand(params) {
222
288
  return [
223
289
  "start",
224
- quoteWindowsCommandArg(`wechat-${params.adapter}`),
290
+ quoteWindowsCommandArg(`${params.channelId ?? "wechat"}-${params.adapter}`),
225
291
  "/D",
226
292
  quoteWindowsCommandArg(params.cwd),
227
293
  quoteWindowsCommandArg(process.execPath),
@@ -269,6 +335,7 @@ function openVisibleClient(params) {
269
335
  adapter: params.adapter,
270
336
  cwd: params.cwd,
271
337
  args,
338
+ channelId: params.channelId,
272
339
  }),
273
340
  ];
274
341
  const child = spawn(command, launchArgs, {
@@ -289,7 +356,7 @@ function openVisibleClient(params) {
289
356
  pid: child.pid,
290
357
  };
291
358
  }
292
- const title = `wechat-${params.adapter}`;
359
+ const title = `${params.channelId ?? "wechat"}-${params.adapter}`;
293
360
  const fullArgs = [process.execPath, ...args];
294
361
  if (process.platform === "darwin") {
295
362
  const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
@@ -486,6 +553,10 @@ class WechatDaemon {
486
553
  profile;
487
554
  authorizedUserId;
488
555
  transport;
556
+ channelId;
557
+ wecomTransport;
558
+ currentInboundConversation = null;
559
+ handlingInboundMessage = false;
489
560
  slots = new Map();
490
561
  // Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
491
562
  slotEnsureChains = new Map();
@@ -506,7 +577,19 @@ class WechatDaemon {
506
577
  this.profile = params.profile;
507
578
  this.authorizedUserId = params.authorizedUserId;
508
579
  this.transport = params.transport;
509
- this.pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(this.cwd));
580
+ this.channelId = params.channelId ?? "wechat";
581
+ this.wecomTransport = params.wecomTransport ?? null;
582
+ if (this.channelId === "wecom") {
583
+ this.currentInboundConversation = {
584
+ channelId: "wecom",
585
+ conversationId: params.authorizedUserId,
586
+ recipientId: params.authorizedUserId,
587
+ metadata: { chatType: "direct" },
588
+ };
589
+ }
590
+ this.pendingWechatMessages = new PendingWechatMessageStore(this.channelId === "wechat"
591
+ ? getPendingWechatMessagesFile(this.cwd)
592
+ : getPendingChannelMessagesFile(this.cwd, this.channelId));
510
593
  }
511
594
  async startIpcServer() {
512
595
  this.endpointToken = buildDaemonToken();
@@ -554,6 +637,7 @@ class WechatDaemon {
554
637
  token: this.endpointToken,
555
638
  cwd: this.cwd,
556
639
  startedAt: this.startedAt,
640
+ channelId: this.channelId,
557
641
  });
558
642
  resolve();
559
643
  });
@@ -562,6 +646,7 @@ class WechatDaemon {
562
646
  getStatus() {
563
647
  return {
564
648
  cwd: this.cwd,
649
+ channelId: this.channelId,
565
650
  activeAdapter: this.activeAdapter ?? undefined,
566
651
  startedAt: this.startedAt,
567
652
  slots: Array.from(this.slots.values()).map((slot) => {
@@ -590,10 +675,52 @@ class WechatDaemon {
590
675
  }
591
676
  async runPollLoop() {
592
677
  let consecutivePollFailures = 0;
593
- log("WeChat daemon is ready.");
678
+ if (this.wecomTransport) {
679
+ this.wecomTransport.setHandlers({
680
+ onMessage: async (channelMessage) => {
681
+ if (this.shutdownPromise) {
682
+ return;
683
+ }
684
+ this.currentInboundConversation = channelMessage.conversation;
685
+ this.handlingInboundMessage = true;
686
+ try {
687
+ if (this.pendingWechatMessages.list().length > 0) {
688
+ await this.flushPendingWechatMessages();
689
+ }
690
+ await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
691
+ }
692
+ finally {
693
+ this.handlingInboundMessage = false;
694
+ }
695
+ },
696
+ onUnauthorized: async (senderId, chatType) => {
697
+ appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
698
+ if (chatType === "direct") {
699
+ await this.wecomTransport.sendText({
700
+ channelId: "wecom",
701
+ conversationId: senderId,
702
+ recipientId: senderId,
703
+ metadata: { chatType: "direct" },
704
+ }, "Unauthorized.", "notice");
705
+ }
706
+ },
707
+ onFatal: async (error) => {
708
+ appendDaemonLog(`wecom_fatal_error: ${error.message}`);
709
+ await this.shutdown();
710
+ },
711
+ onConnected: async () => {
712
+ if (this.pendingWechatMessages.list().length > 0) {
713
+ await this.flushPendingWechatMessages();
714
+ }
715
+ },
716
+ });
717
+ this.wecomTransport.start();
718
+ await this.wecomTransport.waitUntilConnected();
719
+ }
720
+ log(`${this.channelId === "wecom" ? "WeCom" : "WeChat"} daemon is ready.`);
594
721
  log(`Working directory: ${this.cwd}`);
595
- log("Switch from WeChat with /codex, /claude, /opencode, or /pi.");
596
- appendDaemonLog(`started: cwd=${this.cwd}`);
722
+ log(`Switch from ${this.channelId === "wecom" ? "WeCom" : "WeChat"} with /codex, /claude, /opencode, or /pi.`);
723
+ appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
597
724
  const activeSlot = this.getActiveSlot();
598
725
  const welcomeText = t("daemon.welcome", {
599
726
  cwd: this.cwd,
@@ -601,6 +728,13 @@ class WechatDaemon {
601
728
  bindings: formatBindingsListMessage(listBindings()),
602
729
  });
603
730
  await this.queueWechatMessage(this.authorizedUserId, welcomeText);
731
+ if (this.channelId === "wecom") {
732
+ while (!this.shutdownPromise) {
733
+ await delay(1_000);
734
+ }
735
+ await this.shutdownPromise;
736
+ return;
737
+ }
604
738
  while (!this.shutdownPromise) {
605
739
  let pollResult;
606
740
  try {
@@ -705,6 +839,12 @@ class WechatDaemon {
705
839
  await this.waitForPendingWechatForwardTasks();
706
840
  await this.textSendChain.catch(() => undefined);
707
841
  await this.attachmentSendChain.catch(() => undefined);
842
+ try {
843
+ this.wecomTransport?.stop();
844
+ }
845
+ catch {
846
+ // Best effort shutdown.
847
+ }
708
848
  for (const slot of this.slots.values()) {
709
849
  try {
710
850
  await slot.runtime.dispose();
@@ -736,7 +876,7 @@ class WechatDaemon {
736
876
  return { shuttingDown: true };
737
877
  case "ensure_slot":
738
878
  if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
739
- throw new Error(`wechat-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
879
+ throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
740
880
  }
741
881
  return await this.ensureSlot(request.adapter, {
742
882
  profile: request.profile,
@@ -828,6 +968,7 @@ class WechatDaemon {
828
968
  cwd: this.cwd,
829
969
  sessionStartMode,
830
970
  cliArgs: options.cliArgs,
971
+ channelId: this.channelId,
831
972
  onError: (error) => {
832
973
  appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
833
974
  },
@@ -937,6 +1078,16 @@ class WechatDaemon {
937
1078
  slot.controller.syncLocalClientEndpoint();
938
1079
  }
939
1080
  createWechatChannelPort(adapter) {
1081
+ if (this.channelId === "wecom") {
1082
+ return new WecomChannelPort({
1083
+ transport: this.wecomTransport,
1084
+ sendText: (target, text, kind) => this.queueWechatMessage(this.authorizedUserId, text, toWechatSendContext(kind), target),
1085
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
1086
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
1087
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
1088
+ },
1089
+ });
1090
+ }
940
1091
  return new WechatChannelPort({
941
1092
  sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
942
1093
  sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
@@ -981,11 +1132,15 @@ class WechatDaemon {
981
1132
  appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
982
1133
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
983
1134
  await channelPort.send({
984
- target: {
985
- channelId: "wechat",
986
- conversationId: this.authorizedUserId,
987
- recipientId: this.authorizedUserId,
988
- },
1135
+ target: this.channelId === "wecom"
1136
+ ? slot.activeConversation ??
1137
+ slot.lastConversation ??
1138
+ this.currentInboundConversation
1139
+ : {
1140
+ channelId: "wechat",
1141
+ conversationId: this.authorizedUserId,
1142
+ recipientId: this.authorizedUserId,
1143
+ },
989
1144
  kind: "final_reply",
990
1145
  text: next.text,
991
1146
  adapter: slot.adapter,
@@ -1064,6 +1219,7 @@ class WechatDaemon {
1064
1219
  slot.pendingConfirmations = [];
1065
1220
  slot.pendingUserInput = null;
1066
1221
  slot.activeTask = null;
1222
+ slot.activeConversation = undefined;
1067
1223
  }));
1068
1224
  },
1069
1225
  taskFailed: (next) => {
@@ -1071,6 +1227,7 @@ class WechatDaemon {
1071
1227
  slot.pendingConfirmations = [];
1072
1228
  slot.pendingUserInput = null;
1073
1229
  slot.activeTask = null;
1230
+ slot.activeConversation = undefined;
1074
1231
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed");
1075
1232
  }));
1076
1233
  },
@@ -1080,6 +1237,7 @@ class WechatDaemon {
1080
1237
  slot.pendingConfirmations = [];
1081
1238
  slot.pendingUserInput = null;
1082
1239
  slot.activeTask = null;
1240
+ slot.activeConversation = undefined;
1083
1241
  this.disposeDeadSlot(slot);
1084
1242
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1085
1243
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error");
@@ -1094,9 +1252,17 @@ class WechatDaemon {
1094
1252
  appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
1095
1253
  });
1096
1254
  }
1255
+ bindCurrentWecomConversation(slot) {
1256
+ if (this.channelId !== "wecom" || !slot || !this.currentInboundConversation) {
1257
+ return;
1258
+ }
1259
+ slot.lastConversation = this.currentInboundConversation;
1260
+ }
1097
1261
  async handleInboundMessage(message) {
1098
1262
  if (message.senderId !== this.authorizedUserId) {
1099
- await this.queueWechatMessage(message.senderId, "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
1263
+ await this.queueWechatMessage(message.senderId, this.channelId === "wecom"
1264
+ ? "Unauthorized. This daemon only accepts messages from the paired WeCom operator."
1265
+ : "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
1100
1266
  return;
1101
1267
  }
1102
1268
  const emojiMatch = resolveEmojiCommand(message.text);
@@ -1108,6 +1274,7 @@ class WechatDaemon {
1108
1274
  reuseExistingVisible: true,
1109
1275
  });
1110
1276
  if (result.activated) {
1277
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1111
1278
  message = { ...message, text: emojiMatch.remainder };
1112
1279
  }
1113
1280
  else {
@@ -1134,6 +1301,7 @@ class WechatDaemon {
1134
1301
  await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
1135
1302
  return;
1136
1303
  }
1304
+ this.bindCurrentWecomConversation(this.getActiveSlot());
1137
1305
  if (switchDirective.remainder) {
1138
1306
  message = { ...message, text: switchDirective.remainder };
1139
1307
  }
@@ -1143,7 +1311,7 @@ class WechatDaemon {
1143
1311
  }
1144
1312
  }
1145
1313
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
1146
- await this.queueWechatMessage(message.senderId, "Stopping wechat-daemon...");
1314
+ await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
1147
1315
  setTimeout(() => {
1148
1316
  void this.shutdown().finally(() => process.exit(0));
1149
1317
  }, 0);
@@ -1163,7 +1331,9 @@ class WechatDaemon {
1163
1331
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1164
1332
  return;
1165
1333
  }
1334
+ this.bindCurrentWecomConversation(slot);
1166
1335
  const routeCurrentMessage = async (currentSlot, command) => {
1336
+ const previousTask = currentSlot.activeTask;
1167
1337
  await routeBridgeMessage({
1168
1338
  message: toChannelInboundMessage(message),
1169
1339
  authorized: true,
@@ -1192,6 +1362,13 @@ class WechatDaemon {
1192
1362
  await this.dispatchInboundWechatText(message, currentSlot);
1193
1363
  },
1194
1364
  });
1365
+ if (this.channelId === "wecom" &&
1366
+ this.currentInboundConversation &&
1367
+ currentSlot.activeTask &&
1368
+ currentSlot.activeTask !== previousTask) {
1369
+ currentSlot.activeConversation = this.currentInboundConversation;
1370
+ currentSlot.lastConversation = this.currentInboundConversation;
1371
+ }
1195
1372
  };
1196
1373
  const command = parseWechatControlCommand(message.text, {
1197
1374
  adapter: slot.adapter,
@@ -1215,6 +1392,7 @@ class WechatDaemon {
1215
1392
  return;
1216
1393
  }
1217
1394
  slot = this.getActiveSlot() ?? slot;
1395
+ this.bindCurrentWecomConversation(slot);
1218
1396
  await routeCurrentMessage(slot, null);
1219
1397
  }
1220
1398
  async handleEmojiBindingsCommand(senderId, cmd) {
@@ -1244,7 +1422,7 @@ class WechatDaemon {
1244
1422
  return;
1245
1423
  case "resume": {
1246
1424
  if (!isWechatResumeEnabled(activeSlot.adapter)) {
1247
- await this.queueWechatMessage(message.senderId, `WeChat /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; WeChat will follow that local session.`);
1425
+ await this.queueWechatMessage(message.senderId, `${this.channelId === "wecom" ? "WeCom" : "WeChat"} /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; the remote channel will follow that local session.`);
1248
1426
  return;
1249
1427
  }
1250
1428
  try {
@@ -1408,7 +1586,9 @@ class WechatDaemon {
1408
1586
  inputPreview: truncatePreview(preview, 180),
1409
1587
  };
1410
1588
  appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
1411
- await slot.runtime.sendInput(buildWechatInboundPrompt(message.text, message.attachments));
1589
+ await slot.runtime.sendInput(this.channelId === "wecom"
1590
+ ? buildWecomInboundPrompt(message.text, message.attachments)
1591
+ : buildWechatInboundPrompt(message.text, message.attachments));
1412
1592
  }
1413
1593
  queueWechatTextAction(action) {
1414
1594
  const run = this.textSendChain.then(action);
@@ -1420,7 +1600,37 @@ class WechatDaemon {
1420
1600
  this.attachmentSendChain = run.then(() => undefined, () => undefined);
1421
1601
  return run;
1422
1602
  }
1423
- async sendWechatMessageNow(senderId, text, context = "message") {
1603
+ async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
1604
+ if (this.channelId === "wecom") {
1605
+ const activeSlot = this.getActiveSlot();
1606
+ const target = targetOverride ??
1607
+ (senderId === this.authorizedUserId
1608
+ ? (this.handlingInboundMessage
1609
+ ? this.currentInboundConversation
1610
+ : null) ??
1611
+ activeSlot?.activeConversation ??
1612
+ activeSlot?.lastConversation ??
1613
+ this.currentInboundConversation
1614
+ : {
1615
+ channelId: "wecom",
1616
+ conversationId: senderId,
1617
+ recipientId: senderId,
1618
+ metadata: { chatType: "direct" },
1619
+ });
1620
+ const startedAtMs = Date.now();
1621
+ appendDaemonLog(`wecom_send_started: context=${context} recipient=${target.recipientId} chars=${Array.from(text).length}`);
1622
+ try {
1623
+ await this.wecomTransport.sendText(target, formatWecomVisibleText(text), toWecomOutputKind(context));
1624
+ appendDaemonLog(`wecom_send_completed: context=${context} recipient=${target.recipientId} elapsed_ms=${Date.now() - startedAtMs}`);
1625
+ return { status: "sent" };
1626
+ }
1627
+ catch (error) {
1628
+ const message = error instanceof Error ? error.message : String(error);
1629
+ appendDaemonLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
1630
+ logError(`Failed to send WeCom ${context}: ${message}`);
1631
+ return { status: "context_unavailable", error, target };
1632
+ }
1633
+ }
1424
1634
  const startedAtMs = Date.now();
1425
1635
  appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
1426
1636
  for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
@@ -1467,11 +1677,11 @@ class WechatDaemon {
1467
1677
  }
1468
1678
  return { status: "failed" };
1469
1679
  }
1470
- queueWechatMessage(senderId, text, context = "message") {
1680
+ queueWechatMessage(senderId, text, context = "message", targetOverride) {
1471
1681
  return this.queueWechatTextAction(async () => {
1472
- const result = await this.sendWechatMessageNow(senderId, text, context);
1682
+ const result = await this.sendWechatMessageNow(senderId, text, context, targetOverride);
1473
1683
  if (result.status === "context_unavailable") {
1474
- const pending = this.pendingWechatMessages.enqueue(senderId, text, context);
1684
+ const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
1475
1685
  if (pending) {
1476
1686
  appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
1477
1687
  }
@@ -1482,7 +1692,7 @@ class WechatDaemon {
1482
1692
  flushPendingWechatMessages() {
1483
1693
  return this.queueWechatTextAction(async () => {
1484
1694
  for (const pending of this.pendingWechatMessages.list()) {
1485
- const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
1695
+ const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
1486
1696
  if (result.status === "sent") {
1487
1697
  this.pendingWechatMessages.remove(pending.id);
1488
1698
  appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
@@ -1793,7 +2003,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1793
2003
  return { action: "stopped", lock, forced };
1794
2004
  }
1795
2005
  export async function runDaemon(options) {
1796
- migrateLegacyChannelFiles((message) => log(message));
2006
+ if ((options.channelId ?? "wechat") === "wechat") {
2007
+ migrateLegacyChannelFiles((message) => log(message));
2008
+ }
1797
2009
  loadEmojiBindings();
1798
2010
  await cleanupDaemonBeforeStart({ cwd: options.cwd });
1799
2011
  const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
@@ -1809,19 +2021,36 @@ export async function runDaemon(options) {
1809
2021
  if (reapedOpencodePids.length > 0) {
1810
2022
  log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
1811
2023
  }
1812
- const credentials = await ensureWechatCredentials({
1813
- requireUserId: true,
1814
- validateExisting: true,
1815
- log,
1816
- });
2024
+ const wecomAccount = options.channelId === "wecom"
2025
+ ? await ensureWecomAccount({ log })
2026
+ : null;
2027
+ const credentials = wecomAccount
2028
+ ? { userId: wecomAccount.operatorUserId }
2029
+ : await ensureWechatCredentials({
2030
+ requireUserId: true,
2031
+ validateExisting: true,
2032
+ log,
2033
+ });
1817
2034
  if (!credentials.userId) {
1818
- throw new Error("Saved WeChat credentials are missing userId.");
2035
+ throw new Error(options.channelId === "wecom"
2036
+ ? "Saved WeCom credentials are missing operatorUserId."
2037
+ : "Saved WeChat credentials are missing userId.");
1819
2038
  }
1820
2039
  const daemon = new WechatDaemon({
1821
2040
  cwd: options.cwd,
1822
2041
  profile: options.profile,
1823
2042
  authorizedUserId: credentials.userId,
1824
2043
  transport: new WeChatTransport({ log, logError }),
2044
+ channelId: options.channelId,
2045
+ wecomTransport: wecomAccount
2046
+ ? new WecomTransport({
2047
+ account: wecomAccount,
2048
+ logger: {
2049
+ log: (message) => log(message),
2050
+ error: (message) => logError(message),
2051
+ },
2052
+ })
2053
+ : null,
1825
2054
  });
1826
2055
  if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
1827
2056
  daemon.takenOverAdapter = cleanupResult.lock.adapter;
@@ -1871,7 +2100,7 @@ export async function main(argv = process.argv.slice(2)) {
1871
2100
  process.exit(1);
1872
2101
  }
1873
2102
  }
1874
- const isDirectRun = Boolean(import.meta.main);
2103
+ const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
1875
2104
  if (isDirectRun) {
1876
2105
  void main();
1877
2106
  }
@@ -59,6 +59,7 @@ export const messages = {
59
59
  "doctor.label.codePage": "Console code page",
60
60
  "doctor.codePage.nonAsciiWarning": "{codePage} (not UTF-8 and the data directory path contains non-ASCII characters; hook scripts from older versions break in this environment — upgrade to the latest release)",
61
61
  "doctor.label.connectivity": "WeChat connectivity",
62
+ "doctor.label.wecomConnectivity": "WeCom connectivity",
62
63
  "doctor.connectivity.ok": "reachable ({baseUrl})",
63
64
  "doctor.connectivity.unreachable": "cannot reach {baseUrl} (check network and firewall)",
64
65
  "doctor.connectivity.proxyHint": "cannot reach {baseUrl}. Proxy {proxy} detected, but Node fetch ignores proxy environment variables by default; if your network requires the proxy, set NODE_USE_ENV_PROXY=1 and retry.",
@@ -82,6 +83,7 @@ export const messages = {
82
83
  "doctor.dataDirMissing": "(will be created on first run)",
83
84
  "doctor.credentialsFound": "found",
84
85
  "doctor.credentialsMissing": "not found (run wechat-setup first)",
86
+ "doctor.credentialsMissingWecom": "not found (run wecom-setup first)",
85
87
  "doctor.mode.bridge": "standalone bridge",
86
88
  "doctor.mode.generic": "generic",
87
89
  "doctor.notSpecified": "(not specified)",
@@ -59,6 +59,7 @@ export const messages = {
59
59
  "doctor.label.codePage": "控制台代码页",
60
60
  "doctor.codePage.nonAsciiWarning": "{codePage}(非 UTF-8,且数据目录路径含非 ASCII 字符;旧版本的 hook 脚本在此环境下会乱码失效,请升级到最新版本)",
61
61
  "doctor.label.connectivity": "微信服务可达性",
62
+ "doctor.label.wecomConnectivity": "企业微信服务可达性",
62
63
  "doctor.connectivity.ok": "可达 ({baseUrl})",
63
64
  "doctor.connectivity.unreachable": "无法访问 {baseUrl}(检查网络与防火墙)",
64
65
  "doctor.connectivity.proxyHint": "无法访问 {baseUrl}。检测到代理 {proxy},但 Node fetch 默认不使用代理环境变量;若必须经代理出网,请设置 NODE_USE_ENV_PROXY=1 后重试。",
@@ -82,6 +83,7 @@ export const messages = {
82
83
  "doctor.dataDirMissing": "(首次运行会创建)",
83
84
  "doctor.credentialsFound": "已找到",
84
85
  "doctor.credentialsMissing": "未找到 (先运行 wechat-setup)",
86
+ "doctor.credentialsMissingWecom": "未找到 (先运行 wecom-setup)",
85
87
  "doctor.mode.bridge": "独立桥接",
86
88
  "doctor.mode.generic": "通用",
87
89
  "doctor.notSpecified": "(未指定)",