@unlinearity/cli-wechat-bridge 1.1.5 → 1.1.6

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.
@@ -8,17 +8,21 @@ import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
8
8
  import { t } from "../i18n/index.js";
9
9
  import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
10
10
  import { BridgeController } from "../bridge/bridge-controller.js";
11
- import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
12
11
  import { readBridgeLockFile, } from "../bridge/bridge-state.js";
13
12
  import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
14
13
  import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
15
14
  import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
16
- import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-forwarding.js";
15
+ import { WECHAT_SEND_MAX_ATTEMPTS, computeWechatSendRetryDelayMs, formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isWechatContextUnavailableError, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
17
16
  import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
18
17
  import { ensureWechatCredentials } from "../wechat/setup.js";
19
18
  import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
20
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";
21
21
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
22
+ import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
23
+ import { routeBridgeMessage } from "../core/bridge-message-router.js";
24
+ import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
25
+ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
22
26
  import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
23
27
  import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
24
28
  const MODULE_FILE = fileURLToPath(import.meta.url);
@@ -493,6 +497,7 @@ class WechatDaemon {
493
497
  textSendChain = Promise.resolve();
494
498
  attachmentSendChain = Promise.resolve();
495
499
  pendingWechatForwardTasks = new Set();
500
+ pendingWechatMessages;
496
501
  shutdownPromise = null;
497
502
  ipcServer = null;
498
503
  endpointToken = "";
@@ -501,6 +506,7 @@ class WechatDaemon {
501
506
  this.profile = params.profile;
502
507
  this.authorizedUserId = params.authorizedUserId;
503
508
  this.transport = params.transport;
509
+ this.pendingWechatMessages = new PendingWechatMessageStore(getPendingWechatMessagesFile(this.cwd));
504
510
  }
505
511
  async startIpcServer() {
506
512
  this.endpointToken = buildDaemonToken();
@@ -624,6 +630,9 @@ class WechatDaemon {
624
630
  appendDaemonLog(`poll_recovered: failures=${consecutivePollFailures}`);
625
631
  consecutivePollFailures = 0;
626
632
  }
633
+ if (pollResult.messages.length > 0 && this.pendingWechatMessages.list().length > 0) {
634
+ await this.flushPendingWechatMessages();
635
+ }
627
636
  if (pollResult.ignoredBacklogCount > 0) {
628
637
  appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
629
638
  if (!this.backlogNoticeSent) {
@@ -899,6 +908,8 @@ class WechatDaemon {
899
908
  }),
900
909
  activeTask: null,
901
910
  lastOutputAt: 0,
911
+ lastFinalReplyAtMs: 0,
912
+ eventForwardChain: Promise.resolve(),
902
913
  };
903
914
  runtime.setEventSink((event) => {
904
915
  this.handleSlotEvent(slot, event);
@@ -925,162 +936,163 @@ class WechatDaemon {
925
936
  }
926
937
  slot.controller.syncLocalClientEndpoint();
927
938
  }
939
+ createWechatChannelPort(adapter) {
940
+ return new WechatChannelPort({
941
+ sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
942
+ sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
943
+ sendFile: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, { recipientId })),
944
+ sendVoice: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(filePath, recipientId)),
945
+ sendVideo: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(filePath, { recipientId })),
946
+ prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
947
+ onEmptyVisibleReply: (currentAdapter, rawText) => {
948
+ appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
949
+ },
950
+ onTextSent: (currentAdapter, text) => {
951
+ appendDaemonLog(`final_reply_sent: adapter=${currentAdapter ?? adapter} chars=${Array.from(text).length}`);
952
+ },
953
+ });
954
+ }
928
955
  handleSlotEvent(slot, event) {
929
956
  slot.controller.syncLocalClientEndpoint();
930
957
  const adapterState = slot.runtime.getState();
958
+ const channelPort = this.createWechatChannelPort(slot.adapter);
931
959
  if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
932
960
  slot.pendingConfirmations = [];
933
961
  }
934
962
  if (slot.pendingUserInput && !adapterState.pendingUserInput) {
935
963
  slot.pendingUserInput = null;
936
964
  }
