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.
@@ -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, isDaemonEndpointAlive, isPidAlive, readDaemonEndpoint, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
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
- appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected}`);
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
- await this.queueWechatMessage(message.senderId, `Active terminal: ${switchAdapter}.\n${detail}`);
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
- async function assertNoLiveDaemon() {
1031
- const endpoint = readDaemonEndpoint();
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
- return;
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
- if (await isDaemonEndpointAlive(endpoint, { timeoutMs: 500 })) {
1036
- throw new Error(`wechat-daemon is already running (pid=${endpoint.pid}, cwd=${endpoint.cwd}).`);
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
- clearDaemonEndpoint(endpoint.pid);
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
- appendDaemonLog(`single_bridge_stale_cleanup: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
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
- appendDaemonLog(`single_bridge_takeover_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
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
- appendDaemonLog(`single_bridge_sigterm_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
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
- appendDaemonLog(`single_bridge_force_stop_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
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
- appendDaemonLog(`single_bridge_sigkill_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
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
- appendDaemonLog(`single_bridge_takeover_complete: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd} forced=${forced}`);
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
- await assertNoLiveDaemon();
1146
- await cleanupSingleBridgeBeforeDaemon();
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
- const requestShutdown = (signal) => {
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.once("SIGINT", () => requestShutdown("SIGINT"));
1180
- process.once("SIGTERM", () => requestShutdown("SIGTERM"));
1181
- process.once("SIGHUP", () => requestShutdown("SIGHUP"));
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.once("SIGBREAK", () => requestShutdown("SIGBREAK"));
1594
+ process.on("SIGBREAK", () => handleSignal("SIGBREAK"));
1184
1595
  }
1185
1596
  process.on("exit", () => {
1186
1597
  clearDaemonEndpoint();