cli-wechat-bridge 1.0.6 → 1.0.9
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 -21
- package/README.md +157 -353
- package/dist/bridge/bridge-adapters.claude.js +102 -3
- package/dist/bridge/bridge-adapters.codex.js +30 -15
- package/dist/bridge/bridge-adapters.core.js +32 -10
- package/dist/bridge/bridge-adapters.opencode.js +79 -22
- package/dist/bridge/bridge-adapters.shared.js +3 -6
- package/dist/bridge/bridge-process-reaper.js +47 -3
- package/dist/bridge/bridge-state.js +6 -3
- package/dist/bridge/bridge-utils.js +4 -2
- package/dist/bridge/claude-hooks.js +5 -0
- package/dist/bridge/wechat-bridge.js +11 -1
- package/dist/commands/check-update.js +1 -1
- package/dist/companion/local-companion-link.js +6 -0
- package/dist/companion/local-companion-start.js +27 -2
- package/dist/companion/local-companion.js +21 -4
- package/dist/daemon/daemon-link.js +6 -0
- package/dist/daemon/emoji-bindings.js +80 -0
- package/dist/daemon/wechat-daemon.js +440 -29
- package/dist/wechat/channel-config.js +1 -0
- package/package.json +3 -4
- package/dist/companion/codex-panel-link.js +0 -72
- package/dist/companion/codex-panel.js +0 -179
- package/dist/media/media-types.js +0 -1
- package/dist/wechat/standalone-bot.js +0 -355
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import net from "node:net";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { resolveDefaultAdapterCommand, } from "../bridge/bridge-adapters.js";
|
|
8
|
-
import { delay, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
|
|
8
|
+
import { delay, getSharedSessionIdFromAdapterState, quoteWindowsCommandArg, } from "../bridge/bridge-adapters.shared.js";
|
|
9
9
|
import { BridgeController } from "../bridge/bridge-controller.js";
|
|
10
10
|
import { forwardWechatFinalReply } from "../bridge/bridge-final-reply.js";
|
|
11
11
|
import { readBridgeLockFile, } from "../bridge/bridge-state.js";
|
|
12
|
-
import { killProcessTreeSync, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
|
|
12
|
+
import { getProcessRecordByPid, isWechatDaemonCommandLine, killProcessTreeSync, listWechatDaemonProcesses, reapOrphanedOpencodeProcesses, reapPeerBridgeProcesses, } from "../bridge/bridge-process-reaper.js";
|
|
13
13
|
import { buildOneTimeCode, buildWechatInboundPrompt, formatApprovalMessage, formatDuration, formatMirroredUserInputMessage, formatPendingApprovalReminder, formatPendingUserInputReminder, formatSessionSwitchMessage, formatTaskFailedMessage, formatUserInputRequestMessage, MESSAGE_START_GRACE_MS, nowIso, OutputBatcher, parsePendingUserInputAnswerCommand, parseWechatControlCommand, truncatePreview, } from "../bridge/bridge-utils.js";
|
|
14
14
|
import { formatUserFacingBridgeFatalError, formatUserFacingInboundError, formatWechatContextTokenStaleLogEntry, formatWechatSendFailureLogEntry, isRetryableWechatSendError, shouldForwardBridgeEventToWechat, } from "../bridge/wechat-bridge.js";
|
|
15
15
|
import { BRIDGE_LOCK_FILE, BRIDGE_LOG_FILE, ensureChannelDataDir, migrateLegacyChannelFiles, } from "../wechat/channel-config.js";
|
|
16
16
|
import { ensureWechatCredentials } from "../wechat/setup.js";
|
|
17
|
+
import { listBindings, loadEmojiBindings, removeBinding, resolveEmojiCommand, setBinding, } from "./emoji-bindings.js";
|
|
17
18
|
import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
|
|
18
19
|
import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
|
|
19
20
|
import { clearLocalCompanionEndpoint, clearLocalCompanionOccupancy, readLocalCompanionEndpoint, } from "../companion/local-companion-link.js";
|
|
20
|
-
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION,
|
|
21
|
+
import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
|
|
21
22
|
const MODULE_FILE = fileURLToPath(import.meta.url);
|
|
22
23
|
const MODULE_DIR = path.dirname(MODULE_FILE);
|
|
23
24
|
const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
|
|
@@ -29,6 +30,9 @@ const WECHAT_SEND_RETRY_BASE_MS = 750;
|
|
|
29
30
|
const SINGLE_BRIDGE_STOP_TIMEOUT_MS = 10_000;
|
|
30
31
|
const SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
31
32
|
const SINGLE_BRIDGE_STOP_POLL_MS = 250;
|
|
33
|
+
const DAEMON_TAKEOVER_STOP_TIMEOUT_MS = 10_000;
|
|
34
|
+
const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
|
|
35
|
+
const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
|
|
32
36
|
const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
|
|
33
37
|
const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
|
|
34
38
|
const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
|
|
@@ -132,6 +136,43 @@ export function parseDaemonSwitchCommand(text) {
|
|
|
132
136
|
return null;
|
|
133
137
|
}
|
|
134
138
|
}
|
|
139
|
+
export function parseEmojiBindingsCommand(text) {
|
|
140
|
+
const trimmed = text.trim();
|
|
141
|
+
const lower = trimmed.toLowerCase();
|
|
142
|
+
if (lower === "/bindings") {
|
|
143
|
+
return { type: "list" };
|
|
144
|
+
}
|
|
145
|
+
const bindMatch = trimmed.match(/^\/bind\s+(\[[^\]]+\])\s+(.+)$/i);
|
|
146
|
+
if (bindMatch) {
|
|
147
|
+
return { type: "bind", emoji: bindMatch[1], command: bindMatch[2].trim() };
|
|
148
|
+
}
|
|
149
|
+
const unbindMatch = trimmed.match(/^\/unbind\s+(\[[^\]]+\])$/i);
|
|
150
|
+
if (unbindMatch) {
|
|
151
|
+
return { type: "unbind", emoji: unbindMatch[1] };
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
export function defaultDaemonSessionStartMode(adapter) {
|
|
156
|
+
return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
|
|
157
|
+
}
|
|
158
|
+
export function resolveDaemonSessionStartMode(params) {
|
|
159
|
+
if (params.reuseExistingVisible && params.visibleConnected) {
|
|
160
|
+
return "restore";
|
|
161
|
+
}
|
|
162
|
+
if (params.explicitSessionStartMode) {
|
|
163
|
+
return params.explicitSessionStartMode;
|
|
164
|
+
}
|
|
165
|
+
if (params.adapter === "codex") {
|
|
166
|
+
return "restore";
|
|
167
|
+
}
|
|
168
|
+
if (params.slotCreated) {
|
|
169
|
+
return "new";
|
|
170
|
+
}
|
|
171
|
+
if (!params.visibleConnected && !params.sharedSessionId) {
|
|
172
|
+
return "new";
|
|
173
|
+
}
|
|
174
|
+
return "restore";
|
|
175
|
+
}
|
|
135
176
|
function toPendingApproval(request) {
|
|
136
177
|
const rawRequest = request.request;
|
|
137
178
|
if (typeof rawRequest.code === "string") {
|
|
@@ -168,6 +209,9 @@ export function buildVisibleClientLaunchArgs(params) {
|
|
|
168
209
|
if (params.adapter !== "codex") {
|
|
169
210
|
args.push("--adapter", params.adapter);
|
|
170
211
|
}
|
|
212
|
+
if (params.sessionStartMode && params.sessionStartMode !== "restore") {
|
|
213
|
+
args.push("--session-start-mode", params.sessionStartMode);
|
|
214
|
+
}
|
|
171
215
|
args.push("--cwd", params.cwd, ...(params.cliArgs ?? []));
|
|
172
216
|
return args;
|
|
173
217
|
}
|
|
@@ -181,6 +225,33 @@ export function buildWindowsVisibleClientLaunchCommand(params) {
|
|
|
181
225
|
...params.args.map((arg) => quoteWindowsCommandArg(arg)),
|
|
182
226
|
].join(" ");
|
|
183
227
|
}
|
|
228
|
+
const LINUX_TERMINALS = [
|
|
229
|
+
{ cmd: "gnome-terminal", buildArgs: (title) => ["--title", title, "--"] },
|
|
230
|
+
{ cmd: "konsole", buildArgs: (title) => ["-p", `tabtitle=${title}`, "-e"] },
|
|
231
|
+
{ cmd: "xfce4-terminal", buildArgs: (title) => ["--title", title, "-e"] },
|
|
232
|
+
{ cmd: "xterm", buildArgs: (title) => ["-title", title, "-e"] },
|
|
233
|
+
];
|
|
234
|
+
let cachedLinuxTerminal;
|
|
235
|
+
function detectLinuxTerminal() {
|
|
236
|
+
if (cachedLinuxTerminal !== undefined) {
|
|
237
|
+
return cachedLinuxTerminal;
|
|
238
|
+
}
|
|
239
|
+
for (const entry of LINUX_TERMINALS) {
|
|
240
|
+
try {
|
|
241
|
+
execFileSync("which", [entry.cmd], { stdio: "ignore" });
|
|
242
|
+
cachedLinuxTerminal = entry;
|
|
243
|
+
return entry;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// not found, try next
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
cachedLinuxTerminal = null;
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function shellQuotePosix(s) {
|
|
253
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
254
|
+
}
|
|
184
255
|
function formatLaunchPreview(launch) {
|
|
185
256
|
return [launch.command, ...launch.args].join(" ");
|
|
186
257
|
}
|
|
@@ -215,12 +286,53 @@ function openVisibleClient(params) {
|
|
|
215
286
|
pid: child.pid,
|
|
216
287
|
};
|
|
217
288
|
}
|
|
289
|
+
const title = `wechat-${params.adapter}`;
|
|
290
|
+
const fullArgs = [process.execPath, ...args];
|
|
291
|
+
if (process.platform === "darwin") {
|
|
292
|
+
const cmdLine = fullArgs.map(shellQuotePosix).join(" ");
|
|
293
|
+
const script = `tell application "Terminal"
|
|
294
|
+
activate
|
|
295
|
+
do script "cd ${shellQuotePosix(params.cwd)} && exec ${cmdLine}"
|
|
296
|
+
end tell`;
|
|
297
|
+
const child = spawn("osascript", ["-e", script], {
|
|
298
|
+
cwd: params.cwd,
|
|
299
|
+
detached: true,
|
|
300
|
+
stdio: "ignore",
|
|
301
|
+
});
|
|
302
|
+
child.once("error", (error) => {
|
|
303
|
+
params.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
304
|
+
});
|
|
305
|
+
child.unref();
|
|
306
|
+
return {
|
|
307
|
+
command: "osascript",
|
|
308
|
+
args: ["-e", script],
|
|
309
|
+
pid: child.pid,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const terminal = detectLinuxTerminal();
|
|
313
|
+
if (terminal) {
|
|
314
|
+
const termArgs = [...terminal.buildArgs(title), ...fullArgs];
|
|
315
|
+
const child = spawn(terminal.cmd, termArgs, {
|
|
316
|
+
cwd: params.cwd,
|
|
317
|
+
env: process.env,
|
|
318
|
+
detached: true,
|
|
319
|
+
stdio: "ignore",
|
|
320
|
+
});
|
|
321
|
+
child.once("error", (error) => {
|
|
322
|
+
params.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
323
|
+
});
|
|
324
|
+
child.unref();
|
|
325
|
+
return {
|
|
326
|
+
command: terminal.cmd,
|
|
327
|
+
args: termArgs,
|
|
328
|
+
pid: child.pid,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
218
331
|
const child = spawn(process.execPath, args, {
|
|
219
332
|
cwd: params.cwd,
|
|
220
333
|
env: process.env,
|
|
221
334
|
detached: true,
|
|
222
335
|
stdio: "ignore",
|
|
223
|
-
windowsHide: false,
|
|
224
336
|
});
|
|
225
337
|
child.once("error", (error) => {
|
|
226
338
|
params.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
@@ -291,6 +403,17 @@ function formatNoActiveAdapterMessage() {
|
|
|
291
403
|
].join("\n");
|
|
292
404
|
}
|
|
293
405
|
export function formatDaemonSwitchResultDetail(result) {
|
|
406
|
+
if (result.activated === false) {
|
|
407
|
+
const previous = result.previousActiveAdapter
|
|
408
|
+
? ` Active terminal remains ${result.previousActiveAdapter}.`
|
|
409
|
+
: " No terminal is active yet.";
|
|
410
|
+
if (result.openedVisible) {
|
|
411
|
+
return result.created
|
|
412
|
+
? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
|
|
413
|
+
: `Tried to open a visible CLI for the existing slot, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
|
|
414
|
+
}
|
|
415
|
+
return `The visible CLI is not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
|
|
416
|
+
}
|
|
294
417
|
if (result.openedVisible && result.visibleConnected) {
|
|
295
418
|
return result.created
|
|
296
419
|
? "Started a new visible CLI."
|
|
@@ -337,6 +460,7 @@ class WechatDaemon {
|
|
|
337
460
|
startedAt = new Date().toISOString();
|
|
338
461
|
bridgeStartedAtMs = Date.now();
|
|
339
462
|
activeAdapter = null;
|
|
463
|
+
takenOverAdapter;
|
|
340
464
|
textSendChain = Promise.resolve();
|
|
341
465
|
attachmentSendChain = Promise.resolve();
|
|
342
466
|
pendingWechatForwardTasks = new Set();
|
|
@@ -542,32 +666,60 @@ class WechatDaemon {
|
|
|
542
666
|
profile: request.profile,
|
|
543
667
|
cliArgs: request.cliArgs,
|
|
544
668
|
openVisible: request.openVisible ?? true,
|
|
669
|
+
sessionStartMode: request.sessionStartMode,
|
|
670
|
+
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
545
671
|
});
|
|
546
672
|
case "switch_adapter":
|
|
547
673
|
return await this.ensureSlot(request.adapter, {
|
|
548
674
|
profile: request.profile,
|
|
549
675
|
cliArgs: request.cliArgs,
|
|
550
676
|
openVisible: request.openVisible ?? true,
|
|
677
|
+
sessionStartMode: request.sessionStartMode,
|
|
678
|
+
reuseExistingVisible: request.reuseExistingVisible ?? true,
|
|
551
679
|
});
|
|
552
680
|
}
|
|
553
681
|
}
|
|
554
682
|
async ensureSlot(adapter, options = {}) {
|
|
683
|
+
const previousActiveAdapter = this.activeAdapter ?? undefined;
|
|
555
684
|
let slot = this.slots.get(adapter);
|
|
556
685
|
let created = false;
|
|
557
686
|
if (!slot) {
|
|
687
|
+
const createSessionStartMode = options.sessionStartMode ??
|
|
688
|
+
(adapter === this.takenOverAdapter ? "restore" : defaultDaemonSessionStartMode(adapter));
|
|
689
|
+
if (adapter === this.takenOverAdapter) {
|
|
690
|
+
this.takenOverAdapter = undefined;
|
|
691
|
+
}
|
|
558
692
|
slot = await this.createSlot(adapter, {
|
|
559
693
|
profile: options.profile ?? this.profile,
|
|
694
|
+
sessionStartMode: createSessionStartMode,
|
|
560
695
|
});
|
|
561
696
|
this.slots.set(adapter, slot);
|
|
562
697
|
created = true;
|
|
563
698
|
}
|
|
564
|
-
this.activeAdapter = adapter;
|
|
565
699
|
let openedVisible = false;
|
|
566
700
|
let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
|
|
701
|
+
const sessionStartMode = resolveDaemonSessionStartMode({
|
|
702
|
+
adapter,
|
|
703
|
+
explicitSessionStartMode: options.sessionStartMode,
|
|
704
|
+
slotCreated: created,
|
|
705
|
+
visibleConnected,
|
|
706
|
+
sharedSessionId: getSharedSessionIdFromAdapterState(slot.runtime.getState()),
|
|
707
|
+
reuseExistingVisible: options.reuseExistingVisible !== false,
|
|
708
|
+
});
|
|
709
|
+
if (!created &&
|
|
710
|
+
options.reuseExistingVisible === false &&
|
|
711
|
+
sessionStartMode === "new" &&
|
|
712
|
+
(adapter === "claude" || adapter === "opencode") &&
|
|
713
|
+
visibleConnected) {
|
|
714
|
+
await this.startFreshSlotSession(slot);
|
|
715
|
+
appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
|
|
716
|
+
}
|
|
567
717
|
if (options.openVisible !== false && !visibleConnected) {
|
|
718
|
+
slot.controller.syncLocalClientEndpoint();
|
|
568
719
|
const launch = openVisibleClient({
|
|
569
720
|
adapter,
|
|
570
721
|
cwd: this.cwd,
|
|
722
|
+
sessionStartMode,
|
|
571
723
|
cliArgs: options.cliArgs,
|
|
572
724
|
onError: (error) => {
|
|
573
725
|
appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
|
|
@@ -588,12 +740,18 @@ class WechatDaemon {
|
|
|
588
740
|
appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
|
|
589
741
|
}
|
|
590
742
|
}
|
|
591
|
-
|
|
743
|
+
const activated = options.openVisible === false || visibleConnected;
|
|
744
|
+
if (activated) {
|
|
745
|
+
this.activeAdapter = adapter;
|
|
746
|
+
}
|
|
747
|
+
appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
|
|
592
748
|
return {
|
|
593
749
|
activeAdapter: adapter,
|
|
594
750
|
created,
|
|
595
751
|
openedVisible,
|
|
596
752
|
visibleConnected,
|
|
753
|
+
activated,
|
|
754
|
+
previousActiveAdapter,
|
|
597
755
|
};
|
|
598
756
|
}
|
|
599
757
|
async createSlot(adapter, options) {
|
|
@@ -604,6 +762,7 @@ class WechatDaemon {
|
|
|
604
762
|
cwd: this.cwd,
|
|
605
763
|
profile: options.profile,
|
|
606
764
|
lifecycle: "persistent",
|
|
765
|
+
sessionStartMode: options.sessionStartMode,
|
|
607
766
|
companionLaunchMode: "daemon_auto",
|
|
608
767
|
});
|
|
609
768
|
const controller = new BridgeController(runtime, this.cwd);
|
|
@@ -624,9 +783,26 @@ class WechatDaemon {
|
|
|
624
783
|
});
|
|
625
784
|
await runtime.start();
|
|
626
785
|
controller.syncLocalClientEndpoint();
|
|
627
|
-
appendDaemonLog(`slot_started: adapter=${adapter} command=${resolveDefaultAdapterCommand(adapter)} cwd=${this.cwd}`);
|
|
786
|
+
appendDaemonLog(`slot_started: adapter=${adapter} command=${resolveDefaultAdapterCommand(adapter)} cwd=${this.cwd} session_start_mode=${options.sessionStartMode ?? "restore"}`);
|
|
628
787
|
return slot;
|
|
629
788
|
}
|
|
789
|
+
async startFreshSlotSession(slot) {
|
|
790
|
+
await slot.outputBatcher.flushNow();
|
|
791
|
+
slot.outputBatcher.clear();
|
|
792
|
+
slot.pendingConfirmation = null;
|
|
793
|
+
slot.pendingUserInput = null;
|
|
794
|
+
slot.activeTask = null;
|
|
795
|
+
if (slot.adapter === "claude") {
|
|
796
|
+
await slot.runtime.reset();
|
|
797
|
+
}
|
|
798
|
+
else if (slot.adapter === "opencode") {
|
|
799
|
+
if (!slot.runtime.createSession) {
|
|
800
|
+
throw new Error("/new is not available in opencode mode.");
|
|
801
|
+
}
|
|
802
|
+
await slot.runtime.createSession();
|
|
803
|
+
}
|
|
804
|
+
slot.controller.syncLocalClientEndpoint();
|
|
805
|
+
}
|
|
630
806
|
handleSlotEvent(slot, event) {
|
|
631
807
|
slot.controller.syncLocalClientEndpoint();
|
|
632
808
|
const adapterState = slot.runtime.getState();
|
|
@@ -775,13 +951,41 @@ class WechatDaemon {
|
|
|
775
951
|
await this.queueWechatMessage(message.senderId, "Unauthorized. This daemon only accepts messages from the configured WeChat owner.");
|
|
776
952
|
return;
|
|
777
953
|
}
|
|
954
|
+
const emojiMatch = resolveEmojiCommand(message.text);
|
|
955
|
+
if (emojiMatch) {
|
|
956
|
+
const switchTarget = parseDaemonSwitchCommand(emojiMatch.command);
|
|
957
|
+
if (switchTarget && emojiMatch.remainder) {
|
|
958
|
+
const result = await this.ensureSlot(switchTarget, {
|
|
959
|
+
openVisible: true,
|
|
960
|
+
reuseExistingVisible: true,
|
|
961
|
+
});
|
|
962
|
+
if (result.activated) {
|
|
963
|
+
message = { ...message, text: emojiMatch.remainder };
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
const detail = formatDaemonSwitchResultDetail(result);
|
|
967
|
+
await this.queueWechatMessage(message.senderId, `Could not activate terminal: ${switchTarget}.\n${detail}`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
else {
|
|
972
|
+
const rewritten = emojiMatch.remainder
|
|
973
|
+
? `${emojiMatch.command} ${emojiMatch.remainder}`
|
|
974
|
+
: emojiMatch.command;
|
|
975
|
+
message = { ...message, text: rewritten };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
778
978
|
const switchAdapter = parseDaemonSwitchCommand(message.text);
|
|
779
979
|
if (switchAdapter) {
|
|
780
980
|
const result = await this.ensureSlot(switchAdapter, {
|
|
781
981
|
openVisible: true,
|
|
982
|
+
reuseExistingVisible: true,
|
|
782
983
|
});
|
|
783
984
|
const detail = formatDaemonSwitchResultDetail(result);
|
|
784
|
-
|
|
985
|
+
const heading = result.activated
|
|
986
|
+
? `Active terminal: ${switchAdapter}.`
|
|
987
|
+
: `Could not activate terminal: ${switchAdapter}.`;
|
|
988
|
+
await this.queueWechatMessage(message.senderId, `${heading}\n${detail}`);
|
|
785
989
|
return;
|
|
786
990
|
}
|
|
787
991
|
if (message.text.trim().toLowerCase() === "/daemon-stop") {
|
|
@@ -791,6 +995,11 @@ class WechatDaemon {
|
|
|
791
995
|
}, 0);
|
|
792
996
|
return;
|
|
793
997
|
}
|
|
998
|
+
const bindingsCmd = parseEmojiBindingsCommand(message.text);
|
|
999
|
+
if (bindingsCmd) {
|
|
1000
|
+
await this.handleEmojiBindingsCommand(message.senderId, bindingsCmd);
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
794
1003
|
const slot = this.getActiveSlot();
|
|
795
1004
|
if (!slot) {
|
|
796
1005
|
await this.queueWechatMessage(message.senderId, formatNoActiveAdapterMessage());
|
|
@@ -820,6 +1029,32 @@ class WechatDaemon {
|
|
|
820
1029
|
}
|
|
821
1030
|
await this.dispatchInboundWechatText(message, slot);
|
|
822
1031
|
}
|
|
1032
|
+
async handleEmojiBindingsCommand(senderId, cmd) {
|
|
1033
|
+
switch (cmd.type) {
|
|
1034
|
+
case "list": {
|
|
1035
|
+
const map = listBindings();
|
|
1036
|
+
if (map.size === 0) {
|
|
1037
|
+
await this.queueWechatMessage(senderId, "No emoji bindings configured.");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const lines = Array.from(map.entries()).map(([emoji, command]) => `${emoji} → ${command}`);
|
|
1041
|
+
await this.queueWechatMessage(senderId, `Emoji bindings:\n${lines.join("\n")}`);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
case "bind": {
|
|
1045
|
+
setBinding(cmd.emoji, cmd.command);
|
|
1046
|
+
await this.queueWechatMessage(senderId, `Bound ${cmd.emoji} → ${cmd.command}`);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
case "unbind": {
|
|
1050
|
+
const removed = removeBinding(cmd.emoji);
|
|
1051
|
+
await this.queueWechatMessage(senderId, removed
|
|
1052
|
+
? `Unbound ${cmd.emoji}`
|
|
1053
|
+
: `No binding found for ${cmd.emoji}`);
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
823
1058
|
async handleSystemCommand(message, activeSlot, command) {
|
|
824
1059
|
switch (command.type) {
|
|
825
1060
|
case "status":
|
|
@@ -1027,15 +1262,180 @@ class WechatDaemon {
|
|
|
1027
1262
|
}
|
|
1028
1263
|
}
|
|
1029
1264
|
}
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1265
|
+
function clearDaemonWorkspaceEndpoints(endpoint) {
|
|
1266
|
+
for (const adapter of DAEMON_ADAPTERS) {
|
|
1267
|
+
clearLocalCompanionEndpoint(endpoint.cwd, undefined, { adapter });
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
function isEndpointDaemonProcess(endpoint) {
|
|
1271
|
+
const record = getProcessRecordByPid(endpoint.pid);
|
|
1272
|
+
return Boolean(record && isWechatDaemonCommandLine(record.commandLine));
|
|
1273
|
+
}
|
|
1274
|
+
function selectDaemonProcessesToStop(records, excludedPids) {
|
|
1275
|
+
const recordPids = new Set(records.map((record) => record.pid));
|
|
1276
|
+
return records.filter((record) => {
|
|
1277
|
+
if (excludedPids.has(record.pid)) {
|
|
1278
|
+
return false;
|
|
1279
|
+
}
|
|
1280
|
+
return !records.some((candidate) => candidate.parentPid === record.pid &&
|
|
1281
|
+
recordPids.has(candidate.pid) &&
|
|
1282
|
+
!excludedPids.has(candidate.pid));
|
|
1283
|
+
});
|
|
1284
|
+
}
|
|
1285
|
+
async function stopDaemonPeerProcesses(params) {
|
|
1286
|
+
const excludedPids = new Set([process.pid, process.ppid]);
|
|
1287
|
+
const peerRecords = selectDaemonProcessesToStop(params.listDaemonProcesses(params.cwd), excludedPids);
|
|
1288
|
+
const stoppedPids = [];
|
|
1289
|
+
for (const peer of peerRecords) {
|
|
1290
|
+
params.daemonLog(`daemon_peer_takeover_attempt: pid=${peer.pid} cwd=${params.cwd} command=${truncatePreview(peer.commandLine, 400)}`);
|
|
1291
|
+
params.killProcess(peer.pid);
|
|
1292
|
+
if (await waitForProcessExit({
|
|
1293
|
+
pid: peer.pid,
|
|
1294
|
+
timeoutMs: params.timeoutMs,
|
|
1295
|
+
pollMs: params.pollMs,
|
|
1296
|
+
isAlive: params.isAlive,
|
|
1297
|
+
sleep: params.sleep,
|
|
1298
|
+
})) {
|
|
1299
|
+
stoppedPids.push(peer.pid);
|
|
1300
|
+
params.daemonLog(`daemon_peer_takeover_complete: pid=${peer.pid}`);
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
params.daemonLog(`daemon_peer_takeover_timeout: pid=${peer.pid}`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
return stoppedPids;
|
|
1307
|
+
}
|
|
1308
|
+
export async function cleanupDaemonBeforeStart(deps = {}) {
|
|
1309
|
+
const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
|
|
1310
|
+
const isAlive = deps.isAlive ?? isPidAlive;
|
|
1311
|
+
const sendRequest = deps.sendRequest ?? sendDaemonRequest;
|
|
1312
|
+
const killProcess = deps.killProcess ?? killProcessTreeSync;
|
|
1313
|
+
const clearEndpoint = deps.clearEndpoint ?? clearDaemonEndpoint;
|
|
1314
|
+
const clearWorkspaceEndpoints = deps.clearWorkspaceEndpoints ?? clearDaemonWorkspaceEndpoints;
|
|
1315
|
+
const isDaemonProcess = deps.isDaemonProcess ?? isEndpointDaemonProcess;
|
|
1316
|
+
const listDaemonProcesses = deps.listDaemonProcesses ??
|
|
1317
|
+
((cwd) => listWechatDaemonProcesses({
|
|
1318
|
+
cwd,
|
|
1319
|
+
excludePids: [process.pid, process.ppid],
|
|
1320
|
+
}));
|
|
1321
|
+
const sleepFn = deps.sleep ?? sleep;
|
|
1322
|
+
const cleanupLog = deps.log ?? log;
|
|
1323
|
+
const daemonLog = deps.daemonLog ?? appendDaemonLog;
|
|
1324
|
+
const stopTimeoutMs = deps.stopTimeoutMs ?? DAEMON_TAKEOVER_STOP_TIMEOUT_MS;
|
|
1325
|
+
const forceStopTimeoutMs = deps.forceStopTimeoutMs ?? DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS;
|
|
1326
|
+
const pollMs = deps.pollMs ?? DAEMON_TAKEOVER_STOP_POLL_MS;
|
|
1327
|
+
const endpoint = readEndpoint();
|
|
1328
|
+
const cleanupCwd = endpoint?.cwd ?? deps.cwd;
|
|
1032
1329
|
if (!endpoint) {
|
|
1033
|
-
|
|
1330
|
+
if (cleanupCwd) {
|
|
1331
|
+
await stopDaemonPeerProcesses({
|
|
1332
|
+
cwd: cleanupCwd,
|
|
1333
|
+
listDaemonProcesses,
|
|
1334
|
+
killProcess,
|
|
1335
|
+
isAlive,
|
|
1336
|
+
sleep: sleepFn,
|
|
1337
|
+
timeoutMs: forceStopTimeoutMs,
|
|
1338
|
+
pollMs,
|
|
1339
|
+
daemonLog,
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
return { action: "none" };
|
|
1034
1343
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1344
|
+
const clearDaemonArtifacts = () => {
|
|
1345
|
+
clearWorkspaceEndpoints(endpoint);
|
|
1346
|
+
clearEndpoint(endpoint.pid);
|
|
1347
|
+
};
|
|
1348
|
+
if (endpoint.pid === process.pid || !isAlive(endpoint.pid)) {
|
|
1349
|
+
cleanupLog(`Found stale wechat-daemon endpoint for ${endpoint.cwd} (pid=${endpoint.pid}). Cleaning it before daemon startup.`);
|
|
1350
|
+
daemonLog(`daemon_stale_endpoint_cleanup: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
|
|
1351
|
+
clearDaemonArtifacts();
|
|
1352
|
+
await stopDaemonPeerProcesses({
|
|
1353
|
+
cwd: endpoint.cwd,
|
|
1354
|
+
listDaemonProcesses,
|
|
1355
|
+
killProcess,
|
|
1356
|
+
isAlive,
|
|
1357
|
+
sleep: sleepFn,
|
|
1358
|
+
timeoutMs: forceStopTimeoutMs,
|
|
1359
|
+
pollMs,
|
|
1360
|
+
daemonLog,
|
|
1361
|
+
});
|
|
1362
|
+
return { action: "cleared_stale_endpoint", endpoint };
|
|
1037
1363
|
}
|
|
1038
|
-
|
|
1364
|
+
cleanupLog(`Found existing wechat-daemon for ${endpoint.cwd} (pid=${endpoint.pid}). Stopping it before daemon startup...`);
|
|
1365
|
+
daemonLog(`daemon_takeover_attempt: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
|
|
1366
|
+
let shutdownAcknowledged = false;
|
|
1367
|
+
try {
|
|
1368
|
+
const response = await sendRequest(endpoint, { command: "shutdown" }, { timeoutMs: 1_000 });
|
|
1369
|
+
if (response.ok) {
|
|
1370
|
+
shutdownAcknowledged = true;
|
|
1371
|
+
}
|
|
1372
|
+
else {
|
|
1373
|
+
daemonLog(`daemon_shutdown_request_failed: pid=${endpoint.pid} error=${truncatePreview(response.error, 400)}`);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
daemonLog(`daemon_shutdown_request_failed: pid=${endpoint.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
|
|
1378
|
+
}
|
|
1379
|
+
let forced = false;
|
|
1380
|
+
let stopped = await waitForProcessExit({
|
|
1381
|
+
pid: endpoint.pid,
|
|
1382
|
+
timeoutMs: stopTimeoutMs,
|
|
1383
|
+
pollMs,
|
|
1384
|
+
isAlive,
|
|
1385
|
+
sleep: sleepFn,
|
|
1386
|
+
});
|
|
1387
|
+
if (!stopped) {
|
|
1388
|
+
if (!shutdownAcknowledged && !isDaemonProcess(endpoint)) {
|
|
1389
|
+
daemonLog(`daemon_force_stop_skipped_unverified: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
|
|
1390
|
+
clearDaemonArtifacts();
|
|
1391
|
+
await stopDaemonPeerProcesses({
|
|
1392
|
+
cwd: endpoint.cwd,
|
|
1393
|
+
listDaemonProcesses,
|
|
1394
|
+
killProcess,
|
|
1395
|
+
isAlive,
|
|
1396
|
+
sleep: sleepFn,
|
|
1397
|
+
timeoutMs: forceStopTimeoutMs,
|
|
1398
|
+
pollMs,
|
|
1399
|
+
daemonLog,
|
|
1400
|
+
});
|
|
1401
|
+
return { action: "cleared_stale_endpoint", endpoint };
|
|
1402
|
+
}
|
|
1403
|
+
forced = true;
|
|
1404
|
+
cleanupLog(`Existing daemon pid=${endpoint.pid} did not stop in ${formatDuration(stopTimeoutMs)}. Forcing cleanup...`);
|
|
1405
|
+
daemonLog(`daemon_force_stop_attempt: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
|
|
1406
|
+
try {
|
|
1407
|
+
killProcess(endpoint.pid);
|
|
1408
|
+
}
|
|
1409
|
+
catch (error) {
|
|
1410
|
+
if (isAlive(endpoint.pid)) {
|
|
1411
|
+
daemonLog(`daemon_force_stop_failed: pid=${endpoint.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
stopped = await waitForProcessExit({
|
|
1415
|
+
pid: endpoint.pid,
|
|
1416
|
+
timeoutMs: forceStopTimeoutMs,
|
|
1417
|
+
pollMs,
|
|
1418
|
+
isAlive,
|
|
1419
|
+
sleep: sleepFn,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
if (!stopped && isAlive(endpoint.pid)) {
|
|
1423
|
+
throw new Error(`Could not stop existing wechat-daemon automatically (pid=${endpoint.pid}, cwd=${endpoint.cwd}).`);
|
|
1424
|
+
}
|
|
1425
|
+
clearDaemonArtifacts();
|
|
1426
|
+
cleanupLog(`Cleaned previous wechat-daemon for ${endpoint.cwd}; daemon startup can continue.`);
|
|
1427
|
+
daemonLog(`daemon_takeover_complete: pid=${endpoint.pid} cwd=${endpoint.cwd} forced=${forced}`);
|
|
1428
|
+
await stopDaemonPeerProcesses({
|
|
1429
|
+
cwd: endpoint.cwd,
|
|
1430
|
+
listDaemonProcesses,
|
|
1431
|
+
killProcess,
|
|
1432
|
+
isAlive,
|
|
1433
|
+
sleep: sleepFn,
|
|
1434
|
+
timeoutMs: forceStopTimeoutMs,
|
|
1435
|
+
pollMs,
|
|
1436
|
+
daemonLog,
|
|
1437
|
+
});
|
|
1438
|
+
return { action: "stopped", endpoint, forced };
|
|
1039
1439
|
}
|
|
1040
1440
|
async function waitForProcessExit(params) {
|
|
1041
1441
|
const deadline = Date.now() + params.timeoutMs;
|
|
@@ -1067,7 +1467,7 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1067
1467
|
const readLock = deps.readLock ?? readBridgeLockFile;
|
|
1068
1468
|
const isAlive = deps.isAlive ?? isPidAlive;
|
|
1069
1469
|
const killProcess = deps.killProcess ?? ((pid, signal) => {
|
|
1070
|
-
if (signal === "SIGKILL") {
|
|
1470
|
+
if (signal === "SIGKILL" || process.platform === "win32") {
|
|
1071
1471
|
killProcessTreeSync(pid);
|
|
1072
1472
|
return;
|
|
1073
1473
|
}
|
|
@@ -1077,6 +1477,7 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1077
1477
|
const clearEndpoint = deps.clearEndpoint ?? clearSingleBridgeEndpoint;
|
|
1078
1478
|
const sleepFn = deps.sleep ?? sleep;
|
|
1079
1479
|
const cleanupLog = deps.log ?? log;
|
|
1480
|
+
const daemonLog = deps.daemonLog ?? appendDaemonLog;
|
|
1080
1481
|
const stopTimeoutMs = deps.stopTimeoutMs ?? SINGLE_BRIDGE_STOP_TIMEOUT_MS;
|
|
1081
1482
|
const forceStopTimeoutMs = deps.forceStopTimeoutMs ?? SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS;
|
|
1082
1483
|
const pollMs = deps.pollMs ?? SINGLE_BRIDGE_STOP_POLL_MS;
|
|
@@ -1090,18 +1491,18 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1090
1491
|
};
|
|
1091
1492
|
if (!isAlive(lock.pid)) {
|
|
1092
1493
|
cleanupLog(`Found stale single bridge lock for ${lock.cwd} (pid=${lock.pid} dead). Cleaning it before daemon startup.`);
|
|
1093
|
-
|
|
1494
|
+
daemonLog(`single_bridge_stale_cleanup: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
|
|
1094
1495
|
clearBridgeArtifacts();
|
|
1095
1496
|
return { action: "cleared_stale_lock", lock };
|
|
1096
1497
|
}
|
|
1097
1498
|
cleanupLog(`Found existing single bridge for ${lock.cwd} (pid=${lock.pid}, adapter=${lock.adapter}). Stopping it before daemon startup...`);
|
|
1098
|
-
|
|
1499
|
+
daemonLog(`single_bridge_takeover_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
|
|
1099
1500
|
try {
|
|
1100
1501
|
killProcess(lock.pid, "SIGTERM");
|
|
1101
1502
|
}
|
|
1102
1503
|
catch (error) {
|
|
1103
1504
|
if (isAlive(lock.pid)) {
|
|
1104
|
-
|
|
1505
|
+
daemonLog(`single_bridge_sigterm_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
|
|
1105
1506
|
}
|
|
1106
1507
|
}
|
|
1107
1508
|
let forced = false;
|
|
@@ -1115,13 +1516,13 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1115
1516
|
if (!stopped) {
|
|
1116
1517
|
forced = true;
|
|
1117
1518
|
cleanupLog(`Single bridge pid=${lock.pid} did not stop in ${formatDuration(stopTimeoutMs)}. Forcing cleanup...`);
|
|
1118
|
-
|
|
1519
|
+
daemonLog(`single_bridge_force_stop_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
|
|
1119
1520
|
try {
|
|
1120
1521
|
killProcess(lock.pid, "SIGKILL");
|
|
1121
1522
|
}
|
|
1122
1523
|
catch (error) {
|
|
1123
1524
|
if (isAlive(lock.pid)) {
|
|
1124
|
-
|
|
1525
|
+
daemonLog(`single_bridge_sigkill_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
|
|
1125
1526
|
}
|
|
1126
1527
|
}
|
|
1127
1528
|
stopped = await waitForProcessExit({
|
|
@@ -1137,13 +1538,14 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
|
|
|
1137
1538
|
}
|
|
1138
1539
|
clearBridgeArtifacts();
|
|
1139
1540
|
cleanupLog(`Cleaned previous single bridge for ${lock.cwd}; daemon startup can continue.`);
|
|
1140
|
-
|
|
1541
|
+
daemonLog(`single_bridge_takeover_complete: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd} forced=${forced}`);
|
|
1141
1542
|
return { action: "stopped", lock, forced };
|
|
1142
1543
|
}
|
|
1143
1544
|
export async function runDaemon(options) {
|
|
1144
1545
|
migrateLegacyChannelFiles((message) => log(message));
|
|
1145
|
-
|
|
1146
|
-
await
|
|
1546
|
+
loadEmojiBindings();
|
|
1547
|
+
await cleanupDaemonBeforeStart({ cwd: options.cwd });
|
|
1548
|
+
const cleanupResult = await cleanupSingleBridgeBeforeDaemon();
|
|
1147
1549
|
const reapedPeerPids = await reapPeerBridgeProcesses({
|
|
1148
1550
|
logger: (message) => appendDaemonLog(message),
|
|
1149
1551
|
});
|
|
@@ -1170,17 +1572,26 @@ export async function runDaemon(options) {
|
|
|
1170
1572
|
authorizedUserId: credentials.userId,
|
|
1171
1573
|
transport: new WeChatTransport({ log, logError }),
|
|
1172
1574
|
});
|
|
1575
|
+
if (cleanupResult.action === "stopped" && isDaemonAdapterKind(cleanupResult.lock.adapter)) {
|
|
1576
|
+
daemon.takenOverAdapter = cleanupResult.lock.adapter;
|
|
1577
|
+
}
|
|
1173
1578
|
await daemon.startIpcServer();
|
|
1174
1579
|
await daemon.runInitialAdapter(options);
|
|
1175
|
-
|
|
1580
|
+
let shutdownInProgress = false;
|
|
1581
|
+
const handleSignal = (signal) => {
|
|
1582
|
+
if (shutdownInProgress) {
|
|
1583
|
+
log(`Received ${signal} during shutdown, forcing exit.`);
|
|
1584
|
+
process.exit(1);
|
|
1585
|
+
}
|
|
1586
|
+
shutdownInProgress = true;
|
|
1176
1587
|
log(`Received ${signal}. Stopping daemon.`);
|
|
1177
1588
|
void daemon.shutdown().finally(() => process.exit(0));
|
|
1178
1589
|
};
|
|
1179
|
-
process.
|
|
1180
|
-
process.
|
|
1181
|
-
process.
|
|
1590
|
+
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
1591
|
+
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
1592
|
+
process.on("SIGHUP", () => handleSignal("SIGHUP"));
|
|
1182
1593
|
if (process.platform === "win32") {
|
|
1183
|
-
process.
|
|
1594
|
+
process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
|
|
1184
1595
|
}
|
|
1185
1596
|
process.on("exit", () => {
|
|
1186
1597
|
clearDaemonEndpoint();
|