cli-wechat-bridge 1.1.7 → 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/LICENSE.txt +661 -661
- package/README.md +461 -434
- package/bin/_run-entry.mjs +29 -6
- 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/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +35 -1
- package/dist/bridge/bridge-adapters.codex.js +118 -7
- package/dist/bridge/bridge-adapters.core.js +25 -1
- package/dist/bridge/bridge-adapters.opencode.js +156 -1
- package/dist/bridge/bridge-adapters.pi.js +45 -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/claude-native-controls.js +133 -0
- package/dist/bridge/native-terminal-control.js +259 -0
- package/dist/bridge/wechat-bridge.js +250 -274
- 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 +28 -2
- package/dist/companion/codex-visible-client-link.js +107 -0
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +13 -2
- package/dist/companion/local-companion.js +23 -0
- package/dist/companion/opencode-tui-bridge-plugin.js +6 -0
- package/dist/companion/pi-tui-bridge-extension.js +62 -3
- package/dist/core/bridge-control.js +10 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/conversation-routing.js +58 -0
- 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 +529 -205
- 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/runtime/legacy-adapter-runtime.js +15 -0
- package/dist/runtime/runtime-types.js +3 -0
- package/dist/utils/atomic-file.js +6 -0
- package/dist/utils/doctor.js +6 -0
- package/dist/utils/version-checker.js +17 -11
- 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 +169 -58
- package/package.json +108 -106
- package/scripts/ensure-node-pty-permissions.mjs +73 -73
|
@@ -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,22 +18,28 @@ 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";
|
|
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";
|
|
34
|
+
import { handleAdapterControl, invalidateModelSnapshot } from "../bridge/adapter-control.js";
|
|
24
35
|
import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
|
|
25
36
|
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
26
37
|
import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
|
|
27
38
|
import { ensureWecomAccount } from "../channels/wecom/setup.js";
|
|
28
39
|
import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
|
|
29
40
|
import { WecomTransport } from "../channels/wecom/wecom-transport.js";
|
|
30
|
-
import {
|
|
41
|
+
import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
|
|
42
|
+
import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
|
|
31
43
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
32
44
|
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
|
|
33
45
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
@@ -44,6 +56,7 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
|
44
56
|
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
45
57
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
46
58
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
59
|
+
const MAX_DAEMON_DEFERRED_INPUTS = 32;
|
|
47
60
|
const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
|
|
48
61
|
function getCliChannelId(argv = process.argv.slice(2)) {
|
|
49
62
|
const index = argv.indexOf("--channel");
|
|
@@ -64,22 +77,6 @@ function computePollRetryDelayMs(consecutiveFailures) {
|
|
|
64
77
|
const exponent = Math.min(normalizedFailures - 1, 5);
|
|
65
78
|
return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
|
|
66
79
|
}
|
|
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
80
|
function toWechatSendContext(kind) {
|
|
84
81
|
if (kind === "mirrored_input") {
|
|
85
82
|
return "mirrored_user_input";
|
|
@@ -217,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
|
|
|
217
214
|
remainder: match[2]?.trim() ?? "",
|
|
218
215
|
};
|
|
219
216
|
}
|
|
220
|
-
export function defaultDaemonSessionStartMode(
|
|
221
|
-
|
|
217
|
+
export function defaultDaemonSessionStartMode(_adapter) {
|
|
218
|
+
// Codex 0.155+ persists app-server threads lazily, so resuming a freshly
|
|
219
|
+
// prepared (not yet persisted) thread crashes the visible TUI bootstrap
|
|
220
|
+
// with "no rollout found". Every adapter therefore opens a fresh session
|
|
221
|
+
// by default; explicit --session-start-mode restore and WeChat /resume
|
|
222
|
+
// remain available for deliberate restores of persisted threads.
|
|
223
|
+
return "new";
|
|
222
224
|
}
|
|
223
225
|
export function resolveDaemonSessionStartMode(params) {
|
|
224
226
|
// An explicit --session-start-mode wins over visible-client reuse: the user
|
|
@@ -231,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
|
|
|
231
233
|
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
232
234
|
return "restore";
|
|
233
235
|
}
|
|
234
|
-
if (params.adapter === "codex") {
|
|
235
|
-
return "restore";
|
|
236
|
-
}
|
|
237
236
|
if (params.slotCreated) {
|
|
238
237
|
return "new";
|
|
239
238
|
}
|
|
@@ -360,9 +359,9 @@ function openVisibleClient(params) {
|
|
|
360
359
|
const fullArgs = [process.execPath, ...args];
|
|
361
360
|
if (process.platform === "darwin") {
|
|
362
361
|
const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
|
|
363
|
-
const script = `tell application "Terminal"
|
|
364
|
-
activate
|
|
365
|
-
do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
|
|
362
|
+
const script = `tell application "Terminal"
|
|
363
|
+
activate
|
|
364
|
+
do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
|
|
366
365
|
end tell`;
|
|
367
366
|
const child = spawn("osascript", ["-e", script], {
|
|
368
367
|
cwd: params.cwd,
|
|
@@ -548,15 +547,16 @@ export function formatDaemonStatus(status) {
|
|
|
548
547
|
}
|
|
549
548
|
return lines.join("\n");
|
|
550
549
|
}
|
|
551
|
-
class WechatDaemon {
|
|
550
|
+
export class WechatDaemon {
|
|
552
551
|
cwd;
|
|
553
552
|
profile;
|
|
554
553
|
authorizedUserId;
|
|
555
554
|
transport;
|
|
555
|
+
channelDriver;
|
|
556
556
|
channelId;
|
|
557
557
|
wecomTransport;
|
|
558
|
-
|
|
559
|
-
|
|
558
|
+
inboundConversationContext = new InboundConversationContext();
|
|
559
|
+
fallbackConversation;
|
|
560
560
|
slots = new Map();
|
|
561
561
|
// Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
|
|
562
562
|
slotEnsureChains = new Map();
|
|
@@ -564,11 +564,14 @@ class WechatDaemon {
|
|
|
564
564
|
bridgeStartedAtMs = Date.now();
|
|
565
565
|
backlogNoticeSent = false;
|
|
566
566
|
activeAdapter = null;
|
|
567
|
+
activeAdapterVersion = 0;
|
|
567
568
|
takenOverAdapter;
|
|
568
569
|
textSendChain = Promise.resolve();
|
|
569
570
|
attachmentSendChain = Promise.resolve();
|
|
570
571
|
pendingWechatForwardTasks = new Set();
|
|
572
|
+
slotInputChains = new Map();
|
|
571
573
|
pendingWechatMessages;
|
|
574
|
+
deps;
|
|
572
575
|
shutdownPromise = null;
|
|
573
576
|
ipcServer = null;
|
|
574
577
|
endpointToken = "";
|
|
@@ -579,17 +582,37 @@ class WechatDaemon {
|
|
|
579
582
|
this.transport = params.transport;
|
|
580
583
|
this.channelId = params.channelId ?? "wechat";
|
|
581
584
|
this.wecomTransport = params.wecomTransport ?? null;
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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);
|
|
593
616
|
}
|
|
594
617
|
async startIpcServer() {
|
|
595
618
|
this.endpointToken = buildDaemonToken();
|
|
@@ -675,51 +698,48 @@ class WechatDaemon {
|
|
|
675
698
|
}
|
|
676
699
|
async runPollLoop() {
|
|
677
700
|
let consecutivePollFailures = 0;
|
|
678
|
-
if (this.
|
|
679
|
-
this.
|
|
680
|
-
|
|
701
|
+
if (this.channelDriver.start) {
|
|
702
|
+
await this.channelDriver.start({
|
|
703
|
+
onInboundMessage: async (channelMessage) => {
|
|
681
704
|
if (this.shutdownPromise) {
|
|
682
705
|
return;
|
|
683
706
|
}
|
|
684
|
-
this.
|
|
685
|
-
this.handlingInboundMessage = true;
|
|
686
|
-
try {
|
|
707
|
+
await this.inboundConversationContext.run(channelMessage.conversation, async () => {
|
|
687
708
|
if (this.pendingWechatMessages.list().length > 0) {
|
|
688
709
|
await this.flushPendingWechatMessages();
|
|
689
710
|
}
|
|
690
711
|
await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
|
|
691
|
-
}
|
|
692
|
-
finally {
|
|
693
|
-
this.handlingInboundMessage = false;
|
|
694
|
-
}
|
|
712
|
+
});
|
|
695
713
|
},
|
|
696
|
-
|
|
714
|
+
onUnauthorizedSender: async (senderId, chatType) => {
|
|
697
715
|
appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
|
|
698
716
|
if (chatType === "direct") {
|
|
699
|
-
await this.
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
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
|
+
}
|
|
705
726
|
}
|
|
706
727
|
},
|
|
707
|
-
|
|
728
|
+
onChannelFatal: async (error) => {
|
|
708
729
|
appendDaemonLog(`wecom_fatal_error: ${error.message}`);
|
|
709
730
|
await this.shutdown();
|
|
710
731
|
},
|
|
711
|
-
|
|
732
|
+
onChannelConnected: async () => {
|
|
712
733
|
if (this.pendingWechatMessages.list().length > 0) {
|
|
713
734
|
await this.flushPendingWechatMessages();
|
|
714
735
|
}
|
|
715
736
|
},
|
|
716
737
|
});
|
|
717
|
-
this.
|
|
718
|
-
await this.wecomTransport.waitUntilConnected();
|
|
738
|
+
await this.channelDriver.waitUntilReady?.();
|
|
719
739
|
}
|
|
720
|
-
log(`${this.
|
|
740
|
+
log(`${this.channelDriver.displayName} daemon is ready.`);
|
|
721
741
|
log(`Working directory: ${this.cwd}`);
|
|
722
|
-
log(`Switch from ${this.
|
|
742
|
+
log(`Switch from ${this.channelDriver.displayName} with /codex, /claude, /opencode, or /pi.`);
|
|
723
743
|
appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
|
|
724
744
|
const activeSlot = this.getActiveSlot();
|
|
725
745
|
const welcomeText = t("daemon.welcome", {
|
|
@@ -728,7 +748,7 @@ class WechatDaemon {
|
|
|
728
748
|
bindings: formatBindingsListMessage(listBindings()),
|
|
729
749
|
});
|
|
730
750
|
await this.queueWechatMessage(this.authorizedUserId, welcomeText);
|
|
731
|
-
if (this.
|
|
751
|
+
if (this.channelDriver.capabilities.pushInbound) {
|
|
732
752
|
while (!this.shutdownPromise) {
|
|
733
753
|
await delay(1_000);
|
|
734
754
|
}
|
|
@@ -828,6 +848,29 @@ class WechatDaemon {
|
|
|
828
848
|
}
|
|
829
849
|
async cleanup() {
|
|
830
850
|
appendDaemonLog("shutdown_started");
|
|
851
|
+
// Stop accepting new channel work and abort an in-flight long poll before
|
|
852
|
+
// waiting for queued sends or adapter disposal. Otherwise Ctrl+C can leave
|
|
853
|
+
// the process alive until the 35-second poll timeout and retain the daemon
|
|
854
|
+
// endpoint for the next startup to clean up.
|
|
855
|
+
try {
|
|
856
|
+
this.transport.stop();
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
// Best effort shutdown.
|
|
860
|
+
}
|
|
861
|
+
try {
|
|
862
|
+
await this.channelDriver.endAllTyping?.();
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
// Best effort shutdown.
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
this.wecomTransport?.stop();
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
// Best effort shutdown.
|
|
872
|
+
}
|
|
873
|
+
clearDaemonEndpoint();
|
|
831
874
|
for (const slot of this.slots.values()) {
|
|
832
875
|
try {
|
|
833
876
|
await slot.outputBatcher.flushNow();
|
|
@@ -839,12 +882,6 @@ class WechatDaemon {
|
|
|
839
882
|
await this.waitForPendingWechatForwardTasks();
|
|
840
883
|
await this.textSendChain.catch(() => undefined);
|
|
841
884
|
await this.attachmentSendChain.catch(() => undefined);
|
|
842
|
-
try {
|
|
843
|
-
this.wecomTransport?.stop();
|
|
844
|
-
}
|
|
845
|
-
catch {
|
|
846
|
-
// Best effort shutdown.
|
|
847
|
-
}
|
|
848
885
|
for (const slot of this.slots.values()) {
|
|
849
886
|
try {
|
|
850
887
|
await slot.runtime.dispose();
|
|
@@ -893,6 +930,244 @@ class WechatDaemon {
|
|
|
893
930
|
sessionStartMode: request.sessionStartMode,
|
|
894
931
|
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
895
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;
|
|
896
1171
|
}
|
|
897
1172
|
}
|
|
898
1173
|
async ensureSlot(adapter, options = {}) {
|
|
@@ -933,7 +1208,7 @@ class WechatDaemon {
|
|
|
933
1208
|
visibleEndpointBeforeProbe?.companionConnectedAt ||
|
|
934
1209
|
visibleEndpointBeforeProbe?.sharedThreadId ||
|
|
935
1210
|
visibleEndpointBeforeProbe?.sharedSessionId);
|
|
936
|
-
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
1211
|
+
let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
|
|
937
1212
|
if (shouldRestartDeadCodexVisibleRuntime({
|
|
938
1213
|
adapter,
|
|
939
1214
|
slotCreated: created,
|
|
@@ -962,10 +1237,14 @@ class WechatDaemon {
|
|
|
962
1237
|
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
963
1238
|
}
|
|
964
1239
|
if (options.openVisible !== false && !visibleConnected) {
|
|
1240
|
+
const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
|
|
965
1241
|
slot.controller.syncLocalClientEndpoint();
|
|
966
|
-
const launch = openVisibleClient({
|
|
1242
|
+
const launch = this.openVisibleClient({
|
|
967
1243
|
adapter,
|
|
968
1244
|
cwd: this.cwd,
|
|
1245
|
+
// A freshly prepared blank thread is semantically a new session, but
|
|
1246
|
+
// the visible client must resume its id from the endpoint instead of
|
|
1247
|
+
// creating a second thread of its own.
|
|
969
1248
|
sessionStartMode,
|
|
970
1249
|
cliArgs: options.cliArgs,
|
|
971
1250
|
channelId: this.channelId,
|
|
@@ -978,14 +1257,17 @@ class WechatDaemon {
|
|
|
978
1257
|
visibleConnected = await waitForVisibleClientConnection({
|
|
979
1258
|
cwd: this.cwd,
|
|
980
1259
|
adapter,
|
|
1260
|
+
timeoutMs: this.deps.visibleClientConnectTimeoutMs,
|
|
1261
|
+
}, {
|
|
1262
|
+
isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
|
|
981
1263
|
});
|
|
982
1264
|
if (visibleConnected) {
|
|
983
1265
|
appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
|
|
984
1266
|
}
|
|
985
1267
|
else {
|
|
986
|
-
log(`${adapter} visible CLI did not connect within ${formatDuration(
|
|
1268
|
+
log(`${adapter} visible CLI did not connect within ${formatDuration(visibleClientTimeoutMs)}. Check ${BRIDGE_LOG_FILE}.`);
|
|
987
1269
|
const cleanedLauncher = cleanupVisibleClientLauncher(launch);
|
|
988
|
-
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}`);
|
|
989
1271
|
}
|
|
990
1272
|
}
|
|
991
1273
|
let visibleReady = visibleConnected;
|
|
@@ -1009,7 +1291,11 @@ class WechatDaemon {
|
|
|
1009
1291
|
}
|
|
1010
1292
|
const activated = options.openVisible === false || (visibleConnected && visibleReady);
|
|
1011
1293
|
if (activated) {
|
|
1294
|
+
const previousSlot = this.getActiveSlot();
|
|
1295
|
+
if (previousSlot && this.activeAdapter !== adapter)
|
|
1296
|
+
invalidateModelSnapshot(previousSlot.runtime);
|
|
1012
1297
|
this.activeAdapter = adapter;
|
|
1298
|
+
this.activeAdapterVersion += 1;
|
|
1013
1299
|
}
|
|
1014
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}`);
|
|
1015
1301
|
return {
|
|
@@ -1019,12 +1305,13 @@ class WechatDaemon {
|
|
|
1019
1305
|
visibleConnected,
|
|
1020
1306
|
visibleReady,
|
|
1021
1307
|
activated,
|
|
1308
|
+
activationVersion: this.activeAdapterVersion,
|
|
1022
1309
|
previousActiveAdapter,
|
|
1023
1310
|
};
|
|
1024
1311
|
}
|
|
1025
1312
|
async createSlot(adapter, options) {
|
|
1026
1313
|
clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
|
|
1027
|
-
const runtime =
|
|
1314
|
+
const runtime = this.createRuntime({
|
|
1028
1315
|
kind: adapter,
|
|
1029
1316
|
command: resolveDefaultAdapterCommand(adapter),
|
|
1030
1317
|
cwd: this.cwd,
|
|
@@ -1039,7 +1326,7 @@ class WechatDaemon {
|
|
|
1039
1326
|
runtime,
|
|
1040
1327
|
controller,
|
|
1041
1328
|
outputBatcher: new OutputBatcher(async (text) => {
|
|
1042
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
|
|
1329
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
|
|
1043
1330
|
}),
|
|
1044
1331
|
pendingConfirmations: [],
|
|
1045
1332
|
pendingUserInput: null,
|
|
@@ -1047,10 +1334,14 @@ class WechatDaemon {
|
|
|
1047
1334
|
adapter,
|
|
1048
1335
|
runtime,
|
|
1049
1336
|
}),
|
|
1050
|
-
|
|
1337
|
+
turns: new TurnCoordinator({
|
|
1338
|
+
restoreLastConversationOnFailure: true,
|
|
1339
|
+
}),
|
|
1051
1340
|
lastOutputAt: 0,
|
|
1052
1341
|
lastFinalReplyAtMs: 0,
|
|
1053
1342
|
eventForwardChain: Promise.resolve(),
|
|
1343
|
+
deferredInputs: [],
|
|
1344
|
+
drainingDeferredInputs: false,
|
|
1054
1345
|
};
|
|
1055
1346
|
runtime.setEventSink((event) => {
|
|
1056
1347
|
this.handleSlotEvent(slot, event);
|
|
@@ -1065,7 +1356,7 @@ class WechatDaemon {
|
|
|
1065
1356
|
slot.outputBatcher.clear();
|
|
1066
1357
|
slot.pendingConfirmations = [];
|
|
1067
1358
|
slot.pendingUserInput = null;
|
|
1068
|
-
slot.
|
|
1359
|
+
slot.turns.complete();
|
|
1069
1360
|
if (slot.adapter === "codex" || slot.adapter === "claude") {
|
|
1070
1361
|
await slot.runtime.reset();
|
|
1071
1362
|
}
|
|
@@ -1107,6 +1398,10 @@ class WechatDaemon {
|
|
|
1107
1398
|
slot.controller.syncLocalClientEndpoint();
|
|
1108
1399
|
const adapterState = slot.runtime.getState();
|
|
1109
1400
|
const channelPort = this.createWechatChannelPort(slot.adapter);
|
|
1401
|
+
const eventTarget = this.channelDriver.capabilities.multiConversation
|
|
1402
|
+
? this.resolveSlotOutputTarget(slot)
|
|
1403
|
+
: this.fallbackConversation;
|
|
1404
|
+
const eventTask = slot.turns.activeTask;
|
|
1110
1405
|
if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
|
|
1111
1406
|
slot.pendingConfirmations = [];
|
|
1112
1407
|
}
|
|
@@ -1128,19 +1423,12 @@ class WechatDaemon {
|
|
|
1128
1423
|
}
|
|
1129
1424
|
},
|
|
1130
1425
|
finalReply: (next) => {
|
|
1426
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1131
1427
|
slot.lastFinalReplyAtMs = Date.now();
|
|
1132
1428
|
appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
1133
1429
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1134
1430
|
await channelPort.send({
|
|
1135
|
-
target:
|
|
1136
|
-
? slot.activeConversation ??
|
|
1137
|
-
slot.lastConversation ??
|
|
1138
|
-
this.currentInboundConversation
|
|
1139
|
-
: {
|
|
1140
|
-
channelId: "wechat",
|
|
1141
|
-
conversationId: this.authorizedUserId,
|
|
1142
|
-
recipientId: this.authorizedUserId,
|
|
1143
|
-
},
|
|
1431
|
+
target: eventTarget,
|
|
1144
1432
|
kind: "final_reply",
|
|
1145
1433
|
text: next.text,
|
|
1146
1434
|
adapter: slot.adapter,
|
|
@@ -1158,7 +1446,7 @@ class WechatDaemon {
|
|
|
1158
1446
|
appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
|
|
1159
1447
|
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
1160
1448
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1161
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice");
|
|
1449
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
|
|
1162
1450
|
}));
|
|
1163
1451
|
}
|
|
1164
1452
|
},
|
|
@@ -1167,7 +1455,7 @@ class WechatDaemon {
|
|
|
1167
1455
|
const pending = toPendingApproval(next);
|
|
1168
1456
|
slot.pendingConfirmations.push(pending);
|
|
1169
1457
|
appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
|
|
1170
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required");
|
|
1458
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatApprovalMessage(pending, adapterState)), "approval_required", eventTarget);
|
|
1171
1459
|
}));
|
|
1172
1460
|
},
|
|
1173
1461
|
userInputRequired: (next) => {
|
|
@@ -1175,14 +1463,14 @@ class WechatDaemon {
|
|
|
1175
1463
|
slot.pendingUserInput = pending;
|
|
1176
1464
|
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
1177
1465
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1178
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
|
|
1466
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required", eventTarget);
|
|
1179
1467
|
}));
|
|
1180
1468
|
},
|
|
1181
1469
|
mirroredUserInput: (next) => {
|
|
1182
1470
|
appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
1183
1471
|
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
1184
1472
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1185
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input");
|
|
1473
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input", eventTarget);
|
|
1186
1474
|
}));
|
|
1187
1475
|
}
|
|
1188
1476
|
},
|
|
@@ -1192,7 +1480,7 @@ class WechatDaemon {
|
|
|
1192
1480
|
appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
|
|
1193
1481
|
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1194
1482
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1195
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason })), "session_switched");
|
|
1483
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.sessionId, source: next.source, reason: next.reason })), "session_switched", eventTarget);
|
|
1196
1484
|
}));
|
|
1197
1485
|
}
|
|
1198
1486
|
},
|
|
@@ -1210,37 +1498,46 @@ class WechatDaemon {
|
|
|
1210
1498
|
}
|
|
1211
1499
|
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1212
1500
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1213
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.threadId, source: next.source, reason: next.reason })), "thread_switched");
|
|
1501
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({ adapter: slot.adapter, sessionId: next.threadId, source: next.source, reason: next.reason })), "thread_switched", eventTarget);
|
|
1214
1502
|
}));
|
|
1215
1503
|
}
|
|
1216
1504
|
},
|
|
1217
1505
|
taskComplete: () => {
|
|
1506
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1218
1507
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
|
|
1219
1508
|
slot.pendingConfirmations = [];
|
|
1220
1509
|
slot.pendingUserInput = null;
|
|
1221
|
-
|
|
1222
|
-
|
|
1510
|
+
if (eventTask) {
|
|
1511
|
+
slot.turns.complete(eventTask);
|
|
1512
|
+
}
|
|
1513
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1223
1514
|
}));
|
|
1224
1515
|
},
|
|
1225
1516
|
taskFailed: (next) => {
|
|
1517
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1226
1518
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1227
1519
|
slot.pendingConfirmations = [];
|
|
1228
1520
|
slot.pendingUserInput = null;
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1521
|
+
if (eventTask) {
|
|
1522
|
+
slot.turns.complete(eventTask);
|
|
1523
|
+
}
|
|
1524
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1525
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatTaskFailedMessage(slot.adapter, next.message)), "task_failed", eventTarget);
|
|
1232
1526
|
}));
|
|
1233
1527
|
},
|
|
1234
1528
|
fatalError: (next) => {
|
|
1529
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1235
1530
|
logError(`${slot.adapter}: ${next.message}`);
|
|
1236
1531
|
appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
|
|
1237
1532
|
slot.pendingConfirmations = [];
|
|
1238
1533
|
slot.pendingUserInput = null;
|
|
1239
|
-
|
|
1240
|
-
|
|
1534
|
+
if (eventTask) {
|
|
1535
|
+
slot.turns.complete(eventTask);
|
|
1536
|
+
}
|
|
1537
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1241
1538
|
this.disposeDeadSlot(slot);
|
|
1242
1539
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1243
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error");
|
|
1540
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
|
|
1244
1541
|
}));
|
|
1245
1542
|
},
|
|
1246
1543
|
shutdownRequested: (next) => {
|
|
@@ -1252,17 +1549,19 @@ class WechatDaemon {
|
|
|
1252
1549
|
appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
|
|
1253
1550
|
});
|
|
1254
1551
|
}
|
|
1552
|
+
resolveSlotOutputTarget(slot) {
|
|
1553
|
+
return slot.turns.resolveTarget(this.fallbackConversation);
|
|
1554
|
+
}
|
|
1255
1555
|
bindCurrentWecomConversation(slot) {
|
|
1256
|
-
|
|
1556
|
+
const conversation = this.inboundConversationContext.get();
|
|
1557
|
+
if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
|
|
1257
1558
|
return;
|
|
1258
1559
|
}
|
|
1259
|
-
slot.
|
|
1560
|
+
slot.turns.observeConversation(conversation);
|
|
1260
1561
|
}
|
|
1261
1562
|
async handleInboundMessage(message) {
|
|
1262
1563
|
if (message.senderId !== this.authorizedUserId) {
|
|
1263
|
-
await this.queueWechatMessage(message.senderId,
|
|
1264
|
-
? "Unauthorized. This daemon only accepts messages from the paired WeCom operator."
|
|
1265
|
-
: "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}.`);
|
|
1266
1565
|
return;
|
|
1267
1566
|
}
|
|
1268
1567
|
const emojiMatch = resolveEmojiCommand(message.text);
|
|
@@ -1333,7 +1632,7 @@ class WechatDaemon {
|
|
|
1333
1632
|
}
|
|
1334
1633
|
this.bindCurrentWecomConversation(slot);
|
|
1335
1634
|
const routeCurrentMessage = async (currentSlot, command) => {
|
|
1336
|
-
const previousTask = currentSlot.activeTask;
|
|
1635
|
+
const previousTask = currentSlot.turns.activeTask;
|
|
1337
1636
|
await routeBridgeMessage({
|
|
1338
1637
|
message: toChannelInboundMessage(message),
|
|
1339
1638
|
authorized: true,
|
|
@@ -1362,12 +1661,12 @@ class WechatDaemon {
|
|
|
1362
1661
|
await this.dispatchInboundWechatText(message, currentSlot);
|
|
1363
1662
|
},
|
|
1364
1663
|
});
|
|
1365
|
-
if (this.
|
|
1366
|
-
this.
|
|
1367
|
-
currentSlot.activeTask &&
|
|
1368
|
-
currentSlot.activeTask !== previousTask) {
|
|
1369
|
-
|
|
1370
|
-
currentSlot.
|
|
1664
|
+
if (this.channelDriver.capabilities.multiConversation &&
|
|
1665
|
+
this.inboundConversationContext.get() &&
|
|
1666
|
+
currentSlot.turns.activeTask &&
|
|
1667
|
+
currentSlot.turns.activeTask !== previousTask) {
|
|
1668
|
+
const conversation = this.inboundConversationContext.get();
|
|
1669
|
+
currentSlot.turns.bindConversation(conversation);
|
|
1371
1670
|
}
|
|
1372
1671
|
};
|
|
1373
1672
|
const command = parseWechatControlCommand(message.text, {
|
|
@@ -1417,12 +1716,16 @@ class WechatDaemon {
|
|
|
1417
1716
|
}
|
|
1418
1717
|
async handleSystemCommand(message, activeSlot, command) {
|
|
1419
1718
|
switch (command.type) {
|
|
1719
|
+
case "model":
|
|
1720
|
+
case "plan":
|
|
1721
|
+
await this.queueWechatMessage(message.senderId, await handleAdapterControl(activeSlot.runtime, message.senderId, command));
|
|
1722
|
+
return;
|
|
1420
1723
|
case "status":
|
|
1421
1724
|
await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
|
|
1422
1725
|
return;
|
|
1423
1726
|
case "resume": {
|
|
1424
1727
|
if (!isWechatResumeEnabled(activeSlot.adapter)) {
|
|
1425
|
-
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.`);
|
|
1426
1729
|
return;
|
|
1427
1730
|
}
|
|
1428
1731
|
try {
|
|
@@ -1440,9 +1743,10 @@ class WechatDaemon {
|
|
|
1440
1743
|
if (command.target) {
|
|
1441
1744
|
await resumeSlot.outputBatcher.flushNow();
|
|
1442
1745
|
}
|
|
1746
|
+
const taskBeforeResume = resumeSlot.turns.activeTask;
|
|
1443
1747
|
const result = await resumeSlot.resumeCoordinator.execute(command.target);
|
|
1444
|
-
if (result.kind === "resumed") {
|
|
1445
|
-
resumeSlot.
|
|
1748
|
+
if (result.kind === "resumed" && taskBeforeResume) {
|
|
1749
|
+
resumeSlot.turns.complete(taskBeforeResume);
|
|
1446
1750
|
}
|
|
1447
1751
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
|
|
1448
1752
|
}
|
|
@@ -1464,6 +1768,9 @@ class WechatDaemon {
|
|
|
1464
1768
|
await activeSlot.runtime.createSession();
|
|
1465
1769
|
appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
|
|
1466
1770
|
return;
|
|
1771
|
+
case "broadcast":
|
|
1772
|
+
await this.handleBroadcastCommand(message, command.text);
|
|
1773
|
+
return;
|
|
1467
1774
|
case "stop": {
|
|
1468
1775
|
const interrupted = await activeSlot.runtime.interrupt();
|
|
1469
1776
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
|
|
@@ -1505,10 +1812,10 @@ class WechatDaemon {
|
|
|
1505
1812
|
}
|
|
1506
1813
|
const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
|
|
1507
1814
|
slot.pendingConfirmations = [];
|
|
1508
|
-
slot.
|
|
1815
|
+
slot.turns.setActiveTask({
|
|
1509
1816
|
startedAt: Date.now(),
|
|
1510
1817
|
inputPreview: preview,
|
|
1511
|
-
};
|
|
1818
|
+
});
|
|
1512
1819
|
appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
|
|
1513
1820
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
|
|
1514
1821
|
? `${count} approvals confirmed. Continuing...`
|
|
@@ -1554,10 +1861,10 @@ class WechatDaemon {
|
|
|
1554
1861
|
return;
|
|
1555
1862
|
}
|
|
1556
1863
|
slot.pendingUserInput = null;
|
|
1557
|
-
slot.
|
|
1864
|
+
slot.turns.setActiveTask({
|
|
1558
1865
|
startedAt: Date.now(),
|
|
1559
1866
|
inputPreview: parsed.preview,
|
|
1560
|
-
};
|
|
1867
|
+
});
|
|
1561
1868
|
appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
|
|
1562
1869
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
|
|
1563
1870
|
}
|
|
@@ -1579,16 +1886,79 @@ class WechatDaemon {
|
|
|
1579
1886
|
}
|
|
1580
1887
|
return this.slots.get(this.activeAdapter) ?? null;
|
|
1581
1888
|
}
|
|
1582
|
-
|
|
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) {
|
|
1583
1939
|
const preview = formatInboundMessagePreview(message);
|
|
1584
|
-
|
|
1940
|
+
const nextTask = {
|
|
1585
1941
|
startedAt: Date.now(),
|
|
1586
1942
|
inputPreview: truncatePreview(preview, 180),
|
|
1587
1943
|
};
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1944
|
+
const inboundConversation = this.channelDriver.capabilities.multiConversation
|
|
1945
|
+
? conversationOverride ?? this.inboundConversationContext.get()
|
|
1946
|
+
: undefined;
|
|
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);
|
|
1960
|
+
}
|
|
1961
|
+
return dispatchResult;
|
|
1592
1962
|
}
|
|
1593
1963
|
queueWechatTextAction(action) {
|
|
1594
1964
|
const run = this.textSendChain.then(action);
|
|
@@ -1601,86 +1971,36 @@ class WechatDaemon {
|
|
|
1601
1971
|
return run;
|
|
1602
1972
|
}
|
|
1603
1973
|
async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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
|
-
}
|
|
1634
|
-
const startedAtMs = Date.now();
|
|
1635
|
-
appendDaemonLog(`wechat_send_started: context=${context} recipient=${senderId} chars=${Array.from(text).length}`);
|
|
1636
|
-
for (let attempt = 1; attempt <= WECHAT_SEND_MAX_ATTEMPTS; attempt += 1) {
|
|
1637
|
-
try {
|
|
1638
|
-
await this.transport.sendText(senderId, text);
|
|
1639
|
-
appendDaemonLog(`wechat_send_completed: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
1640
|
-
return { status: "sent" };
|
|
1641
|
-
}
|
|
1642
|
-
catch (error) {
|
|
1643
|
-
if (error instanceof Error && error.name === "AbortError") {
|
|
1644
|
-
appendDaemonLog(`wechat_send_timeout: context=${context} recipient=${senderId} attempt=${attempt} elapsed_ms=${Date.now() - startedAtMs}`);
|
|
1645
|
-
}
|
|
1646
|
-
if (isWechatContextUnavailableError(error)) {
|
|
1647
|
-
if (isWechatContextTokenStaleError(error)) {
|
|
1648
|
-
this.transport.clearCachedContextToken(senderId);
|
|
1649
|
-
}
|
|
1650
|
-
appendDaemonLog(isWechatContextTokenStaleError(error)
|
|
1651
|
-
? formatWechatContextTokenStaleLogEntry({
|
|
1652
|
-
context,
|
|
1653
|
-
recipientId: senderId,
|
|
1654
|
-
error,
|
|
1655
|
-
})
|
|
1656
|
-
: formatWechatSendFailureLogEntry({
|
|
1657
|
-
context,
|
|
1658
|
-
recipientId: senderId,
|
|
1659
|
-
error,
|
|
1660
|
-
}));
|
|
1661
|
-
return { status: "context_unavailable", error };
|
|
1662
|
-
}
|
|
1663
|
-
if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
|
|
1664
|
-
const delayMs = computeWechatSendRetryDelayMs(attempt);
|
|
1665
|
-
appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
|
|
1666
|
-
await delay(delayMs);
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
|
|
1670
|
-
appendDaemonLog(formatWechatSendFailureLogEntry({
|
|
1671
|
-
context,
|
|
1672
|
-
recipientId: senderId,
|
|
1673
|
-
error,
|
|
1674
|
-
}));
|
|
1675
|
-
return { status: "failed" };
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
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
|
+
});
|
|
1679
1993
|
}
|
|
1680
1994
|
queueWechatMessage(senderId, text, context = "message", targetOverride) {
|
|
1995
|
+
const activeSlot = this.getActiveSlot();
|
|
1996
|
+
const queuedTarget = this.channelDriver.capabilities.multiConversation && senderId === this.authorizedUserId
|
|
1997
|
+
? targetOverride ??
|
|
1998
|
+
this.inboundConversationContext.get() ??
|
|
1999
|
+
(activeSlot ? this.resolveSlotOutputTarget(activeSlot) : this.fallbackConversation)
|
|
2000
|
+
: targetOverride;
|
|
1681
2001
|
return this.queueWechatTextAction(async () => {
|
|
1682
|
-
const result = await this.sendWechatMessageNow(senderId, text, context,
|
|
1683
|
-
if (result.status === "
|
|
2002
|
+
const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
|
|
2003
|
+
if (result.status === "target_stale") {
|
|
1684
2004
|
const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
|
|
1685
2005
|
if (pending) {
|
|
1686
2006
|
appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
|
|
@@ -1698,7 +2018,7 @@ class WechatDaemon {
|
|
|
1698
2018
|
appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
1699
2019
|
continue;
|
|
1700
2020
|
}
|
|
1701
|
-
if (result.status === "
|
|
2021
|
+
if (result.status === "target_stale") {
|
|
1702
2022
|
break;
|
|
1703
2023
|
}
|
|
1704
2024
|
appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
@@ -2042,6 +2362,7 @@ export async function runDaemon(options) {
|
|
|
2042
2362
|
authorizedUserId: credentials.userId,
|
|
2043
2363
|
transport: new WeChatTransport({ log, logError }),
|
|
2044
2364
|
channelId: options.channelId,
|
|
2365
|
+
accountId: wecomAccount?.botId,
|
|
2045
2366
|
wecomTransport: wecomAccount
|
|
2046
2367
|
? new WecomTransport({
|
|
2047
2368
|
account: wecomAccount,
|
|
@@ -2066,6 +2387,9 @@ export async function runDaemon(options) {
|
|
|
2066
2387
|
}
|
|
2067
2388
|
shutdownInProgress = true;
|
|
2068
2389
|
log(`Received ${signal}. Stopping daemon.`);
|
|
2390
|
+
// Remove this process's endpoint synchronously so a new terminal can be
|
|
2391
|
+
// started immediately even if asynchronous adapter cleanup takes time.
|
|
2392
|
+
clearDaemonEndpoint();
|
|
2069
2393
|
void daemon.shutdown().finally(() => process.exit(0));
|
|
2070
2394
|
};
|
|
2071
2395
|
process.on("SIGINT", () => handleSignal("SIGINT"));
|