937
- switch (event.type) {
938
- case "stdout":
939
- case "stderr":
965
+ slot.eventForwardChain = slot.eventForwardChain
966
+ .then(() => forwardBridgeEvent(event, {
967
+ stdout: (next) => {
940
968
  slot.lastOutputAt = Date.now();
941
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
942
- slot.outputBatcher.push(event.text);
969
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
970
+ slot.outputBatcher.push(next.text);
943
971
  }
944
- break;
945
- case "final_reply":
946
- appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
972
+ },
973
+ stderr: (next) => {
974
+ slot.lastOutputAt = Date.now();
975
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
976
+ slot.outputBatcher.push(next.text);
977
+ }
978
+ },
979
+ finalReply: (next) => {
980
+ slot.lastFinalReplyAtMs = Date.now();
981
+ appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
947
982
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
948
- await forwardWechatFinalReply({
949
- adapter: slot.adapter,
950
- rawText: event.text,
951
- onEmptyVisibleReply: ({ rawVisibleText }) => {
952
- appendDaemonLog(`empty_visible_final_reply: adapter=${slot.adapter} raw=${truncatePreview(rawVisibleText)}`);
953
- },
954
- sender: {
955
- sendText: async (text) => {
956
- const sent = await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, text), "final_reply");
957
- if (sent) {
958
- appendDaemonLog(`final_reply_sent: adapter=${slot.adapter} chars=${Array.from(text).length}`);
959
- }
960
- return sent;
961
- },
962
- sendImage: (imagePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(imagePath, {
963
- recipientId: this.authorizedUserId,
964
- })),
965
- sendFile: (filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, {
966
- recipientId: this.authorizedUserId,
967
- })),
968
- sendVoice: (voicePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(voicePath, this.authorizedUserId)),
969
- sendVideo: (videoPath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(videoPath, {
970
- recipientId: this.authorizedUserId,
971
- })),
983
+ await channelPort.send({
984
+ target: {
985
+ channelId: "wechat",
986
+ conversationId: this.authorizedUserId,
987
+ recipientId: this.authorizedUserId,
972
988
  },
989
+ kind: "final_reply",
990
+ text: next.text,
991
+ adapter: slot.adapter,
973
992
  });
974
993
  }));
975
- break;
976
- case "status":
977
- if (event.message) {
978
- log(`${slot.adapter} ${event.status}: ${event.message}`);
979
- appendDaemonLog(`${slot.adapter}_${event.status}: ${event.message}`);
994
+ },
995
+ status: (next) => {
996
+ if (next.message) {
997
+ log(`${slot.adapter} ${next.status}: ${next.message}`);
998
+ appendDaemonLog(`${slot.adapter}_${next.status}: ${next.message}`);
980
999
  }
981
- break;
982
- case "notice":
1000
+ },
1001
+ notice: (next) => {
983
1002
  slot.lastOutputAt = Date.now();
984
- appendDaemonLog(`${slot.adapter}_${event.level}_notice: ${truncatePreview(event.text)}`);
985
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
1003
+ appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
1004
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
986
1005
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
987
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, event.text), "notice");
1006
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice");
988
1007
  }));
989
1008
  }
990
- break;
991
- case "approval_required":
1009
+ },
1010
+ approvalRequired: (next) => {
992
1011
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
993
- const pending = toPendingApproval(event);
1012
+ const pending = toPendingApproval(next);
994
1013
  slot.pendingConfirmations.push(pending);
995
1014
  appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
996
1015
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
997
1016
  }));
998
- break;
999
- case "user_input_required": {
1000
- // Mirror the pending request synchronously, before the async WeChat
1001
- // send (which retries with backoff). Otherwise a fast user reply can
1002
- // race past the still-unwritten mirror and be dispatched as plain
1003
- // input into a CLI that is waiting for a structured form answer.
1004
- const pending = toPendingUserInput(event.request);
1017
+ },
1018
+ userInputRequired: (next) => {
1019
+ const pending = toPendingUserInput(next.request);
1005
1020
  slot.pendingUserInput = pending;
1006
1021
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
1007
1022
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1008
1023
  await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
1009
1024
  }));
