@unlinearity/cli-wechat-bridge 1.1.6 → 1.2.1
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 +468 -475
- 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 +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 +161 -4
- package/dist/bridge/bridge-adapters.codex.js +206 -14
- package/dist/bridge/bridge-adapters.core.js +25 -1
- package/dist/bridge/bridge-adapters.opencode.js +298 -20
- 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 +41 -2
- package/dist/bridge/bridge-state.js +16 -3
- package/dist/bridge/bridge-utils.js +6 -31
- package/dist/bridge/claude-hook.js +6 -0
- package/dist/bridge/claude-native-controls.js +145 -0
- package/dist/bridge/codex-visible-thread-proxy.js +140 -0
- package/dist/bridge/native-terminal-control.js +271 -0
- package/dist/bridge/opencode-v2-compat.js +450 -0
- package/dist/bridge/wechat-bridge.js +380 -171
- 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 +49 -3
- 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/opencode-v2-tui-bridge-plugin.js +93 -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/channel-types.js +3 -1
- 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 +6 -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 +675 -122
- package/dist/i18n/index.js +6 -0
- package/dist/i18n/messages-en.js +9 -1
- package/dist/i18n/messages-zh.js +9 -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 +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 +16 -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";
|
|
@@ -12,17 +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,
|
|
20
|
-
import { getPendingWechatMessagesFile, PendingWechatMessageStore, } from "../channels/wechat/wechat-outbound-queue.js";
|
|
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";
|
|
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";
|
|
36
|
+
import { isDirectModuleRun } from "../core/direct-run.js";
|
|
25
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";
|
|
26
43
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
27
44
|
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
|
|
28
45
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
@@ -39,12 +56,17 @@ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
|
39
56
|
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
40
57
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
41
58
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
59
|
+
const MAX_DAEMON_DEFERRED_INPUTS = 32;
|
|
42
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
|
+
}
|
|
43
65
|
function log(message) {
|
|
44
|
-
process.stderr.write(`[
|
|
66
|
+
process.stderr.write(`[${getCliChannelId()}-daemon] ${message}\n`);
|
|
45
67
|
}
|
|
46
68
|
function logError(message) {
|
|
47
|
-
process.stderr.write(`[
|
|
69
|
+
process.stderr.write(`[${getCliChannelId()}-daemon] ERROR: ${message}\n`);
|
|
48
70
|
}
|
|
49
71
|
function appendDaemonLog(message) {
|
|
50
72
|
ensureChannelDataDir();
|
|
@@ -55,6 +77,32 @@ function computePollRetryDelayMs(consecutiveFailures) {
|
|
|
55
77
|
const exponent = Math.min(normalizedFailures - 1, 5);
|
|
56
78
|
return Math.min(POLL_RETRY_MAX_MS, POLL_RETRY_BASE_MS * 2 ** exponent);
|
|
57
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
|
+
}
|
|
58
106
|
function isDaemonAdapterKind(value) {
|
|
59
107
|
return value === "codex" || value === "claude" || value === "opencode" || value === "pi";
|
|
60
108
|
}
|
|
@@ -76,6 +124,7 @@ export function parseDaemonCliArgs(argv) {
|
|
|
76
124
|
let profile;
|
|
77
125
|
let initialAdapter;
|
|
78
126
|
let openVisible = true;
|
|
127
|
+
let channelId = "wechat";
|
|
79
128
|
for (let i = 0; i < argv.length; i += 1) {
|
|
80
129
|
const arg = argv[i];
|
|
81
130
|
if (!arg) {
|
|
@@ -84,10 +133,10 @@ export function parseDaemonCliArgs(argv) {
|
|
|
84
133
|
const next = argv[i + 1];
|
|
85
134
|
if (arg === "--help" || arg === "-h") {
|
|
86
135
|
process.stdout.write([
|
|
87
|
-
|
|
136
|
+
`Usage: ${channelId}-daemon [--cwd <path>] [--adapter <codex|claude|opencode|pi>] [--profile <name-or-path>] [--no-open]`,
|
|
88
137
|
"",
|
|
89
|
-
|
|
90
|
-
|
|
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.`,
|
|
91
140
|
"",
|
|
92
141
|
].join("\n"));
|
|
93
142
|
process.exit(0);
|
|
@@ -100,6 +149,14 @@ export function parseDaemonCliArgs(argv) {
|
|
|
100
149
|
i += 1;
|
|
101
150
|
continue;
|
|
102
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
|
+
}
|
|
103
160
|
if (arg === "--adapter") {
|
|
104
161
|
if (!isDaemonAdapterKind(next)) {
|
|
105
162
|
throw new Error(`Invalid adapter: ${next ?? "(missing)"}`);
|
|
@@ -122,7 +179,13 @@ export function parseDaemonCliArgs(argv) {
|
|
|
122
179
|
}
|
|
123
180
|
throw new Error(`Unknown argument: ${arg}`);
|
|
124
181
|
}
|
|
125
|
-
return {
|
|
182
|
+
return {
|
|
183
|
+
cwd,
|
|
184
|
+
profile,
|
|
185
|
+
initialAdapter,
|
|
186
|
+
openVisible,
|
|
187
|
+
...(channelId === "wecom" ? { channelId } : {}),
|
|
188
|
+
};
|
|
126
189
|
}
|
|
127
190
|
export function parseDaemonSwitchCommand(text) {
|
|
128
191
|
const normalized = text.trim().toLowerCase();
|
|
@@ -151,8 +214,13 @@ export function parseDaemonSwitchDirective(text) {
|
|
|
151
214
|
remainder: match[2]?.trim() ?? "",
|
|
152
215
|
};
|
|
153
216
|
}
|
|
154
|
-
export function defaultDaemonSessionStartMode(
|
|
155
|
-
|
|
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";
|
|
156
224
|
}
|
|
157
225
|
export function resolveDaemonSessionStartMode(params) {
|
|
158
226
|
// An explicit --session-start-mode wins over visible-client reuse: the user
|
|
@@ -165,9 +233,6 @@ export function resolveDaemonSessionStartMode(params) {
|
|
|
165
233
|
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
166
234
|
return "restore";
|
|
167
235
|
}
|
|
168
|
-
if (params.adapter === "codex") {
|
|
169
|
-
return "restore";
|
|
170
|
-
}
|
|
171
236
|
if (params.slotCreated) {
|
|
172
237
|
return "new";
|
|
173
238
|
}
|
|
@@ -221,7 +286,7 @@ export function buildVisibleClientLaunchArgs(params) {
|
|
|
221
286
|
export function buildWindowsVisibleClientLaunchCommand(params) {
|
|
222
287
|
return [
|
|
223
288
|
"start",
|
|
224
|
-
quoteWindowsCommandArg(
|
|
289
|
+
quoteWindowsCommandArg(`${params.channelId ?? "wechat"}-${params.adapter}`),
|
|
225
290
|
"/D",
|
|
226
291
|
quoteWindowsCommandArg(params.cwd),
|
|
227
292
|
quoteWindowsCommandArg(process.execPath),
|
|
@@ -269,6 +334,7 @@ function openVisibleClient(params) {
|
|
|
269
334
|
adapter: params.adapter,
|
|
270
335
|
cwd: params.cwd,
|
|
271
336
|
args,
|
|
337
|
+
channelId: params.channelId,
|
|
272
338
|
}),
|
|
273
339
|
];
|
|
274
340
|
const child = spawn(command, launchArgs, {
|
|
@@ -289,13 +355,13 @@ function openVisibleClient(params) {
|
|
|
289
355
|
pid: child.pid,
|
|
290
356
|
};
|
|
291
357
|
}
|
|
292
|
-
const title =
|
|
358
|
+
const title = `${params.channelId ?? "wechat"}-${params.adapter}`;
|
|
293
359
|
const fullArgs = [process.execPath, ...args];
|
|
294
360
|
if (process.platform === "darwin") {
|
|
295
361
|
const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
|
|
296
|
-
const script = `tell application "Terminal"
|
|
297
|
-
activate
|
|
298
|
-
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}"
|
|
299
365
|
end tell`;
|
|
300
366
|
const child = spawn("osascript", ["-e", script], {
|
|
301
367
|
cwd: params.cwd,
|
|
@@ -481,11 +547,16 @@ export function formatDaemonStatus(status) {
|
|
|
481
547
|
}
|
|
482
548
|
return lines.join("\n");
|
|
483
549
|
}
|
|
484
|
-
class WechatDaemon {
|
|
550
|
+
export class WechatDaemon {
|
|
485
551
|
cwd;
|
|
486
552
|
profile;
|
|
487
553
|
authorizedUserId;
|
|
488
554
|
transport;
|
|
555
|
+
channelDriver;
|
|
556
|
+
channelId;
|
|
557
|
+
wecomTransport;
|
|
558
|
+
inboundConversationContext = new InboundConversationContext();
|
|
559
|
+
fallbackConversation;
|
|
489
560
|
slots = new Map();
|
|
490
561
|
// Per-adapter serialization chains for ensureSlot (see ensureSlot comment).
|
|
491
562
|
slotEnsureChains = new Map();
|
|
@@ -493,11 +564,14 @@ class WechatDaemon {
|
|
|
493
564
|
bridgeStartedAtMs = Date.now();
|
|
494
565
|
backlogNoticeSent = false;
|
|
495
566
|
activeAdapter = null;
|
|
567
|
+
activeAdapterVersion = 0;
|
|
496
568
|
takenOverAdapter;
|
|
497
569
|
textSendChain = Promise.resolve();
|
|
498
570
|
attachmentSendChain = Promise.resolve();
|
|
499
571
|
pendingWechatForwardTasks = new Set();
|
|
572
|
+
slotInputChains = new Map();
|
|
500
573
|
pendingWechatMessages;
|
|
574
|
+
deps;
|
|
501
575
|
shutdownPromise = null;
|
|
502
576
|
ipcServer = null;
|
|
503
577
|
endpointToken = "";
|
|
@@ -506,7 +580,39 @@ class WechatDaemon {
|
|
|
506
580
|
this.profile = params.profile;
|
|
507
581
|
this.authorizedUserId = params.authorizedUserId;
|
|
508
582
|
this.transport = params.transport;
|
|
509
|
-
this.
|
|
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);
|
|
510
616
|
}
|
|
511
617
|
async startIpcServer() {
|
|
512
618
|
this.endpointToken = buildDaemonToken();
|
|
@@ -554,6 +660,7 @@ class WechatDaemon {
|
|
|
554
660
|
token: this.endpointToken,
|
|
555
661
|
cwd: this.cwd,
|
|
556
662
|
startedAt: this.startedAt,
|
|
663
|
+
channelId: this.channelId,
|
|
557
664
|
});
|
|
558
665
|
resolve();
|
|
559
666
|
});
|
|
@@ -562,6 +669,7 @@ class WechatDaemon {
|
|
|
562
669
|
getStatus() {
|
|
563
670
|
return {
|
|
564
671
|
cwd: this.cwd,
|
|
672
|
+
channelId: this.channelId,
|
|
565
673
|
activeAdapter: this.activeAdapter ?? undefined,
|
|
566
674
|
startedAt: this.startedAt,
|
|
567
675
|
slots: Array.from(this.slots.values()).map((slot) => {
|
|
@@ -590,10 +698,49 @@ class WechatDaemon {
|
|
|
590
698
|
}
|
|
591
699
|
async runPollLoop() {
|
|
592
700
|
let consecutivePollFailures = 0;
|
|
593
|
-
|
|
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.`);
|
|
594
741
|
log(`Working directory: ${this.cwd}`);
|
|
595
|
-
log(
|
|
596
|
-
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}`);
|
|
597
744
|
const activeSlot = this.getActiveSlot();
|
|
598
745
|
const welcomeText = t("daemon.welcome", {
|
|
599
746
|
cwd: this.cwd,
|
|
@@ -601,6 +748,13 @@ class WechatDaemon {
|
|
|
601
748
|
bindings: formatBindingsListMessage(listBindings()),
|
|
602
749
|
});
|
|
603
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
|
+
}
|
|
604
758
|
while (!this.shutdownPromise) {
|
|
605
759
|
let pollResult;
|
|
606
760
|
try {
|
|
@@ -694,6 +848,29 @@ class WechatDaemon {
|
|
|
694
848
|
}
|
|
695
849
|
async cleanup() {
|
|
696
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();
|
|
697
874
|
for (const slot of this.slots.values()) {
|
|
698
875
|
try {
|
|
699
876
|
await slot.outputBatcher.flushNow();
|
|
@@ -736,7 +913,7 @@ class WechatDaemon {
|
|
|
736
913
|
return { shuttingDown: true };
|
|
737
914
|
case "ensure_slot":
|
|
738
915
|
if (!isSameWorkspaceCwd(request.cwd, this.cwd)) {
|
|
739
|
-
throw new Error(
|
|
916
|
+
throw new Error(`${this.channelId}-daemon is bound to ${this.cwd}; requested cwd was ${request.cwd}.`);
|
|
740
917
|
}
|
|
741
918
|
return await this.ensureSlot(request.adapter, {
|
|
742
919
|
profile: request.profile,
|
|
@@ -753,6 +930,244 @@ class WechatDaemon {
|
|
|
753
930
|
sessionStartMode: request.sessionStartMode,
|
|
754
931
|
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
755
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;
|
|
756
1171
|
}
|
|
757
1172
|
}
|
|
758
1173
|
async ensureSlot(adapter, options = {}) {
|
|
@@ -793,7 +1208,7 @@ class WechatDaemon {
|
|
|
793
1208
|
visibleEndpointBeforeProbe?.companionConnectedAt ||
|
|
794
1209
|
visibleEndpointBeforeProbe?.sharedThreadId ||
|
|
795
1210
|
visibleEndpointBeforeProbe?.sharedSessionId);
|
|
796
|
-
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
1211
|
+
let visibleConnected = this.isVisibleClientAlive(this.cwd, adapter);
|
|
797
1212
|
if (shouldRestartDeadCodexVisibleRuntime({
|
|
798
1213
|
adapter,
|
|
799
1214
|
slotCreated: created,
|
|
@@ -822,12 +1237,17 @@ class WechatDaemon {
|
|
|
822
1237
|
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
823
1238
|
}
|
|
824
1239
|
if (options.openVisible !== false && !visibleConnected) {
|
|
1240
|
+
const visibleClientTimeoutMs = this.deps.visibleClientConnectTimeoutMs ?? VISIBLE_CLIENT_CONNECT_TIMEOUT_MS;
|
|
825
1241
|
slot.controller.syncLocalClientEndpoint();
|
|
826
|
-
const launch = openVisibleClient({
|
|
1242
|
+
const launch = this.openVisibleClient({
|
|
827
1243
|
adapter,
|
|
828
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.
|
|
829
1248
|
sessionStartMode,
|
|
830
1249
|
cliArgs: options.cliArgs,
|
|
1250
|
+
channelId: this.channelId,
|
|
831
1251
|
onError: (error) => {
|
|
832
1252
|
appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
|
|
833
1253
|
},
|
|
@@ -837,14 +1257,17 @@ class WechatDaemon {
|
|
|
837
1257
|
visibleConnected = await waitForVisibleClientConnection({
|
|
838
1258
|
cwd: this.cwd,
|
|
839
1259
|
adapter,
|
|
1260
|
+
timeoutMs: this.deps.visibleClientConnectTimeoutMs,
|
|
1261
|
+
}, {
|
|
1262
|
+
isAlive: (cwd, adapter) => this.isVisibleClientAlive(cwd, adapter),
|
|
840
1263
|
});
|
|
841
1264
|
if (visibleConnected) {
|
|
842
1265
|
appendDaemonLog(`visible_client_connected: adapter=${adapter} cwd=${this.cwd}`);
|
|
843
1266
|
}
|
|
844
1267
|
else {
|
|
845
|
-
log(`${adapter} visible CLI did not connect within ${formatDuration(
|
|
1268
|
+
log(`${adapter} visible CLI did not connect within ${formatDuration(visibleClientTimeoutMs)}. Check ${BRIDGE_LOG_FILE}.`);
|
|
846
1269
|
const cleanedLauncher = cleanupVisibleClientLauncher(launch);
|
|
847
|
-
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}`);
|
|
848
1271
|
}
|
|
849
1272
|
}
|
|
850
1273
|
let visibleReady = visibleConnected;
|
|
@@ -868,7 +1291,11 @@ class WechatDaemon {
|
|
|
868
1291
|
}
|
|
869
1292
|
const activated = options.openVisible === false || (visibleConnected && visibleReady);
|
|
870
1293
|
if (activated) {
|
|
1294
|
+
const previousSlot = this.getActiveSlot();
|
|
1295
|
+
if (previousSlot && this.activeAdapter !== adapter)
|
|
1296
|
+
invalidateModelSnapshot(previousSlot.runtime);
|
|
871
1297
|
this.activeAdapter = adapter;
|
|
1298
|
+
this.activeAdapterVersion += 1;
|
|
872
1299
|
}
|
|
873
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}`);
|
|
874
1301
|
return {
|
|
@@ -878,12 +1305,13 @@ class WechatDaemon {
|
|
|
878
1305
|
visibleConnected,
|
|
879
1306
|
visibleReady,
|
|
880
1307
|
activated,
|
|
1308
|
+
activationVersion: this.activeAdapterVersion,
|
|
881
1309
|
previousActiveAdapter,
|
|
882
1310
|
};
|
|
883
1311
|
}
|
|
884
1312
|
async createSlot(adapter, options) {
|
|
885
1313
|
clearLocalCompanionEndpoint(this.cwd, undefined, { adapter });
|
|
886
|
-
const runtime =
|
|
1314
|
+
const runtime = this.createRuntime({
|
|
887
1315
|
kind: adapter,
|
|
888
1316
|
command: resolveDefaultAdapterCommand(adapter),
|
|
889
1317
|
cwd: this.cwd,
|
|
@@ -898,7 +1326,7 @@ class WechatDaemon {
|
|
|
898
1326
|
runtime,
|
|
899
1327
|
controller,
|
|
900
1328
|
outputBatcher: new OutputBatcher(async (text) => {
|
|
901
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text));
|
|
1329
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(adapter, text), "message", this.resolveSlotOutputTarget(slot));
|
|
902
1330
|
}),
|
|
903
1331
|
pendingConfirmations: [],
|
|
904
1332
|
pendingUserInput: null,
|
|
@@ -906,10 +1334,14 @@ class WechatDaemon {
|
|
|
906
1334
|
adapter,
|
|
907
1335
|
runtime,
|
|
908
1336
|
}),
|
|
909
|
-
|
|
1337
|
+
turns: new TurnCoordinator({
|
|
1338
|
+
restoreLastConversationOnFailure: true,
|
|
1339
|
+
}),
|
|
910
1340
|
lastOutputAt: 0,
|
|
911
1341
|
lastFinalReplyAtMs: 0,
|
|
912
1342
|
eventForwardChain: Promise.resolve(),
|
|
1343
|
+
deferredInputs: [],
|
|
1344
|
+
drainingDeferredInputs: false,
|
|
913
1345
|
};
|
|
914
1346
|
runtime.setEventSink((event) => {
|
|
915
1347
|
this.handleSlotEvent(slot, event);
|
|
@@ -924,7 +1356,7 @@ class WechatDaemon {
|
|
|
924
1356
|
slot.outputBatcher.clear();
|
|
925
1357
|
slot.pendingConfirmations = [];
|
|
926
1358
|
slot.pendingUserInput = null;
|
|
927
|
-
slot.
|
|
1359
|
+
slot.turns.complete();
|
|
928
1360
|
if (slot.adapter === "codex" || slot.adapter === "claude") {
|
|
929
1361
|
await slot.runtime.reset();
|
|
930
1362
|
}
|
|
@@ -937,6 +1369,16 @@ class WechatDaemon {
|
|
|
937
1369
|
slot.controller.syncLocalClientEndpoint();
|
|
938
1370
|
}
|
|
939
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
|
+
}
|
|
940
1382
|
return new WechatChannelPort({
|
|
941
1383
|
sendText: (recipientId, text, context) => this.queueWechatMessage(recipientId, text, context),
|
|
942
1384
|
sendImage: (recipientId, filePath) => this.queueWechatAttachmentAction(() => this.transport.sendImage(filePath, { recipientId })),
|
|
@@ -956,6 +1398,10 @@ class WechatDaemon {
|
|
|
956
1398
|
slot.controller.syncLocalClientEndpoint();
|
|
957
1399
|
const adapterState = slot.runtime.getState();
|
|
958
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;
|
|
959
1405
|
if (slot.pendingConfirmations.length > 0 && !adapterState.pendingApproval) {
|
|
960
1406
|
slot.pendingConfirmations = [];
|
|
961
1407
|
}
|
|
@@ -977,15 +1423,12 @@ class WechatDaemon {
|
|
|
977
1423
|
}
|
|
978
1424
|
},
|
|
979
1425
|
finalReply: (next) => {
|
|
1426
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
980
1427
|
slot.lastFinalReplyAtMs = Date.now();
|
|
981
1428
|
appendDaemonLog(`final_reply: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
982
1429
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
983
1430
|
await channelPort.send({
|
|
984
|
-
target:
|
|
985
|
-
channelId: "wechat",
|
|
986
|
-
conversationId: this.authorizedUserId,
|
|
987
|
-
recipientId: this.authorizedUserId,
|
|
988
|
-
},
|
|
1431
|
+
target: eventTarget,
|
|
989
1432
|
kind: "final_reply",
|
|
990
1433
|
text: next.text,
|
|
991
1434
|
adapter: slot.adapter,
|
|
@@ -1003,7 +1446,7 @@ class WechatDaemon {
|
|
|
1003
1446
|
appendDaemonLog(`${slot.adapter}_${next.level}_notice: ${truncatePreview(next.text)}`);
|
|
1004
1447
|
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
1005
1448
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1006
|
-
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice");
|
|
1449
|
+
await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, next.text), "notice", eventTarget);
|
|
1007
1450
|
}));
|
|
1008
1451
|
}
|
|
1009
1452
|
},
|
|
@@ -1012,7 +1455,7 @@ class WechatDaemon {
|
|
|
1012
1455
|
const pending = toPendingApproval(next);
|
|
1013
1456
|
slot.pendingConfirmations.push(pending);
|
|
1014
1457
|
appendDaemonLog(`approval_required: adapter=${slot.adapter} source=${pending.source} command=${truncatePreview(pending.commandPreview)}`);
|
|
1015
|
-
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);
|
|
1016
1459
|
}));
|
|
1017
1460
|
},
|
|
1018
1461
|
userInputRequired: (next) => {
|
|
@@ -1020,14 +1463,14 @@ class WechatDaemon {
|
|
|
1020
1463
|
slot.pendingUserInput = pending;
|
|
1021
1464
|
appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
|
|
1022
1465
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1023
|
-
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);
|
|
1024
1467
|
}));
|
|
1025
1468
|
},
|
|
1026
1469
|
mirroredUserInput: (next) => {
|
|
1027
1470
|
appendDaemonLog(`mirrored_local_input: adapter=${slot.adapter} text=${truncatePreview(next.text)}`);
|
|
1028
1471
|
if (shouldForwardBridgeEventToWechat(slot.adapter, next.type, { text: next.text })) {
|
|
1029
1472
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1030
|
-
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);
|
|
1031
1474
|
}));
|
|
1032
1475
|
}
|
|
1033
1476
|
},
|
|
@@ -1037,7 +1480,7 @@ class WechatDaemon {
|
|
|
1037
1480
|
appendDaemonLog(`session_switched: adapter=${slot.adapter} session=${next.sessionId} source=${next.source} reason=${next.reason}`);
|
|
1038
1481
|
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1039
1482
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1040
|
-
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);
|
|
1041
1484
|
}));
|
|
1042
1485
|
}
|
|
1043
1486
|
},
|
|
@@ -1055,34 +1498,46 @@ class WechatDaemon {
|
|
|
1055
1498
|
}
|
|
1056
1499
|
if (shouldForwardSessionSwitchEvent(next.reason) && shouldForwardBridgeEventToWechat(slot.adapter, next.type)) {
|
|
1057
1500
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1058
|
-
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);
|
|
1059
1502
|
}));
|
|
1060
1503
|
}
|
|
1061
1504
|
},
|
|
1062
1505
|
taskComplete: () => {
|
|
1506
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1063
1507
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(() => {
|
|
1064
1508
|
slot.pendingConfirmations = [];
|
|
1065
1509
|
slot.pendingUserInput = null;
|
|
1066
|
-
|
|
1510
|
+
if (eventTask) {
|
|
1511
|
+
slot.turns.complete(eventTask);
|
|
1512
|
+
}
|
|
1513
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1067
1514
|
}));
|
|
1068
1515
|
},
|
|
1069
1516
|
taskFailed: (next) => {
|
|
1517
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1070
1518
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1071
1519
|
slot.pendingConfirmations = [];
|
|
1072
1520
|
slot.pendingUserInput = null;
|
|
1073
|
-
|
|
1074
|
-
|
|
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);
|
|
1075
1526
|
}));
|
|
1076
1527
|
},
|
|
1077
1528
|
fatalError: (next) => {
|
|
1529
|
+
void this.channelDriver.endTyping?.(this.authorizedUserId);
|
|
1078
1530
|
logError(`${slot.adapter}: ${next.message}`);
|
|
1079
1531
|
appendDaemonLog(`fatal_error: adapter=${slot.adapter} message=${next.message}`);
|
|
1080
1532
|
slot.pendingConfirmations = [];
|
|
1081
1533
|
slot.pendingUserInput = null;
|
|
1082
|
-
|
|
1534
|
+
if (eventTask) {
|
|
1535
|
+
slot.turns.complete(eventTask);
|
|
1536
|
+
}
|
|
1537
|
+
void this.maybeDrainDeferredInputs(slot);
|
|
1083
1538
|
this.disposeDeadSlot(slot);
|
|
1084
1539
|
this.trackWechatForwardTask(slot.outputBatcher.flushNow().then(async () => {
|
|
1085
|
-
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);
|
|
1086
1541
|
}));
|
|
1087
1542
|
},
|
|
1088
1543
|
shutdownRequested: (next) => {
|
|
@@ -1094,9 +1549,19 @@ class WechatDaemon {
|
|
|
1094
1549
|
appendDaemonLog(`event_forward_failed: adapter=${slot.adapter} message=${message}`);
|
|
1095
1550
|
});
|
|
1096
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;
|
|
1559
|
+
}
|
|
1560
|
+
slot.turns.observeConversation(conversation);
|
|
1561
|
+
}
|
|
1097
1562
|
async handleInboundMessage(message) {
|
|
1098
1563
|
if (message.senderId !== this.authorizedUserId) {
|
|
1099
|
-
await this.queueWechatMessage(message.senderId,
|
|
1564
|
+
await this.queueWechatMessage(message.senderId, `Unauthorized. This daemon only accepts messages from ${this.channelDriver.operatorDescription}.`);
|
|
1100
1565
|
return;
|
|
1101
1566
|
}
|
|
1102
1567
|
const emojiMatch = resolveEmojiCommand(message.text);
|
|
@@ -1108,6 +1573,7 @@ class WechatDaemon {
|
|
|
1108
1573
|
reuseExistingVisible: true,
|
|
1109
1574
|
});
|
|
1110
1575
|
if (result.activated) {
|
|
1576
|
+
this.bindCurrentWecomConversation(this.getActiveSlot());
|
|
1111
1577
|
message = { ...message, text: emojiMatch.remainder };
|
|
1112
1578
|
}
|
|
1113
1579
|
else {
|
|
@@ -1134,6 +1600,7 @@ class WechatDaemon {
|
|
|
1134
1600
|
await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchDirective.adapter}.\n${detail}`);
|
|
1135
1601
|
return;
|
|
1136
1602
|
}
|
|
1603
|
+
this.bindCurrentWecomConversation(this.getActiveSlot());
|
|
1137
1604
|
if (switchDirective.remainder) {
|
|
1138
1605
|
message = { ...message, text: switchDirective.remainder };
|
|
1139
1606
|
}
|
|
@@ -1143,7 +1610,7 @@ class WechatDaemon {
|
|
|
1143
1610
|
}
|
|
1144
1611
|
}
|
|
1145
1612
|
if (message.text.trim().toLowerCase() === "/daemon-stop") {
|
|
1146
|
-
await this.queueWechatMessage(message.senderId,
|
|
1613
|
+
await this.queueWechatMessage(message.senderId, `Stopping ${this.channelId}-daemon...`);
|
|
1147
1614
|
setTimeout(() => {
|
|
1148
1615
|
void this.shutdown().finally(() => process.exit(0));
|
|
1149
1616
|
}, 0);
|
|
@@ -1163,7 +1630,9 @@ class WechatDaemon {
|
|
|
1163
1630
|
await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
|
|
1164
1631
|
return;
|
|
1165
1632
|
}
|
|
1633
|
+
this.bindCurrentWecomConversation(slot);
|
|
1166
1634
|
const routeCurrentMessage = async (currentSlot, command) => {
|
|
1635
|
+
const previousTask = currentSlot.turns.activeTask;
|
|
1167
1636
|
await routeBridgeMessage({
|
|
1168
1637
|
message: toChannelInboundMessage(message),
|
|
1169
1638
|
authorized: true,
|
|
@@ -1192,6 +1661,13 @@ class WechatDaemon {
|
|
|
1192
1661
|
await this.dispatchInboundWechatText(message, currentSlot);
|
|
1193
1662
|
},
|
|
1194
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
|
+
}
|
|
1195
1671
|
};
|
|
1196
1672
|
const command = parseWechatControlCommand(message.text, {
|
|
1197
1673
|
adapter: slot.adapter,
|
|
@@ -1215,6 +1691,7 @@ class WechatDaemon {
|
|
|
1215
1691
|
return;
|
|
1216
1692
|
}
|
|
1217
1693
|
slot = this.getActiveSlot() ?? slot;
|
|
1694
|
+
this.bindCurrentWecomConversation(slot);
|
|
1218
1695
|
await routeCurrentMessage(slot, null);
|
|
1219
1696
|
}
|
|
1220
1697
|
async handleEmojiBindingsCommand(senderId, cmd) {
|
|
@@ -1239,12 +1716,16 @@ class WechatDaemon {
|
|
|
1239
1716
|
}
|
|
1240
1717
|
async handleSystemCommand(message, activeSlot, command) {
|
|
1241
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;
|
|
1242
1723
|
case "status":
|
|
1243
1724
|
await this.queueWechatMessage(message.senderId, formatDaemonStatus(this.getStatus()));
|
|
1244
1725
|
return;
|
|
1245
1726
|
case "resume": {
|
|
1246
1727
|
if (!isWechatResumeEnabled(activeSlot.adapter)) {
|
|
1247
|
-
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.`);
|
|
1248
1729
|
return;
|
|
1249
1730
|
}
|
|
1250
1731
|
try {
|
|
@@ -1262,9 +1743,10 @@ class WechatDaemon {
|
|
|
1262
1743
|
if (command.target) {
|
|
1263
1744
|
await resumeSlot.outputBatcher.flushNow();
|
|
1264
1745
|
}
|
|
1746
|
+
const taskBeforeResume = resumeSlot.turns.activeTask;
|
|
1265
1747
|
const result = await resumeSlot.resumeCoordinator.execute(command.target);
|
|
1266
|
-
if (result.kind === "resumed") {
|
|
1267
|
-
resumeSlot.
|
|
1748
|
+
if (result.kind === "resumed" && taskBeforeResume) {
|
|
1749
|
+
resumeSlot.turns.complete(taskBeforeResume);
|
|
1268
1750
|
}
|
|
1269
1751
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(resumeSlot.adapter, result.message));
|
|
1270
1752
|
}
|
|
@@ -1286,6 +1768,9 @@ class WechatDaemon {
|
|
|
1286
1768
|
await activeSlot.runtime.createSession();
|
|
1287
1769
|
appendDaemonLog(`new_session: adapter=${activeSlot.adapter}`);
|
|
1288
1770
|
return;
|
|
1771
|
+
case "broadcast":
|
|
1772
|
+
await this.handleBroadcastCommand(message, command.text);
|
|
1773
|
+
return;
|
|
1289
1774
|
case "stop": {
|
|
1290
1775
|
const interrupted = await activeSlot.runtime.interrupt();
|
|
1291
1776
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(activeSlot.adapter, interrupted
|
|
@@ -1327,10 +1812,10 @@ class WechatDaemon {
|
|
|
1327
1812
|
}
|
|
1328
1813
|
const preview = slot.pendingConfirmations[0]?.commandPreview ?? "";
|
|
1329
1814
|
slot.pendingConfirmations = [];
|
|
1330
|
-
slot.
|
|
1815
|
+
slot.turns.setActiveTask({
|
|
1331
1816
|
startedAt: Date.now(),
|
|
1332
1817
|
inputPreview: preview,
|
|
1333
|
-
};
|
|
1818
|
+
});
|
|
1334
1819
|
appendDaemonLog(`approval_confirmed: adapter=${slot.adapter} count=${count} command=${truncatePreview(preview)}`);
|
|
1335
1820
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, count > 1
|
|
1336
1821
|
? `${count} approvals confirmed. Continuing...`
|
|
@@ -1376,10 +1861,10 @@ class WechatDaemon {
|
|
|
1376
1861
|
return;
|
|
1377
1862
|
}
|
|
1378
1863
|
slot.pendingUserInput = null;
|
|
1379
|
-
slot.
|
|
1864
|
+
slot.turns.setActiveTask({
|
|
1380
1865
|
startedAt: Date.now(),
|
|
1381
1866
|
inputPreview: parsed.preview,
|
|
1382
|
-
};
|
|
1867
|
+
});
|
|
1383
1868
|
appendDaemonLog(`user_input_answered: adapter=${slot.adapter} preview=${parsed.preview}`);
|
|
1384
1869
|
await this.queueWechatMessage(message.senderId, prefixDaemonAdapterMessage(slot.adapter, "Answer submitted. Continuing..."));
|
|
1385
1870
|
}
|
|
@@ -1401,14 +1886,79 @@ class WechatDaemon {
|
|
|
1401
1886
|
}
|
|
1402
1887
|
return this.slots.get(this.activeAdapter) ?? null;
|
|
1403
1888
|
}
|
|
1404
|
-
|
|
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) {
|
|
1405
1939
|
const preview = formatInboundMessagePreview(message);
|
|
1406
|
-
|
|
1940
|
+
const nextTask = {
|
|
1407
1941
|
startedAt: Date.now(),
|
|
1408
1942
|
inputPreview: truncatePreview(preview, 180),
|
|
1409
1943
|
};
|
|
1410
|
-
|
|
1411
|
-
|
|
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;
|
|
1412
1962
|
}
|
|
1413
1963
|
queueWechatTextAction(action) {
|
|
1414
1964
|
const run = this.textSendChain.then(action);
|
|
@@ -1420,58 +1970,38 @@ class WechatDaemon {
|
|
|
1420
1970
|
this.attachmentSendChain = run.then(() => undefined, () => undefined);
|
|
1421
1971
|
return run;
|
|
1422
1972
|
}
|
|
1423
|
-
async sendWechatMessageNow(senderId, text, context = "message") {
|
|
1424
|
-
const
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
recipientId: senderId,
|
|
1444
|
-
error,
|
|
1445
|
-
})
|
|
1446
|
-
: formatWechatSendFailureLogEntry({
|
|
1447
|
-
context,
|
|
1448
|
-
recipientId: senderId,
|
|
1449
|
-
error,
|
|
1450
|
-
}));
|
|
1451
|
-
return { status: "context_unavailable", error };
|
|
1452
|
-
}
|
|
1453
|
-
if (attempt < WECHAT_SEND_MAX_ATTEMPTS && isRetryableWechatSendError(error)) {
|
|
1454
|
-
const delayMs = computeWechatSendRetryDelayMs(attempt);
|
|
1455
|
-
appendDaemonLog(`wechat_send_retry: context=${context} recipient=${senderId} attempt=${attempt} delay_ms=${delayMs} error=${truncatePreview(describeWechatTransportError(error), 400)}`);
|
|
1456
|
-
await delay(delayMs);
|
|
1457
|
-
continue;
|
|
1458
|
-
}
|
|
1459
|
-
logError(`Failed to send WeChat ${context}: ${describeWechatTransportError(error)}`);
|
|
1460
|
-
appendDaemonLog(formatWechatSendFailureLogEntry({
|
|
1461
|
-
context,
|
|
1462
|
-
recipientId: senderId,
|
|
1463
|
-
error,
|
|
1464
|
-
}));
|
|
1465
|
-
return { status: "failed" };
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
return { status: "failed" };
|
|
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
|
+
});
|
|
1469
1993
|
}
|
|
1470
|
-
queueWechatMessage(senderId, text, context = "message") {
|
|
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;
|
|
1471
2001
|
return this.queueWechatTextAction(async () => {
|
|
1472
|
-
const result = await this.sendWechatMessageNow(senderId, text, context);
|
|
1473
|
-
if (result.status === "
|
|
1474
|
-
const pending = this.pendingWechatMessages.enqueue(senderId, text, context);
|
|
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);
|
|
1475
2005
|
if (pending) {
|
|
1476
2006
|
appendDaemonLog(`wechat_send_queued: id=${pending.id} context=${context} recipient=${senderId} pending=${this.pendingWechatMessages.list().length}`);
|
|
1477
2007
|
}
|
|
@@ -1482,13 +2012,13 @@ class WechatDaemon {
|
|
|
1482
2012
|
flushPendingWechatMessages() {
|
|
1483
2013
|
return this.queueWechatTextAction(async () => {
|
|
1484
2014
|
for (const pending of this.pendingWechatMessages.list()) {
|
|
1485
|
-
const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context);
|
|
2015
|
+
const result = await this.sendWechatMessageNow(pending.recipientId, pending.text, pending.context, pending.target);
|
|
1486
2016
|
if (result.status === "sent") {
|
|
1487
2017
|
this.pendingWechatMessages.remove(pending.id);
|
|
1488
2018
|
appendDaemonLog(`wechat_pending_sent: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
1489
2019
|
continue;
|
|
1490
2020
|
}
|
|
1491
|
-
if (result.status === "
|
|
2021
|
+
if (result.status === "target_stale") {
|
|
1492
2022
|
break;
|
|
1493
2023
|
}
|
|
1494
2024
|
appendDaemonLog(`wechat_pending_retryable_failure: id=${pending.id} context=${pending.context} recipient=${pending.recipientId}`);
|
|
@@ -1793,7 +2323,9 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1793
2323
|
return { action: "stopped", lock, forced };
|
|
1794
2324
|
}
|
|
1795
2325
|
export async function runDaemon(options) {
|
|
1796
|
-
|
|
2326
|
+
if ((options.channelId ?? "wechat") === "wechat") {
|
|
2327
|
+
migrateLegacyChannelFiles((message) => log(message));
|
|
2328
|
+
}
|
|
1797
2329
|
loadEmojiBindings();
|
|
1798
2330
|
await cleanupDaemonBeforeStart({ cwd: options.cwd });
|
|
1799
2331
|
const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
|
|
@@ -1809,19 +2341,37 @@ export async function runDaemon(options) {
|
|
|
1809
2341
|
if (reapedOpencodePids.length > 0) {
|
|
1810
2342
|
log(`Cleaned ${reapedOpencodePids.length} orphaned OpenCode process(es): ${reapedOpencodePids.join(", ")}`);
|
|
1811
2343
|
}
|
|
1812
|
-
const
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
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
|
+
});
|
|
1817
2354
|
if (!credentials.userId) {
|
|
1818
|
-
throw new Error(
|
|
2355
|
+
throw new Error(options.channelId === "wecom"
|
|
2356
|
+
? "Saved WeCom credentials are missing operatorUserId."
|
|
2357
|
+
: "Saved WeChat credentials are missing userId.");
|
|
1819
2358
|
}
|
|
1820
2359
|
const daemon = new WechatDaemon({
|
|
1821
2360
|
cwd: options.cwd,
|
|
1822
2361
|
profile: options.profile,
|
|
1823
2362
|
authorizedUserId: credentials.userId,
|
|
1824
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,
|
|
1825
2375
|
});
|
|
1826
2376
|
if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
|
|
1827
2377
|
daemon.takenOverAdapter = cleanupResult.lock.adapter;
|
|
@@ -1837,6 +2387,9 @@ export async function runDaemon(options) {
|
|
|
1837
2387
|
}
|
|
1838
2388
|
shutdownInProgress = true;
|
|
1839
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();
|
|
1840
2393
|
void daemon.shutdown().finally(() => process.exit(0));
|
|
1841
2394
|
};
|
|
1842
2395
|
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
@@ -1871,7 +2424,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1871
2424
|
process.exit(1);
|
|
1872
2425
|
}
|
|
1873
2426
|
}
|
|
1874
|
-
const isDirectRun =
|
|
2427
|
+
const isDirectRun = isDirectModuleRun(import.meta.url, process.argv, import.meta.main);
|
|
1875
2428
|
if (isDirectRun) {
|
|
1876
2429
|
void main();
|
|
1877
2430
|
}
|