comfyui-mcp 0.52.49 → 0.52.51

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.
@@ -38,7 +38,7 @@
38
38
  // thread/start has no instructions field).
39
39
  import { spawn, spawnSync } from "node:child_process";
40
40
  import { createRequire } from "node:module";
41
- import { promises as fsp } from "node:fs";
41
+ import { copyFileSync, existsSync, mkdirSync, promises as fsp } from "node:fs";
42
42
  import os from "node:os";
43
43
  import path from "node:path";
44
44
  import readline from "node:readline";
@@ -71,7 +71,10 @@ const configuredHostSpawnRetryMs = Number(process.env.COMFYUI_MCP_CODEX_HOST_SPA
71
71
  const CODEX_HOST_SPAWN_RETRY_MS = Number.isFinite(configuredHostSpawnRetryMs) && configuredHostSpawnRetryMs >= 0
72
72
  ? configuredHostSpawnRetryMs
73
73
  : 150;
74
- const CODEX_HOST_SPAWN_RETRIES = 1;
74
+ // Two attempts: a stale WinGet path (os error 3) can be followed immediately
75
+ // by a sharing-violation (os error 32) while the replacement image is copied
76
+ // or scanned (#2045). A single retry died on that second failure.
77
+ const CODEX_HOST_SPAWN_RETRIES = 2;
75
78
  async function settlesWithin(promise, timeoutMs) {
76
79
  let timer;
77
80
  try {
@@ -592,6 +595,139 @@ export function isWindowsCodeModeHostSharingViolation(message) {
592
595
  m.includes("utilise par un autre processus") ||
593
596
  m.includes("utilizado por otro proceso"));
594
597
  }
598
+ /**
599
+ * #2045 — Windows ERROR_PATH_NOT_FOUND (os error 3) when WinGet deletes a
600
+ * versioned Node package dir (`node-vX.Y.Z-win-x64\\...`) out from under a live
601
+ * app-server. Distinct from os error 2 (file not found in an existing dir),
602
+ * which stays terminal (#1929).
603
+ */
604
+ export function isWindowsCodeModeHostPathNotFound(message) {
605
+ if (!message)
606
+ return false;
607
+ const m = message.toLowerCase();
608
+ const isHost = m.includes("code-mode host") || m.includes("codex-code-mode-host");
609
+ if (!isHost)
610
+ return false;
611
+ if (/\bos error 2\b/.test(m))
612
+ return false;
613
+ return (/\bos error 3\b/.test(m) ||
614
+ m.includes("cannot find the path specified") ||
615
+ m.includes("chemin d'acces specifie est introuvable") ||
616
+ m.includes("chemin d'accès spécifié est introuvable") ||
617
+ m.includes("no puede encontrar la ruta"));
618
+ }
619
+ export function isWindowsCodeModeHostSpawnRetryable(message) {
620
+ return isWindowsCodeModeHostSharingViolation(message) || isWindowsCodeModeHostPathNotFound(message);
621
+ }
622
+ const CODEX_PLATFORM_PACKAGE = {
623
+ "win32-x64": { pkg: "@openai/codex-win32-x64", triple: "x86_64-pc-windows-msvc" },
624
+ "win32-arm64": { pkg: "@openai/codex-win32-arm64", triple: "aarch64-pc-windows-msvc" },
625
+ "darwin-x64": { pkg: "@openai/codex-darwin-x64", triple: "x86_64-apple-darwin" },
626
+ "darwin-arm64": { pkg: "@openai/codex-darwin-arm64", triple: "aarch64-apple-darwin" },
627
+ "linux-x64": { pkg: "@openai/codex-linux-x64", triple: "x86_64-unknown-linux-musl" },
628
+ "linux-arm64": { pkg: "@openai/codex-linux-arm64", triple: "aarch64-unknown-linux-musl" },
629
+ };
630
+ function vendorHostName(platform = process.platform) {
631
+ return platform === "win32" ? "codex-code-mode-host.exe" : "codex-code-mode-host";
632
+ }
633
+ function vendorExeName(platform = process.platform) {
634
+ return platform === "win32" ? "codex.exe" : "codex";
635
+ }
636
+ function platformOfHostPath(hostPath) {
637
+ return hostPath.toLowerCase().endsWith(".exe") ? "win32" : process.platform;
638
+ }
639
+ /** WinGet's versioned Node dir and nvm-windows layouts vanish on an update. */
640
+ export function isVolatileCodexPackagePath(p) {
641
+ const n = p.replace(/\\/g, "/").toLowerCase();
642
+ return (n.includes("/winget/packages/") ||
643
+ /\/node-v\d+\.\d+\.\d+-win/.test(n) ||
644
+ /\/nvm\/v?\d/.test(n) ||
645
+ n.includes("/.nvm/"));
646
+ }
647
+ export function codeModeHostPathFromError(message) {
648
+ const m = message.match(/code-mode host\s+(.+?):\s/i);
649
+ const p = m?.[1]?.trim();
650
+ return p || null;
651
+ }
652
+ export function resolveCodexCodeModeHostPath(opts = {}) {
653
+ const platform = opts.platform ?? process.platform;
654
+ const arch = opts.arch ?? process.arch;
655
+ const spec = CODEX_PLATFORM_PACKAGE[`${platform}-${arch}`];
656
+ if (!spec)
657
+ return null;
658
+ const exists = opts.existsSync ?? existsSync;
659
+ const resolve = opts.resolve ?? ((id) => createRequire(import.meta.url).resolve(id));
660
+ try {
661
+ const pkgJson = resolve(`${spec.pkg}/package.json`);
662
+ const host = path.join(path.dirname(pkgJson), "vendor", spec.triple, "bin", vendorHostName(platform));
663
+ return exists(host) ? host : null;
664
+ }
665
+ catch {
666
+ return null;
667
+ }
668
+ }
669
+ export function stableCodexVendorDir(home = os.homedir()) {
670
+ const override = process.env.COMFYUI_MCP_CODEX_VENDOR_DIR?.trim();
671
+ return override || path.join(home, ".comfyui-mcp", "codex-vendor");
672
+ }
673
+ /**
674
+ * Copy the vendor `codex` + `codex-code-mode-host` pair out of a volatile
675
+ * WinGet/nvm package dir into a stable location. If the source is already gone
676
+ * or the dest is locked (os error 32), keep a usable dest copy.
677
+ */
678
+ export function ensureStableCodexVendor(srcHost, opts = {}) {
679
+ const exists = opts.existsSync ?? existsSync;
680
+ const mkdir = opts.mkdirSync ?? mkdirSync;
681
+ const copy = opts.copyFileSync ?? copyFileSync;
682
+ const destDir = opts.destDir ?? stableCodexVendorDir();
683
+ const plat = platformOfHostPath(srcHost);
684
+ const destHost = path.join(destDir, vendorHostName(plat));
685
+ const destExe = path.join(destDir, vendorExeName(plat));
686
+ const srcExe = path.join(path.dirname(srcHost), vendorExeName(plat));
687
+ const destUsable = exists(destHost) && exists(destExe);
688
+ if (!exists(srcHost) || !exists(srcExe))
689
+ return destUsable ? destHost : null;
690
+ try {
691
+ mkdir(destDir, { recursive: true });
692
+ copy(srcExe, destExe);
693
+ copy(srcHost, destHost);
694
+ return exists(destHost) && exists(destExe) ? destHost : destUsable ? destHost : null;
695
+ }
696
+ catch {
697
+ return destUsable ? destHost : null;
698
+ }
699
+ }
700
+ export function resolveCodexLaunchBin(opts = {}) {
701
+ const exists = opts.existsSync ?? existsSync;
702
+ const host = (opts.resolveHost ?? (() => resolveCodexCodeModeHostPath({ existsSync: exists })))();
703
+ if (host && isVolatileCodexPackagePath(host)) {
704
+ const ensure = opts.ensureStable ?? ((src) => ensureStableCodexVendor(src));
705
+ const stableHost = ensure(host);
706
+ if (stableHost) {
707
+ const exe = path.join(path.dirname(stableHost), vendorExeName(platformOfHostPath(stableHost)));
708
+ if (exists(exe))
709
+ return exe;
710
+ }
711
+ }
712
+ try {
713
+ const js = opts.resolveJs?.() ??
714
+ (() => {
715
+ const require = createRequire(import.meta.url);
716
+ const pkgPath = require.resolve("@openai/codex/package.json");
717
+ const pkg = require("@openai/codex/package.json");
718
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.codex;
719
+ if (!binRel)
720
+ return null;
721
+ return path.join(pkgPath.replace(/[\\/]package\.json$/, ""), binRel.replace(/^\.[\\/]/, ""));
722
+ })();
723
+ if (js && exists(js))
724
+ return js;
725
+ }
726
+ catch {
727
+ // bundled package not installed — fall through to PATH.
728
+ }
729
+ return "codex";
730
+ }
595
731
  /**
596
732
  * The Codex app-server adapter. One instance per PanelAgent; it holds the live
597
733
  * app-server client + current thread/turn ids and re-opens on each `run()`.
@@ -676,31 +812,25 @@ export class CodexBackend {
676
812
  return closing;
677
813
  }
678
814
  /**
679
- * Resolve the codex binary: prefer the bundled `@openai/codex` launcher (via
680
- * require.resolve of its package bin) so no separate install is needed; fall
681
- * back to a `codex` on PATH. Throws a clear message if neither is available.
815
+ * Resolve the codex binary: prefer a stable copy of the vendor exe when the
816
+ * active package lives under a WinGet/nvm versioned dir (#2045), else the
817
+ * bundled `@openai/codex` launcher, else a `codex` on PATH. A cached path
818
+ * that no longer exists is dropped so the next spawn cannot keep a stale
819
+ * WinGet Node folder.
682
820
  */
683
821
  resolveBin() {
684
- if (this.bin)
685
- return this.bin;
686
- try {
687
- const require = createRequire(import.meta.url);
688
- // The package exposes bin/codex.js; resolve its package.json then derive the
689
- // bin path relative to the package dir (works regardless of OS separators).
690
- const pkgPath = require.resolve("@openai/codex/package.json");
691
- const pkg = require("@openai/codex/package.json");
692
- const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.codex;
693
- if (binRel) {
694
- const sep = pkgPath.includes("\\") ? "\\" : "/";
695
- const pkgDir = pkgPath.replace(/[\\/]package\.json$/, "");
696
- this.bin = `${pkgDir}${sep}${binRel.replace(/^\.[\\/]/, "")}`;
822
+ if (this.bin && this.bin !== "codex") {
823
+ if (!existsSync(this.bin))
824
+ this.bin = null;
825
+ else if (!/\.(c|m)?js$/i.test(this.bin)) {
826
+ const host = path.join(path.dirname(this.bin), vendorHostName(platformOfHostPath(this.bin)));
827
+ if (!existsSync(host))
828
+ this.bin = null;
697
829
  }
698
830
  }
699
- catch {
700
- // bundled package not installed — fall through to PATH.
701
- }
702
- if (!this.bin)
703
- this.bin = "codex"; // PATH fallback (a `codex` on PATH)
831
+ if (this.bin)
832
+ return this.bin;
833
+ this.bin = resolveCodexLaunchBin();
704
834
  return this.bin;
705
835
  }
706
836
  /**
@@ -967,11 +1097,16 @@ export class CodexBackend {
967
1097
  // signal — every raw app-server notification for the active turn re-arms
968
1098
  // PanelAgent's idle watchdog so a long, quiet generation doesn't falsely trip.
969
1099
  let turnSeq = 0;
1100
+ let liveClient = client;
970
1101
  for await (const turn of opts.channel) {
971
- yield* stampTurn(this.runTurn(client, turn, opts.onActivity), ++turnSeq);
972
- // A timed-out interrupt detaches this client. End the old run before it can
973
- // consume another queued turn; PanelAgent will start a fresh app-server.
974
- if (this.client !== client)
1102
+ // A timed-out interrupt nulls this.client stop draining so PanelAgent
1103
+ // can start a fresh app-server. A stale-host recycle (#2045) replaces
1104
+ // the client in place; keep going on the replacement.
1105
+ if (!this.client)
1106
+ return;
1107
+ liveClient = this.client;
1108
+ yield* stampTurn(this.runTurn(liveClient, turn, opts.onActivity), ++turnSeq);
1109
+ if (!this.client)
975
1110
  return;
976
1111
  }
977
1112
  }
@@ -982,6 +1117,7 @@ export class CodexBackend {
982
1117
  * so a stale/interleaved same-thread notification can't complete the wrong turn. */
983
1118
  async *runTurn(client, turn, onActivity) {
984
1119
  const threadId = this.threadId;
1120
+ let liveClient = client;
985
1121
  // Event queue bridging the push-based notification handler to this pull-based
986
1122
  // async generator. The handler enqueues normalized AgentEvents; we drain.
987
1123
  const queue = [];
@@ -1117,16 +1253,38 @@ export class CodexBackend {
1117
1253
  // Assigned after turn input is built — the retry timer only fires after a
1118
1254
  // turn/start has already run, so this is never invoked while still a no-op.
1119
1255
  let issueTurnStart = () => { };
1256
+ let handler = () => { };
1257
+ let hostRecycleInFlight = false;
1258
+ const watchExit = (watched) => {
1259
+ void watched.exitPromise.then(() => {
1260
+ if (done)
1261
+ return;
1262
+ if (watched !== liveClient)
1263
+ return;
1264
+ // Closing the old app-server is the point of a stale-host recycle;
1265
+ // its exit must not finish the replacement turn (#2045).
1266
+ if (hostRecycleInFlight)
1267
+ return;
1268
+ emitTerminalError(watched.exitError ? msgOf(watched.exitError) : "codex app-server connection closed.");
1269
+ });
1270
+ };
1120
1271
  const tryRetryCodeModeHostSpawn = (message) => {
1121
1272
  if (interrupted || finishedResult || retryPending)
1122
1273
  return false;
1123
1274
  if (hostSpawnRetries >= CODEX_HOST_SPAWN_RETRIES)
1124
1275
  return false;
1125
- if (!isWindowsCodeModeHostSharingViolation(message))
1276
+ if (!isWindowsCodeModeHostSpawnRetryable(message))
1126
1277
  return false;
1127
1278
  hostSpawnRetries += 1;
1128
1279
  retryPending = true;
1129
- logger.warn(`[codex-backend] code-mode host spawn hit a Windows sharing violation; retrying once after ${CODEX_HOST_SPAWN_RETRY_MS}ms (#1929)`);
1280
+ const reportedHost = codeModeHostPathFromError(message);
1281
+ const recycle = isWindowsCodeModeHostPathNotFound(message) &&
1282
+ typeof this.bin === "string" &&
1283
+ this.bin !== "codex" &&
1284
+ (!reportedHost || !existsSync(reportedHost) || isVolatileCodexPackagePath(reportedHost));
1285
+ logger.warn(recycle
1286
+ ? `[codex-backend] code-mode host spawn hit a stale path; recycling the app-server then retrying after ${CODEX_HOST_SPAWN_RETRY_MS}ms (#2045)`
1287
+ : `[codex-backend] code-mode host spawn failed; retrying after ${CODEX_HOST_SPAWN_RETRY_MS}ms (#1929/#2045)`);
1130
1288
  try {
1131
1289
  onActivity?.();
1132
1290
  }
@@ -1135,13 +1293,63 @@ export class CodexBackend {
1135
1293
  }
1136
1294
  retryTimer = setTimeout(() => {
1137
1295
  retryTimer = undefined;
1138
- if (interrupted || finishedResult || this.disposed) {
1139
- retryPending = false;
1140
- return;
1141
- }
1142
- turnIdKnown = false;
1143
- buffered.length = 0;
1144
- issueTurnStart();
1296
+ void (async () => {
1297
+ if (interrupted || finishedResult || this.disposed) {
1298
+ retryPending = false;
1299
+ return;
1300
+ }
1301
+ if (recycle) {
1302
+ hostRecycleInFlight = true;
1303
+ this.bin = null;
1304
+ if (this.client === liveClient)
1305
+ this.client = null;
1306
+ await this.beginClientClose(liveClient, "stale code-mode host recycle teardown failed");
1307
+ if (interrupted || finishedResult || this.disposed) {
1308
+ hostRecycleInFlight = false;
1309
+ retryPending = false;
1310
+ return;
1311
+ }
1312
+ try {
1313
+ await this.prepare();
1314
+ }
1315
+ catch (err) {
1316
+ hostRecycleInFlight = false;
1317
+ retryPending = false;
1318
+ emitTerminalError(msgOf(err));
1319
+ return;
1320
+ }
1321
+ const next = this.client;
1322
+ if (!next) {
1323
+ hostRecycleInFlight = false;
1324
+ retryPending = false;
1325
+ emitTerminalError("codex app-server not initialized");
1326
+ return;
1327
+ }
1328
+ liveClient = next;
1329
+ hostRecycleInFlight = false;
1330
+ liveClient.notificationHandler = handler;
1331
+ watchExit(liveClient);
1332
+ try {
1333
+ await liveClient.request("thread/resume", {
1334
+ threadId,
1335
+ cwd: this.deps.cwd ?? process.cwd(),
1336
+ model: this.resolveTurnModel(),
1337
+ approvalPolicy: "never",
1338
+ sandbox: this.sandbox,
1339
+ });
1340
+ }
1341
+ catch {
1342
+ // Thread may not survive a process recycle; still retry the turn.
1343
+ }
1344
+ if (interrupted || finishedResult || this.disposed) {
1345
+ retryPending = false;
1346
+ return;
1347
+ }
1348
+ }
1349
+ turnIdKnown = false;
1350
+ buffered.length = 0;
1351
+ issueTurnStart();
1352
+ })();
1145
1353
  }, CODEX_HOST_SPAWN_RETRY_MS);
1146
1354
  retryTimer.unref?.();
1147
1355
  return true;
@@ -1254,8 +1462,8 @@ export class CodexBackend {
1254
1462
  if (params.willRetry === true)
1255
1463
  break;
1256
1464
  const message = e.message ?? "Codex error";
1257
- // #1929 — a Windows sharing-violation on the bundled code-mode host is
1258
- // the same transient the user already proved by retrying the call.
1465
+ // #1929 / #2045 — a Windows sharing-violation or stale WinGet path on
1466
+ // the bundled code-mode host is the same transient /restart recovered.
1259
1467
  if (tryRetryCodeModeHostSpawn(message))
1260
1468
  break;
1261
1469
  // A non-retrying `error` ends the turn: emit it AND finish, so a turn that
@@ -1277,8 +1485,8 @@ export class CodexBackend {
1277
1485
  break;
1278
1486
  }
1279
1487
  };
1280
- const prev = client.notificationHandler;
1281
- client.notificationHandler = (msg) => {
1488
+ const prev = liveClient.notificationHandler;
1489
+ handler = (msg) => {
1282
1490
  // Until the turnId is known, buffer everything — we can't yet tell which
1283
1491
  // turn a notification belongs to, so we can't yet decide whether it counts
1284
1492
  // as liveness either. Replayed (and bumped) after turn/start resolves, so
@@ -1294,21 +1502,16 @@ export class CodexBackend {
1294
1502
  }
1295
1503
  apply(msg);
1296
1504
  };
1505
+ liveClient.notificationHandler = handler;
1297
1506
  // Watch for the app-server child dying mid-turn: reject/finish the turn so the
1298
1507
  // local drain below is woken instead of waiting forever (P0-2). Crucially this
1299
1508
  // ALWAYS routes through emitTerminalError so even a child that dies while
1300
1509
  // turn/start is still pending leaves the turn with a terminal `result` — the
1301
1510
  // turn-start .catch() running first (rejecting the pending request) no longer
1302
1511
  // lets this watcher finish() without a result and hang the gate (P0-B).
1303
- void client.exitPromise.then(() => {
1304
- if (done)
1305
- return;
1306
- // emitTerminalError is a no-op if a result already fired, so it's safe to
1307
- // call alongside the turn-start .catch() (which may run first when the child
1308
- // dies while turn/start is pending) — it guarantees the turn still ends with
1309
- // exactly one terminal result and never hangs the gate (P0-B).
1310
- emitTerminalError(client.exitError ? msgOf(client.exitError) : "codex app-server connection closed.");
1311
- });
1512
+ // A stale-host recycle (#2045) replaces liveClient; the old child's exit
1513
+ // must not finish the replacement turn.
1514
+ watchExit(liveClient);
1312
1515
  // FIRST-TURN PERSONA: the app-server has no thread-level instructions field,
1313
1516
  // so the panel system prompt is prepended to the first turn's input as a
1314
1517
  // clearly-marked system/context preamble (later turns send plain text).
@@ -1372,7 +1575,7 @@ export class CodexBackend {
1372
1575
  issueTurnStart = () => {
1373
1576
  // turn/start delivers the user text plus any resolved image input items.
1374
1577
  const turnModel = this.resolveTurnModel();
1375
- client
1578
+ liveClient
1376
1579
  .request("turn/start", {
1377
1580
  threadId,
1378
1581
  input: turnInput,
@@ -1461,8 +1664,9 @@ export class CodexBackend {
1461
1664
  this.abortActiveTurn = null;
1462
1665
  // Restore the prior handler ONLY if it's still ours (close() may have nulled
1463
1666
  // it during shutdown — don't resurrect a stale handler onto a dead client).
1464
- if (client.notificationHandler && !client.exitError)
1465
- client.notificationHandler = prev ?? null;
1667
+ if (liveClient.notificationHandler && !liveClient.exitError) {
1668
+ liveClient.notificationHandler = prev ?? null;
1669
+ }
1466
1670
  this.turnId = null;
1467
1671
  // #1152 — the app-server does NOT necessarily read a localImage at
1468
1672
  // turn/start, which is what this used to assume ("the bytes were read at