1010
- break;
1011
- }
1012
- case "mirrored_user_input":
1013
- appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(event.text)}`);
1014
- if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
1025
+ },
1026
+ mirroredUserInput: (next) => {
1027
+ appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
1028
+ if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
1015
1029
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1016
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, event.text)), "mirrored_user_input");
1030
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input");
1017
1031
  }));
1018
1032
  }
1019
- break;
1020
- case "session_switched":
1021
- if (event.source === "local") {
1033
+ },
1034
+ sessionSwitched: (next) => {
1035
+ if (next.source === "local")
1022
1036
  slot.resumeCoordinator.clear();
1023
- }
1024
- appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${event.sessionId} source=${event.source} reason=${event.reason}`);
1025
- if (shouldForwardSessionSwitchEvent(event.reason) &&
1026
- shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1037
+ appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
1038
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1027
1039
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1028
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
1029
- adapter: slot.adapter,
1030
- sessionId: event.sessionId,
1031
- source: event.source,
1032
- reason: event.reason,
1033
- })), "session_switched");
1040
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason })), "session_switched");
1034
1041
  }));
1035
1042
  }
1036
- break;
1037
- case "thread_switched":
1038
- if (event.source === "local") {
1043
+ },
1044
+ threadSwitched: (next) => {
1045
+ if (next.source === "local")
1039
1046
  slot.resumeCoordinator.clear();
1047
+ appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${next.threadId} source=${next.source} reason=${next.reason}`);
1048
+ if (shouldSuppressCodexLocalThreadNotice({
1049
+ adapter: slot.adapter,
1050
+ source: next.source,
1051
+ activeTurnOrigin: slot.runtime.getState().activeTurnOrigin,
1052
+ lastFinalReplyAtMs: slot.lastFinalReplyAtMs,
1053
+ })) {
1054
+ return;
1040
1055
  }
1041
- appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${event.threadId} source=${event.source} reason=${event.reason}`);
1042
- if (shouldForwardSessionSwitchEvent(event.reason) &&
1043
- shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
1056
+ if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
1044
1057
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1045
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
1046
- adapter: slot.adapter,
1047
- sessionId: event.threadId,
1048
- source: event.source,
1049
- reason: event.reason,
1050
- })), "thread_switched");
1058
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.threadId, source: next.source, reason: next.reason })), "thread_switched");
1051
1059
  }));
1052
1060
  }
1053
- break;
1054
- case "task_complete":
1061
+ },
1062
+ taskComplete: () => {
1055
1063
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
1056
1064
  slot.pendingConfirmations = [];
1057
1065
  slot.pendingUserInput = null;
1058
1066
  slot.activeTask = null;
1059
1067
  }));
1060
- break;
1061
- case "task_failed":
1068
+ },
1069
+ taskFailed: (next) => {
1062
1070
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1063
1071
  slot.pendingConfirmations = [];
1064
1072
  slot.pendingUserInput = null;
1065
1073
  slot.activeTask = null;
1066
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, event.message)), "task_failed");
1074
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed");
1067
1075
  }));
