cli-wechat-bridge 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/bin/_run-entry.mjs +6 -1
- package/bin/wechat-check-update.mjs +6 -1
- package/bin/wechat-claude.mjs +6 -1
- package/bin/wechat-codex.mjs +6 -1
- package/bin/wechat-daemon.mjs +6 -1
- package/bin/wechat-opencode.mjs +6 -1
- package/bin/wechat-pi.mjs +6 -1
- package/bin/wechat-setup.mjs +6 -1
- package/bin/wecom-check-update.mjs +6 -1
- package/bin/wecom-claude.mjs +6 -1
- package/bin/wecom-codex.mjs +6 -1
- package/bin/wecom-daemon.mjs +6 -1
- package/bin/wecom-opencode.mjs +6 -1
- package/bin/wecom-pi.mjs +6 -1
- package/bin/wecom-setup.mjs +6 -1
- package/dist/bridge/bridge-adapters.claude.js +6 -0
- package/dist/bridge/bridge-adapters.codex.js +6 -0
- package/dist/bridge/bridge-adapters.core.js +6 -0
- package/dist/bridge/bridge-adapters.opencode.js +6 -0
- package/dist/bridge/bridge-adapters.pi.js +6 -0
- package/dist/bridge/bridge-adapters.shared.js +6 -0
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +6 -0
- package/dist/bridge/bridge-process-reaper.js +6 -0
- package/dist/bridge/bridge-state.js +6 -0
- package/dist/bridge/bridge-utils.js +6 -31
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/native-terminal-control.js +6 -0
- package/dist/bridge/wechat-bridge.js +148 -241
- package/dist/bridge/wechat-forwarding.js +6 -0
- package/dist/bridge/wechat-outbound-queue.js +6 -0
- package/dist/channels/wechat/bridge-final-reply.js +13 -22
- package/dist/channels/wechat/index.js +6 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +18 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +39 -0
- package/dist/channels/wecom/bridge-final-reply.js +6 -0
- package/dist/channels/wecom/setup-cli.js +6 -0
- package/dist/channels/wecom/setup.js +6 -0
- package/dist/channels/wecom/wecom-config.js +6 -0
- package/dist/channels/wecom/wecom-driver.js +106 -0
- package/dist/channels/wecom/wecom-media.js +6 -0
- package/dist/channels/wecom/wecom-transport.js +6 -0
- package/dist/commands/check-update.js +6 -0
- package/dist/companion/codex-remote-client.js +6 -0
- package/dist/companion/codex-visible-client-link.js +6 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +6 -0
- package/dist/companion/local-companion.js +6 -0
- package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
- package/dist/companion/pi-tui-bridge-extension.js +6 -0
- package/dist/core/bridge-control.js +2 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/conversation-routing.js +11 -1
- package/dist/core/direct-run.js +6 -0
- package/dist/core/inbound-message-claims.js +6 -0
- package/dist/core/outbound-target.js +32 -0
- package/dist/core/text-utils.js +6 -0
- package/dist/core/turn-coordinator.js +85 -0
- package/dist/daemon/daemon-link.js +6 -0
- package/dist/daemon/emoji-bindings.js +6 -0
- package/dist/daemon/wechat-daemon.js +451 -173
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +7 -1
- package/dist/i18n/messages-zh.js +7 -1
- package/dist/runtime/create-runtime-host.js +6 -0
- package/dist/utils/atomic-file.js +6 -0
- package/dist/utils/doctor.js +6 -0
- package/dist/utils/version-checker.js +6 -0
- package/dist/wechat/channel-config.js +6 -0
- package/dist/wechat/setup.js +6 -0
- package/dist/wechat/wechat-channel.js +6 -0
- package/dist/wechat/wechat-transport.js +73 -0
- package/package.json +1 -1
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
// Copyright (c) 2026 UNLINEARITY <unlinearity@gmail.com>
|
|
4
|
+
// Source: https://github.com/UNLINEARITY/CLI-WeChat-Bridge
|
|
5
|
+
// This file is part of CLI-WeChat-Bridge. Modifications and derivative works
|
|
6
|
+
// must be released under AGPL-3.0-or-later with full source code; see
|
|
7
|
+
// LICENSE.txt. Network services built on it must offer source to users.
|
|
2
8
|
import fs from "node:fs";
|
|
3
9
|
import net from "node:net";
|
|
4
10
|
import path from "node:path";
|
|
@@ -12,16 +18,19 @@ import { readBridgeLockFile, } from "../bridge/bridge-state.js";
|
|
|
12
18
|
import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
|
|
13
19
|
import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
|
|
14
20
|
import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
|
|
15
|
-
import {
|
|
21
|
+
import { WECHAT_SEND_CONTEXTS, formatUserFacingBridgeFatalError, formatUserFacingInboundError, isWechatSendContext, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
|
|
16
22
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
|
|
17
23
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
18
24
|
import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
|
|
19
|
-
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError,
|
|
25
|
+
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, WeChatTransport, } from "../wechat/wechat-transport.js";
|
|
20
26
|
import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
|
|
21
27
|
import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
|
|
22
28
|
import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
|
|
23
29
|
import { routeBridgeMessage } from "../core/bridge-message-router.js";
|
|
24
|
-
import {
|
|
30
|
+
import { canDrainDeferredCodexInboundQueue, formatDeferredCodexInboundQueueMessage, isRetryableDeferredCodexDrainError, shouldDeferCodexInboundMessage, } from "../core/bridge-defer.js";
|
|
31
|
+
import { InboundConversationContext } from "../core/conversation-routing.js";
|
|
32
|
+
import { TurnCoordinator } from "../core/turn-coordinator.js";
|
|
33
|
+
import { resolveDaemonOutboundTarget } from "../core/outbound-target.js";
|
|
25
34
|
import { handleAdapterControl, invalidateModelSnapshot } from "../bridge/adapter-control.js";
|
|
26
35
|
import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
|
|
27
36
|
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
@@ -29,7 +38,8 @@ import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
|
|
|
29
38
|
import { ensureWecomAccount } from "../channels/wecom/setup.js";
|
|
30
39
|
import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
|
|
31
40
|
import { WecomTransport } from "../channels/wecom/wecom-transport.js";
|
|
32
|
-
import {
|
|
41
|
+
import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
|
|
42
|
+
import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
|
|
33
43
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
34
44
|
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
|
|
35
45
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
@@ -46,6 +56,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
|
46
56
|
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
47
57
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
48
58
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
59
|
+
const MAX_DAEMON_DEFERRED_INPUTS = 32;
|
|
49
60
|
const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
|
|
50
61
|
function getCliChannelId(argv = process.argv.slice(2)) {
|
|
51
62
|
const index = argv.indexOf("--channel");
|
|
@@ -66,22 +77,6 @@ function computePollRetryDelayMs(consecutiveFailures) {
|
|
|
66
77
|
const exponent = Math.min(normalizedFailures - 1, 5);
|
|
67
78
|
return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
|
|
68
79
|
}
|
|
69
|
-
function toWecomOutputKind(context) {
|
|
70
|
-
switch (context) {
|
|
71
|
-
case "notice":
|
|
72
|
-
case "thinking":
|
|
73
|
-
case "final_reply":
|
|
74
|
-
case "approval_required":
|
|
75
|
-
case "user_input_required":
|
|
76
|
-
case "task_failed":
|
|
77
|
-
case "fatal_error":
|
|
78
|
-
return context;
|
|
79
|
-
case "mirrored_user_input":
|
|
80
|
-
return "mirrored_input";
|
|
81
|
-
default:
|
|
82
|
-
return "notice";
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
80
|
function toWechatSendContext(kind) {
|
|
86
81
|
if (kind === "mirrored_input") {
|
|
87
82
|
return "mirrored_user_input";
|
|
@@ -552,11 +547,12 @@ export function formatDaemonStatus(status) {
|
|
|
552
547
|
}
|
|
553
548
|
return lines.join("\n");
|
|
554
549
|
}
|
|
555
|
-
class WechatDaemon {
|
|
550
|
+
export class WechatDaemon {
|
|
556
551
|
cwd;
|
|
557
552
|
profile;
|
|
558
553
|
authorizedUserId;
|
|
559
554
|
transport;
|
|
555
|
+
channelDriver;
|
|
560
556
|
channelId;
|
|
561
557
|
wecomTransport;
|
|
562
558
|
inboundConversationContext = new InboundConversationContext();
|
|
@@ -568,11 +564,14 @@ class WechatDaemon {
|
|
|
568
564
|
bridgeStartedAtMs = Date.now();
|
|
569
565
|
backlogNoticeSent = false;
|
|
570
566
|
activeAdapter = null;
|
|
567
|
+
activeAdapterVersion = 0;
|
|
571
568
|
takenOverAdapter;
|
|
572
569
|
textSendChain = Promise.resolve();
|
|
573
570
|
attachmentSendChain = Promise.resolve();
|
|
574
571
|
pendingWechatForwardTasks = new Set();
|
|
572
|
+
slotInputChains = new Map();
|
|
575
573
|
pendingWechatMessages;
|
|
574
|
+
deps;
|
|
576
575
|
shutdownPromise = null;
|
|
577
576
|
ipcServer = null;
|
|
578
577
|
endpointToken = "";
|
|
@@ -583,15 +582,37 @@ class WechatDaemon {
|
|
|
583
582
|
this.transport = params.transport;
|
|
584
583
|
this.channelId = params.channelId ?? "wechat";
|
|
585
584
|
this.wecomTransport = params.wecomTransport ?? null;
|
|
586
|
-
this.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
:
|
|
585
|
+
this.deps = params.deps ?? {};
|
|
586
|
+
this.channelDriver = this.wecomTransport
|
|
587
|
+
? new WecomChannelDriver({
|
|
588
|
+
transport: this.wecomTransport,
|
|
589
|
+
accountId: params.accountId,
|
|
590
|
+
operatorId: params.authorizedUserId,
|
|
591
|
+
logError,
|
|
592
|
+
})
|
|
593
|
+
: new WechatChannelDriver({
|
|
594
|
+
transport: this.transport,
|
|
595
|
+
logError,
|
|
596
|
+
buildInboundPrompt: (text, attachments) => buildWechatInboundPrompt(text, attachments.filter((attachment) => attachment.kind === "image" || attachment.kind === "file")),
|
|
597
|
+
});
|
|
598
|
+
this.fallbackConversation = this.channelDriver.defaultConversation()
|
|
599
|
+
?? this.channelDriver.directConversation(params.authorizedUserId);
|
|
600
|
+
this.pendingWechatMessages = this.deps.pendingWechatMessages ??
|
|
601
|
+
new PendingWechatMessageStore(this.channelId === "wechat"
|
|
602
|
+
? getPendingWechatMessagesFile(this.cwd)
|
|
603
|
+
: getPendingChannelMessagesFile(this.cwd, this.channelId));
|
|
604
|
+
}
|
|
605
|
+
daemonLog(message) {
|
|
606
|
+
(this.deps.daemonLog ?? appendDaemonLog)(message);
|
|
607
|
+
}
|
|
608
|
+
createRuntime(options) {
|
|
609
|
+
return (this.deps.createRuntime ?? createRuntimeHost)(options);
|
|
610
|
+
}
|
|
611
|
+
isVisibleClientAlive(cwd, adapter) {
|
|
612
|
+
return (this.deps.isVisibleClientAlive ?? isVisibleClientAlive)(cwd, adapter);
|
|
613
|
+
}
|
|
614
|
+
openVisibleClient(options) {
|
|
615
|
+
return (this.deps.openVisibleClient ?? openVisibleClient)(options);
|
|
595
616
|
}
|
|
596
617
|
async startIpcServer() {
|
|
597
618
|
this.endpointToken = buildDaemonToken();
|
|
@@ -677,9 +698,9 @@ class WechatDaemon {
|
|
|
677
698
|
}
|
|
678
699
|
async runPollLoop() {
|
|
679
700
|
let consecutivePollFailures = 0;
|
|
680
|
-
if (this.
|
|
681
|
-
this.
|
|
682
|
-
|
|
701
|
+
if (this.channelDriver.start) {
|
|
702
|
+
await this.channelDriver.start({
|
|
703
|
+
onInboundMessage: async (channelMessage) => {
|
|
683
704
|
if (this.shutdownPromise) {
|
|
684
705
|
return;
|
|
685
706
|
}
|
|
@@ -690,33 +711,35 @@ class WechatDaemon {
|
|
|
690
711
|
await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
|
|
691
712
|
});
|
|
692
713
|
},
|
|
693
|
-
|
|
714
|
+
onUnauthorizedSender: async (senderId, chatType) => {
|
|
694
715
|
appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
|
|
695
716
|
if (chatType === "direct") {
|
|
696
|
-
await this.
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}
|
|
717
|
+
const result = await this.channelDriver.sendText({
|
|
718
|
+
target: this.channelDriver.directConversation(senderId),
|
|
719
|
+
text: "Unauthorized.",
|
|
720
|
+
context: "notice",
|
|
721
|
+
log: appendDaemonLog,
|
|
722
|
+
});
|
|
723
|
+
if (result.status !== "sent") {
|
|
724
|
+
logError(`Failed to reply unauthorized notice to ${senderId}`);
|
|
725
|
+
}
|
|
702
726
|
}
|
|
703
727
|
},
|
|
704
|
-
|
|
728
|
+
onChannelFatal: async (error) => {
|
|
705
729
|
appendDaemonLog(`wecom_fatal_error: ${error.message}`);
|
|
706
730
|
await this.shutdown();
|
|
707
731
|
},
|
|
708
|
-
|
|
732
|
+
onChannelConnected: async () => {
|
|
709
733
|
if (this.pendingWechatMessages.list().length > 0) {
|
|
710
734
|
await this.flushPendingWechatMessages();
|
|
711
735
|
}
|
|
712
736
|
},
|
|
713
737
|
});
|
|
714
|
-
this.
|
|
715
|
-
await this.wecomTransport.waitUntilConnected();
|
|
738
|
+
await this.channelDriver.waitUntilReady?.();
|
|
716
739
|
}
|
|
717
|
-
log(`${this.
|
|
740
|
+
log(`${this.channelDriver.displayName} daemon is ready.`);
|
|
718
741
|
log(`Working directory: ${this.cwd}`);
|
|
719
|
-
log(`Switch from ${this.
|
|
742
|
+
log(`Switch from ${this.channelDriver.displayName} with /codex, /claude, /opencode, or /pi.`);
|
|
720
743
|
appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
|
|
721
744
|
const activeSlot = this.getActiveSlot();
|
|
722
745
|
const welcomeText = t("daemon.welcome", {
|
|
@@ -725,7 +748,7 @@ class WechatDaemon {
|
|
|
725
748
|
bindings: formatBindingsListMessage(listBindings()),
|
|
726
749
|
});
|
|
727
750
|
await this.queueWechatMessage(this.authorizedUserId, welcomeText);
|
|
728
|
-
if (this.
|
|
751
|
+
if (this.channelDriver.capabilities.pushInbound) {
|
|
729
752
|
while (!this.shutdownPromise) {
|
|
730
753
|
await delay(1_000);
|
|
731
754
|
}
|
|
@@ -835,6 +858,12 @@ class WechatDaemon {
|
|
|
835
858
|
catch {
|
|
836
859
|
// Best effort shutdown.
|
|
837
860
|
}
|
|
861
|
+
try {
|
|
862
|
+
await this.channelDriver.endAllTyping?.();
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// Best effort shutdown.
|
|
866
|
+
}
|
|
838
867
|
try {
|
|
839
868
|
this.wecomTransport?.stop();
|
|
840
869
|
}
|
|
@@ -901,6 +930,244 @@ class WechatDaemon {
|
|
|
901
930
|
sessionStartMode: request.sessionStartMode,
|
|
902
931
|
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
903
932
|
});
|
|
933
|
+
case "send_text":
|
|
934
|
+
return await this.handleDaemonSendText(request);
|
|
935
|
+
case "forward_input":
|
|
936
|
+
return await this.handleDaemonForwardInput(request);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async handleDaemonSendText(request) {
|
|
940
|
+
if (!request.text.trim()) {
|
|
941
|
+
throw new Error("send_text requires non-empty text.");
|
|
942
|
+
}
|
|
943
|
+
if (request.channel && request.channel !== this.channelId) {
|
|
944
|
+
throw new Error(`${this.channelId}-daemon cannot send to ${request.channel}.`);
|
|
945
|
+
}
|
|
946
|
+
const context = request.context ?? "message";
|
|
947
|
+
if (!isWechatSendContext(context)) {
|
|
948
|
+
throw new Error(`Invalid send_text context: ${JSON.stringify(request.context)}. Valid contexts: ${WECHAT_SEND_CONTEXTS.join(", ")}.`);
|
|
949
|
+
}
|
|
950
|
+
const target = this.channelDriver.capabilities.multiConversation
|
|
951
|
+
? {
|
|
952
|
+
...this.channelDriver.directConversation(request.recipientId),
|
|
953
|
+
...(request.conversationId && request.conversationId !== request.recipientId
|
|
954
|
+
? { conversationId: request.conversationId }
|
|
955
|
+
: {}),
|
|
956
|
+
...(request.metadata ? { metadata: request.metadata } : {}),
|
|
957
|
+
}
|
|
958
|
+
: undefined;
|
|
959
|
+
const sent = await this.queueWechatMessage(request.recipientId, request.text, context, target);
|
|
960
|
+
return {
|
|
961
|
+
sent,
|
|
962
|
+
recipientId: request.recipientId,
|
|
963
|
+
...(request.conversationId ? { conversationId: request.conversationId } : {}),
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
getSlotState(adapter) {
|
|
967
|
+
const slot = this.slots.get(adapter);
|
|
968
|
+
if (!slot)
|
|
969
|
+
return null;
|
|
970
|
+
return {
|
|
971
|
+
active: this.activeAdapter === adapter,
|
|
972
|
+
hasActiveTask: slot.turns.hasActiveTask,
|
|
973
|
+
...(slot.turns.activeConversation?.conversationId
|
|
974
|
+
? { activeConversationId: slot.turns.activeConversation.conversationId }
|
|
975
|
+
: {}),
|
|
976
|
+
...(slot.turns.lastConversation?.conversationId
|
|
977
|
+
? { lastConversationId: slot.turns.lastConversation.conversationId }
|
|
978
|
+
: {}),
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
async handleDaemonForwardInput(request) {
|
|
982
|
+
if (!request.text.trim()) {
|
|
983
|
+
throw new Error("forward_input requires non-empty text.");
|
|
984
|
+
}
|
|
985
|
+
if (request.cwd && !isSameWorkspaceCwd(request.cwd, this.cwd)) {
|
|
986
|
+
throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
|
|
987
|
+
}
|
|
988
|
+
const adapter = request.adapter ?? this.activeAdapter;
|
|
989
|
+
if (!adapter) {
|
|
990
|
+
throw new Error("No active adapter slot is available.");
|
|
991
|
+
}
|
|
992
|
+
const senderId = request.senderId ?? this.authorizedUserId;
|
|
993
|
+
const conversationId = request.conversationId ?? senderId;
|
|
994
|
+
const conversation = {
|
|
995
|
+
channelId: this.channelId,
|
|
996
|
+
conversationId,
|
|
997
|
+
recipientId: request.recipientId ?? senderId,
|
|
998
|
+
...(request.contextToken ? { opaqueRef: request.contextToken } : {}),
|
|
999
|
+
...(request.metadata ? { metadata: request.metadata } : {}),
|
|
1000
|
+
};
|
|
1001
|
+
const previousActiveAdapter = this.activeAdapter;
|
|
1002
|
+
const ensureResult = await this.ensureSlot(adapter, {
|
|
1003
|
+
openVisible: true,
|
|
1004
|
+
reuseExistingVisible: true,
|
|
1005
|
+
});
|
|
1006
|
+
if (!ensureResult.activated) {
|
|
1007
|
+
return {
|
|
1008
|
+
forwarded: false,
|
|
1009
|
+
adapter,
|
|
1010
|
+
conversationId,
|
|
1011
|
+
reason: "not_activated",
|
|
1012
|
+
message: formatDaemonSwitchResultDetail(ensureResult),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
const slot = this.slots.get(adapter);
|
|
1016
|
+
if (!slot) {
|
|
1017
|
+
throw new Error(`Adapter slot ${adapter} is not available.`);
|
|
1018
|
+
}
|
|
1019
|
+
const inboundMessage = {
|
|
1020
|
+
senderId,
|
|
1021
|
+
sender: senderId,
|
|
1022
|
+
sessionId: conversationId,
|
|
1023
|
+
text: request.text,
|
|
1024
|
+
attachments: [],
|
|
1025
|
+
contextToken: request.contextToken,
|
|
1026
|
+
createdAt: new Date().toISOString(),
|
|
1027
|
+
createdAtMs: Date.now(),
|
|
1028
|
+
};
|
|
1029
|
+
try {
|
|
1030
|
+
const routed = await this.serializeSlotInput(slot, () => this.inboundConversationContext.run(conversation, () => this.routeDaemonInput(slot, inboundMessage, conversation)));
|
|
1031
|
+
if (routed.kind === "dispatched") {
|
|
1032
|
+
return { forwarded: true, adapter, conversationId };
|
|
1033
|
+
}
|
|
1034
|
+
this.restoreActiveAdapterAfterRejectedInput(adapter, ensureResult.activationVersion, previousActiveAdapter);
|
|
1035
|
+
return {
|
|
1036
|
+
forwarded: false,
|
|
1037
|
+
...(routed.queued ? { queued: true } : {}),
|
|
1038
|
+
...(routed.queuePosition !== undefined ? { queuePosition: routed.queuePosition } : {}),
|
|
1039
|
+
adapter,
|
|
1040
|
+
conversationId,
|
|
1041
|
+
reason: routed.reason,
|
|
1042
|
+
message: routed.message,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
catch (error) {
|
|
1046
|
+
this.restoreActiveAdapterAfterRejectedInput(adapter, ensureResult.activationVersion, previousActiveAdapter);
|
|
1047
|
+
throw error;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
async routeDaemonInput(slot, inboundMessage, conversation) {
|
|
1051
|
+
const runtimeState = slot.runtime.getState();
|
|
1052
|
+
const adapterState = slot.turns.hasActiveTask && runtimeState.status === "idle"
|
|
1053
|
+
? { ...runtimeState, status: "busy" }
|
|
1054
|
+
: runtimeState;
|
|
1055
|
+
let reason;
|
|
1056
|
+
let routeMessage;
|
|
1057
|
+
let queued = false;
|
|
1058
|
+
let queuePosition;
|
|
1059
|
+
const sendReminder = (text, context) => this.queueWechatMessage(inboundMessage.senderId, text, context, conversation);
|
|
1060
|
+
const routeResult = await routeBridgeMessage({
|
|
1061
|
+
message: toChannelInboundMessage(inboundMessage),
|
|
1062
|
+
authorized: true,
|
|
1063
|
+
command: null,
|
|
1064
|
+
adapterState,
|
|
1065
|
+
hasPendingApproval: slot.pendingConfirmations.length > 0,
|
|
1066
|
+
hasPendingUserInput: Boolean(slot.pendingUserInput),
|
|
1067
|
+
shouldDefer: shouldDeferCodexInboundMessage({
|
|
1068
|
+
adapter: slot.adapter,
|
|
1069
|
+
status: adapterState.status,
|
|
1070
|
+
activeTurnOrigin: adapterState.activeTurnOrigin,
|
|
1071
|
+
hasPendingConfirmation: slot.pendingConfirmations.length > 0,
|
|
1072
|
+
hasSystemCommand: false,
|
|
1073
|
+
}),
|
|
1074
|
+
onUnauthorized: async () => undefined,
|
|
1075
|
+
handleCommand: async () => false,
|
|
1076
|
+
remindPendingApproval: async () => {
|
|
1077
|
+
routeMessage = prefixDaemonAdapterMessage(slot.adapter, formatPendingApprovalReminder(slot.pendingConfirmations[0], slot.runtime.getState()));
|
|
1078
|
+
reason = "pending_approval";
|
|
1079
|
+
await sendReminder(routeMessage, "approval_required");
|
|
1080
|
+
},
|
|
1081
|
+
remindPendingUserInput: async () => {
|
|
1082
|
+
routeMessage = prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
|
|
1083
|
+
? formatPendingUserInputReminder(slot.pendingUserInput)
|
|
1084
|
+
: `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`);
|
|
1085
|
+
reason = "pending_user_input";
|
|
1086
|
+
await sendReminder(routeMessage, "user_input_required");
|
|
1087
|
+
},
|
|
1088
|
+
remindBusy: async () => {
|
|
1089
|
+
routeMessage = prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`);
|
|
1090
|
+
reason = "busy";
|
|
1091
|
+
await sendReminder(routeMessage, "notice");
|
|
1092
|
+
},
|
|
1093
|
+
defer: async () => {
|
|
1094
|
+
if (slot.deferredInputs.length >= MAX_DAEMON_DEFERRED_INPUTS) {
|
|
1095
|
+
routeMessage = prefixDaemonAdapterMessage(slot.adapter, `The deferred input queue is full (${MAX_DAEMON_DEFERRED_INPUTS}). Wait for the current local turn to finish before retrying.`);
|
|
1096
|
+
reason = "busy";
|
|
1097
|
+
await sendReminder(routeMessage, "notice");
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
slot.deferredInputs.push({ message: inboundMessage, conversation });
|
|
1101
|
+
queuePosition = slot.deferredInputs.length;
|
|
1102
|
+
queued = true;
|
|
1103
|
+
reason = "deferred";
|
|
1104
|
+
routeMessage = prefixDaemonAdapterMessage(slot.adapter, formatDeferredCodexInboundQueueMessage(queuePosition));
|
|
1105
|
+
await sendReminder(routeMessage, "notice");
|
|
1106
|
+
this.daemonLog(`deferred_inbound_input: adapter=${slot.adapter} position=${queuePosition} text=${truncatePreview(inboundMessage.text)}`);
|
|
1107
|
+
},
|
|
1108
|
+
dispatch: async () => {
|
|
1109
|
+
await this.dispatchInboundWechatText(inboundMessage, slot, conversation);
|
|
1110
|
+
},
|
|
1111
|
+
});
|
|
1112
|
+
if (routeResult.kind === "dispatched") {
|
|
1113
|
+
return { kind: "dispatched" };
|
|
1114
|
+
}
|
|
1115
|
+
return {
|
|
1116
|
+
kind: routeResult.kind,
|
|
1117
|
+
reason: reason ?? (routeResult.kind === "deferred" ? "deferred" : "busy"),
|
|
1118
|
+
message: routeMessage,
|
|
1119
|
+
...(queued ? { queued: true } : {}),
|
|
1120
|
+
...(queuePosition !== undefined ? { queuePosition } : {}),
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
restoreActiveAdapterAfterRejectedInput(adapter, activationVersion, previousActiveAdapter) {
|
|
1124
|
+
if (this.activeAdapter === adapter && this.activeAdapterVersion === activationVersion) {
|
|
1125
|
+
this.activeAdapter = previousActiveAdapter;
|
|
1126
|
+
this.activeAdapterVersion += 1;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
serializeSlotInput(slot, task) {
|
|
1130
|
+
const tail = this.slotInputChains.get(slot.adapter) ?? Promise.resolve();
|
|
1131
|
+
const run = tail.then(task, task);
|
|
1132
|
+
this.slotInputChains.set(slot.adapter, run.then(() => undefined, () => undefined));
|
|
1133
|
+
return run;
|
|
1134
|
+
}
|
|
1135
|
+
async maybeDrainDeferredInputs(slot) {
|
|
1136
|
+
if (slot.drainingDeferredInputs)
|
|
1137
|
+
return;
|
|
1138
|
+
const state = slot.runtime.getState();
|
|
1139
|
+
if (!canDrainDeferredCodexInboundQueue({
|
|
1140
|
+
adapter: slot.adapter,
|
|
1141
|
+
deferredCount: slot.deferredInputs.length,
|
|
1142
|
+
status: state.status,
|
|
1143
|
+
activeTurnId: state.activeTurnId,
|
|
1144
|
+
hasPendingConfirmation: slot.pendingConfirmations.length > 0,
|
|
1145
|
+
hasPendingUserInput: Boolean(slot.pendingUserInput),
|
|
1146
|
+
hasPendingApproval: Boolean(state.pendingApproval),
|
|
1147
|
+
hasActiveTask: slot.turns.hasActiveTask,
|
|
1148
|
+
}))
|
|
1149
|
+
return;
|
|
1150
|
+
const next = slot.deferredInputs.shift();
|
|
1151
|
+
if (!next)
|
|
1152
|
+
return;
|
|
1153
|
+
slot.drainingDeferredInputs = true;
|
|
1154
|
+
try {
|
|
1155
|
+
const routed = await this.inboundConversationContext.run(next.conversation ?? this.fallbackConversation, () => this.routeDaemonInput(slot, next.message, next.conversation ?? this.fallbackConversation));
|
|
1156
|
+
if (routed.kind !== "dispatched") {
|
|
1157
|
+
slot.deferredInputs.unshift(next);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
catch (error) {
|
|
1161
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
1162
|
+
if (isRetryableDeferredCodexDrainError(errorText)) {
|
|
1163
|
+
slot.deferredInputs.unshift(next);
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
await this.queueWechatMessage(next.message.senderId, formatUserFacingInboundError({ adapter: slot.adapter, cwd: this.cwd, errorText }), "inbound_error", next.conversation);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
slot.drainingDeferredInputs = false;
|
|
904
1171
|
}
|
|
905
1172
|
}
|
|
906
1173
|
async ensureSlot(adapter, options = {}) {
|
|
@@ -941,7 +1208,7 @@ class WechatDaemon {
|
|
|
941
1208
|
visibleEndpointBeforeProbe?.companionConnectedAt ||
|
|
942
1209
|
visibleEndpointBeforeProbe?.sharedThreadId ||
|
|
943
1210
|
visibleEndpointBeforeProbe?.sharedSessionId);
|
|
944
|
-
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
1211
|
+
let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
|
|
945
1212
|
if (shouldRestartDeadCodexVisibleRuntime({
|
|
946
1213
|
adapter,
|
|
947
1214
|
slotCreated: created,
|
|
@@ -970,8 +1237,9 @@ class WechatDaemon {
|
|
|
970
1237
|
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
971
1238
|
}
|
|
972
1239
|
if (options.openVisible !== false && !visibleConnected) {
|
|
1240
|
+
const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
|
|
973
1241
|
slot.controller.syncLocalClientEndpoint();
|
|
974
|
-
const launch = openVisibleClient({
|
|
1242
|
+
const launch = this.openVisibleClient({
|
|
975
1243
|
adapter,
|
|
976
1244
|
cwd: this.cwd,
|
|
977
1245
|
// A freshly prepared blank thread is semantically a new session, but
|
|
@@ -989,14 +1257,17 @@ class WechatDaemon {
|
|
|
989
1257
|
visibleConnected = await waitForVisibleClientConnection({
|
|
990
1258
|
cwd: this.cwd,
|
|
991
1259
|
adapter,
|
|
1260
|
+
timeoutMs: this.deps.visibleClientConnectTimeoutMs,
|
|
1261
|
+
}, {
|
|
1262
|
+
isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
|
|
992
1263
|
});
|
|
993
1264
|
if (visibleConnected) {
|
|
994
1265
|
appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
|
|
995
1266
|
}
|
|
996
1267
|
else {
|
|
997
|
-
log(`${adapter} visible CLI did not connect within ${formatDuration(
|
|
1268
|
+
log(`${adapter} visible CLI did not connect within ${formatDuration(visibleClientTimeoutMs)}. Check ${BRIDGE_LOG_FILE}.`);
|
|
998
1269
|
const cleanedLauncher = cleanupVisibleClientLauncher(launch);
|
|
999
|
-
appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${
|
|
1270
|
+
appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${visibleClientTimeoutMs} cleaned_launcher=${cleanedLauncher}`);
|
|
1000
1271
|
}
|
|
1001
1272
|
}
|
|
1002
1273
|
let visibleReady = visibleConnected;
|
|
@@ -1024,6 +1295,7 @@ class WechatDaemon {
|
|
|
1024
1295
|
if (previousSlot && this.activeAdapter !== adapter)
|
|
1025
1296
|
invalidateModelSnapshot(previousSlot.runtime);
|
|
1026
1297
|
this.activeAdapter = adapter;
|
|
1298
|
+
this.activeAdapterVersion += 1;
|
|
1027
1299
|
}
|
|
1028
1300
|
appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} visible_ready=${visibleReady} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
|
|
1029
1301
|
return {
|
|
@@ -1033,12 +1305,13 @@ class WechatDaemon {
|
|
|
1033
1305
|
visibleConnected,
|
|
1034
1306
|
visibleReady,
|
|
1035
1307
|
activated,
|
|
1308
|
+
activationVersion: this.activeAdapterVersion,
|
|
1036
1309
|
previousActiveAdapter,
|
|
1037
1310
|
};
|
|
1038
1311
|
}
|
|
1039
1312
|
async createSlot(adapter, options) {
|
|
1040
1313
|
clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
|
|
1041
|
-
const runtime =
|
|
1314
|
+
const runtime = this.createRuntime({
|
|
1042
1315
|
kind: adapter,
|
|
1043
1316
|
command: resolveDefaultAdapterCommand(adapter),
|
|
1044
1317
|
cwd: this.cwd,
|
|
@@ -1061,10 +1334,14 @@ class WechatDaemon {
|
|
|
1061
1334
|
adapter,
|
|
1062
1335
|
runtime,
|
|
1063
1336
|
}),
|
|
1064
|
-
|
|
1337
|
+
turns: new TurnCoordinator({
|
|
1338
|
+
restoreLastConversationOnFailure: true,
|
|
1339
|
+
}),
|
|
1065
1340
|
lastOutputAt: 0,
|
|
1066
1341
|
lastFinalReplyAtMs: 0,
|
|
1067
1342
|
eventForwardChain: Promise.resolve(),
|
|
1343
|
+
deferredInputs: [],
|
|
1344
|
+
drainingDeferredInputs: false,
|
|
1068
1345
|
};
|
|
1069
1346
|
runtime.setEventSink((event) => {
|
|
1070
1347
|
this.handleSlotEvent(slot, event);
|
|
@@ -1079,7 +1356,7 @@ class WechatDaemon {
|
|
|
1079
1356
|
slot.outputBatcher.clear();
|
|
1080
1357
|
slot.pendingConfirmations = [];
|
|
1081
1358
|
slot.pendingUserInput = null;
|
|
1082
|
-
|
|
1359
|
+
slot.turns.complete();
|
|
1083
1360
|
if (slot.adapter === "codex" || slot.adapter === "claude") {
|
|
1084
1361
|
await slot.runtime.reset();
|
|
1085
1362
|
}
|
|
@@ -1121,10 +1398,10 @@ class WechatDaemon {
|
|
|
1121
1398
|
slot.controller.syncLocalClientEndpoint();
|
|
1122
1399
|
const adapterState = slot.runtime.getState();
|
|
1123
1400
|
const channelPort = this.createWechatChannelPort(slot.adapter);
|
|
1124
|
-
const eventTarget = this.
|
|
1401
|
+
const eventTarget = this.channelDriver.capabilities.multiConversation
|
|
1125
1402
|
? this.resolveSlotOutputTarget(slot)
|
|
1126
1403
|
: this.fallbackConversation;
|
|
1127
|
-
const eventTask = slot.activeTask;
|
|
1404
|
+
const eventTask = slot.turns.activeTask;
|
|
1128
1405
|
if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
|
|
1129
1406
|
slot.pendingConfirmations = [];
|
|
1130
1407
|
}
|
|
@@ -1146,6 +1423,7 @@ class WechatDaemon {
|
|
|
1146
1423
|
}
|
|
1147
1424
|
},
|
|
1148
1425
|
finalReply: (next) => {
|
|
1426
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1149
1427
|
slot.lastFinalReplyAtMs = Date.now();
|
|
1150
1428
|
appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
1151
1429
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
@@ -1225,32 +1503,38 @@ class WechatDaemon {
|
|
|
1225
1503
|
}
|
|
1226
1504
|
},
|
|
1227
1505
|
taskComplete: () => {
|
|
1506
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1228
1507
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
|
|
1229
1508
|
slot.pendingConfirmations = [];
|
|
1230
1509
|
slot.pendingUserInput = null;
|
|
1231
1510
|
if (eventTask) {
|
|
1232
|
-
|
|
1511
|
+
slot.turns.complete(eventTask);
|
|
1233
1512
|
}
|
|
1513
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1234
1514
|
}));
|
|
1235
1515
|
},
|
|
1236
1516
|
taskFailed: (next) => {
|
|
1517
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1237
1518
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1238
1519
|
slot.pendingConfirmations = [];
|
|
1239
1520
|
slot.pendingUserInput = null;
|
|
1240
1521
|
if (eventTask) {
|
|
1241
|
-
|
|
1522
|
+
slot.turns.complete(eventTask);
|
|
1242
1523
|
}
|
|
1524
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1243
1525
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed", eventTarget);
|
|
1244
1526
|
}));
|
|
1245
1527
|
},
|
|
1246
1528
|
fatalError: (next) => {
|
|
1529
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1247
1530
|
logError(`${slot.adapter}: ${next.message}`);
|
|
1248
1531
|
appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
|
|
1249
1532
|
slot.pendingConfirmations = [];
|
|
1250
1533
|
slot.pendingUserInput = null;
|
|
1251
1534
|
if (eventTask) {
|
|
1252
|
-
|
|
1535
|
+
slot.turns.complete(eventTask);
|
|
1253
1536
|
}
|
|
1537
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1254
1538
|
this.disposeDeadSlot(slot);
|
|
1255
1539
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1256
1540
|
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
|
|
@@ -1266,24 +1550,18 @@ class WechatDaemon {
|
|
|
1266
1550
|
});
|
|
1267
1551
|
}
|
|
1268
1552
|
resolveSlotOutputTarget(slot) {
|
|
1269
|
-
return
|
|
1270
|
-
active: slot.activeConversation,
|
|
1271
|
-
last: slot.lastConversation,
|
|
1272
|
-
fallback: this.fallbackConversation,
|
|
1273
|
-
});
|
|
1553
|
+
return slot.turns.resolveTarget(this.fallbackConversation);
|
|
1274
1554
|
}
|
|
1275
1555
|
bindCurrentWecomConversation(slot) {
|
|
1276
1556
|
const conversation = this.inboundConversationContext.get();
|
|
1277
|
-
if (this.
|
|
1557
|
+
if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
|
|
1278
1558
|
return;
|
|
1279
1559
|
}
|
|
1280
|
-
slot.
|
|
1560
|
+
slot.turns.observeConversation(conversation);
|
|
1281
1561
|
}
|
|
1282
1562
|
async handleInboundMessage(message) {
|
|
1283
1563
|
if (message.senderId !== this.authorizedUserId) {
|
|
1284
|
-
await this.queueWechatMessage(message.senderId,
|
|
1285
|
-
? "Unauthorized. This daemon only accepts messages from the paired WeCom operator."
|
|
1286
|
-
: "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
|
|
1564
|
+
await this.queueWechatMessage(message.senderId, `Unauthorized. This daemon only accepts messages from ${this.channelDriver.operatorDescription}.`);
|
|
1287
1565
|
return;
|
|
1288
1566
|
}
|
|
1289
1567
|
const emojiMatch = resolveEmojiCommand(message.text);
|
|
@@ -1354,7 +1632,7 @@ class WechatDaemon {
|
|
|
1354
1632
|
}
|
|
1355
1633
|
this.bindCurrentWecomConversation(slot);
|
|
1356
1634
|
const routeCurrentMessage = async (currentSlot, command) => {
|
|
1357
|
-
const previousTask = currentSlot.activeTask;
|
|
1635
|
+
const previousTask = currentSlot.turns.activeTask;
|
|
1358
1636
|
await routeBridgeMessage({
|
|
1359
1637
|
message: toChannelInboundMessage(message),
|
|
1360
1638
|
authorized: true,
|
|
@@ -1383,13 +1661,12 @@ class WechatDaemon {
|
|
|
1383
1661
|
await this.dispatchInboundWechatText(message, currentSlot);
|
|
1384
1662
|
},
|
|
1385
1663
|
});
|
|
1386
|
-
if (this.
|
|
1664
|
+
if (this.channelDriver.capabilities.multiConversation &&
|
|
1387
1665
|
this.inboundConversationContext.get() &&
|
|
1388
|
-
currentSlot.activeTask &&
|
|
1389
|
-
currentSlot.activeTask !== previousTask) {
|
|
1666
|
+
currentSlot.turns.activeTask &&
|
|
1667
|
+
currentSlot.turns.activeTask !== previousTask) {
|
|
1390
1668
|
const conversation = this.inboundConversationContext.get();
|
|
1391
|
-
currentSlot.
|
|
1392
|
-
currentSlot.lastConversation = conversation;
|
|
1669
|
+
currentSlot.turns.bindConversation(conversation);
|
|
1393
1670
|
}
|
|
1394
1671
|
};
|
|
1395
1672
|
const command = parseWechatControlCommand(message.text, {
|
|
@@ -1448,7 +1725,7 @@ class WechatDaemon {
|
|
|
1448
1725
|
return;
|
|
1449
1726
|
case "resume": {
|
|
1450
1727
|
if (!isWechatResumeEnabled(activeSlot.adapter)) {
|
|
1451
|
-
await this.queueWechatMessage(message.senderId, `${this.
|
|
1728
|
+
await this.queueWechatMessage(message.senderId, `${this.channelDriver.displayName} /resume is disabled for ${activeSlot.adapter} in daemon mode. Use /resume directly inside the visible terminal; the remote channel will follow that local session.`);
|
|
1452
1729
|
return;
|
|
1453
1730
|
}
|
|
1454
1731
|
try {
|
|
@@ -1466,10 +1743,10 @@ class WechatDaemon {
|
|
|
1466
1743
|
if (command.target) {
|
|
1467
1744
|
await resumeSlot.outputBatcher.flushNow();
|
|
1468
1745
|
}
|
|
1469
|
-
const taskBeforeResume = resumeSlot.activeTask;
|
|
1746
|
+
const taskBeforeResume = resumeSlot.turns.activeTask;
|
|
1470
1747
|
const result = await resumeSlot.resumeCoordinator.execute(command.target);
|
|
1471
1748
|
if (result.kind === "resumed" && taskBeforeResume) {
|
|
1472
|
-
|
|
1749
|
+
resumeSlot.turns.complete(taskBeforeResume);
|
|
1473
1750
|
}
|
|
1474
1751
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
|
|
1475
1752
|
}
|
|
@@ -1491,6 +1768,9 @@ class WechatDaemon {
|
|
|
1491
1768
|
await activeSlot.runtime.createSession();
|
|
1492
1769
|
appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
|
|
1493
1770
|
return;
|
|
1771
|
+
case "broadcast":
|
|
1772
|
+
await this.handleBroadcastCommand(message, command.text);
|
|
1773
|
+
return;
|
|
1494
1774
|
case "stop": {
|
|
1495
1775
|
const interrupted = await activeSlot.runtime.interrupt();
|
|
1496
1776
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
|
|
@@ -1532,10 +1812,10 @@ class WechatDaemon {
|
|
|
1532
1812
|
}
|
|
1533
1813
|
const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
|
|
1534
1814
|
slot.pendingConfirmations = [];
|
|
1535
|
-
slot.
|
|
1815
|
+
slot.turns.setActiveTask({
|
|
1536
1816
|
startedAt: Date.now(),
|
|
1537
1817
|
inputPreview: preview,
|
|
1538
|
-
};
|
|
1818
|
+
});
|
|
1539
1819
|
appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
|
|
1540
1820
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
|
|
1541
1821
|
? `${count} approvals confirmed. Continuing...`
|
|
@@ -1581,10 +1861,10 @@ class WechatDaemon {
|
|
|
1581
1861
|
return;
|
|
1582
1862
|
}
|
|
1583
1863
|
slot.pendingUserInput = null;
|
|
1584
|
-
slot.
|
|
1864
|
+
slot.turns.setActiveTask({
|
|
1585
1865
|
startedAt: Date.now(),
|
|
1586
1866
|
inputPreview: parsed.preview,
|
|
1587
|
-
};
|
|
1867
|
+
});
|
|
1588
1868
|
appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
|
|
1589
1869
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
|
|
1590
1870
|
}
|
|
@@ -1606,30 +1886,79 @@ class WechatDaemon {
|
|
|
1606
1886
|
}
|
|
1607
1887
|
return this.slots.get(this.activeAdapter) ?? null;
|
|
1608
1888
|
}
|
|
1609
|
-
|
|
1889
|
+
isSlotBusyForBroadcast(slot) {
|
|
1890
|
+
const state = slot.runtime.getState();
|
|
1891
|
+
if (slot.turns.hasActiveTask)
|
|
1892
|
+
return true;
|
|
1893
|
+
if (slot.pendingConfirmations.length > 0 || slot.pendingUserInput)
|
|
1894
|
+
return true;
|
|
1895
|
+
if (slot.deferredInputs.length > 0)
|
|
1896
|
+
return true;
|
|
1897
|
+
if (state.status === "busy" || state.status === "awaiting_approval" || state.status === "awaiting_input") {
|
|
1898
|
+
return true;
|
|
1899
|
+
}
|
|
1900
|
+
return false;
|
|
1901
|
+
}
|
|
1902
|
+
async handleBroadcastCommand(message, text) {
|
|
1903
|
+
const startedSlots = DAEMON_ADAPTERS
|
|
1904
|
+
.map((adapter) => this.slots.get(adapter))
|
|
1905
|
+
.filter((slot) => Boolean(slot));
|
|
1906
|
+
if (startedSlots.length === 0) {
|
|
1907
|
+
await this.queueWechatMessage(message.senderId, "/all needs at least one running adapter. Open one first with /codex, /claude, /opencode, or /pi.");
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
const busySlots = startedSlots.filter((slot) => this.isSlotBusyForBroadcast(slot));
|
|
1911
|
+
if (busySlots.length > 0) {
|
|
1912
|
+
const names = busySlots.map((slot) => slot.adapter).join(", ");
|
|
1913
|
+
appendDaemonLog(`broadcast_canceled_busy: busy=[${names}]`);
|
|
1914
|
+
await this.queueWechatMessage(message.senderId, `/all canceled: ${busySlots.length > 1 ? "these workers are" : "this worker is"} still busy — ${names}. Wait for the current replies or use /stop first.`);
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
const broadcastMessage = { ...message, text };
|
|
1918
|
+
const dispatched = [];
|
|
1919
|
+
const skipped = [];
|
|
1920
|
+
for (const adapter of DAEMON_ADAPTERS) {
|
|
1921
|
+
const slot = this.slots.get(adapter);
|
|
1922
|
+
if (!slot) {
|
|
1923
|
+
skipped.push(adapter);
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
appendDaemonLog(`broadcast_input: adapter=${adapter} text=${truncatePreview(text)}`);
|
|
1927
|
+
await this.dispatchInboundWechatText(broadcastMessage, slot);
|
|
1928
|
+
dispatched.push(adapter);
|
|
1929
|
+
}
|
|
1930
|
+
const lines = [
|
|
1931
|
+
`Broadcast dispatched to ${dispatched.length} ${dispatched.length > 1 ? "workers" : "worker"}: ${dispatched.join(", ")}. Each reply arrives prefixed with its adapter name.`,
|
|
1932
|
+
];
|
|
1933
|
+
if (skipped.length > 0) {
|
|
1934
|
+
lines.push(`Not started (skipped): ${skipped.join(", ")}. Start them with their /command to include them next time.`);
|
|
1935
|
+
}
|
|
1936
|
+
await this.queueWechatMessage(message.senderId, lines.join("\n"));
|
|
1937
|
+
}
|
|
1938
|
+
async dispatchInboundWechatText(message, slot, conversationOverride) {
|
|
1610
1939
|
const preview = formatInboundMessagePreview(message);
|
|
1611
1940
|
const nextTask = {
|
|
1612
1941
|
startedAt: Date.now(),
|
|
1613
1942
|
inputPreview: truncatePreview(preview, 180),
|
|
1614
1943
|
};
|
|
1615
|
-
const inboundConversation = this.
|
|
1616
|
-
? this.inboundConversationContext.get()
|
|
1944
|
+
const inboundConversation = this.channelDriver.capabilities.multiConversation
|
|
1945
|
+
? conversationOverride ?? this.inboundConversationContext.get()
|
|
1617
1946
|
: undefined;
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
throw error;
|
|
1947
|
+
const dispatchResult = await slot.turns.dispatch({
|
|
1948
|
+
task: nextTask,
|
|
1949
|
+
conversation: inboundConversation,
|
|
1950
|
+
onBusy: async () => {
|
|
1951
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`), "notice", conversationOverride);
|
|
1952
|
+
},
|
|
1953
|
+
forward: async () => {
|
|
1954
|
+
appendDaemonLog(`forwarded_input: adapter=${slot.adapter} text=${truncatePreview(preview)}`);
|
|
1955
|
+
await slot.runtime.sendInput(this.channelDriver.buildInboundPrompt(message.text, message.attachments));
|
|
1956
|
+
},
|
|
1957
|
+
});
|
|
1958
|
+
if (dispatchResult.status === "dispatched") {
|
|
1959
|
+
void this.channelDriver.beginTyping?.(message.senderId);
|
|
1632
1960
|
}
|
|
1961
|
+
return dispatchResult;
|
|
1633
1962
|
}
|
|
1634
1963
|
queueWechatTextAction(action) {
|
|
1635
1964
|
const run = this.textSendChain.then(action);
|
|
@@ -1642,88 +1971,36 @@ class WechatDaemon {
|
|
|
1642
1971
|
return run;
|
|
1643
1972
|
}
|
|
1644
1973
|
async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
catch (error) {
|
|
1665
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1666
|
-
appendDaemonLog(`wecom_send_failed: context=${context} recipient=${target.recipientId} error=${truncatePreview(message, 400)}`);
|
|
1667
|
-
logError(`Failed to send WeCom ${context}: ${message}`);
|
|
1668
|
-
return { status: "context_unavailable", error, target };
|
|
1669
|
-
}
|
|
1670
|
-
}
|
|
1671
|
-
const startedAtMs = Date.now();
|
|
1672
|
-
appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
|
|
1673
|
-
for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
|
|
1674
|
-
try {
|
|
1675
|
-
await this.transport.sendText(senderId, text);
|
|
1676
|
-
appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
1677
|
-
return { status: "sent" };
|
|
1678
|
-
}
|
|
1679
|
-
catch (error) {
|
|
1680
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
1681
|
-
appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
1682
|
-
}
|
|
1683
|
-
if (isWechatContextUnavailableError(error)) {
|
|
1684
|
-
if (isWechatContextTokenStaleError(error)) {
|
|
1685
|
-
this.transport.clearCachedContextToken(senderId);
|
|
1686
|
-
}
|
|
1687
|
-
appendDaemonLog(isWechatContextTokenStaleError(error)
|
|
1688
|
-
? formatWechatContextTokenStaleLogEntry({
|
|
1689
|
-
context,
|
|
1690
|
-
recipientId: senderId,
|
|
1691
|
-
error,
|
|
1692
|
-
})
|
|
1693
|
-
: formatWechatSendFailureLogEntry({
|
|
1694
|
-
context,
|
|
1695
|
-
recipientId: senderId,
|
|
1696
|
-
error,
|
|
1697
|
-
}));
|
|
1698
|
-
return { status: "context_unavailable", error };
|
|
1699
|
-
}
|
|
1700
|
-
if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
|
|
1701
|
-
const delayMs = computeWechatSendRetryDelayMs(attempt);
|
|
1702
|
-
appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
|
|
1703
|
-
await delay(delayMs);
|
|
1704
|
-
continue;
|
|
1705
|
-
}
|
|
1706
|
-
logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
|
|
1707
|
-
appendDaemonLog(formatWechatSendFailureLogEntry({
|
|
1708
|
-
context,
|
|
1709
|
-
recipientId: senderId,
|
|
1710
|
-
error,
|
|
1711
|
-
}));
|
|
1712
|
-
return { status: "failed" };
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1715
|
-
return { status: "failed" };
|
|
1974
|
+
const activeSlot = this.getActiveSlot();
|
|
1975
|
+
const target = resolveDaemonOutboundTarget({
|
|
1976
|
+
senderId,
|
|
1977
|
+
operatorId: this.authorizedUserId,
|
|
1978
|
+
override: targetOverride,
|
|
1979
|
+
multiConversation: this.channelDriver.capabilities.multiConversation,
|
|
1980
|
+
inboundConversation: this.channelDriver.capabilities.multiConversation
|
|
1981
|
+
? this.inboundConversationContext.get()
|
|
1982
|
+
: null,
|
|
1983
|
+
activeSlotTarget: activeSlot ? this.resolveSlotOutputTarget(activeSlot) : null,
|
|
1984
|
+
fallbackConversation: this.fallbackConversation,
|
|
1985
|
+
directConversation: (id) => this.channelDriver.directConversation(id),
|
|
1986
|
+
});
|
|
1987
|
+
return this.channelDriver.sendText({
|
|
1988
|
+
target,
|
|
1989
|
+
text,
|
|
1990
|
+
context,
|
|
1991
|
+
log: appendDaemonLog,
|
|
1992
|
+
});
|
|
1716
1993
|
}
|
|
1717
1994
|
queueWechatMessage(senderId, text, context = "message", targetOverride) {
|
|
1718
1995
|
const activeSlot = this.getActiveSlot();
|
|
1719
|
-
const queuedTarget = this.
|
|
1996
|
+
const queuedTarget = this.channelDriver.capabilities.multiConversation && senderId === this.authorizedUserId
|
|
1720
1997
|
? targetOverride ??
|
|
1721
1998
|
this.inboundConversationContext.get() ??
|
|
1722
1999
|
(activeSlot ? this.resolveSlotOutputTarget(activeSlot) : this.fallbackConversation)
|
|
1723
2000
|
: targetOverride;
|
|
1724
2001
|
return this.queueWechatTextAction(async () => {
|
|
1725
2002
|
const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
|
|
1726
|
-
if (result.status === "
|
|
2003
|
+
if (result.status === "target_stale") {
|
|
1727
2004
|
const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
|
|
1728
2005
|
if (pending) {
|
|
1729
2006
|
appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
|
|
@@ -1741,7 +2018,7 @@ class WechatDaemon {
|
|
|
1741
2018
|
appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
1742
2019
|
continue;
|
|
1743
2020
|
}
|
|
1744
|
-
if (result.status === "
|
|
2021
|
+
if (result.status === "target_stale") {
|
|
1745
2022
|
break;
|
|
1746
2023
|
}
|
|
1747
2024
|
appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
@@ -2085,6 +2362,7 @@ export async function runDaemon(options) {
|
|
|
2085
2362
|
authorizedUserId: credentials.userId,
|
|
2086
2363
|
transport: new WeChatTransport({ log, logError }),
|
|
2087
2364
|
channelId: options.channelId,
|
|
2365
|
+
accountId: wecomAccount?.botId,
|
|
2088
2366
|
wecomTransport: wecomAccount
|
|
2089
2367
|
? new WecomTransport({
|
|
2090
2368
|
account: wecomAccount,
|