cli-wechat-bridge 1.0.5 → 1.0.7

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.
@@ -5,11 +5,11 @@ import path from "node:path";
5
5
  import { 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";
@@ -17,7 +17,7 @@ import { ensureWechatCredentials } from "../wechat/setup.js";
17
17
  import { classifyWechatTransportError, DEFAULT_LONG_POLL_TIMEOUT_MS, describeWechatTransportError, isWechatContextTokenStaleError, WeChatTransport, } from "../wechat/wechat-transport.js";
18
18
  import { createRuntimeHost, } from "../runtime/create-runtime-host.js";
19
19
  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";
20
+ import { attachDaemonRequestListener, buildDaemonToken, clearDaemonEndpoint, DAEMON_PROTOCOL_VERSION, isPidAlive, readDaemonEndpoint, sendDaemonRequest, sendDaemonResponse, writeDaemonEndpoint, } from "./daemon-link.js";
21
21
  const MODULE_FILE = fileURLToPath(import.meta.url);
22
22
  const MODULE_DIR = path.dirname(MODULE_FILE);
23
23
  const RUNTIME_ENTRY_EXTENSION = path.extname(MODULE_FILE) === ".ts" ? ".ts" : ".js";
@@ -29,6 +29,9 @@ const WECHAT_SEND_RETRY_BASE_MS = 750;
29
29
  const SINGLE_BRIDGE_STOP_TIMEOUT_MS = 10_000;
30
30
  const SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS = 3_000;
31
31
  const SINGLE_BRIDGE_STOP_POLL_MS = 250;
32
+ const DAEMON_TAKEOVER_STOP_TIMEOUT_MS = 10_000;
33
+ const DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS = 3_000;
34
+ const DAEMON_TAKEOVER_STOP_POLL_MS = 250;
32
35
  const VISIBLE_CLIENT_CONNECT_TIMEOUT_MS = 15_000;
33
36
  const VISIBLE_CLIENT_CONNECT_POLL_MS = 250;
34
37
  const DAEMON_ADAPTERS = ["codex", "claude", "opencode"];
@@ -132,6 +135,27 @@ export function parseDaemonSwitchCommand(text) {
132
135
  return null;
133
136
  }
134
137
  }