1068
- break;
1069
- case "fatal_error":
1070
- logError(`${slot.adapter}: ${event.message}`);
1071
- appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${event.message}`);
1076
+ },
1077
+ fatalError: (next) => {
1078
+ logError(`${slot.adapter}: ${next.message}`);
1079
+ appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
1072
1080
  slot.pendingConfirmations = [];
1073
1081
  slot.pendingUserInput = null;
1074
1082
  slot.activeTask = null;
1075
1083
  this.disposeDeadSlot(slot);
1076
1084
  this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
1077
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(event.message)), "fatal_error");
1085
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error");
1078
1086
  }));
1079
- break;
1080
- case "shutdown_requested":
1081
- appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${event.reason}`);
1082
- break;
1083
- }
1087
+ },
1088
+ shutdownRequested: (next) => {
1089
+ appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${next.reason}`);
1090
+ },
1091
+ }))
1092
+ .catch((error) => {
1093
+ const message = error instanceof Error ? error.message : String(error);
1094
+ appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
1095
+ });
1084
1096
  }
1085
1097
  async handleInboundMessage(message) {
1086
1098
  if (message.senderId !== this.authorizedUserId) {
@@ -1151,21 +1163,47 @@ class WechatDaemon {
1151
1163
  await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
1152
1164
  return;
1153
1165
  }
1166
+ const routeCurrentMessage = async (currentSlot, command) => {
1167
+ await routeBridgeMessage({
1168
+ message: toChannelInboundMessage(message),
1169
+ authorized: true,
1170
+ command,
1171
+ adapterState: currentSlot.runtime.getState(),
1172
+ hasPendingApproval: currentSlot.pendingConfirmations.length > 0,
1173
+ hasPendingUserInput: Boolean(currentSlot.pendingUserInput),
1174
+ onUnauthorized: async () => undefined,
1175
+ handleCommand: async (nextCommand) => {
1176
+ await this.handleSystemCommand(message, currentSlot, nextCommand);
1177
+ return true;
1178
+ },
1179
+ remindPendingApproval: async () => {
1180
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, formatPendingApprovalReminder(currentSlot.pendingConfirmations[0], currentSlot.runtime.getState())));
1181
+ },
1182
+ remindPendingUserInput: async () => {
1183
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, currentSlot.pendingUserInput
1184
+ ? formatPendingUserInputReminder(currentSlot.pendingUserInput)
1185
+ : `${currentSlot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1186
+ },
1187
+ remindBusy: async () => {
1188
+ await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, `${currentSlot.adapter} is still working. Wait for the current reply or use /stop.`));
1189
+ },
1190
+ defer: async () => undefined,
1191
+ dispatch: async () => {
1192
+ await this.dispatchInboundWechatText(message, currentSlot);
1193
+ },
1194
+ });
1195
+ };
1154
1196
  const command = parseWechatControlCommand(message.text, {
1155
1197
  adapter: slot.adapter,
1156
1198
  hasPendingConfirmation: slot.pendingConfirmations.length > 0,
1157
1199
  hasPendingUserInput: Boolean(slot.pendingUserInput),
1158
1200
  });
