@unlinearity/cli-wechat-bridge 1.1.5 → 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 +434 -427
- package/bin/_run-entry.mjs +47 -9
- 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 +10 -0
- package/bin/wecom-claude.mjs +10 -0
- package/bin/wecom-codex.mjs +10 -0
- package/bin/wecom-daemon.mjs +10 -0
- package/bin/wecom-opencode.mjs +10 -0
- package/bin/wecom-pi.mjs +10 -0
- package/bin/wecom-setup.mjs +10 -0
- package/dist/bridge/adapter-control.js +58 -0
- package/dist/bridge/bridge-adapters.claude.js +163 -5
- package/dist/bridge/bridge-adapters.codex.js +331 -29
- package/dist/bridge/bridge-adapters.core.js +27 -2
- package/dist/bridge/bridge-adapters.opencode.js +156 -1
- package/dist/bridge/bridge-adapters.pi.js +46 -1
- package/dist/bridge/bridge-adapters.shared.js +8 -1
- package/dist/bridge/bridge-controller.js +6 -0
- package/dist/bridge/bridge-final-reply.js +8 -65
- package/dist/bridge/bridge-process-reaper.js +41 -2
- package/dist/bridge/bridge-state.js +17 -4
- package/dist/bridge/bridge-utils.js +10 -127
- 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 +520 -274
- package/dist/bridge/wechat-forwarding.js +8 -58
- package/dist/bridge/wechat-outbound-queue.js +8 -0
- package/dist/channels/wechat/bridge-final-reply.js +56 -0
- package/dist/channels/wechat/channel-message.js +24 -0
- package/dist/channels/wechat/index.js +11 -0
- package/dist/channels/wechat/wechat-channel-port.js +67 -0
- package/dist/channels/wechat/wechat-driver.js +191 -0
- package/dist/channels/wechat/wechat-forwarding.js +97 -0
- package/dist/channels/wechat/wechat-outbound-queue.js +118 -0
- package/dist/channels/wecom/bridge-final-reply.js +32 -0
- package/dist/channels/wecom/setup-cli.js +12 -0
- package/dist/channels/wecom/setup.js +182 -0
- package/dist/channels/wecom/wecom-channel-port.js +45 -0
- package/dist/channels/wecom/wecom-config.js +72 -0
- package/dist/channels/wecom/wecom-driver.js +106 -0
- package/dist/channels/wecom/wecom-media.js +147 -0
- package/dist/channels/wecom/wecom-message.js +149 -0
- package/dist/channels/wecom/wecom-transport.js +314 -0
- package/dist/commands/check-update.js +6 -0
- package/dist/companion/codex-remote-client.js +30 -3
- 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 +62 -11
- package/dist/companion/local-companion.js +25 -1
- 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 +42 -0
- package/dist/core/bridge-defer.js +25 -0
- package/dist/core/bridge-event-forwarder.js +50 -0
- package/dist/core/bridge-message-router.js +32 -0
- package/dist/core/channel-driver.js +1 -0
- package/dist/core/channel-types.js +3 -0
- package/dist/core/conversation-routing.js +58 -0
- package/dist/core/direct-run.js +23 -0
- package/dist/core/inbound-message-claims.js +67 -0
- package/dist/core/outbound-target.js +32 -0
- package/dist/core/text-utils.js +69 -0
- package/dist/core/turn-coordinator.js +85 -0
- package/dist/daemon/daemon-link.js +9 -0
- package/dist/daemon/emoji-bindings.js +6 -0
- package/dist/daemon/wechat-daemon.js +827 -209
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +11 -3
- package/dist/i18n/messages-zh.js +11 -3
- 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 +36 -10
- package/dist/utils/version-checker.js +17 -11
- package/dist/wechat/channel-config.js +6 -0
- package/dist/wechat/setup.js +119 -13
- package/dist/wechat/wechat-channel.js +6 -0
- package/dist/wechat/wechat-transport.js +169 -58
- package/package.json +14 -4
- 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";
|
|
@@ -8,17 +14,32 @@ import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
|
|
|
8
14
|
import { t } from "../i18n/index.js";
|
|
9
15
|
import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
|
|
10
16
|
import { BridgeController } from "../bridge/bridge-controller.js";
|
|
11
|
-
import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
|
|
12
17
|
import { readBridgeLockFile, } from "../bridge/bridge-state.js";
|
|
13
18
|
import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
|
|
14
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";
|
|
15
20
|
import { ResumeSessionCoordinator, isWechatResumeEnabled, shouldForwardSessionSwitchEvent, } from "../bridge/bridge-session-resume.js";
|
|
16
|
-
import {
|
|
21
|
+
import { WECHAT_SEND_CONTEXTS, formatUserFacingBridgeFatalError, formatUserFacingInboundError, isWechatSendContext, shouldForwardBridgeEventToWechat, shouldSuppressCodexLocalThreadNotice, } from "../channels/wechat/wechat-forwarding.js";
|
|
17
22
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, appendBoundedLog, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
|
|
18
23
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
19
24
|
import { formatBindCommandUsage, formatBindingsListMessage, isBindCommandPrefix, listBindings, loadEmojiBindings, parseEmojiBindingsCommand, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
|
|
20
|
-
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError,
|
|
25
|
+
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, WeChatTransport, } from "../wechat/wechat-transport.js";
|
|
26
|
+
import { getPendingChannelMessagesFile, getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
|
|
21
27
|
import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
|
|
28
|
+
import { toChannelInboundMessage } from "../channels/wechat/channel-message.js";
|
|
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";
|
|
35
|
+
import { forwardBridgeEvent } from "../core/bridge-event-forwarder.js";
|
|
36
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
37
|
+
import { WechatChannelPort } from "../channels/wechat/wechat-channel-port.js";
|
|
38
|
+
import { ensureWecomAccount } from "../channels/wecom/setup.js";
|
|
39
|
+
import { WecomChannelPort } from "../channels/wecom/wecom-channel-port.js";
|
|
40
|
+
import { WecomTransport } from "../channels/wecom/wecom-transport.js";
|
|
41
|
+
import { WecomChannelDriver } from "../channels/wecom/wecom-driver.js";
|
|
42
|
+
import { WechatChannelDriver } from "../channels/wechat/wechat-driver.js";
|
|
22
43
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
23
44
|
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
|
|
24
45
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
@@ -35,12 +56,17 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
|
35
56
|
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
36
57
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
37
58
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
59
|
+
const MAX_DAEMON_DEFERRED_INPUTS = 32;
|
|
38
60
|
const DAEMON_ADAPTERS = ["codex", "claude", "opencode", "pi"];
|
|
61
|
+
function getCliChannelId(argv = process.argv.slice(2)) {
|
|
62
|
+
const index = argv.indexOf("--channel");
|
|
63
|
+
return index >= 0 && argv[index + 1] === "wecom" ? "wecom" : "wechat";
|
|
64
|
+
}
|
|
39
65
|
function log(message) {
|
|
40
|
-
process.stderr.write(`[
|
|
66
|
+
process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
|
|
41
67
|
}
|
|
42
68
|
function logError(message) {
|
|
43
|
-
process.stderr.write(`[
|
|
69
|
+
process.stderr.write(`[${getCliChannelId()}-daemon] ERROR: ${message}\n`);
|
|
44
70
|
}
|
|
45
71
|
function appendDaemonLog(message) {
|
|
46
72
|
ensureChannelDataDir();
|
|
@@ -51,6 +77,32 @@ function computePollRetryDelayMs(consecutiveFailures) {
|
|
|
51
77
|
const exponent = Math.min(normalizedFailures - 1, 5);
|
|
52
78
|
return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
|
|
53
79
|
}
|
|
80
|
+
function toWechatSendContext(kind) {
|
|
81
|
+
if (kind === "mirrored_input") {
|
|
82
|
+
return "mirrored_user_input";
|
|
83
|
+
}
|
|
84
|
+
if (kind === "status") {
|
|
85
|
+
return "message";
|
|
86
|
+
}
|
|
87
|
+
return kind;
|
|
88
|
+
}
|
|
89
|
+
function toLegacyInboundWechatMessage(message) {
|
|
90
|
+
return {
|
|
91
|
+
senderId: message.senderId,
|
|
92
|
+
sender: message.senderId,
|
|
93
|
+
sessionId: message.conversation.conversationId,
|
|
94
|
+
text: message.text,
|
|
95
|
+
attachments: message.attachments.map((attachment) => ({
|
|
96
|
+
kind: attachment.kind === "image" ? "image" : "file",
|
|
97
|
+
path: attachment.path,
|
|
98
|
+
fileName: attachment.fileName || path.basename(attachment.path),
|
|
99
|
+
sizeBytes: attachment.sizeBytes ?? 0,
|
|
100
|
+
})),
|
|
101
|
+
contextToken: message.conversation.opaqueRef,
|
|
102
|
+
createdAt: message.createdAt,
|
|
103
|
+
createdAtMs: Date.parse(message.createdAt),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
54
106
|
function isDaemonAdapterKind(value) {
|
|
55
107
|
return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
|
|
56
108
|
}
|
|
@@ -72,6 +124,7 @@ export function parseDaemonCliArgs(argv) {
|
|
|
72
124
|
let profile;
|
|
73
125
|
let initialAdapter;
|
|
74
126
|
let openVisible = true;
|
|
127
|
+
let channelId = "wechat";
|
|
75
128
|
for (let i = 0; i < argv.length; i += 1) {
|
|
76
129
|
const arg = argv[i];
|
|
77
130
|
if (!arg) {
|
|
@@ -80,10 +133,10 @@ export function parseDaemonCliArgs(argv) {
|
|
|
80
133
|
const next = argv[i + 1];
|
|
81
134
|
if (arg === "--help" || arg === "-h") {
|
|
82
135
|
process.stdout.write([
|
|
83
|
-
|
|
136
|
+
`Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
|
|
84
137
|
"",
|
|
85
|
-
|
|
86
|
-
|
|
138
|
+
`Keeps one ${channelId === "wecom" ? "WeCom" : "WeChat"} connection alive and switches between Codex, Claude Code, OpenCode, and Pi.`,
|
|
139
|
+
`Send /codex, /claude, /opencode, or /pi in ${channelId === "wecom" ? "WeCom" : "WeChat"} to switch the active terminal.`,
|
|
87
140
|
"",
|
|
88
141
|
].join("\n"));
|
|
89
142
|
process.exit(0);
|
|
@@ -96,6 +149,14 @@ export function parseDaemonCliArgs(argv) {
|
|
|
96
149
|
i += 1;
|
|
97
150
|
continue;
|
|
98
151
|
}
|
|
152
|
+
if (arg === "--channel") {
|
|
153
|
+
if (!next || (next !== "wechat" && next !== "wecom")) {
|
|
154
|
+
throw new Error(`Invalid channel: ${next ?? "(missing)"}`);
|
|
155
|
+
}
|
|
156
|
+
channelId = next;
|
|
157
|
+
i += 1;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
99
160
|
if (arg === "--adapter") {
|
|
100
161
|
if (!isDaemonAdapterKind(next)) {
|
|
101
162
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
@@ -118,7 +179,13 @@ export function parseDaemonCliArgs(argv) {
|
|
|
118
179
|
}
|
|
119
180
|
throw new Error(`Unknown argument: ${arg}`);
|
|
120
181
|
}
|
|
121
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
cwd,
|
|
184
|
+
profile,
|
|
185
|
+
initialAdapter,
|
|
186
|
+
openVisible,
|
|
187
|
+
...(channelId === "wecom" ? { channelId } : {}),
|
|
188
|
+
};
|
|
122
189
|
}
|
|
123
190
|
export function parseDaemonSwitchCommand(text) {
|
|
124
191
|
const normalized = text.trim().toLowerCase();
|
|
@@ -147,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
|
|
|
147
214
|
remainder: match[2]?.trim() ?? "",
|
|
148
215
|
};
|
|
149
216
|
}
|
|
150
|
-
export function defaultDaemonSessionStartMode(
|
|
151
|
-
|
|
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";
|
|
152
224
|
}
|
|
153
225
|
export function resolveDaemonSessionStartMode(params) {
|
|
154
226
|
// An explicit --session-start-mode wins over visible-client reuse: the user
|
|
@@ -161,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
|
|
|
161
233
|
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
162
234
|
return "restore";
|
|
163
235
|
}
|
|
164
|
-
if (params.adapter === "codex") {
|
|
165
|
-
return "restore";
|
|
166
|
-
}
|
|
167
236
|
if (params.slotCreated) {
|
|
168
237
|
return "new";
|
|
169
238
|
}
|
|
@@ -217,7 +286,7 @@ export function buildVisibleClientLaunchArgs(params) {
|
|
|
217
286
|
export function buildWindowsVisibleClientLaunchCommand(params) {
|
|
218
287
|
return [
|
|
219
288
|
"start",
|
|
220
|
-
quoteWindowsCommandArg(
|
|
289
|
+
quoteWindowsCommandArg(`${params.channelId ?? "wechat"}-${params.adapter}`),
|
|
221
290
|
"/D",
|
|
222
291
|
quoteWindowsCommandArg(params.cwd),
|
|
223
292
|
quoteWindowsCommandArg(process.execPath),
|
|
@@ -265,6 +334,7 @@ function openVisibleClient(params) {
|
|
|
265
334
|
adapter: params.adapter,
|
|
266
335
|
cwd: params.cwd,
|
|
267
336
|
args,
|
|
337
|
+
channelId: params.channelId,
|
|
268
338
|
}),
|
|
269
339
|
];
|
|
270
340
|
const child = spawn(command, launchArgs, {
|
|
@@ -285,13 +355,13 @@ function openVisibleClient(params) {
|
|
|
285
355
|
pid: child.pid,
|
|
286
356
|
};
|
|
287
357
|
}
|
|
288
|
-
const title =
|
|
358
|
+
const title = `${params.channelId ?? "wechat"}-${params.adapter}`;
|
|
289
359
|
const fullArgs = [process.execPath, ...args];
|
|
290
360
|
if (process.platform === "darwin") {
|
|
291
361
|
const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
|
|
292
|
-
const script = `tell application "Terminal"
|
|
293
|
-
activate
|
|
294
|
-
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}"
|
|
295
365
|
end tell`;
|
|
296
366
|
const child = spawn("osascript", ["-e", script], {
|
|
297
367
|
cwd: params.cwd,
|
|
@@ -477,11 +547,16 @@ export function formatDaemonStatus(status) {
|
|
|
477
547
|
}
|
|
478
548
|
return lines.join("\n");
|
|
479
549
|
}
|
|
480
|
-
class WechatDaemon {
|
|
550
|
+
export class WechatDaemon {
|
|
481
551
|
cwd;
|
|
482
552
|
profile;
|
|
483
553
|
authorizedUserId;
|
|
484
554
|
transport;
|
|
555
|
+
channelDriver;
|
|
556
|
+
channelId;
|
|
557
|
+
wecomTransport;
|
|
558
|
+
inboundConversationContext = new InboundConversationContext();
|
|
559
|
+
fallbackConversation;
|
|
485
560
|
slots = new Map();
|
|
486
561
|
// Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
|
|
487
562
|
slotEnsureChains = new Map();
|
|
@@ -489,10 +564,14 @@ class WechatDaemon {
|
|
|
489
564
|
bridgeStartedAtMs = Date.now();
|
|
490
565
|
backlogNoticeSent = false;
|
|
491
566
|
activeAdapter = null;
|
|
567
|
+
activeAdapterVersion = 0;
|
|
492
568
|
takenOverAdapter;
|
|
493
569
|
textSendChain = Promise.resolve();
|
|
494
570
|
attachmentSendChain = Promise.resolve();
|
|
495
571
|
pendingWechatForwardTasks = new Set();
|
|
572
|
+
slotInputChains = new Map();
|
|
573
|
+
pendingWechatMessages;
|
|
574
|
+
deps;
|
|
496
575
|
shutdownPromise = null;
|
|
497
576
|
ipcServer = null;
|
|
498
577
|
endpointToken = "";
|
|
@@ -501,6 +580,39 @@ class WechatDaemon {
|
|
|
501
580
|
this.profile = params.profile;
|
|
502
581
|
this.authorizedUserId = params.authorizedUserId;
|
|
503
582
|
this.transport = params.transport;
|
|
583
|
+
this.channelId = params.channelId ?? "wechat";
|
|
584
|
+
this.wecomTransport = params.wecomTransport ?? null;
|
|
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);
|
|
504
616
|
}
|
|
505
617
|
async startIpcServer() {
|
|
506
618
|
this.endpointToken = buildDaemonToken();
|
|
@@ -548,6 +660,7 @@ class WechatDaemon {
|
|
|
548
660
|
token: this.endpointToken,
|
|
549
661
|
cwd: this.cwd,
|
|
550
662
|
startedAt: this.startedAt,
|
|
663
|
+
channelId: this.channelId,
|
|
551
664
|
});
|
|
552
665
|
resolve();
|
|
553
666
|
});
|
|
@@ -556,6 +669,7 @@ class WechatDaemon {
|
|
|
556
669
|
getStatus() {
|
|
557
670
|
return {
|
|
558
671
|
cwd: this.cwd,
|
|
672
|
+
channelId: this.channelId,
|
|
559
673
|
activeAdapter: this.activeAdapter ?? undefined,
|
|
560
674
|
startedAt: this.startedAt,
|
|
561
675
|
slots: Array.from(this.slots.values()).map((slot) => {
|
|
@@ -584,10 +698,49 @@ class WechatDaemon {
|
|
|
584
698
|
}
|
|
585
699
|
async runPollLoop() {
|
|
586
700
|
let consecutivePollFailures = 0;
|
|
587
|
-
|
|
701
|
+
if (this.channelDriver.start) {
|
|
702
|
+
await this.channelDriver.start({
|
|
703
|
+
onInboundMessage: async (channelMessage) => {
|
|
704
|
+
if (this.shutdownPromise) {
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
await this.inboundConversationContext.run(channelMessage.conversation, async () => {
|
|
708
|
+
if (this.pendingWechatMessages.list().length > 0) {
|
|
709
|
+
await this.flushPendingWechatMessages();
|
|
710
|
+
}
|
|
711
|
+
await this.handleInboundMessage(toLegacyInboundWechatMessage(channelMessage));
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
onUnauthorizedSender: async (senderId, chatType) => {
|
|
715
|
+
appendDaemonLog(`wecom_unauthorized: sender=${senderId} chat_type=${chatType}`);
|
|
716
|
+
if (chatType === "direct") {
|
|
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
|
+
}
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
onChannelFatal: async (error) => {
|
|
729
|
+
appendDaemonLog(`wecom_fatal_error: ${error.message}`);
|
|
730
|
+
await this.shutdown();
|
|
731
|
+
},
|
|
732
|
+
onChannelConnected: async () => {
|
|
733
|
+
if (this.pendingWechatMessages.list().length > 0) {
|
|
734
|
+
await this.flushPendingWechatMessages();
|
|
735
|
+
}
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
await this.channelDriver.waitUntilReady?.();
|
|
739
|
+
}
|
|
740
|
+
log(`${this.channelDriver.displayName} daemon is ready.`);
|
|
588
741
|
log(`Working directory: ${this.cwd}`);
|
|
589
|
-
log(
|
|
590
|
-
appendDaemonLog(`started: cwd=${this.cwd}`);
|
|
742
|
+
log(`Switch from ${this.channelDriver.displayName} with /codex, /claude, /opencode, or /pi.`);
|
|
743
|
+
appendDaemonLog(`started: channel=${this.channelId} cwd=${this.cwd}`);
|
|
591
744
|
const activeSlot = this.getActiveSlot();
|
|
592
745
|
const welcomeText = t("daemon.welcome", {
|
|
593
746
|
cwd: this.cwd,
|
|
@@ -595,6 +748,13 @@ class WechatDaemon {
|
|
|
595
748
|
bindings: formatBindingsListMessage(listBindings()),
|
|
596
749
|
});
|
|
597
750
|
await this.queueWechatMessage(this.authorizedUserId, welcomeText);
|
|
751
|
+
if (this.channelDriver.capabilities.pushInbound) {
|
|
752
|
+
while (!this.shutdownPromise) {
|
|
753
|
+
await delay(1_000);
|
|
754
|
+
}
|
|
755
|
+
await this.shutdownPromise;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
598
758
|
while (!this.shutdownPromise) {
|
|
599
759
|
let pollResult;
|
|
600
760
|
try {
|
|
@@ -624,6 +784,9 @@ class WechatDaemon {
|
|
|
624
784
|
appendDaemonLog(`poll_recovered: failures=${consecutivePollFailures}`);
|
|
625
785
|
consecutivePollFailures = 0;
|
|
626
786
|
}
|
|
787
|
+
if (pollResult.messages.length > 0 && this.pendingWechatMessages.list().length > 0) {
|
|
788
|
+
await this.flushPendingWechatMessages();
|
|
789
|
+
}
|
|
627
790
|
if (pollResult.ignoredBacklogCount > 0) {
|
|
628
791
|
appendDaemonLog(`ignored_startup_backlog: count=${pollResult.ignoredBacklogCount}`);
|
|
629
792
|
if (!this.backlogNoticeSent) {
|
|
@@ -685,6 +848,29 @@ class WechatDaemon {
|
|
|
685
848
|
}
|
|
686
849
|
async cleanup() {
|
|
687
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();
|
|
688
874
|
for (const slot of this.slots.values()) {
|
|
689
875
|
try {
|
|
690
876
|
await slot.outputBatcher.flushNow();
|
|
@@ -727,7 +913,7 @@ class WechatDaemon {
|
|
|
727
913
|
return { shuttingDown: true };
|
|
728
914
|
case "ensure_slot":
|
|
729
915
|
if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
|
|
730
|
-
throw new Error(
|
|
916
|
+
throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
|
|
731
917
|
}
|
|
732
918
|
return await this.ensureSlot(request.adapter, {
|
|
733
919
|
profile: request.profile,
|
|
@@ -744,6 +930,244 @@ class WechatDaemon {
|
|
|
744
930
|
sessionStartMode: request.sessionStartMode,
|
|
745
931
|
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
746
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;
|
|
747
1171
|
}
|
|
748
1172
|
}
|
|
749
1173
|
async ensureSlot(adapter, options = {}) {
|
|
@@ -784,7 +1208,7 @@ class WechatDaemon {
|
|
|
784
1208
|
visibleEndpointBeforeProbe?.companionConnectedAt ||
|
|
785
1209
|
visibleEndpointBeforeProbe?.sharedThreadId ||
|
|
786
1210
|
visibleEndpointBeforeProbe?.sharedSessionId);
|
|
787
|
-
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
1211
|
+
let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
|
|
788
1212
|
if (shouldRestartDeadCodexVisibleRuntime({
|
|
789
1213
|
adapter,
|
|
790
1214
|
slotCreated: created,
|
|
@@ -813,12 +1237,17 @@ class WechatDaemon {
|
|
|
813
1237
|
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
814
1238
|
}
|
|
815
1239
|
if (options.openVisible !== false && !visibleConnected) {
|
|
1240
|
+
const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
|
|
816
1241
|
slot.controller.syncLocalClientEndpoint();
|
|
817
|
-
const launch = openVisibleClient({
|
|
1242
|
+
const launch = this.openVisibleClient({
|
|
818
1243
|
adapter,
|
|
819
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.
|
|
820
1248
|
sessionStartMode,
|
|
821
1249
|
cliArgs: options.cliArgs,
|
|
1250
|
+
channelId: this.channelId,
|
|
822
1251
|
onError: (error) => {
|
|
823
1252
|
appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
|
|
824
1253
|
},
|
|
@@ -828,14 +1257,17 @@ class WechatDaemon {
|
|
|
828
1257
|
visibleConnected = await waitForVisibleClientConnection({
|
|
829
1258
|
cwd: this.cwd,
|
|
830
1259
|
adapter,
|
|
1260
|
+
timeoutMs: this.deps.visibleClientConnectTimeoutMs,
|
|
1261
|
+
}, {
|
|
1262
|
+
isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
|
|
831
1263
|
});
|
|
832
1264
|
if (visibleConnected) {
|
|
833
1265
|
appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
|
|
834
1266
|
}
|
|
835
1267
|
else {
|
|
836
|
-
log(`${adapter} visible CLI did not connect within ${formatDuration(
|
|
1268
|
+
log(`${adapter} visible CLI did not connect within ${formatDuration(visibleClientTimeoutMs)}. Check ${BRIDGE_LOG_FILE}.`);
|
|
837
1269
|
const cleanedLauncher = cleanupVisibleClientLauncher(launch);
|
|
838
|
-
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}`);
|
|
839
1271
|
}
|
|
840
1272
|
}
|
|
841
1273
|
let visibleReady = visibleConnected;
|
|
@@ -859,7 +1291,11 @@ class WechatDaemon {
|
|
|
859
1291
|
}
|
|
860
1292
|
const activated = options.openVisible === false || (visibleConnected && visibleReady);
|
|
861
1293
|
if (activated) {
|
|
1294
|
+
const previousSlot = this.getActiveSlot();
|
|
1295
|
+
if (previousSlot && this.activeAdapter !== adapter)
|
|
1296
|
+
invalidateModelSnapshot(previousSlot.runtime);
|
|
862
1297
|
this.activeAdapter = adapter;
|
|
1298
|
+
this.activeAdapterVersion += 1;
|
|
863
1299
|
}
|
|
864
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}`);
|
|
865
1301
|
return {
|
|
@@ -869,12 +1305,13 @@ class WechatDaemon {
|
|
|
869
1305
|
visibleConnected,
|
|
870
1306
|
visibleReady,
|
|
871
1307
|
activated,
|
|
1308
|
+
activationVersion: this.activeAdapterVersion,
|
|
872
1309
|
previousActiveAdapter,
|
|
873
1310
|
};
|
|
874
1311
|
}
|
|
875
1312
|
async createSlot(adapter, options) {
|
|
876
1313
|
clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
|
|
877
|
-
const runtime =
|
|
1314
|
+
const runtime = this.createRuntime({
|
|
878
1315
|
kind: adapter,
|
|
879
1316
|
command: resolveDefaultAdapterCommand(adapter),
|
|
880
1317
|
cwd: this.cwd,
|
|
@@ -889,7 +1326,7 @@ class WechatDaemon {
|
|
|
889
1326
|
runtime,
|
|
890
1327
|
controller,
|
|
891
1328
|
outputBatcher: new OutputBatcher(async (text) => {
|
|
892
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
|
|
1329
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
|
|
893
1330
|
}),
|
|
894
1331
|
pendingConfirmations: [],
|
|
895
1332
|
pendingUserInput: null,
|
|
@@ -897,8 +1334,14 @@ class WechatDaemon {
|
|
|
897
1334
|
adapter,
|
|
898
1335
|
runtime,
|
|
899
1336
|
}),
|
|
900
|
-
|
|
1337
|
+
turns: new TurnCoordinator({
|
|
1338
|
+
restoreLastConversationOnFailure: true,
|
|
1339
|
+
}),
|
|
901
1340
|
lastOutputAt: 0,
|
|
1341
|
+
lastFinalReplyAtMs: 0,
|
|
1342
|
+
eventForwardChain: Promise.resolve(),
|
|
1343
|
+
deferredInputs: [],
|
|
1344
|
+
drainingDeferredInputs: false,
|
|
902
1345
|
};
|
|
903
1346
|
runtime.setEventSink((event) => {
|
|
904
1347
|
this.handleSlotEvent(slot, event);
|
|
@@ -913,7 +1356,7 @@ class WechatDaemon {
|
|
|
913
1356
|
slot.outputBatcher.clear();
|
|
914
1357
|
slot.pendingConfirmations = [];
|
|
915
1358
|
slot.pendingUserInput = null;
|
|
916
|
-
slot.
|
|
1359
|
+
slot.turns.complete();
|
|
917
1360
|
if (slot.adapter === "codex" || slot.adapter === "claude") {
|
|
918
1361
|
await slot.runtime.reset();
|
|
919
1362
|
}
|
|
@@ -925,166 +1368,200 @@ class WechatDaemon {
|
|
|
925
1368
|
}
|
|
926
1369
|
slot.controller.syncLocalClientEndpoint();
|
|
927
1370
|
}
|
|
1371
|
+
createWechatChannelPort(adapter) {
|
|
1372
|
+
if (this.channelId === "wecom") {
|
|
1373
|
+
return new WecomChannelPort({
|
|
1374
|
+
transport: this.wecomTransport,
|
|
1375
|
+
sendText: (target, text, kind) => this.queueWechatMessage(this.authorizedUserId, text, toWechatSendContext(kind), target),
|
|
1376
|
+
prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
|
|
1377
|
+
onEmptyVisibleReply: (currentAdapter, rawText) => {
|
|
1378
|
+
appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
|
|
1379
|
+
},
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
return new WechatChannelPort({
|
|
1383
|
+
sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
|
|
1384
|
+
sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
|
|
1385
|
+
sendFile: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, { recipientId })),
|
|
1386
|
+
sendVoice: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(filePath, recipientId)),
|
|
1387
|
+
sendVideo: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(filePath, { recipientId })),
|
|
1388
|
+
prefixText: (currentAdapter, text) => prefixDaemonAdapterMessage(currentAdapter ?? adapter, text),
|
|
1389
|
+
onEmptyVisibleReply: (currentAdapter, rawText) => {
|
|
1390
|
+
appendDaemonLog(`empty_visible_final_reply: adapter=${currentAdapter ?? adapter} raw=${truncatePreview(rawText)}`);
|
|
1391
|
+
},
|
|
1392
|
+
onTextSent: (currentAdapter, text) => {
|
|
1393
|
+
appendDaemonLog(`final_reply_sent: adapter=${currentAdapter ?? adapter} chars=${Array.from(text).length}`);
|
|
1394
|
+
},
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
928
1397
|
handleSlotEvent(slot, event) {
|
|
929
1398
|
slot.controller.syncLocalClientEndpoint();
|
|
930
1399
|
const adapterState = slot.runtime.getState();
|
|
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;
|
|
931
1405
|
if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
|
|
932
1406
|
slot.pendingConfirmations = [];
|
|
933
1407
|
}
|
|
934
1408
|
if (slot.pendingUserInput && !adapterState.pendingUserInput) {
|
|
935
1409
|
slot.pendingUserInput = null;
|
|
936
1410
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1411
|
+
slot.eventForwardChain = slot.eventForwardChain
|
|
1412
|
+
.then(() => forwardBridgeEvent(event, {
|
|
1413
|
+
stdout: (next) => {
|
|
940
1414
|
slot.lastOutputAt = Date.now();
|
|
941
|
-
if (shouldForwardBridgeEventToWechat(slot.adapter,
|
|
942
|
-
slot.outputBatcher.push(
|
|
1415
|
+
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1416
|
+
slot.outputBatcher.push(next.text);
|
|
943
1417
|
}
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
1418
|
+
},
|
|
1419
|
+
stderr: (next) => {
|
|
1420
|
+
slot.lastOutputAt = Date.now();
|
|
1421
|
+
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1422
|
+
slot.outputBatcher.push(next.text);
|
|
1423
|
+
}
|
|
1424
|
+
},
|
|
1425
|
+
finalReply: (next) => {
|
|
1426
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1427
|
+
slot.lastFinalReplyAtMs = Date.now();
|
|
1428
|
+
appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
947
1429
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
948
|
-
await
|
|
1430
|
+
await channelPort.send({
|
|
1431
|
+
target: eventTarget,
|
|
1432
|
+
kind: "final_reply",
|
|
1433
|
+
text: next.text,
|
|
949
1434
|
adapter: slot.adapter,
|
|
950
|
-
rawText: event.text,
|
|
951
|
-
onEmptyVisibleReply: ({ rawVisibleText }) => {
|
|
952
|
-
appendDaemonLog(`empty_visible_final_reply: adapter=${slot.adapter} raw=${truncatePreview(rawVisibleText)}`);
|
|
953
|
-
},
|
|
954
|
-
sender: {
|
|
955
|
-
sendText: async (text) => {
|
|
956
|
-
const sent = await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, text), "final_reply");
|
|
957
|
-
if (sent) {
|
|
958
|
-
appendDaemonLog(`final_reply_sent: adapter=${slot.adapter} chars=${Array.from(text).length}`);
|
|
959
|
-
}
|
|
960
|
-
return sent;
|
|
961
|
-
},
|
|
962
|
-
sendImage: (imagePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(imagePath, {
|
|
963
|
-
recipientId: this.authorizedUserId,
|
|
964
|
-
})),
|
|
965
|
-
sendFile: (filePath) => this.queueWechatAttachmentAction(() => this.transport.sendFile(filePath, {
|
|
966
|
-
recipientId: this.authorizedUserId,
|
|
967
|
-
})),
|
|
968
|
-
sendVoice: (voicePath) => this.queueWechatAttachmentAction(() => this.transport.sendVoice(voicePath, this.authorizedUserId)),
|
|
969
|
-
sendVideo: (videoPath) => this.queueWechatAttachmentAction(() => this.transport.sendVideo(videoPath, {
|
|
970
|
-
recipientId: this.authorizedUserId,
|
|
971
|
-
})),
|
|
972
|
-
},
|
|
973
1435
|
});
|
|
974
1436
|
}));
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
if (
|
|
978
|
-
log(`${slot.adapter} ${
|
|
979
|
-
appendDaemonLog(`${slot.adapter}_${
|
|
1437
|
+
},
|
|
1438
|
+
status: (next) => {
|
|
1439
|
+
if (next.message) {
|
|
1440
|
+
log(`${slot.adapter} ${next.status}: ${next.message}`);
|
|
1441
|
+
appendDaemonLog(`${slot.adapter}_${next.status}: ${next.message}`);
|
|
980
1442
|
}
|
|
981
|
-
|
|
982
|
-
|
|
1443
|
+
},
|
|
1444
|
+
notice: (next) => {
|
|
983
1445
|
slot.lastOutputAt = Date.now();
|
|
984
|
-
appendDaemonLog(`${slot.adapter}_${
|
|
985
|
-
if (shouldForwardBridgeEventToWechat(slot.adapter,
|
|
1446
|
+
appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
|
|
1447
|
+
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
986
1448
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
987
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter,
|
|
1449
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
|
|
988
1450
|
}));
|
|
989
1451
|
}
|
|
990
|
-
|
|
991
|
-
|
|
1452
|
+
},
|
|
1453
|
+
approvalRequired: (next) => {
|
|
992
1454
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
993
|
-
const pending = toPendingApproval(
|
|
1455
|
+
const pending = toPendingApproval(next);
|
|
994
1456
|
slot.pendingConfirmations.push(pending);
|
|
995
1457
|
appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
|
|
996
|
-
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);
|
|
997
1459
|
}));
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
// send (which retries with backoff). Otherwise a fast user reply can
|
|
1002
|
-
// race past the still-unwritten mirror and be dispatched as plain
|
|
1003
|
-
// input into a CLI that is waiting for a structured form answer.
|
|
1004
|
-
const pending = toPendingUserInput(event.request);
|
|
1460
|
+
},
|
|
1461
|
+
userInputRequired: (next) => {
|
|
1462
|
+
const pending = toPendingUserInput(next.request);
|
|
1005
1463
|
slot.pendingUserInput = pending;
|
|
1006
1464
|
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
1007
1465
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1008
|
-
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);
|
|
1009
1467
|
}));
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
if (shouldForwardBridgeEventToWechat(slot.adapter, event.type, { text: event.text })) {
|
|
1468
|
+
},
|
|
1469
|
+
mirroredUserInput: (next) => {
|
|
1470
|
+
appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
1471
|
+
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
1015
1472
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1016
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter,
|
|
1473
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatMirroredUserInputMessage(slot.adapter, next.text)), "mirrored_user_input", eventTarget);
|
|
1017
1474
|
}));
|
|
1018
1475
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
if (
|
|
1476
|
+
},
|
|
1477
|
+
sessionSwitched: (next) => {
|
|
1478
|
+
if (next.source === "local")
|
|
1022
1479
|
slot.resumeCoordinator.clear();
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
1026
|
-
shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
|
|
1480
|
+
appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
|
|
1481
|
+
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1027
1482
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1028
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
|
|
1029
|
-
adapter: slot.adapter,
|
|
1030
|
-
sessionId: event.sessionId,
|
|
1031
|
-
source: event.source,
|
|
1032
|
-
reason: event.reason,
|
|
1033
|
-
})), "session_switched");
|
|
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);
|
|
1034
1484
|
}));
|
|
1035
1485
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
if (
|
|
1486
|
+
},
|
|
1487
|
+
threadSwitched: (next) => {
|
|
1488
|
+
if (next.source === "local")
|
|
1039
1489
|
slot.resumeCoordinator.clear();
|
|
1490
|
+
appendDaemonLog(`thread_switched: adapter=${slot.adapter} thread=${next.threadId} source=${next.source} reason=${next.reason}`);
|
|
1491
|
+
if (shouldSuppressCodexLocalThreadNotice({
|
|
1492
|
+
adapter: slot.adapter,
|
|
1493
|
+
source: next.source,
|
|
1494
|
+
activeTurnOrigin: slot.runtime.getState().activeTurnOrigin,
|
|
1495
|
+
lastFinalReplyAtMs: slot.lastFinalReplyAtMs,
|
|
1496
|
+
})) {
|
|
1497
|
+
return;
|
|
1040
1498
|
}
|
|
1041
|
-
|
|
1042
|
-
if (shouldForwardSessionSwitchEvent(event.reason) &&
|
|
1043
|
-
shouldForwardBridgeEventToWechat(slot.adapter, event.type)) {
|
|
1499
|
+
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1044
1500
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1045
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatSessionSwitchMessage({
|
|
1046
|
-
adapter: slot.adapter,
|
|
1047
|
-
sessionId: event.threadId,
|
|
1048
|
-
source: event.source,
|
|
1049
|
-
reason: event.reason,
|
|
1050
|
-
})), "thread_switched");
|
|
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);
|
|
1051
1502
|
}));
|
|
1052
1503
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1504
|
+
},
|
|
1505
|
+
taskComplete: () => {
|
|
1506
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1055
1507
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
|
|
1056
1508
|
slot.pendingConfirmations = [];
|
|
1057
1509
|
slot.pendingUserInput = null;
|
|
1058
|
-
|
|
1510
|
+
if (eventTask) {
|
|
1511
|
+
slot.turns.complete(eventTask);
|
|
1512
|
+
}
|
|
1513
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1059
1514
|
}));
|
|
1060
|
-
|
|
1061
|
-
|
|
1515
|
+
},
|
|
1516
|
+
taskFailed: (next) => {
|
|
1517
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1062
1518
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1063
1519
|
slot.pendingConfirmations = [];
|
|
1064
1520
|
slot.pendingUserInput = null;
|
|
1065
|
-
|
|
1066
|
-
|
|
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);
|
|
1067
1526
|
}));
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1527
|
+
},
|
|
1528
|
+
fatalError: (next) => {
|
|
1529
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1530
|
+
logError(`${slot.adapter}: ${next.message}`);
|
|
1531
|
+
appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
|
|
1072
1532
|
slot.pendingConfirmations = [];
|
|
1073
1533
|
slot.pendingUserInput = null;
|
|
1074
|
-
|
|
1534
|
+
if (eventTask) {
|
|
1535
|
+
slot.turns.complete(eventTask);
|
|
1536
|
+
}
|
|
1537
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1075
1538
|
this.disposeDeadSlot(slot);
|
|
1076
1539
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1077
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(
|
|
1540
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserFacingBridgeFatalError(next.message)), "fatal_error", eventTarget);
|
|
1078
1541
|
}));
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${
|
|
1082
|
-
|
|
1542
|
+
},
|
|
1543
|
+
shutdownRequested: (next) => {
|
|
1544
|
+
appendDaemonLog(`slot_shutdown_requested: adapter=${slot.adapter} reason=${next.reason}`);
|
|
1545
|
+
},
|
|
1546
|
+
}))
|
|
1547
|
+
.catch((error) => {
|
|
1548
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1549
|
+
appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
resolveSlotOutputTarget(slot) {
|
|
1553
|
+
return slot.turns.resolveTarget(this.fallbackConversation);
|
|
1554
|
+
}
|
|
1555
|
+
bindCurrentWecomConversation(slot) {
|
|
1556
|
+
const conversation = this.inboundConversationContext.get();
|
|
1557
|
+
if (!this.channelDriver.capabilities.multiConversation || !slot || !conversation) {
|
|
1558
|
+
return;
|
|
1083
1559
|
}
|
|
1560
|
+
slot.turns.observeConversation(conversation);
|
|
1084
1561
|
}
|
|
1085
1562
|
async handleInboundMessage(message) {
|
|
1086
1563
|
if (message.senderId !== this.authorizedUserId) {
|
|
1087
|
-
await this.queueWechatMessage(message.senderId,
|
|
1564
|
+
await this.queueWechatMessage(message.senderId, `Unauthorized. This daemon only accepts messages from ${this.channelDriver.operatorDescription}.`);
|
|
1088
1565
|
return;
|
|
1089
1566
|
}
|
|
1090
1567
|
const emojiMatch = resolveEmojiCommand(message.text);
|
|
@@ -1096,6 +1573,7 @@ class WechatDaemon {
|
|
|
1096
1573
|
reuseExistingVisible: true,
|
|
1097
1574
|
});
|
|
1098
1575
|
if (result.activated) {
|
|
1576
|
+
this.bindCurrentWecomConversation(this.getActiveSlot());
|
|
1099
1577
|
message = { ...message, text: emojiMatch.remainder };
|
|
1100
1578
|
}
|
|
1101
1579
|
else {
|
|
@@ -1122,6 +1600,7 @@ class WechatDaemon {
|
|
|
1122
1600
|
await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
|
|
1123
1601
|
return;
|
|
1124
1602
|
}
|
|
1603
|
+
this.bindCurrentWecomConversation(this.getActiveSlot());
|
|
1125
1604
|
if (switchDirective.remainder) {
|
|
1126
1605
|
message = { ...message, text: switchDirective.remainder };
|
|
1127
1606
|
}
|
|
@@ -1131,7 +1610,7 @@ class WechatDaemon {
|
|
|
1131
1610
|
}
|
|
1132
1611
|
}
|
|
1133
1612
|
if (message.text.trim().toLowerCase() === "/daemon-stop") {
|
|
1134
|
-
await this.queueWechatMessage(message.senderId,
|
|
1613
|
+
await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
|
|
1135
1614
|
setTimeout(() => {
|
|
1136
1615
|
void this.shutdown().finally(() => process.exit(0));
|
|
1137
1616
|
}, 0);
|
|
@@ -1151,21 +1630,56 @@ class WechatDaemon {
|
|
|
1151
1630
|
await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
|
|
1152
1631
|
return;
|
|
1153
1632
|
}
|
|
1633
|
+
this.bindCurrentWecomConversation(slot);
|
|
1634
|
+
const routeCurrentMessage = async (currentSlot, command) => {
|
|
1635
|
+
const previousTask = currentSlot.turns.activeTask;
|
|
1636
|
+
await routeBridgeMessage({
|
|
1637
|
+
message: toChannelInboundMessage(message),
|
|
1638
|
+
authorized: true,
|
|
1639
|
+
command,
|
|
1640
|
+
adapterState: currentSlot.runtime.getState(),
|
|
1641
|
+
hasPendingApproval: currentSlot.pendingConfirmations.length > 0,
|
|
1642
|
+
hasPendingUserInput: Boolean(currentSlot.pendingUserInput),
|
|
1643
|
+
onUnauthorized: async () => undefined,
|
|
1644
|
+
handleCommand: async (nextCommand) => {
|
|
1645
|
+
await this.handleSystemCommand(message, currentSlot, nextCommand);
|
|
1646
|
+
return true;
|
|
1647
|
+
},
|
|
1648
|
+
remindPendingApproval: async () => {
|
|
1649
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, formatPendingApprovalReminder(currentSlot.pendingConfirmations[0], currentSlot.runtime.getState())));
|
|
1650
|
+
},
|
|
1651
|
+
remindPendingUserInput: async () => {
|
|
1652
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, currentSlot.pendingUserInput
|
|
1653
|
+
? formatPendingUserInputReminder(currentSlot.pendingUserInput)
|
|
1654
|
+
: `${currentSlot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
|
|
1655
|
+
},
|
|
1656
|
+
remindBusy: async () => {
|
|
1657
|
+
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(currentSlot.adapter, `${currentSlot.adapter} is still working. Wait for the current reply or use /stop.`));
|
|
1658
|
+
},
|
|
1659
|
+
defer: async () => undefined,
|
|
1660
|
+
dispatch: async () => {
|
|
1661
|
+
await this.dispatchInboundWechatText(message, currentSlot);
|
|
1662
|
+
},
|
|
1663
|
+
});
|
|
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);
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1154
1672
|
const command = parseWechatControlCommand(message.text, {
|
|
1155
1673
|
adapter: slot.adapter,
|
|
1156
1674
|
hasPendingConfirmation: slot.pendingConfirmations.length > 0,
|
|
1157
1675
|
hasPendingUserInput: Boolean(slot.pendingUserInput),
|
|
1158
1676
|
});
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
await
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
if (slot.pendingUserInput) {
|
|
1168
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, formatPendingUserInputReminder(slot.pendingUserInput)));
|
|
1677
|
+
const slotState = slot.runtime.getState();
|
|
1678
|
+
if (command ||
|
|
1679
|
+
slot.pendingConfirmations.length > 0 ||
|
|
1680
|
+
slot.pendingUserInput ||
|
|
1681
|
+
slotState.status === "awaiting_input") {
|
|
1682
|
+
await routeCurrentMessage(slot, command);
|
|
1169
1683
|
return;
|
|
1170
1684
|
}
|
|
1171
1685
|
const visibleResult = await this.ensureSlot(slot.adapter, {
|
|
@@ -1177,21 +1691,8 @@ class WechatDaemon {
|
|
|
1177
1691
|
return;
|
|
1178
1692
|
}
|
|
1179
1693
|
slot = this.getActiveSlot() ?? slot;
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
// Belt-and-braces with the pendingUserInput mirror above: the adapter
|
|
1183
|
-
// state is authoritative even when the mirror was cleared or the WeChat
|
|
1184
|
-
// notification is still queued.
|
|
1185
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, slot.pendingUserInput
|
|
1186
|
-
? formatPendingUserInputReminder(slot.pendingUserInput)
|
|
1187
|
-
: `${slot.adapter} is waiting for structured input. Reply with /answer <key>=<value> ...`));
|
|
1188
|
-
return;
|
|
1189
|
-
}
|
|
1190
|
-
if (adapterState.status === "busy" || adapterState.status === "awaiting_approval") {
|
|
1191
|
-
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, `${slot.adapter} is still working. Wait for the current reply or use /stop.`));
|
|
1192
|
-
return;
|
|
1193
|
-
}
|
|
1194
|
-
await this.dispatchInboundWechatText(message, slot);
|
|
1694
|
+
this.bindCurrentWecomConversation(slot);
|
|
1695
|
+
await routeCurrentMessage(slot, null);
|
|
1195
1696
|
}
|
|
1196
1697
|
async handleEmojiBindingsCommand(senderId, cmd) {
|
|
1197
1698
|
switch (cmd.type) {
|
|
@@ -1215,12 +1716,16 @@ class WechatDaemon {
|
|
|
1215
1716
|
}
|
|
1216
1717
|
async handleSystemCommand(message, activeSlot, command) {
|
|
1217
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;
|
|
1218
1723
|
case "status":
|
|
1219
1724
|
await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
|
|
1220
1725
|
return;
|
|
1221
1726
|
case "resume": {
|
|
1222
1727
|
if (!isWechatResumeEnabled(activeSlot.adapter)) {
|
|
1223
|
-
await this.queueWechatMessage(message.senderId,
|
|
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.`);
|
|
1224
1729
|
return;
|
|
1225
1730
|
}
|
|
1226
1731
|
try {
|
|
@@ -1238,9 +1743,10 @@ class WechatDaemon {
|
|
|
1238
1743
|
if (command.target) {
|
|
1239
1744
|
await resumeSlot.outputBatcher.flushNow();
|
|
1240
1745
|
}
|
|
1746
|
+
const taskBeforeResume = resumeSlot.turns.activeTask;
|
|
1241
1747
|
const result = await resumeSlot.resumeCoordinator.execute(command.target);
|
|
1242
|
-
if (result.kind === "resumed") {
|
|
1243
|
-
resumeSlot.
|
|
1748
|
+
if (result.kind === "resumed" && taskBeforeResume) {
|
|
1749
|
+
resumeSlot.turns.complete(taskBeforeResume);
|
|
1244
1750
|
}
|
|
1245
1751
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
|
|
1246
1752
|
}
|
|
@@ -1262,6 +1768,9 @@ class WechatDaemon {
|
|
|
1262
1768
|
await activeSlot.runtime.createSession();
|
|
1263
1769
|
appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
|
|
1264
1770
|
return;
|
|
1771
|
+
case "broadcast":
|
|
1772
|
+
await this.handleBroadcastCommand(message, command.text);
|
|
1773
|
+
return;
|
|
1265
1774
|
case "stop": {
|
|
1266
1775
|
const interrupted = await activeSlot.runtime.interrupt();
|
|
1267
1776
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
|
|
@@ -1303,10 +1812,10 @@ class WechatDaemon {
|
|
|
1303
1812
|
}
|
|
1304
1813
|
const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
|
|
1305
1814
|
slot.pendingConfirmations = [];
|
|
1306
|
-
slot.
|
|
1815
|
+
slot.turns.setActiveTask({
|
|
1307
1816
|
startedAt: Date.now(),
|
|
1308
1817
|
inputPreview: preview,
|
|
1309
|
-
};
|
|
1818
|
+
});
|
|
1310
1819
|
appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
|
|
1311
1820
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
|
|
1312
1821
|
? `${count} approvals confirmed. Continuing...`
|
|
@@ -1352,10 +1861,10 @@ class WechatDaemon {
|
|
|
1352
1861
|
return;
|
|
1353
1862
|
}
|
|
1354
1863
|
slot.pendingUserInput = null;
|
|
1355
|
-
slot.
|
|
1864
|
+
slot.turns.setActiveTask({
|
|
1356
1865
|
startedAt: Date.now(),
|
|
1357
1866
|
inputPreview: parsed.preview,
|
|
1358
|
-
};
|
|
1867
|
+
});
|
|
1359
1868
|
appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
|
|
1360
1869
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
|
|
1361
1870
|
}
|
|
@@ -1377,14 +1886,79 @@ class WechatDaemon {
|
|
|
1377
1886
|
}
|
|
1378
1887
|
return this.slots.get(this.activeAdapter) ?? null;
|
|
1379
1888
|
}
|
|
1380
|
-
|
|
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) {
|
|
1381
1939
|
const preview = formatInboundMessagePreview(message);
|
|
1382
|
-
|
|
1940
|
+
const nextTask = {
|
|
1383
1941
|
startedAt: Date.now(),
|
|
1384
1942
|
inputPreview: truncatePreview(preview, 180),
|
|
1385
1943
|
};
|
|
1386
|
-
|
|
1387
|
-
|
|
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;
|
|
1388
1962
|
}
|
|
1389
1963
|
queueWechatTextAction(action) {
|
|
1390
1964
|
const run = this.textSendChain.then(action);
|
|
@@ -1396,39 +1970,60 @@ class WechatDaemon {
|
|
|
1396
1970
|
this.attachmentSendChain = run.then(() => undefined, () => undefined);
|
|
1397
1971
|
return run;
|
|
1398
1972
|
}
|
|
1399
|
-
|
|
1973
|
+
async sendWechatMessageNow(senderId, text, context = "message", targetOverride) {
|
|
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
|
+
});
|
|
1993
|
+
}
|
|
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;
|
|
1400
2001
|
return this.queueWechatTextAction(async () => {
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2002
|
+
const result = await this.sendWechatMessageNow(senderId, text, context, queuedTarget);
|
|
2003
|
+
if (result.status === "target_stale") {
|
|
2004
|
+
const pending = this.pendingWechatMessages.enqueue(senderId, text, context, result.target);
|
|
2005
|
+
if (pending) {
|
|
2006
|
+
appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
|
|
1405
2007
|
}
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
}
|
|
1422
|
-
logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
|
|
1423
|
-
appendDaemonLog(formatWechatSendFailureLogEntry({
|
|
1424
|
-
context,
|
|
1425
|
-
recipientId: senderId,
|
|
1426
|
-
error,
|
|
1427
|
-
}));
|
|
1428
|
-
return false;
|
|
2008
|
+
}
|
|
2009
|
+
return result.status === "sent";
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
flushPendingWechatMessages() {
|
|
2013
|
+
return this.queueWechatTextAction(async () => {
|
|
2014
|
+
for (const pending of this.pendingWechatMessages.list()) {
|
|
2015
|
+
const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
|
|
2016
|
+
if (result.status === "sent") {
|
|
2017
|
+
this.pendingWechatMessages.remove(pending.id);
|
|
2018
|
+
appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
2019
|
+
continue;
|
|
2020
|
+
}
|
|
2021
|
+
if (result.status === "target_stale") {
|
|
2022
|
+
break;
|
|
1429
2023
|
}
|
|
2024
|
+
appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
2025
|
+
break;
|
|
1430
2026
|
}
|
|
1431
|
-
return false;
|
|
1432
2027
|
});
|
|
1433
2028
|
}
|
|
1434
2029
|
trackWechatForwardTask(task) {
|
|
@@ -1728,7 +2323,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1728
2323
|
return { action: "stopped", lock, forced };
|
|
1729
2324
|
}
|
|
1730
2325
|
export async function runDaemon(options) {
|
|
1731
|
-
|
|
2326
|
+
if ((options.channelId ?? "wechat") === "wechat") {
|
|
2327
|
+
migrateLegacyChannelFiles((message) => log(message));
|
|
2328
|
+
}
|
|
1732
2329
|
loadEmojiBindings();
|
|
1733
2330
|
await cleanupDaemonBeforeStart({ cwd: options.cwd });
|
|
1734
2331
|
const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
|
|
@@ -1744,19 +2341,37 @@ export async function runDaemon(options) {
|
|
|
1744
2341
|
if (reapedOpencodePids.length > 0) {
|
|
1745
2342
|
log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
|
|
1746
2343
|
}
|
|
1747
|
-
const
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
2344
|
+
const wecomAccount = options.channelId === "wecom"
|
|
2345
|
+
? await ensureWecomAccount({ log })
|
|
2346
|
+
: null;
|
|
2347
|
+
const credentials = wecomAccount
|
|
2348
|
+
? { userId: wecomAccount.operatorUserId }
|
|
2349
|
+
: await ensureWechatCredentials({
|
|
2350
|
+
requireUserId: true,
|
|
2351
|
+
validateExisting: true,
|
|
2352
|
+
log,
|
|
2353
|
+
});
|
|
1752
2354
|
if (!credentials.userId) {
|
|
1753
|
-
throw new Error(
|
|
2355
|
+
throw new Error(options.channelId === "wecom"
|
|
2356
|
+
? "Saved WeCom credentials are missing operatorUserId."
|
|
2357
|
+
: "Saved WeChat credentials are missing userId.");
|
|
1754
2358
|
}
|
|
1755
2359
|
const daemon = new WechatDaemon({
|
|
1756
2360
|
cwd: options.cwd,
|
|
1757
2361
|
profile: options.profile,
|
|
1758
2362
|
authorizedUserId: credentials.userId,
|
|
1759
2363
|
transport: new WeChatTransport({ log, logError }),
|
|
2364
|
+
channelId: options.channelId,
|
|
2365
|
+
accountId: wecomAccount?.botId,
|
|
2366
|
+
wecomTransport: wecomAccount
|
|
2367
|
+
? new WecomTransport({
|
|
2368
|
+
account: wecomAccount,
|
|
2369
|
+
logger: {
|
|
2370
|
+
log: (message) => log(message),
|
|
2371
|
+
error: (message) => logError(message),
|
|
2372
|
+
},
|
|
2373
|
+
})
|
|
2374
|
+
: null,
|
|
1760
2375
|
});
|
|
1761
2376
|
if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
|
|
1762
2377
|
daemon.takenOverAdapter = cleanupResult.lock.adapter;
|
|
@@ -1772,6 +2387,9 @@ export async function runDaemon(options) {
|
|
|
1772
2387
|
}
|
|
1773
2388
|
shutdownInProgress = true;
|
|
1774
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();
|
|
1775
2393
|
void daemon.shutdown().finally(() => process.exit(0));
|
|
1776
2394
|
};
|
|
1777
2395
|
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
@@ -1806,7 +2424,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1806
2424
|
process.exit(1);
|
|
1807
2425
|
}
|
|
1808
2426
|
}
|
|
1809
|
-
const isDirectRun =
|
|
2427
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
1810
2428
|
if (isDirectRun) {
|
|
1811
2429
|
void main();
|
|
1812
2430
|
}
|