138
+ export function defaultDaemonSessionStartMode(adapter) {
139
+ return adapter === "claude" || adapter === "opencode" ? "new" : "restore";
140
+ }
141
+ export function resolveDaemonSessionStartMode(params) {
142
+ if (params.reuseExistingVisible && params.visibleConnected) {
143
+ return "restore";
144
+ }
145
+ if (params.explicitSessionStartMode) {
146
+ return params.explicitSessionStartMode;
147
+ }
148
+ if (params.adapter === "codex") {
149
+ return "restore";
150
+ }
151
+ if (params.slotCreated) {
152
+ return "new";
153
+ }
154
+ if (!params.visibleConnected && !params.sharedSessionId) {
155
+ return "new";
156
+ }
157
+ return "restore";
158
+ }
135
159
  function toPendingApproval(request) {
136
160
  const rawRequest = request.request;
137
161
  if (typeof rawRequest.code === "string") {
@@ -168,6 +192,9 @@ export function buildVisibleClientLaunchArgs(params) {
168
192
  if (params.adapter !== "codex") {
169
193
  args.push("--adapter", params.adapter);
170
194
  }
195
+ if (params.sessionStartMode && params.sessionStartMode !== "restore") {
196
+ args.push("--session-start-mode", params.sessionStartMode);
197
+ }
171
198
  args.push("--cwd", params.cwd, ...(params.cliArgs ?? []));
172
199
  return args;
173
200
  }
@@ -291,6 +318,17 @@ function formatNoActiveAdapterMessage() {
291
318
  ].join("\n");
292
319
  }
293
320
  export function formatDaemonSwitchResultDetail(result) {
321
+ if (result.activated === false) {
322
+ const previous = result.previousActiveAdapter
323
+ ? ` Active terminal remains ${result.previousActiveAdapter}.`
324
+ : " No terminal is active yet.";
325
+ if (result.openedVisible) {
326
+ return result.created
327
+ ? `Started the bridge slot and tried to open the visible CLI, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`
328
+ : `Tried to open a visible CLI for the existing slot, but it has not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
329
+ }
330
+ return `The visible CLI is not connected yet.${previous} Check ${BRIDGE_LOG_FILE}.`;
331
+ }
294
332
  if (result.openedVisible && result.visibleConnected) {
295
333
  return result.created
296
334
  ? "Started a new visible CLI."
@@ -542,32 +580,56 @@ class WechatDaemon {
542
580
  profile: request.profile,
543
581
  cliArgs: request.cliArgs,
544
582
  openVisible: request.openVisible ?? true,
583
+ sessionStartMode: request.sessionStartMode,
584
+ reuseExistingVisible: request.reuseExistingVisible ?? true,
545
585
  });
546
586
  case "switch_adapter":
547
587
  return await this.ensureSlot(request.adapter, {
548
588
  profile: request.profile,
549
589
  cliArgs: request.cliArgs,
550
590
  openVisible: request.openVisible ?? true,
591
+ sessionStartMode: request.sessionStartMode,
592
+ reuseExistingVisible: request.reuseExistingVisible ?? true,
551
593
  });
552
594
  }
553
595
  }
554
596
  async ensureSlot(adapter, options = {}) {
597
+ const previousActiveAdapter = this.activeAdapter ?? undefined;
555
598
  let slot = this.slots.get(adapter);
556
599
  let created = false;
557
600
  if (!slot) {
601
+ const createSessionStartMode = options.sessionStartMode ?? defaultDaemonSessionStartMode(adapter);
558
602
  slot = await this.createSlot(adapter, {
559
603
  profile: options.profile ?? this.profile,
604
+ sessionStartMode: createSessionStartMode,
560
605
  });
561
606
  this.slots.set(adapter, slot);
562
607
  created = true;
563
608
  }
564
- this.activeAdapter = adapter;
565
609
  let openedVisible = false;
566
610
  let visibleConnected = isVisibleClientAlive(this.cwd, adapter);
611
+ const sessionStartMode = resolveDaemonSessionStartMode({
612
+ adapter,
613
+ explicitSessionStartMode: options.sessionStartMode,
614
+ slotCreated: created,
615
+ visibleConnected,
616
+ sharedSessionId: getSharedSessionIdFromAdapterState(slot.runtime.getState()),
617
+ reuseExistingVisible: options.reuseExistingVisible !== false,
618
+ });
619
+ if (!created &&
620
+ options.reuseExistingVisible === false &&
621
+ sessionStartMode === "new" &&
622
+ (adapter === "claude" || adapter === "opencode") &&
623
+ visibleConnected) {
624
+ await this.startFreshSlotSession(slot);
625
+ appendDaemonLog(`fresh_session_started: adapter=${adapter} source=start_command`);
626
+ }
567
627
  if (options.openVisible !== false && !visibleConnected) {
628
+ slot.controller.syncLocalClientEndpoint();
568
629
  const launch = openVisibleClient({
569
630
  adapter,
570
631
  cwd: this.cwd,
632
+ sessionStartMode,
571
633
  cliArgs: options.cliArgs,
572
634
  onError: (error) => {
573
635
  appendDaemonLog(`visible_client_open_error: adapter=${adapter} error=${truncatePreview(error.message, 400)}`);
@@ -588,12 +650,18 @@ class WechatDaemon {
588
650
  appendDaemonLog(`visible_client_connect_timeout: adapter=${adapter} cwd=${this.cwd} timeout_ms=${VISIBLE_CLIENT_CONNECT_TIMEOUT_MS} cleaned_launcher=${cleanedLauncher}`);
589
651
  }
590
652
  }
591
- appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected}`);
653
+ const activated = options.openVisible === false || visibleConnected;
654
+ if (activated) {
655
+ this.activeAdapter = adapter;
656
+ }
657
+ appendDaemonLog(`switch_adapter: adapter=${adapter} created=${created} opened_visible=${openedVisible} visible_connected=${visibleConnected} activated=${activated} previous_active=${previousActiveAdapter ?? "(none)"} session_start_mode=${sessionStartMode}`);
592
658
  return {
593
659
  activeAdapter: adapter,
594
660
  created,
595
661
  openedVisible,
596
662
  visibleConnected,
663
+ activated,
664
+ previousActiveAdapter,
597
665
  };
598
666
  }
599
667
  async createSlot(adapter, options) {
@@ -604,6 +672,7 @@ class WechatDaemon {
604
672
  cwd: this.cwd,
605
673
  profile: options.profile,
606
674
  lifecycle: "persistent",
675
+ sessionStartMode: options.sessionStartMode,
607
676
  companionLaunchMode: "daemon_auto",
608
677
  });
609
678
  const controller = new BridgeController(runtime, this.cwd);
@@ -624,9 +693,26 @@ class WechatDaemon {
624
693
  });
625
694
  await runtime.start();
626
695
  controller.syncLocalClientEndpoint();
627
- appendDaemonLog(`slot_started: adapter=${adapter} command=${resolveDefaultAdapterCommand(adapter)} cwd=${this.cwd}`);
696
+ appendDaemonLog(`slot_started: adapter=${adapter} command=${resolveDefaultAdapterCommand(adapter)} cwd=${this.cwd} session_start_mode=${options.sessionStartMode ?? "restore"}`);
628
697
  return slot;
629
698
  }
