comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
455 lines • 20.9 kB
JavaScript
// Orchestrator self-updater + self-restarter.
//
// self-update.ts can refresh the package ON DISK, but a long-lived orchestrator
// process keeps running the OLD code until someone restarts it — the exact trap
// that produced "regression" reports from a days-old process serving a freshly
// rebuilt dist. This engine closes the loop for the PANEL ORCHESTRATOR mode
// (`connect` / `--panel-orchestrator`) only:
//
// - PUBLISHED installs (global / local / npx): periodically re-check the npm
// registry. global/local self-update on disk (self-update.ts) and then
// restart into the new code; an UNPINNED npx launch respawns pinned to the
// new version (npx fetches it on launch). An exact npx version pin is a
// stability boundary and is never replaced by this process.
// - DEV installs (npm link / checkout): NEVER touch the disk — instead watch
// the running entry script's mtime and restart when a rebuild lands, so
// `npm run build` is all a developer needs.
//
// Restart discipline: a restart may NEVER eat a reply. We wait until every
// agent is idle with nothing queued (plus any extra caller gate — held
// messages, a render in flight), announce to the panel, spawn a DETACHED
// replacement with the same command line, drop any panel-op.lock we still
// hold (#1953 — the successor otherwise waits forever behind a dead pid),
// then run the caller's clean teardown and exit. The replacement rides the
// bridge's existing bind-retry while our port frees; agent sessions resume
// from the durable session store; the panel reconnects on its own.
//
// Loop safety: restarts are strictly CHANGE-driven (a version newer than the
// one running, or an entry mtime newer than the one we booted with), so a
// freshly restarted process observes "no change" and settles. A minimum-uptime
// guard adds defense in depth. NEVER used in MCP stdio mode — the MCP client
// owns that lifecycle.
//
// Opt-outs (default ON):
// COMFYUI_MCP_AUTO_UPDATE_DISABLE=1 master off (checks + restarts;
// COMFYUI_MCP_AUTOUPDATE=0 still works)
// COMFYUI_MCP_AUTORESTART=0 keep checking/notifying, never restart
// COMFYUI_MCP_UPDATE_CHECK_MS registry re-check period (default 1h)
import { spawn } from "node:child_process";
import { readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { PACKAGE_NAME, checkAndSelfUpdate, detectInstallMode, getLatestPublishedVersion, isAutoUpdateDisabled, isNewer, } from "./self-update.js";
import { releaseOwnedPanelLock } from "./panel-pin-guard.js";
import { logger } from "../utils/logger.js";
import { deferredAutoUpdateNote } from "./auto-update-gate.js";
import { pinBoundBridgePort, resolveBridgePort } from "./bridge-ports.js";
/** Registry re-check period. An hour keeps update latency low at ~24 tiny
* registry GETs a day; override for tuning/tests. */
const DEFAULT_UPDATE_CHECK_MS = 60 * 60 * 1000;
/** Dev rebuild watch period — a cheap statSync, so it can be tight. */
const DEV_WATCH_MS = 10_000;
/** A rebuild writes many files; require the entry mtime to hold still for one
* extra poll so we don't restart into a half-written dist. */
const DEV_SETTLE_POLLS = 2;
/** How often to re-test the idle gate while a restart is pending. */
const IDLE_POLL_MS = 5_000;
/** Defense-in-depth against restart churn: never restart a process younger
* than this (change-driven triggers make a true loop impossible anyway). */
const MIN_UPTIME_MS = 2 * 60 * 1000;
/** npm's exec runner passes the package spec that selected this process. */
const NPX_PACKAGE_SPEC_ENV = "npm_config_package";
/** An exact version is a pin; tags and ranges retain the normal update policy. */
const EXACT_VERSION_SPEC = /^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
function envValue(env, name) {
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name);
return key === undefined ? undefined : env[key];
}
function exactVersionFromSpec(raw) {
if (!raw)
return undefined;
const prefix = `${PACKAGE_NAME}@`;
if (!raw.toLowerCase().startsWith(prefix))
return undefined;
const requested = raw.slice(prefix.length).trim();
return EXACT_VERSION_SPEC.test(requested) ? requested : undefined;
}
/** Read the launch spec npm records beside an npx execution cache. */
function npxCachePackageSpec(packageDir) {
if (!packageDir)
return undefined;
try {
// npx's package root is `_npx/<hash>`, two levels above the resolved
// package (`_npx/<hash>/node_modules/comfyui-mcp`). Its `_npx.packages`
// entry preserves an exact positional spec even though the child process
// itself receives only the resolved package's argv.
const cacheRoot = resolve(packageDir, "..", "..");
const metadata = JSON.parse(readFileSync(join(cacheRoot, "package.json"), "utf8"));
const packages = metadata._npx?.packages;
if (!Array.isArray(packages))
return undefined;
return packages.find((candidate) => typeof candidate === "string" && candidate.toLowerCase().startsWith(`${PACKAGE_NAME}@`));
}
catch {
return undefined;
}
}
/** Parse a raw npx command line when one is available to the caller. */
function npxArgvPackageSpec(argv) {
const npxIndex = argv.findIndex((arg) => /(?:^|[\\/])npx(?:\.cmd)?$/i.test(arg));
if (npxIndex < 0)
return undefined;
for (const arg of argv.slice(npxIndex + 1)) {
if (arg === "--" || arg === "-y" || arg === "--yes")
continue;
if (arg.startsWith("-"))
continue;
if (arg.toLowerCase().startsWith(`${PACKAGE_NAME}@`))
return arg;
if (arg.toLowerCase() === PACKAGE_NAME)
return undefined;
break;
}
return undefined;
}
/**
* Return the exact version requested by the npx invocation, if one is known.
*
* Positional npx launches do not pass their package spec in the child
* environment or argv. npm does preserve it in the npx cache-root manifest;
* that is the production source of truth. Environment/raw-command fallbacks
* keep this helper useful for alternate npm runners and direct tests. Treat
* only an exact semver as a pin: a bare package, `@latest`, and ranges/tags
* retain the existing auto-update behavior.
*/
export function npxVersionPin(env = process.env, packageDir, argv = process.argv) {
return (exactVersionFromSpec(envValue(env, NPX_PACKAGE_SPEC_ENV)?.trim()) ??
exactVersionFromSpec(npxArgvPackageSpec(argv)) ??
exactVersionFromSpec(npxCachePackageSpec(packageDir)));
}
function defaultEntryMtime() {
try {
const entry = process.argv[1];
if (!entry)
return undefined;
return statSync(entry).mtimeMs;
}
catch {
return undefined;
}
}
/**
* Env handed to a self-restart child. Pins `COMFYUI_MCP_BRIDGE_PORT` to the
* port the parent is actually bound to so a compiled-default change cannot
* silently move a live session (#2030).
*/
export function selfRestartChildEnv(parentEnv = process.env, boundPort = resolveBridgePort(parentEnv)) {
const gen = Number(parentEnv.COMFYUI_MCP_RESTART_GEN ?? "0") + 1;
return pinBoundBridgePort(boundPort, {
...parentEnv,
COMFYUI_MCP_RESTART_GEN: String(gen),
});
}
/**
* Spawn the replacement orchestrator, detached, sharing our stdio so it keeps
* logging into the same terminal. Same argv for dev/global/local (the on-disk
* code is already new); npx respawns via `npx -y comfyui-mcp@<version>` because
* re-execing the cached entry would just re-run the OLD cached code. Args after
* the version are our own argv tail — typed by the user at launch, not remote
* input.
*
* The bound port is a distinct argument to `selfRestartChildEnv` — not re-derived
* from the parent env inside that helper — so a test that passes 9180 against an
* unpinned parent goes red if this call drops `boundPort`.
*/
export function defaultSpawnReplacement(opts = {}) {
try {
const parentEnv = opts.env ?? process.env;
const boundPort = opts.boundPort ?? resolveBridgePort(parentEnv);
const env = selfRestartChildEnv(parentEnv, boundPort);
const spawnFn = opts.spawn ?? spawn;
const isWin = process.platform === "win32";
const child = opts.npxVersion
? spawnFn(isWin ? "npx.cmd" : "npx", ["-y", `${PACKAGE_NAME}@${opts.npxVersion}`, ...process.argv.slice(2)], { detached: true, stdio: "inherit", env, shell: isWin })
: spawnFn(process.execPath, process.argv.slice(1), {
detached: true,
stdio: "inherit",
env,
});
if (!child.pid)
return false;
child.unref();
return true;
}
catch (err) {
logger.error(`[self-restart] spawn failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
export const defaultSelfRestartDeps = {
env: () => process.env,
detectInstall: () => detectInstallMode(),
checkAndSelfUpdate,
latestVersion: () => getLatestPublishedVersion(),
entryMtime: defaultEntryMtime,
allIdle: () => true,
applyAllowed: () => true,
announce: () => { },
teardown: async () => { },
spawnReplacement: defaultSpawnReplacement,
releasePanelLock: () => {
releaseOwnedPanelLock();
},
exit: (code) => process.exit(code),
uptimeMs: () => process.uptime() * 1000,
};
function isRestartDisabled(env) {
const v = (env.COMFYUI_MCP_AUTORESTART ?? "").trim().toLowerCase();
return v === "0" || v === "false" || v === "no" || v === "off";
}
/**
* Can this process restart itself to apply an update? Both switches, in one
* place (#875): the master off (COMFYUI_MCP_AUTO_UPDATE_DISABLE) and the
* restart-only opt-out (COMFYUI_MCP_AUTORESTART=0).
*
* Exported so the phone-pairing disclosure can say whether a rotating pair URL
* is likely to be invalidated on its own, without re-deriving the rule from the
* env vars and drifting from what the restarter actually does.
*/
export function canSelfRestart(env = process.env) {
return !isAutoUpdateDisabled(env) && !isRestartDisabled(env);
}
export class SelfRestarter {
deps;
info;
timers = [];
/** Baseline entry mtime captured at start — a REBUILD is mtime > this. */
bootMtime;
/** Last mtime seen by the dev watcher + how many polls it has held still. */
lastSeenMtime;
stablePolls = 0;
/** Armed restart (reason + optional npx target); only one at a time. */
pending = null;
restarting = false;
/** Versions already announced when restart is disabled — once each. */
announced = new Set();
/** Exact npx launch pins are intentional and must not trigger a replacement. */
npxPin;
/** apply_updates_now: one tick (and its armed restart) may ignore the gate. */
forceApply = false;
stopped = false;
constructor(deps = {}) {
this.deps = { ...defaultSelfRestartDeps, ...deps };
this.info = this.deps.detectInstall();
this.npxPin =
this.info.mode === "npx"
? npxVersionPin(this.deps.env(), this.info.packageDir, this.deps.argv?.() ?? process.argv)
: undefined;
}
/** Wire the periodic checks. No-op when the master switch is off. */
start() {
const env = this.deps.env();
if (isAutoUpdateDisabled(env)) {
logger.info("[self-restart] disabled via COMFYUI_MCP_AUTO_UPDATE_DISABLE/COMFYUI_MCP_AUTOUPDATE");
return;
}
const gen = Number(env.COMFYUI_MCP_RESTART_GEN ?? "0");
if (gen > 0) {
logger.info(`[self-restart] running v${this.info.currentVersion ?? "?"} after self-restart (gen ${gen}, ${this.info.mode})`);
}
if (this.npxPin) {
logger.info(`[self-restart] npx launch is pinned to ${this.npxPin} — automatic version replacement is disabled`);
}
if (this.info.isDevLink || this.info.mode === "linked") {
// DEV: restart-on-rebuild. Never mutates the checkout.
this.bootMtime = this.deps.entryMtime();
const t = setInterval(() => this.devTick(), DEV_WATCH_MS);
t.unref?.();
this.timers.push(t);
logger.info("[self-restart] dev install — watching the built entry for rebuilds (restart-on-build)");
}
else if (this.info.mode !== "unknown") {
const period = Number(env.COMFYUI_MCP_UPDATE_CHECK_MS) || DEFAULT_UPDATE_CHECK_MS;
const t = setInterval(() => void this.updateTick(), period);
t.unref?.();
this.timers.push(t);
logger.info(`[self-restart] ${this.info.mode} install — checking npm for updates every ${Math.round(period / 60000)}m`);
}
else {
logger.info("[self-restart] install mode unknown — periodic update checks disabled (manual updates only)");
return;
}
const idleTimer = setInterval(() => this.tryRestart(), IDLE_POLL_MS);
idleTimer.unref?.();
this.timers.push(idleTimer);
}
stop() {
this.stopped = true;
for (const t of this.timers)
clearInterval(t);
this.timers = [];
}
/** DEV: arm a restart once a rebuild lands and the dist has settled. */
devTick() {
if (this.stopped || this.pending || this.restarting)
return;
const m = this.deps.entryMtime();
if (m === undefined || this.bootMtime === undefined || m <= this.bootMtime)
return;
if (m !== this.lastSeenMtime) {
// Fresh write — start (or restart) the settle counter.
this.lastSeenMtime = m;
this.stablePolls = 1;
return;
}
this.stablePolls += 1;
if (this.stablePolls < DEV_SETTLE_POLLS)
return;
this.arm({ reason: "a new dev build landed" });
}
/**
* Re-evaluate after the pair-time toggle flips. An armed restart that was
* waiting on the tunnel gate can fire; otherwise a fresh check runs so an
* update found while deferred is not stuck until the next hourly tick.
*/
requestApplyPolicyRefresh() {
this.tryRestart();
if (!this.pending && !this.restarting && !this.stopped) {
void this.updateTick();
}
}
/** PUBLISHED: CHECK the registry; APPLY only when the transport gate allows. */
async updateTick(opts = {}) {
if (this.stopped || this.pending || this.restarting)
return;
if (opts.forceApply)
this.forceApply = true;
const apply = this.forceApply || this.deps.applyAllowed();
try {
if (this.deps.panelTick) {
const panelRes = await this.deps.panelTick({ apply });
if (panelRes?.action === "deferred" && panelRes.from && panelRes.to) {
this.noteDeferred("panel", panelRes.from, panelRes.to);
}
else if (panelRes?.action === "updated" && panelRes.note) {
this.deps.announce(`⬆️ ${panelRes.note}`);
}
}
if (!apply) {
// CHECK-ONLY. Must not call checkAndSelfUpdate — that mutates on disk.
const latest = await this.deps.latestVersion();
const current = this.info.currentVersion;
if (latest && current && isNewer(latest, current)) {
this.noteDeferred("orchestrator", current, latest);
}
this.forceApply = false;
return;
}
if (this.info.mode === "npx") {
// npx can't be updated on disk — an unpinned launch respawns pinned to
// the new version. An exact package spec is an explicit stability
// boundary; never turn `@0.52.66` into `@0.52.67` from inside the
// healthy process.
if (this.npxPin)
return;
const latest = await this.deps.latestVersion();
const current = this.info.currentVersion;
if (!latest || !current || !isNewer(latest, current)) {
if (!this.pending)
this.forceApply = false;
return;
}
this.arm({ reason: `updating ${current} → ${latest}`, npxVersion: latest });
return;
}
// global/local: self-update.ts replaces the package on disk; "updated"
// means the NEXT process will run the new code.
const res = await this.deps.checkAndSelfUpdate();
if (res.action === "updated") {
this.arm({ reason: `updated ${res.from ?? "?"} → ${res.to ?? "?"}` });
}
else if (!this.pending) {
this.forceApply = false;
}
}
catch (err) {
if (!this.pending)
this.forceApply = false;
logger.debug(`[self-restart] update check failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
noteDeferred(component, from, to) {
const key = `deferred:${component}:${from}:${to}`;
if (this.announced.has(key))
return;
this.announced.add(key);
const note = deferredAutoUpdateNote({ component, from, to });
logger.info(`[self-restart] ${note}`);
this.deps.announce(note);
}
/** Record the restart intent. With AUTORESTART off, notify once instead. */
arm(next) {
if (isRestartDisabled(this.deps.env())) {
const key = next.npxVersion ?? next.reason;
if (!this.announced.has(key)) {
this.announced.add(key);
const note = `⬆️ ${PACKAGE_NAME}: ${next.reason} — auto-restart is off (COMFYUI_MCP_AUTORESTART=0); restart the orchestrator to load it.`;
logger.info(`[self-restart] ${note}`);
this.deps.announce(note);
}
return;
}
logger.info(`[self-restart] ${next.reason} — restarting when every agent is idle`);
this.pending = next;
this.tryRestart();
}
/** Fire the armed restart once the process is old enough and ALL idle. */
tryRestart() {
if (!this.pending || this.restarting || this.stopped)
return;
if (this.deps.uptimeMs() < MIN_UPTIME_MS)
return;
// #1963 — a restart armed at the desk must still refuse to fire after a
// phone pairs over a quick-tunnel. Pending stays set; the next policy
// refresh or disconnect (pairTunnel cleared) lets it through.
if (!this.forceApply && !this.deps.applyAllowed())
return;
if (!this.deps.allIdle())
return;
this.restarting = true;
const { reason, npxVersion } = this.pending;
this.pending = null;
void this.doRestart(reason, npxVersion);
}
async doRestart(reason, npxVersion) {
logger.info(`[self-restart] restarting now (${reason})`);
this.deps.announce(`⬆️ ${reason} — restarting the agent orchestrator. Back in a few seconds; your session resumes automatically.`);
// Spawn FIRST so a spawn failure aborts the restart with the old process
// fully intact. The child's bridge bind-retry outlasts our teardown.
if (!this.deps.spawnReplacement({ npxVersion })) {
logger.error("[self-restart] could not spawn the replacement — staying on the current process");
this.deps.announce("⚠️ Restart failed to launch a replacement — still running the current version.");
this.restarting = false;
this.forceApply = false;
return;
}
// The successor is already running. Drop any panel-op.lock we still hold
// so it is not stuck behind a pid that is about to exit (#1953). Spawn
// failure must NOT release: we are staying alive and may still own a
// legitimate in-flight op.
try {
this.deps.releasePanelLock();
}
catch (err) {
logger.debug(`[self-restart] release panel lock: ${err instanceof Error ? err.message : String(err)}`);
}
this.stop();
try {
await this.deps.teardown();
}
catch (err) {
logger.debug(`[self-restart] teardown: ${err instanceof Error ? err.message : String(err)}`);
}
this.deps.exit(0);
}
}
//# sourceMappingURL=self-restart.js.map