1159
- if (command) {
1160
- await this.handleSystemCommand(message, slot, command);
1161
- return;
1162
- }
1163
- if (slot.pendingConfirmations.length > 0) {
1164
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingApprovalReminder(slot.pendingConfirmations[0], slot.runtime.getState())));
1165
- return;
1166
- }
1167
- if (slot.pendingUserInput) {
1168
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
1201
+ const slotState = slot.runtime.getState();
1202
+ if (command ||
1203
+ slot.pendingConfirmations.length > 0 ||
1204
+ slot.pendingUserInput ||
1205
+ slotState.status === "awaiting_input") {
1206
+ await routeCurrentMessage(slot, command);
1169
1207
  return;
1170
1208
  }
1171
1209
  const visibleResult = await this.ensureSlot(slot.adapter, {
@@ -1177,21 +1215,7 @@ class WechatDaemon {
1177
1215
  return;
1178
1216
  }
1179
1217
  slot = this.getActiveSlot() ?? slot;
1180
- const adapterState = slot.runtime.getState();
1181
- if (adapterState.status === "awaiting_input") {
1182
- // Belt-and-braces with the pendingUserInput mirror above: the adapter
1183
- // state is authoritative even when the mirror was cleared or the WeChat
1184
- // notification is still queued.
1185
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
1186
- ? formatPendingUserInputReminder(slot.pendingUserInput)
1187
- : `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
1188
- return;
1189
- }
1190
- if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
1191
- await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
1192
- return;
1193
- }
1194
- await this.dispatchInboundWechatText(message, slot);
1218
+ await routeCurrentMessage(slot, null);
1195
1219
  }
1196
1220
  async handleEmojiBindingsCommand(senderId, cmd) {
1197
1221
  switch (cmd.type) {
@@ -1396,39 +1420,80 @@ class WechatDaemon {
1396
1420
  this.attachmentSendChain = run.then(() => undefined, () => undefined);
1397
1421
  return run;
1398
1422
  }
1399
- queueWechatMessage(senderId, text, context = "message") {
1400
- return this.queueWechatTextAction(async () => {
1401
- for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1402
- try {
1403
- await this.transport.sendText(senderId, text);
1404
- return true;
1423
+ async sendWechatMessageNow(senderId, text, context = "message") {
1424
+ const startedAtMs = Date.now();
1425
+ appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
1426
+ for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
1427
+ try {
1428
+ await this.transport.sendText(senderId, text);
1429
+ appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1430
+ return { status: "sent" };
1431
+ }
1432
+ catch (error) {
1433
+ if (error instanceof Error && error.name === "AbortError") {
1434
+ appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
1405
1435
  }
1406
- catch (error) {
1436
+ if (isWechatContextUnavailableError(error)) {
1407
1437
  if (isWechatContextTokenStaleError(error)) {
1408
1438
  this.transport.clearCachedContextToken(senderId);
1409
- appendDaemonLog(formatWechatContextTokenStaleLogEntry({
1439
+ }
1440
+ appendDaemonLog(isWechatContextTokenStaleError(error)
1441
+ ? formatWechatContextTokenStaleLogEntry({
1442
+ context,
1443
+ recipientId: senderId,
1444
+ error,
1445
+ })
1446
+ : formatWechatSendFailureLogEntry({
1410
1447
  context,
1411
1448
  recipientId: senderId,
1412
1449
  error,
1413
1450
  }));
1414
- return false;
1415
- }
1416
- if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1417
- const delayMs = computeWechatSendRetryDelayMs(attempt);
1418
- appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1419
- await delay(delayMs);
1420
- continue;
1421
- }
1422
- logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1423
- appendDaemonLog(formatWechatSendFailureLogEntry({
1424
- context,
1425
- recipientId: senderId,
1426
- error,
1427
- }));
1428
- return false;
1451
+ return { status: "context_unavailable", error };
1429
1452
  }
1453
+ if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
1454
+ const delayMs = computeWechatSendRetryDelayMs(attempt);
1455
+ appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
1456
+ await delay(delayMs);
1457
+ continue;
1458
+ }
1459
+ logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
1460
+ appendDaemonLog(formatWechatSendFailureLogEntry({
1461
+ context,
1462
+ recipientId: senderId,
1463
+ error,
1464
+ }));
1465
+ return { status: "failed" };
1466
+ }
1467
+ }
1468
+ return { status: "failed" };
1469
+ }
1470
+ queueWechatMessage(senderId, text, context = "message") {
1471
+ return this.queueWechatTextAction(async () => {
1472
+ const result = await this.sendWechatMessageNow(senderId, text, context);
1473
+ if (result.status === "context_unavailable") {
1474
+ const pending = this.pendingWechatMessages.enqueue(senderId, text, context);
1475
+ if (pending) {
1476
+ appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
1477
+ }
1478
+ }
1479
+ return result.status === "sent";
1480
+ });
1481
+ }
1482
+ flushPendingWechatMessages() {
1483
+ return this.queueWechatTextAction(async () => {
1484
+ for (const pending of this.pendingWechatMessages.list()) {
1485
+ const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
1486
+ if (result.status === "sent") {
1487
+ this.pendingWechatMessages.remove(pending.id);
1488
+ appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
1489
+ continue;
1490
+ }
1491
+ if (result.status === "context_unavailable") {
1492
+ break;
1493
+ }
1494
+ appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
1495
+ break;
1430
1496
  }
1431
- return false;
1432
1497
  });
1433
1498
  }
1434
1499
  trackWechatForwardTask(task) {
@@ -25,7 +25,7 @@ export const messages = {
25
25
  // === Hook health check ===
26
26
  "hook.healthCheck.warning": "[Warning] No hook events received from Claude after 15s.\nThe hook system may not be working — Claude output will not reach WeChat.",
27
27
  "hook.healthCheck.logHint": "\nCheck: {logPath}",
28
- "hook.healthCheck.fixes": "\nCommon fixes:\n- Ensure Node.js >= 22.6.0: node --version\n- Reinstall: npm install -g cli-wechat-bridge@latest\n- Check firewall: allow localhost TCP connections",
28
+ "hook.healthCheck.fixes": "\nCommon fixes:\n- Ensure Node.js >= 22.13.0: node --version\n- Reinstall: npm install -g cli-wechat-bridge@latest\n- Check firewall: allow localhost TCP connections",
29
29
  // === PTY fallback ===
30
30
  "pty.fallback.warning": "[Warning] PTY unavailable — using fallback mode (TERM=dumb).\nCore features (messaging, approvals, session management) work normally; terminal rendering (colors, progress bars) may be degraded.\nFix: Linux install build-essential python3 | Windows run npm rebuild node-pty | macOS run xcode-select --install\nRun --doctor for detailed environment info.\n",
31
31
  // === Spawn diagnostic ===
@@ -36,7 +36,7 @@ export const messages = {
36
36
  "spawn.diagnostic.linuxBuildTools": "- Linux requires build tools for node-pty: sudo apt install build-essential python3\n- Then reinstall: npm install -g cli-wechat-bridge@latest",
37
37
  "spawn.diagnostic.notFound": "- The command \"{target}\" was not found on PATH.\n- Verify it is installed and accessible from your terminal.",
38
38
  "spawn.diagnostic.generic": "- Reinstall: npm install -g cli-wechat-bridge@latest",
39
- "spawn.diagnostic.nodeVersion": "- Ensure Node.js >= 22.6.0: node --version",
39
+ "spawn.diagnostic.nodeVersion": "- Ensure Node.js >= 22.13.0: node --version",
40
40
  "spawn.diagnostic.winFull": "- Ensure Windows 10 build 18309+ (run 'winver' to check)\n- Install Visual C++ Redistributable: https://aka.ms/vs/17/release/vc_redist.x64.exe\n- Run: npm rebuild node-pty\n- Try running as Administrator",
41
41
  // === Emoji bindings ===
42
42
  "binding.usage": "Invalid format.\nUsage: /bind [emoji] command\nExample: /bind 🚀 deploy --prod",
@@ -25,7 +25,7 @@ export const messages = {
25
25
  // === Hook 健康检查 ===
26
26
  "hook.healthCheck.warning": "[警告] 15 秒内未收到 Claude hook 事件。\nhook 系统可能未正常工作 — Claude 的输出将无法到达微信。",
27
27
  "hook.healthCheck.logHint": "\n查看日志: {logPath}",
28
- "hook.healthCheck.fixes": "\n常见修复方法:\n- 确保 Node.js >= 22.6.0: node --version\n- 重新安装: npm install -g cli-wechat-bridge@latest\n- 检查防火墙: 允许 localhost TCP 连接",
28
+ "hook.healthCheck.fixes": "\n常见修复方法:\n- 确保 Node.js >= 22.13.0: node --version\n- 重新安装: npm install -g cli-wechat-bridge@latest\n- 检查防火墙: 允许 localhost TCP 连接",
29
29
  // === PTY 回退 ===
30
30
  "pty.fallback.warning": "[警告] PTY 不可用 — 已切换到回退模式(TERM=dumb)。\n核心功能(消息收发、审批、会话管理)正常工作;终端渲染(颜色、进度条)可能受限。\n修复方法: Linux 安装 build-essential python3 | Windows 运行 npm rebuild node-pty | macOS 运行 xcode-select --install\n运行 --doctor 查看详细环境信息。\n",
31
31
  // === 启动诊断 ===
@@ -36,7 +36,7 @@ export const messages = {
36
36
  "spawn.diagnostic.linuxBuildTools": "- Linux 需要编译工具来构建 node-pty: sudo apt install build-essential python3\n- 然后重新安装: npm install -g cli-wechat-bridge@latest",
37
37
  "spawn.diagnostic.notFound": "- 命令 \"{target}\" 未在 PATH 中找到。\n- 请确认已安装并可从终端访问。",
38
38
  "spawn.diagnostic.generic": "- 重新安装: npm install -g cli-wechat-bridge@latest",
39
- "spawn.diagnostic.nodeVersion": "- 确保 Node.js >= 22.6.0: node --version",
39
+ "spawn.diagnostic.nodeVersion": "- 确保 Node.js >= 22.13.0: node --version",
40
40
  "spawn.diagnostic.winFull": "- 确保 Windows 10 build 18309+(运行 winver 查看)\n- 安装 Visual C++ Redistributable: https://aka.ms/vs/17/release/vc_redist.x64.exe\n- 运行: npm rebuild node-pty\n- 尝试以管理员身份运行",
41
41
  // === Emoji 绑定 ===
42
42
  "binding.usage": "格式错误。\n用法: /bind [表情] 命令\n示例: /bind 🚀 deploy --prod",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unlinearity/cli-wechat-bridge",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and Pi over WeChat",
5
5
  "type": "module",
6
6
  "files": [
@@ -61,7 +61,7 @@
61
61
  },
62
62
  "engines": {
63
63
  "bun": ">=1.0.0",
64
- "node": ">=24.0.0"
64
+ "node": ">=22.13.0"
65
65
  },
66
66
  "scripts": {
67
67
  "postinstall": "node scripts/ensure-node-pty-permissions.mjs"