699
+ async startFreshSlotSession(slot) {
700
+ await slot.outputBatcher.flushNow();
701
+ slot.outputBatcher.clear();
702
+ slot.pendingConfirmation = null;
703
+ slot.pendingUserInput = null;
704
+ slot.activeTask = null;
705
+ if (slot.adapter === "claude") {
706
+ await slot.runtime.reset();
707
+ }
708
+ else if (slot.adapter === "opencode") {
709
+ if (!slot.runtime.createSession) {
710
+ throw new Error("/new is not available in opencode mode.");
711
+ }
712
+ await slot.runtime.createSession();
713
+ }
714
+ slot.controller.syncLocalClientEndpoint();
715
+ }
630
716
  handleSlotEvent(slot, event) {
631
717
  slot.controller.syncLocalClientEndpoint();
632
718
  const adapterState = slot.runtime.getState();
@@ -703,7 +789,7 @@ class WechatDaemon {
703
789
  const pending = toPendingUserInput(event.request);
704
790
  slot.pendingUserInput = pending;
705
791
  appendDaemonLog(`user_input_required: adapter=${slot.adapter} questions=${pending.questions.length}`);
706
- await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "approval_required");
792
+ await this.queueWechatMessage(this.authorizedUserId, prefixDaemonAdapterMessage(slot.adapter, formatUserInputRequestMessage(pending, adapterState)), "user_input_required");
707
793
  }));
708
794
  break;
709
795
  case "mirrored_user_input":
@@ -779,9 +865,13 @@ class WechatDaemon {
779
865
  if (switchAdapter) {
780
866
  const result = await this.ensureSlot(switchAdapter, {
781
867
  openVisible: true,
868
+ reuseExistingVisible: true,
782
869
  });
783
870
  const detail = formatDaemonSwitchResultDetail(result);
784
- await this.queueWechatMessage(message.senderId, `Active terminal: ${switchAdapter}.\n${detail}`);
871
+ const heading = result.activated
872
+ ? `Active terminal: ${switchAdapter}.`
873
+ : `Could not activate terminal: ${switchAdapter}.`;
874
+ await this.queueWechatMessage(message.senderId, `${heading}\n${detail}`);
785
875
  return;
786
876
  }
787
877
  if (message.text.trim().toLowerCase() === "/daemon-stop") {
@@ -1027,15 +1117,180 @@ class WechatDaemon {
1027
1117
  }
1028
1118
  }
1029
1119
  }
1030
- async function assertNoLiveDaemon() {
1031
- const endpoint = readDaemonEndpoint();
1120
+ function clearDaemonWorkspaceEndpoints(endpoint) {
1121
+ for (const adapter of DAEMON_ADAPTERS) {
1122
+ clearLocalCompanionEndpoint(endpoint.cwd, undefined, { adapter });
1123
+ }
1124
+ }
1125
+ function isEndpointDaemonProcess(endpoint) {
1126
+ const record = getProcessRecordByPid(endpoint.pid);
1127
+ return Boolean(record && isWechatDaemonCommandLine(record.commandLine));
1128
+ }
1129
+ function selectDaemonProcessesToStop(records, excludedPids) {
1130
+ const recordPids = new Set(records.map((record) => record.pid));
1131
+ return records.filter((record) => {
1132
+ if (excludedPids.has(record.pid)) {
1133
+ return false;
1134
+ }
1135
+ return !records.some((candidate) => candidate.parentPid === record.pid &&
1136
+ recordPids.has(candidate.pid) &&
1137
+ !excludedPids.has(candidate.pid));
1138
+ });
1139
+ }
1140
+ async function stopDaemonPeerProcesses(params) {
1141
+ const excludedPids = new Set([process.pid, process.ppid]);
1142
+ const peerRecords = selectDaemonProcessesToStop(params.listDaemonProcesses(params.cwd), excludedPids);
1143
+ const stoppedPids = [];
1144
+ for (const peer of peerRecords) {
1145
+ params.daemonLog(`daemon_peer_takeover_attempt: pid=${peer.pid} cwd=${params.cwd} command=${truncatePreview(peer.commandLine, 400)}`);
1146
+ params.killProcess(peer.pid);
1147
+ if (await waitForProcessExit({
1148
+ pid: peer.pid,
1149
+ timeoutMs: params.timeoutMs,
1150
+ pollMs: params.pollMs,
1151
+ isAlive: params.isAlive,
1152
+ sleep: params.sleep,
1153
+ })) {
1154
+ stoppedPids.push(peer.pid);
1155
+ params.daemonLog(`daemon_peer_takeover_complete: pid=${peer.pid}`);
1156
+ }
1157
+ else {
1158
+ params.daemonLog(`daemon_peer_takeover_timeout: pid=${peer.pid}`);
1159
+ }
1160
+ }
1161
+ return stoppedPids;
1162
+ }
1163
+ export async function cleanupDaemonBeforeStart(deps = {}) {
1164
+ const readEndpoint = deps.readEndpoint ?? readDaemonEndpoint;
1165
+ const isAlive = deps.isAlive ?? isPidAlive;
1166
+ const sendRequest = deps.sendRequest ?? sendDaemonRequest;
1167
+ const killProcess = deps.killProcess ?? killProcessTreeSync;
1168
+ const clearEndpoint = deps.clearEndpoint ?? clearDaemonEndpoint;
1169
+ const clearWorkspaceEndpoints = deps.clearWorkspaceEndpoints ?? clearDaemonWorkspaceEndpoints;
1170
+ const isDaemonProcess = deps.isDaemonProcess ?? isEndpointDaemonProcess;
1171
+ const listDaemonProcesses = deps.listDaemonProcesses ??
1172
+ ((cwd) => listWechatDaemonProcesses({
1173
+ cwd,
1174
+ excludePids: [process.pid, process.ppid],
1175
+ }));
1176
+ const sleepFn = deps.sleep ?? sleep;
1177
+ const cleanupLog = deps.log ?? log;
1178
+ const daemonLog = deps.daemonLog ?? appendDaemonLog;
1179
+ const stopTimeoutMs = deps.stopTimeoutMs ?? DAEMON_TAKEOVER_STOP_TIMEOUT_MS;
1180
+ const forceStopTimeoutMs = deps.forceStopTimeoutMs ?? DAEMON_TAKEOVER_FORCE_STOP_TIMEOUT_MS;
1181
+ const pollMs = deps.pollMs ?? DAEMON_TAKEOVER_STOP_POLL_MS;
1182
+ const endpoint = readEndpoint();
1183
+ const cleanupCwd = endpoint?.cwd ?? deps.cwd;
1032
1184
  if (!endpoint) {
1033
- return;
1185
+ if (cleanupCwd) {
1186
+ await stopDaemonPeerProcesses({
1187
+ cwd: cleanupCwd,
1188
+ listDaemonProcesses,
1189
+ killProcess,
1190
+ isAlive,
1191
+ sleep: sleepFn,
1192
+ timeoutMs: forceStopTimeoutMs,
1193
+ pollMs,
1194
+ daemonLog,
1195
+ });
1196
+ }
1197
+ return { action: "none" };
1034
1198
  }
1035
- if (await isDaemonEndpointAlive(endpoint, { timeoutMs: 500 })) {
1036
- throw new Error(`wechat-daemon is already running (pid=${endpoint.pid}, cwd=${endpoint.cwd}).`);
1199
+ const clearDaemonArtifacts = () => {
1200
+ clearWorkspaceEndpoints(endpoint);
1201
+ clearEndpoint(endpoint.pid);
1202
+ };
1203
+ if (endpoint.pid === process.pid || !isAlive(endpoint.pid)) {
1204
+ cleanupLog(`Found stale wechat-daemon endpoint for ${endpoint.cwd} (pid=${endpoint.pid}). Cleaning it before daemon startup.`);
1205
+ daemonLog(`daemon_stale_endpoint_cleanup: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
1206
+ clearDaemonArtifacts();
1207
+ await stopDaemonPeerProcesses({
1208
+ cwd: endpoint.cwd,
1209
+ listDaemonProcesses,
1210
+ killProcess,
1211
+ isAlive,
1212
+ sleep: sleepFn,
1213
+ timeoutMs: forceStopTimeoutMs,
1214
+ pollMs,
1215
+ daemonLog,
1216
+ });
1217
+ return { action: "cleared_stale_endpoint", endpoint };
1218
+ }
1219
+ cleanupLog(`Found existing wechat-daemon for ${endpoint.cwd} (pid=${endpoint.pid}). Stopping it before daemon startup...`);
1220
+ daemonLog(`daemon_takeover_attempt: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
1221
+ let shutdownAcknowledged = false;
1222
+ try {
1223
+ const response = await sendRequest(endpoint, { command: "shutdown" }, { timeoutMs: 1_000 });
1224
+ if (response.ok) {
1225
+ shutdownAcknowledged = true;
1226
+ }
1227
+ else {
1228
+ daemonLog(`daemon_shutdown_request_failed: pid=${endpoint.pid} error=${truncatePreview(response.error, 400)}`);
1229
+ }
1230
+ }
1231
+ catch (error) {
1232
+ daemonLog(`daemon_shutdown_request_failed: pid=${endpoint.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1037
1233
  }
1038
- clearDaemonEndpoint(endpoint.pid);
1234
+ let forced = false;
1235
+ let stopped = await waitForProcessExit({
1236
+ pid: endpoint.pid,
1237
+ timeoutMs: stopTimeoutMs,
1238
+ pollMs,
1239
+ isAlive,
1240
+ sleep: sleepFn,
1241
+ });
1242
+ if (!stopped) {
1243
+ if (!shutdownAcknowledged && !isDaemonProcess(endpoint)) {
1244
+ daemonLog(`daemon_force_stop_skipped_unverified: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
1245
+ clearDaemonArtifacts();
1246
+ await stopDaemonPeerProcesses({
1247
+ cwd: endpoint.cwd,
1248
+ listDaemonProcesses,
1249
+ killProcess,
1250
+ isAlive,
1251
+ sleep: sleepFn,
1252
+ timeoutMs: forceStopTimeoutMs,
1253
+ pollMs,
1254
+ daemonLog,
1255
+ });
1256
+ return { action: "cleared_stale_endpoint", endpoint };
1257
+ }
1258
+ forced = true;
1259
+ cleanupLog(`Existing daemon pid=${endpoint.pid} did not stop in ${formatDuration(stopTimeoutMs)}. Forcing cleanup...`);
1260
+ daemonLog(`daemon_force_stop_attempt: pid=${endpoint.pid} cwd=${endpoint.cwd}`);
1261
+ try {
1262
+ killProcess(endpoint.pid);
1263
+ }
1264
+ catch (error) {
1265
+ if (isAlive(endpoint.pid)) {
1266
+ daemonLog(`daemon_force_stop_failed: pid=${endpoint.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1267
+ }
1268
+ }
1269
+ stopped = await waitForProcessExit({
1270
+ pid: endpoint.pid,
1271
+ timeoutMs: forceStopTimeoutMs,
1272
+ pollMs,
1273
+ isAlive,
1274
+ sleep: sleepFn,
1275
+ });
1276
+ }
1277
+ if (!stopped && isAlive(endpoint.pid)) {
1278
+ throw new Error(`Could not stop existing wechat-daemon automatically (pid=${endpoint.pid}, cwd=${endpoint.cwd}).`);
1279
+ }
1280
+ clearDaemonArtifacts();
1281
+ cleanupLog(`Cleaned previous wechat-daemon for ${endpoint.cwd}; daemon startup can continue.`);
1282
+ daemonLog(`daemon_takeover_complete: pid=${endpoint.pid} cwd=${endpoint.cwd} forced=${forced}`);
1283
+ await stopDaemonPeerProcesses({
1284
+ cwd: endpoint.cwd,
1285
+ listDaemonProcesses,
1286
+ killProcess,
1287
+ isAlive,
1288
+ sleep: sleepFn,
1289
+ timeoutMs: forceStopTimeoutMs,
1290
+ pollMs,
1291
+ daemonLog,
1292
+ });
1293
+ return { action: "stopped", endpoint, forced };
1039
1294
  }
1040
1295
  async function waitForProcessExit(params) {
1041
1296
  const deadline = Date.now() + params.timeoutMs;
@@ -1077,6 +1332,7 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1077
1332
  const clearEndpoint = deps.clearEndpoint ?? clearSingleBridgeEndpoint;
1078
1333
  const sleepFn = deps.sleep ?? sleep;
1079
1334
  const cleanupLog = deps.log ?? log;
1335
+ const daemonLog = deps.daemonLog ?? appendDaemonLog;
1080
1336
  const stopTimeoutMs = deps.stopTimeoutMs ?? SINGLE_BRIDGE_STOP_TIMEOUT_MS;
1081
1337
  const forceStopTimeoutMs = deps.forceStopTimeoutMs ?? SINGLE_BRIDGE_FORCE_STOP_TIMEOUT_MS;
1082
1338
  const pollMs = deps.pollMs ?? SINGLE_BRIDGE_STOP_POLL_MS;
@@ -1090,18 +1346,18 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1090
1346
  };
1091
1347
  if (!isAlive(lock.pid)) {
1092
1348
  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}`);
1349
+ daemonLog(`single_bridge_stale_cleanup: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1094
1350
  clearBridgeArtifacts();
1095
1351
  return { action: "cleared_stale_lock", lock };
1096
1352
  }
1097
1353
  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}`);
1354
+ daemonLog(`single_bridge_takeover_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1099
1355
  try {
1100
1356
  killProcess(lock.pid, "SIGTERM");
1101
1357
  }
1102
1358
  catch (error) {
1103
1359
  if (isAlive(lock.pid)) {
1104
- appendDaemonLog(`single_bridge_sigterm_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1360
+ daemonLog(`single_bridge_sigterm_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1105
1361
  }
1106
1362
  }
1107
1363
  let forced = false;
@@ -1115,13 +1371,13 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1115
1371
  if (!stopped) {
1116
1372
  forced = true;
1117
1373
  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}`);
1374
+ daemonLog(`single_bridge_force_stop_attempt: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd}`);
1119
1375
  try {
1120
1376
  killProcess(lock.pid, "SIGKILL");
1121
1377
  }
1122
1378
  catch (error) {
1123
1379
  if (isAlive(lock.pid)) {
1124
- appendDaemonLog(`single_bridge_sigkill_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1380
+ daemonLog(`single_bridge_sigkill_failed: pid=${lock.pid} error=${truncatePreview(error instanceof Error ? error.message : String(error), 400)}`);
1125
1381
  }
1126
1382
  }
1127
1383
  stopped = await waitForProcessExit({
@@ -1137,12 +1393,12 @@ export async function cleanupSingleBridgeBeforeDaemon(deps = {}) {
1137
1393
  }
1138
1394
  clearBridgeArtifacts();
1139
1395
  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}`);
1396
+ daemonLog(`single_bridge_takeover_complete: pid=${lock.pid} adapter=${lock.adapter} cwd=${lock.cwd} forced=${forced}`);
1141
1397
  return { action: "stopped", lock, forced };
1142
1398
  }
1143
1399
  export async function runDaemon(options) {
1144
1400
  migrateLegacyChannelFiles((message) => log(message));
1145
- await assertNoLiveDaemon();
1401
+ await cleanupDaemonBeforeStart({ cwd: options.cwd });
1146
1402
  await cleanupSingleBridgeBeforeDaemon();
1147
1403
  const reapedPeerPids = await reapPeerBridgeProcesses({
1148
1404
  logger: (message) => appendDaemonLog(message),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "name": "cli-wechat-bridge",
3
- "version": "1.0.5",
2
+ "name": "cli-wechat-bridge",
3
+ "version": "1.0.7",
4
4
  "description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
5
5
  "type": "module",
6
6
  "files": [
@@ -55,8 +55,8 @@
55
55
  "lint:fix": "eslint bin src test --fix",
56
56
  "typecheck:src": "tsc -p tsconfig.typecheck.src.json --noEmit --pretty false",
57
57
  "quality": "npm run lint && npm run typecheck:src && bun test test && npm run build",
58
- "smoke:global": "node scripts/smoke-global-install.mjs",
59
- "publish:dual": "node scripts/publish-dual.mjs",
58
+ "smoke:global": "node scripts/smoke-global-install.mjs",
59
+ "publish:dual": "node scripts/publish-dual.mjs",
60
60
  "setup": "node --no-warnings --experimental-strip-types src/wechat/setup.ts",
61
61
  "start": "node --no-warnings --experimental-strip-types src/wechat/wechat-channel.ts",
62
62
  "check": "node --no-warnings --experimental-strip-types src/wechat/wechat-channel.ts --check",
@@ -80,16 +80,15 @@
80
80
  "test:watch": "bun test --watch test"
81
81
  },
82
82
  "dependencies": {
83
- "@anthropic-ai/sdk": "^0.80.0",
84
83
  "@modelcontextprotocol/sdk": "^1.27.1",
85
84
  "@opencode-ai/sdk": "^1.3.2",
86
- "ajv-formats": "^3.0.1",
87
85
  "node-pty": "^1.1.0",
88
86
  "qrcode-terminal": "^0.12.0"
89
87
  },
90
88
  "devDependencies": {
91
89
  "@eslint/js": "^10.0.1",
92
90
  "@types/bun": "latest",
91
+ "@types/node": "^24.12.4",
93
92
  "eslint": "^10.3.0",
94
93
  "typescript": "^6.0.2",
95
94
  "typescript-eslint": "^8.59.3"
@@ -1,72 +0,0 @@
1
- import crypto from "node:crypto";
2
- import fs from "node:fs";
3
- import { ensureWorkspaceChannelDir, getWorkspaceChannelPaths, } from "../wechat/channel-config.js";
4
- export function buildCodexPanelToken() {
5
- return crypto.randomBytes(18).toString("hex");
6
- }
7
- export function writeCodexPanelEndpoint(endpoint) {
8
- const { endpointFile } = ensureWorkspaceChannelDir(endpoint.cwd);
9
- fs.writeFileSync(endpointFile, JSON.stringify(endpoint, null, 2), "utf8");
10
- }
11
- export function readCodexPanelEndpoint(cwd) {
12
- try {
13
- const { endpointFile } = getWorkspaceChannelPaths(cwd);
14
- if (!fs.existsSync(endpointFile)) {
15
- return null;
16
- }
17
- return JSON.parse(fs.readFileSync(endpointFile, "utf8"));
18
- }
19
- catch {
20
- return null;
21
- }
22
- }
23
- export function clearCodexPanelEndpoint(cwd, instanceId) {
24
- try {
25
- const { endpointFile } = getWorkspaceChannelPaths(cwd);
26
- if (!fs.existsSync(endpointFile)) {
27
- return;
28
- }
29
- if (!instanceId) {
30
- fs.rmSync(endpointFile, { force: true });
31
- return;
32
- }
33
- const endpoint = readCodexPanelEndpoint(cwd);
34
- if (!endpoint || endpoint.instanceId === instanceId) {
35
- fs.rmSync(endpointFile, { force: true });
36
- }
37
- }
38
- catch {
39
- // Best effort cleanup.
40
- }
41
- }
42
- export function sendCodexPanelMessage(socket, message) {
43
- socket.write(`${JSON.stringify(message)}\n`);
44
- }
45
- export function attachCodexPanelMessageListener(socket, onMessage) {
46
- let buffer = "";
47
- const onData = (chunk) => {
48
- buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
49
- while (true) {
50
- const newlineIndex = buffer.indexOf("\n");
51
- if (newlineIndex < 0) {
52
- return;
53
- }
54
- const line = buffer.slice(0, newlineIndex).trim();
55
- buffer = buffer.slice(newlineIndex + 1);
56
- if (!line) {
57
- continue;
58
- }
59
- try {
60
- onMessage(JSON.parse(line));
61
- }
62
- catch {
63
- // Ignore malformed local IPC frames.
64
- }
65
- }
66
- };
67
- socket.setEncoding("utf8");
68
- socket.on("data", onData);
69
- return () => {
70
- socket.off("data", onData);
71
- };
72
- }