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),
965 lines • 47 kB
JavaScript
// Live ENVIRONMENT-CAPABILITIES probe for the panel orchestrator.
//
// Gathered ONCE at orchestrator startup (or lazily on the first tab) and CACHED,
// then prepended as a compact one-line block to the panel agent's system prompt
// so the agent is immediately aware of the machine — no probing, no guessing.
//
// Every probe is best-effort and time-bounded: a hung ComfyUI, a slow PowerShell,
// or a missing python must NEVER stall session start. Each field degrades to
// "unknown" (and is simply omitted from the rendered block) on any failure.
//
// Split into:
// - gatherEnvCapabilities(opts): does all the I/O (fetch /system_stats, probe
// triton/sageattention, resolve backends) with hard timeouts.
// - formatEnvBlock(caps): a PURE function (stats object → compact string) that
// is unit-tested and omits unknown fields cleanly.
import { execFile } from "node:child_process";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { comfyuiFetch } from "../comfyui/fetch.js";
import { platform, release, totalmem, cpus } from "node:os";
import { isForceRemoteFlagSet } from "../config.js";
import { resolveComfyuiPython, pythonVersionsAgree, torchVersionsAgree } from "./workspace-env.js";
import { resolveLiveInterpreter } from "./live-interpreter.js";
import { parsePyproject } from "./node-authoring.js";
import { compareSemver, detectInstallMode } from "./self-update.js";
import { logger } from "../utils/logger.js";
import { parseKitchenLog } from "./kitchen.js";
// The interpreter resolver lives in workspace-env (which owns ComfyUI-base
// resolution) so the environment read and this panel probe share ONE live-first
// implementation and can never disagree (#401 / PR #433). Re-export for
// back-compat with existing importers/tests.
export { resolveComfyuiPython };
const IS_WIN = platform() === "win32";
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
/** Wrap a promise with a timeout; resolves to `undefined` (never rejects) on
* timeout or error, so a single slow probe can't hang session start. */
async function withTimeout(p, ms) {
let timer;
try {
return await Promise.race([
// unknown-ok: undefined IS the unknown value for this helper by design — the
// timeout branch below resolves to exactly the same thing, and every caller
// treats it as "this probe did not answer".
p.catch(() => undefined),
new Promise((resolve) => {
timer = setTimeout(() => resolve(undefined), ms);
timer.unref?.();
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
function bytesToGb(bytes) {
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0)
return undefined;
return Math.round(bytes / (1024 * 1024 * 1024));
}
/** Map node's os.platform()/release() to a friendly OS string. Best-effort:
* Windows 11 is detected from the NT build (>= 22000), else "Windows 10". */
function friendlyOs() {
const p = platform();
if (p === "win32") {
const build = Number(release().split(".")[2] ?? "0");
return build >= 22000 ? "Windows 11" : "Windows 10";
}
if (p === "darwin")
return "macOS";
if (p === "linux")
return "Linux";
return p;
}
/** Prefer the OS string ComfyUI itself reports (more specific), else node's. */
function pickOs(statsOs) {
if (statsOs && statsOs.trim()) {
const s = statsOs.trim();
// ComfyUI reports things like "nt", "posix", "Windows" — normalize the bland
// ones back to the friendly node-derived label.
if (/^(nt|posix|windows|win32)$/i.test(s))
return friendlyOs();
return s;
}
return friendlyOs();
}
/** Tidy ComfyUI's verbose device name into just the model. It reports e.g.
* "cuda:0 NVIDIA GeForce RTX 4090 : cudaMallocAsync" — strip the "cuda:N "
* prefix and the " : <allocator>" suffix. Best-effort; unrecognized shapes pass
* through trimmed. */
function cleanGpuName(name) {
return name
.trim()
.replace(/^(?:cuda|cpu|mps|xpu|hip|rocm):\d+\s+/i, "")
.replace(/\s*:\s*\w+(?:malloc\w*|MallocAsync)?\s*$/i, "")
.trim();
}
/** Strip the local build suffix from a torch version (2.10.0+cu130 → 2.10.0). */
function cleanTorch(v) {
if (!v)
return undefined;
const m = v.match(/^([\d.]+)/);
return m ? m[1] : v;
}
/** Shorten a python version to major.minor (3.13.12 → 3.13). */
function shortPython(v) {
if (!v)
return undefined;
const m = v.replace(/^Python\s+/i, "").match(/^(\d+\.\d+)/);
return m ? m[1] : v;
}
/** Probe which ComfyUI-Manager API generation the target serves — /v2/manager/*
* is the v4 lineage; released 3.x answers only on /manager/*. Best-effort. */
async function probeManagerGeneration(comfyuiUrl, timeoutMs) {
const probe = async (path) => {
try {
const res = await comfyuiFetch(new URL(path, comfyuiUrl), {
signal: AbortSignal.timeout(timeoutMs),
});
return res.ok;
}
catch {
return false;
}
};
if (await probe("/v2/manager/queue/status"))
return "v4";
if (await probe("/manager/queue/status"))
return "legacy";
return "unknown";
}
/** Is the COMFYUI_URL host loopback? → LOCAL, else REMOTE. Unknown URL → LOCAL
* (the panel's overwhelming default; never block on an unparseable URL).
* --force-remote overrides this, keeping it in sync with isRemoteMode(). */
/** The host of a ComfyUI base URL, or undefined. */
function hostFromUrl(url) {
if (!url)
return undefined;
try {
return new URL(url).hostname;
}
catch {
return undefined;
}
}
/** The TCP port of a ComfyUI base URL (defaulting by scheme), or undefined. */
function portFromUrl(url) {
if (!url)
return undefined;
try {
const u = new URL(url);
if (u.port)
return Number(u.port);
return u.protocol === "https:" ? 443 : 80;
}
catch {
return undefined;
}
}
function classifyLocation(url) {
if (!url)
return "LOCAL";
if (isForceRemoteFlagSet())
return "REMOTE";
try {
const host = new URL(url).hostname.toLowerCase();
if (host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "0.0.0.0") {
return "LOCAL";
}
return "REMOTE";
}
catch {
return "LOCAL";
}
}
// ---------------------------------------------------------------------------
// /system_stats fetch (direct HTTP — no client dependency, mirrors the environment read)
// ---------------------------------------------------------------------------
async function fetchSystemStats(comfyuiUrl, timeoutMs) {
const base = comfyuiUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
try {
const res = await comfyuiFetch(`${base}/system_stats`, { signal: controller.signal });
if (!res.ok)
return undefined;
return (await res.json());
}
catch {
return undefined;
}
finally {
clearTimeout(timer);
}
}
/** True when a /system_stats payload actually reports a GPU device (a non-CPU
* entry). ComfyUI can answer /system_stats during cold start BEFORE its CUDA
* device list is populated, so the first response may carry system info but no
* GPU — which would leave GPU/VRAM blank in the env block. */
function hasGpuDevice(stats) {
const devices = Array.isArray(stats?.devices) ? stats.devices : [];
return devices.some((d) => (d.type ?? "").toLowerCase() !== "cpu");
}
/**
* Fetch /system_stats with a BOUNDED retry so GPU/VRAM reliably populate even
* when ComfyUI is still coming up: retry a few times (short backoff) while the
* fetch fails OR returns no GPU device, then return the best result we got (never
* discarded just because the GPU wasn't ready — os/python/torch still populate).
* Every attempt is individually time-bounded (via withTimeout) and the whole loop
* is bounded by attempts*(timeout+slack) + (attempts-1)*backoff, so session start
* stays time-bounded and can NEVER hang. In the common case (a warm local ComfyUI)
* the first attempt returns a GPU immediately and no retry happens.
*/
async function fetchSystemStatsWithRetry(comfyuiUrl, timeoutMs, attempts = 3, backoffMs = 300) {
let best;
for (let i = 0; i < attempts; i++) {
const stats = await withTimeout(fetchSystemStats(comfyuiUrl, timeoutMs), timeoutMs + 500);
if (stats) {
best = stats;
if (hasGpuDevice(stats))
return stats; // GPU present → done, no more retries
}
// Fetch failed, or answered without a GPU device yet (cold start) — brief
// backoff, then retry (skip the wait after the final attempt).
if (i < attempts - 1) {
await new Promise((resolve) => {
const t = setTimeout(resolve, backoffMs);
t.unref?.();
});
}
}
return best;
}
// ---------------------------------------------------------------------------
// Triton / SageAttention detection from the ComfyUI LOG (authoritative for the
// HOST — local OR a remote pod). The python import-probe below reaches only the
// ORCHESTRATOR's machine; in REMOTE mode COMFYUI_PATH is unset, so it can't see
// what a remote ComfyUI has. But ComfyUI logs what it's ACTUALLY using at startup
// ("Using sage attention", "Enabling comfy-kitchen triton backend"), so a positive
// log marker is ground truth wherever ComfyUI runs. We only read a POSITIVE signal
// (absence isn't proof of not-installed). This is what the agent needs to pick
// host-correct node settings (attention mode / precision / torch.compile).
// ---------------------------------------------------------------------------
async function detectAttentionFromComfyLog(comfyuiUrl, timeoutMs) {
const base = comfyuiUrl.replace(/\/+$/, "");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
timer.unref?.();
try {
const res = await comfyuiFetch(`${base}/internal/logs`, { signal: controller.signal });
if (!res.ok)
return {};
const data = (await res.json());
// /internal/logs returns { entries: [{ t, m }] } (m = message) on recent
// ComfyUI; tolerate a raw string / other shapes defensively.
const entries = data?.entries;
const text = Array.isArray(entries)
? entries
.map((e) => (typeof e === "string" ? e : (e?.m ?? "")))
.join("\n")
: typeof data === "string"
? data
: JSON.stringify(data);
const out = {};
if (/Using sage attention/i.test(text))
out.sageattention = "installed";
if (/Enabling comfy-kitchen triton backend|Found triton \d/i.test(text))
out.triton = "installed";
const kitchenLog = parseKitchenLog(text);
if (kitchenLog.version.status === "known")
out.kitchen = `Kitchen: ${kitchenLog.version.value}`;
return out;
}
catch {
return {};
}
finally {
clearTimeout(timer);
}
}
// ---------------------------------------------------------------------------
// Triton + SageAttention detection (find the ComfyUI python, then import-probe)
// ---------------------------------------------------------------------------
/**
* Back-compat shim: returns just the interpreter path (LIVE-first resolution lives
* in workspace-env.resolveComfyuiPython). Prefer resolveComfyuiPython when the caller
* needs to know whether the interpreter is the LIVE server's own (issue #401).
*/
export function findComfyuiPython(comfyuiPath, statsArgv) {
return resolveComfyuiPython(comfyuiPath, statsArgv).python;
}
/**
* The exact python source the capability probe runs. Exported so a test can assert
* on the SOURCE: the guarantee that matters most here is a NEGATIVE one — that we
* never import torch inside a 5s budget — and no behavioural assertion can notice a
* future edit that quietly puts the import back.
*/
export const TRITON_PROBE_SOURCE = [
"import importlib.util as u, sys",
"print('python', ' '.join(sys.version.split()))",
"print('triton', u.find_spec('triton') is not None)",
"print('sageattention', u.find_spec('sageattention') is not None)",
"ts = u.find_spec('torch')",
"print('torch', ts is not None)",
"tv = ''",
"if ts is not None:",
" try:",
" import importlib.metadata as md",
" tv = md.version('torch')",
" except Exception:",
// torch present but with no dist-info (a hand-copied tree). "installed" still
// stands; only the version comparison is skipped.
" tv = ''",
"print('torch_version', tv)",
].join("\n");
/**
* Probe whether triton + sageattention are importable in the ComfyUI python.
* Best-effort, hard ~5s timeout. Returns one tri-state per package:
* installed | not-installed | unknown
* "unknown" when we couldn't run python at all (no interpreter / spawn error /
* timeout) — we don't want to claim "not installed" when we simply couldn't ask.
*/
export function probeTritonSage(pythonExe, timeoutMs = 5000) {
return new Promise((resolve) => {
if (!pythonExe) {
resolve({ triton: "unknown", sageattention: "unknown", torch: "unknown" });
return;
}
// Print a clear per-package marker so we can tell which imported, even if one
// succeeds and the other fails (avoids an all-or-nothing answer). Also print
// the interpreter's own major.minor so the caller can confirm we probed the
// SAME python the running ComfyUI reports (#401 — a mismatch means we're
// looking at the wrong environment and any negative is untrustworthy).
// Print the FULL `sys.version` banner (single line) — the same string
// /system_stats reports — so the caller can spot a build/compiler disagreement,
// not just a major.minor one (#401 review).
// torch is the 0.52.1 discriminator: a venv and its Homebrew/uv base share a
// python banner, but only the venv has the torch /system_stats reports.
//
// The version comes from DIST METADATA, never `import torch`. Importing torch
// initializes CUDA/MPS and loads a stack of native libraries: measured at 1.89s
// warm on a local NVMe RTX box, i.e. 38% of this probe's entire 5s budget, and
// multiples of that cold or on a network-mounted venv. Blowing the budget is not
// a partial loss — CPython block-buffers stdout when it is a pipe, so the kill
// discards the triton/sageattention lines that had ALREADY been computed, and
// the caller's `if (!out.trim())` branch reports everything as "unknown". That
// would degrade a working `Triton: installed` to unknown on exactly the
// heavy-CUDA machines #401 is about. `importlib.metadata.version` returns the
// identical string ("2.10.0+cu130" both ways, measured) for 43ms.
//
// Written with real newlines because the metadata lookup needs a try/except,
// which has no inline form. `-u` keeps stdout unbuffered so that if the child
// is killed anyway, the lines printed before the kill still reach us.
const code = TRITON_PROBE_SOURCE;
let done = false;
const child = execFile(pythonExe, ["-u", "-c", code], { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
if (done)
return;
done = true;
// Spawn failure (ENOENT) or non-import error with no output → unknown.
const out = (stdout || "").toString();
if (!out.trim()) {
resolve({ triton: "unknown", sageattention: "unknown", torch: "unknown" });
return;
}
const read = (name) => {
const m = out.match(new RegExp(`^${name}\\s+(True|False)`, "m"));
if (!m)
return "unknown";
return m[1] === "True" ? "installed" : "not-installed";
};
const pyMatch = out.match(/^python\s+(.+)$/m);
const torchVer = out.match(/^torch_version\s+(\S+)/m)?.[1];
// If python ran but errored AFTER printing partial output, still trust
// whatever lines we got; missing lines stay "unknown".
void err;
resolve({
triton: read("triton"),
sageattention: read("sageattention"),
torch: read("torch"),
torchVersion: torchVer || undefined,
pythonVersion: pyMatch ? pyMatch[1].trim() : undefined,
});
});
child.on?.("error", () => {
if (done)
return;
done = true;
resolve({ triton: "unknown", sageattention: "unknown", torch: "unknown" });
});
});
}
/**
* Packages the RUNNING server's own launch flags PROVE are importable in whatever
* environment it is actually using (#401 recurrence, 0.49.4).
*
* This is an observation, not an inference. ComfyUI does not merely *prefer* these
* backends — it calls `exit(-1)` during startup when the flag is given and the module
* cannot be imported (`comfy/ldm/modules/attention.py`: "To use the
* `--use-sage-attention` feature, the `sageattention` package must be installed
* first."). So a server that is UP, answering `/system_stats`, and reporting the flag in
* its OWN `sys.argv` has already demonstrated the import succeeded. `argv` here is the
* server's self-report, which is exactly the right question — "what is the process that
* answered me running with?" — not a layout guess.
*
* Only flags with that hard fail-fast contract belong here. A flag that merely enables an
* optional path would make this fabricate a positive.
*/
const ARGV_PROVEN_PACKAGES = [{ pkg: "sageattention", flag: "--use-sage-attention" }];
/**
* Which of the packages we report are proven present by the running server's argv.
*
* EXACT token match only — the RAW argv string, not a trimmed or lower-cased version of
* it. `sys.argv` is what argparse itself parsed: `" --use-sage-attention "` and
* `"--USE-SAGE-ATTENTION"` are tokens ComfyUI did NOT recognise as the flag, so the
* server did not enable the backend and nothing was proven. Normalising them here would
* manufacture the positive out of a token that had no effect.
*
* Earlier versions also split on `=` to accept `--flag=value`; that spelling cannot occur
* for an argparse `store_true` flag, so the leniency bought nothing and only widened what
* could be mistaken for the flag. This function's whole job is to assert a POSITIVE, and
* a fabricated positive reads to an agent as "the acceleration is there" — the exact
* false report in the other direction from the one #401 is about.
*/
export function packagesProvenByServerArgv(argv) {
const proven = new Set();
if (!Array.isArray(argv))
return proven;
const tokens = argv.filter((t) => typeof t === "string");
for (const { pkg, flag } of ARGV_PROVEN_PACKAGES) {
if (tokens.includes(flag))
proven.add(pkg);
}
return proven;
}
/**
* Did the interpreter we probed CONTRADICT something the running server proved?
*
* Returns the name of the first contradicted package, or undefined. A contradiction is
* decisive evidence about the SOURCE, not just about that one datum: an interpreter that
* reports "sageattention is absent" for a server that could not have started without
* sageattention is provably not the environment the server imports from, so every
* negative it produced is worthless — including the ones nothing happens to contradict.
*
* This is the hole the 0.49.4 recurrence fell through. The previous guard cross-checked
* the probe's python version against `/system_stats`, but a venv reports the SAME
* `sys.version` as the base interpreter it was created from — by construction. So the
* version check can never separate a venv from its base, which is precisely the confusion
* that produces a wrong-environment probe, and "versions agree" was read as corroboration
* when it carried no information at all.
*/
export function contradictedPackage(probe, proven, serverTorch) {
if (!probe)
return undefined;
for (const pkg of proven) {
if (probe[pkg] === "not-installed")
return pkg;
}
// /system_stats.pytorch_version is what the RUNNING server imported. A probe
// that finds no torch — or a different build — is looking at the base
// interpreter, not the venv. Python versions agree in that case by
// construction, so this is the discriminator the version check cannot be
// (#401 recurrence, 0.52.1). An honest "unknown" (we could not ask) is not
// a contradiction.
const reported = serverTorch?.trim();
if (reported) {
if (probe.torch === "not-installed")
return "torch";
if (probe.torchVersion && !torchVersionsAgree(probe.torchVersion, reported))
return "torch";
}
return undefined;
}
/**
* Reconcile a raw import-probe tri-state against how much we trust the interpreter
* we probed (#401). A definitive "not-installed" is only believable when we probed
* ComfyUI's OWN python — i.e. the LIVE interpreter resolved from the running server's
* argv root, whose major.minor also matches what /system_stats reports. When the
* interpreter is NOT the live one (a bare PATH fallback, or a different/persisted
* workspace) or its version disagrees with the running ComfyUI, we are looking at the
* wrong environment, so a "not-installed" is downgraded to "unknown" rather than
* emitted as a false negative that would make an agent disable working acceleration.
* Positives pass through unchanged (a positive can't be a harmful false negative).
*/
export function reconcileProbeState(state, opts) {
const s = state ?? "unknown";
// Compares the FULL `sys.version` banner when both sides carry one (build date and
// compiler included), degrading to the dotted version at whatever precision both
// supply. A CONTRADICTION check only — cloned venvs report identical banners.
const versionMismatch = !!(opts.runningPython &&
opts.probePython &&
!pythonVersionsAgree(opts.runningPython, opts.probePython));
// Only an OBSERVED interpreter may state a negative. Nothing inferred from install
// layout qualifies, however plausible: a sole .venv under the server's own root, a
// matching python, a matching torch build — all of those are satisfied by a sibling
// environment that is not the one ComfyUI is running, and a wrong guess is exactly
// what produced the false "Triton: not installed" that made an agent strip working
// acceleration (#401). Everything else degrades to "unknown".
//
// The asymmetry is deliberate: positives ALWAYS pass through. A package we can see
// installed is really installed, whichever environment we happened to look at.
const trustworthy = opts.observed && !versionMismatch;
return s === "not-installed" && !trustworthy ? "unknown" : s;
}
// ---------------------------------------------------------------------------
// Backend availability (active provider + whether the OTHER is resolvable)
// ---------------------------------------------------------------------------
/** Can a module be resolved from this package's perspective? Best-effort. */
function canResolve(specifier) {
try {
const require = createRequire(import.meta.url);
require.resolve(specifier);
return true;
}
catch {
return false;
}
}
/**
* Human labels for every panel backend id (PANEL_AGENT_BACKEND / per-tab
* selection). The env block reports the ACTUAL active provider for the turn — an
* unrecognized id degrades to "unknown" rather than being silently mislabeled as
* "Claude" (#358: a Grok turn must NOT say "Backend: Claude"). Keep in sync with
* the BackendId union in orchestrator/agent-backend.ts.
*/
export const BACKEND_LABELS = {
claude: "Claude",
codex: "Codex",
chatgpt: "ChatGPT",
gemini: "Gemini",
antigravity: "Antigravity",
pi: "Pi",
grok: "Grok",
glm: "GLM",
kimi: "Kimi",
moonshot: "Kimi K3 (Moonshot)",
minimax: "MiniMax",
atlascloud: "Atlas Cloud",
ollama: "Ollama",
openrouter: "OpenRouter",
copilot: "Copilot",
lmstudio: "LM Studio",
llamacpp: "llama.cpp",
custom: "Custom endpoint",
};
/**
* Determine the active backend label and whether ANY other provider is available.
* activeBackendId is a PANEL_AGENT_BACKEND / per-tab backend id (e.g. "claude",
* "codex", "gemini", "grok", "ollama"…). An unknown id resolves to "unknown" so
* the env block never reports a WRONG specific provider (#358).
*/
export function resolveBackends(activeBackendId) {
const id = activeBackendId.toLowerCase();
const claudeAvailable = canResolve("@anthropic-ai/claude-agent-sdk");
// Codex can be the @openai/codex package OR a `codex` CLI on PATH; Gemini the
// @google/gemini-cli package OR a `gemini` CLI on PATH. Resolving the package is
// the cheap, synchronous signal we use here (a PATH-only CLI reads as absent).
const codexAvailable = canResolve("@openai/codex");
const geminiAvailable = canResolve("@google/gemini-cli");
// Own-property lookup ONLY — a plain-object index would resolve inherited keys
// like "constructor"/"__proto__" to prototype members (a function/object),
// wrongly labeling those ids instead of degrading to "unknown".
const backend = Object.hasOwn(BACKEND_LABELS, id) ? BACKEND_LABELS[id] : "unknown";
// "Other available" = one of the three SDK/CLI-resolvable providers OTHER than
// the active one is present. (A cheap synchronous signal; the full provider set
// is larger, but these three are the ones we can detect without I/O.)
const otherBackendAvailable = (claudeAvailable && id !== "claude") ||
(codexAvailable && id !== "codex") ||
(geminiAvailable && id !== "gemini");
return { backend, otherBackendAvailable };
}
// The panel's known custom_nodes dir names (registry unpack vs repo name), kept
// in sync with panel-installer's FAST_PATH_DIRS.
//
// Canonical Registry name FIRST (panel #1515). The order is no longer the thing
// that decides the answer — `readInstalledPanelVersion` now picks the NEWEST of
// the copies it finds — but it is still the tie-break when two copies carry
// versions that cannot be compared, and the Registry install is the one the
// installer manages.
const PANEL_DIR_NAMES = ["comfyui-agent-panel", "comfyui-mcp-panel"];
// pyproject `[project].name` — the AUTHORITATIVE panel identity (a dir may be
// squatted; the project name is not). Mirrors panel-installer's PANEL_REGISTRY_ID.
const PANEL_PROJECT_NAME = "comfyui-agent-panel";
/**
* Fallback panel version from DISK. The panel version normally rides the panel's
* `hello`, but a stale (browser-cached) panel — one loaded before the panel began
* advertising its version — omits it, leaving the agent's ENV line `panel=?` and
* bug reports unable to version-match the panel. Read the INSTALLED panel's
* version from its `pyproject.toml` under ComfyUI's custom_nodes.
*
* Parsing is delegated to the shipped `parsePyproject` (the same minimal reader
* that gates panel install/reinstall) so there is ONE pyproject convention, not
* two. We only accept a version whose `[project].name` is authoritatively the
* panel's — a non-panel pyproject squatting a known dir name yields undefined,
* never a wrong version. Best-effort: returns undefined on any failure, never
* throws.
*
* NEWEST WINS, NOT FIRST-LISTED (panel #1515).
*
* The pack can be installed TWICE — a git clone at `custom_nodes/comfyui-mcp-panel`
* alongside a Manager/Registry install at `custom_nodes/comfyui-agent-panel`. That
* is not hypothetical: it is the documented #1269 / #641 two-panels state, and
* panel-recovery's own PANEL_DIR_NAMES comment exists to stop this module's advice
* from manufacturing it. Both dirs carry the same authoritative `[project].name`,
* so the name screen passes for BOTH and the loop returned whichever the hardcoded
* list happened to name first.
*
* In panel #1515 that read `0.7.3` off an abandoned clone while the live pack was
* `0.15.24`, and stamped 0.7.3 onto the agent's ENVIRONMENT line — the surface bug
* reports version-match against. A list's order is not evidence about which copy
* ComfyUI serves, so it may not decide the answer.
*
* The tie-break is NEWEST, and it is the panel's own rule rather than a guess: the
* bundle guard added for #1269 arbitrates the page so the newer copy runs and the
* older stands down ("A stale copy must never win just because its directory sorts
* first" — web/js/lib/duplicate-panel-guard.js). Reporting the newest install
* therefore names the bundle the browser will actually run.
*
* Two versions that cannot be compared (compareSemver answers 0 for anything that
* is not `major.minor.patch`) keep the first-found copy, which is the pre-existing
* behaviour and PANEL_DIR_NAMES' remaining job.
*/
export function readInstalledPanelVersion(comfyuiPath) {
if (!comfyuiPath)
return undefined;
let best;
for (const name of PANEL_DIR_NAMES) {
try {
const toml = readFileSync(join(comfyuiPath, "custom_nodes", name, "pyproject.toml"), "utf8");
const parsed = parsePyproject(toml);
if (parsed.projectName !== PANEL_PROJECT_NAME || !parsed.version)
continue;
// Keep scanning after a hit: the FIRST readable copy is not evidence that
// it is the live one.
if (best === undefined || compareSemver(parsed.version, best) > 0)
best = parsed.version;
}
catch {
/* try the next known dir name */
}
}
return best;
}
/**
* The comfyui-mcp version INSTALLED on disk right now (#846) — undefined when it
* cannot be read.
*
* Distinct from the version the calling process is RUNNING, which cannot change
* while it runs (a Node process cannot hot-swap its own code) and is therefore
* passed in. After an in-place upgrade the two diverge, and reporting only the
* cached one described a moment that had passed.
*/
export function readInstalledMcpVersion() {
try {
return detectInstallMode().currentVersion ?? undefined;
}
catch {
return undefined;
}
}
export async function gatherEnvCapabilities(opts) {
const caps = {};
// --- our own build versions (caller-supplied; panel version rides the hello) ---
caps.mcpVersion = opts.mcpVersion;
// Re-read EVERY gather (#846). Guarded: a failed read must leave the line saying
// nothing about drift, never invent a mismatch out of a missing reading.
try {
caps.mcpVersionInstalled = (opts.readInstalledMcpVersion ?? readInstalledMcpVersion)();
}
catch {
caps.mcpVersionInstalled = undefined;
}
// Prefer the live hello's panel version; fall back to the INSTALLED panel's
// pyproject version so a stale (cached) panel that omits it from the hello
// still gets the agent's ENV line stamped (report_issue can version-match).
caps.panelVersion = opts.panelVersion ?? readInstalledPanelVersion(opts.comfyuiPath);
// --- cheap, synchronous local machine facts (node os) ---
caps.os = friendlyOs();
try {
const cpuList = cpus();
if (cpuList.length && cpuList[0]?.model)
caps.cpu = cpuList[0].model.trim();
}
catch {
/* leave cpu unknown */
}
caps.ramGb = bytesToGb(totalmem());
caps.location = classifyLocation(opts.comfyuiUrl);
// --- backends (synchronous module resolution) ---
const { backend, otherBackendAvailable } = resolveBackends(opts.backendId ?? "claude");
caps.backend = backend;
caps.otherBackendAvailable = otherBackendAvailable;
// --- /system_stats (GPU/VRAM/CUDA/torch/python/comfyui versions) ---
const stats = opts.comfyuiUrl
? await fetchSystemStatsWithRetry(opts.comfyuiUrl, opts.statsTimeoutMs ?? 4000)
: undefined;
let statsArgv;
let statsCwd;
let statsEmbedded;
// RAW sys.version banner — caps.python is truncated to major.minor for DISPLAY, so
// the contradiction check needs the untruncated string (#401 review).
let statsPythonRaw;
// RAW pytorch_version — caps.torch is cleaned for DISPLAY; contradiction needs
// the string the server actually reported (#401 recurrence, 0.52.1).
let statsTorchRaw;
if (stats) {
applyStats(caps, stats);
statsArgv = stats.system?.argv;
statsCwd = stats.system?.cwd;
statsPythonRaw = stats.system?.python_version?.trim() || undefined;
statsTorchRaw = stats.system?.pytorch_version?.trim() || undefined;
statsEmbedded =
typeof stats.system?.embedded_python === "boolean"
? stats.system.embedded_python
: undefined;
}
// --- ComfyUI-Manager API generation (v4 vs released 3.x) ---
if (opts.comfyuiUrl) {
caps.manager = await withTimeout(probeManagerGeneration(opts.comfyuiUrl, opts.statsTimeoutMs ?? 4000), (opts.statsTimeoutMs ?? 4000) + 1000).then((m) => m ?? "unknown");
}
// --- triton + sageattention (resolve the LIVE python, import-probe, ~5s cap) ---
// In REMOTE mode the local python-import probe can't reach the server's host, and a
// coincident LOCAL path is NOT the remote interpreter — probing it risks BOTH a
// false negative and a false positive for the remote server. So skip the probe
// entirely and rely solely on the ComfyUI-log positive markers below (#401 round 3).
const remote = caps.location === "REMOTE";
let observed = false;
let ts;
if (!remote) {
// GROUND TRUTH first (we launched it / the OS says so); otherwise probe the layout
// GUESS, whose positives are still real but whose negatives can never stand.
const live = resolveLiveInterpreter({
port: opts.port ?? portFromUrl(opts.comfyuiUrl) ?? 8188,
host: hostFromUrl(opts.comfyuiUrl),
remote,
serverArgv: statsArgv,
});
observed = !!live;
const python = live?.python ??
resolveComfyuiPython(opts.comfyuiPath, statsArgv, {
cwd: statsCwd,
remote,
embeddedPython: statsEmbedded,
}).python;
const tritonTimeout = opts.tritonTimeoutMs ?? 5000;
ts = await withTimeout(probeTritonSage(python, tritonTimeout), tritonTimeout + 1000);
}
// A definitive "not-installed" requires that we OBSERVED which interpreter the
// server runs (we launched it, or the OS told us) AND that it does not contradict
// the running instance's python. Everything else — a bare PATH fallback, a
// different workspace, a Desktop bundle whose layout we merely guessed at, or a
// REMOTE server we cannot observe at all — degrades to "unknown" rather than emit
// the false negative that made an agent disable working acceleration (#401).
// A positive ("installed") always passes through: it can't be a harmful false
// negative, and the log-marker check below only ever reinforces a positive.
//
// Before any of that: if the probe contradicts something the SERVER's own launch flags
// prove, the probe is looking at the wrong environment and every negative it produced
// is void — not just the contradicted one. That is the 0.49.4 recurrence exactly: a
// server running with --use-sage-attention (which it could not have started without)
// while the probed interpreter reported BOTH sageattention and triton absent. Voiding
// only sageattention would have left the false "Triton: not installed" standing, which
// is the finding that made an agent strip working acceleration in the first place.
// …and a contradiction discredits the SOURCE, so its POSITIVES go too. The
// pass-positives-through asymmetry is right for an interpreter that is merely
// UNOBSERVED — a package really is installed somewhere, and saying so is harmless.
// It is wrong once we have proof the interpreter is not the server's environment:
// "Triton: installed" read off the wrong venv tells an agent to enable triton kernels
// on a server that may not have them, which fails the render. That is the SAME false
// capability report as #401 with the sign flipped, and voiding one direction while
// keeping the other would be believing a witness we have just caught being wrong.
// The genuinely observed positives survive anyway: the argv-proven flag is re-applied
// immediately below, and the ComfyUI-log markers after it.
const provenByArgv = packagesProvenByServerArgv(statsArgv);
const contradicted = contradictedPackage(ts, provenByArgv, statsTorchRaw);
if (contradicted) {
logger.info("Discarding this interpreter's capability results entirely: it contradicts the running ComfyUI's own launch flags", {
contradicted,
probePython: ts?.pythonVersion ?? "(unreported)",
runningPython: statsPythonRaw ?? "(unreported)",
});
}
const reconcile = (state) => contradicted
? "unknown"
: reconcileProbeState(state, {
observed,
runningPython: statsPythonRaw ?? caps.python,
probePython: ts?.pythonVersion,
});
caps.triton = reconcile(ts?.triton);
caps.sageattention = reconcile(ts?.sageattention);
// The launch flag is a stronger observation than any local import probe — it is the
// running process telling us what it loaded. Applied after reconcile so it can only
// ever raise a state to "installed", never lower one.
for (const pkg of provenByArgv)
caps[pkg] = "installed";
// A positive signal from the ComfyUI HOST's log wins over the local python probe:
// in remote mode (pod) the probe can't reach the host, and even locally the log
// reflects what ComfyUI is actually USING. So the agent sees the host's real
// Sage/Triton state and configures nodes accordingly.
if (opts.comfyuiUrl) {
const logTimeout = opts.statsTimeoutMs ?? 4000;
const fromLog = await withTimeout(detectAttentionFromComfyLog(opts.comfyuiUrl, logTimeout), logTimeout + 1000);
if (fromLog?.sageattention)
caps.sageattention = fromLog.sageattention;
if (fromLog?.triton)
caps.triton = fromLog.triton;
if (fromLog?.kitchen)
caps.kitchen = fromLog.kitchen;
}
return caps;
}
/** Merge a /system_stats payload into a caps object (pure-ish; mutates caps). */
export function applyStats(caps, stats) {
const sys = stats.system ?? {};
caps.os = pickOs(sys.os);
caps.python = shortPython(sys.python_version) ?? caps.python;
caps.comfyui = sys.comfyui_version || caps.comfyui;
// torch version + CUDA line. ComfyUI may report pytorch_version (e.g.
// "2.10.0+cu130"); derive the CUDA line from the +cuXXX suffix when present.
const torchRaw = sys.pytorch_version;
if (torchRaw) {
caps.torch = cleanTorch(torchRaw);
const cu = torchRaw.match(/\+cu(\d{2,3})/i);
if (cu) {
const digits = cu[1];
// cu130 → 13.0, cu128 → 12.8 (last digit is the minor).
caps.cuda = `${digits.slice(0, -1)}.${digits.slice(-1)}`;
}
}
// Primary CUDA/GPU device (first non-CPU device, else first device).
const devices = Array.isArray(stats.devices) ? stats.devices : [];
const gpu = devices.find((d) => (d.type ?? "").toLowerCase() !== "cpu") ?? devices[0];
if (gpu) {
if (gpu.name)
caps.gpu = cleanGpuName(gpu.name);
caps.vramTotalGb = bytesToGb(gpu.vram_total);
caps.vramFreeGb = bytesToGb(gpu.vram_free);
}
}
// ---------------------------------------------------------------------------
// formatEnvBlock — PURE: caps → compact single-line block (unit tested)
// ---------------------------------------------------------------------------
/**
* Render one capability tri-state for the banner.
*
* "unknown" USED to be omitted, which is where the whole three-state model was thrown
* away: an omitted Triton line and a Triton line that says "absent" are the same text to
* the reader, and the guidance sentence below tells the agent that absence means "default
* to sdpa + no torch.compile". So every careful degrade-to-unknown upstream landed on the
* agent as the false negative it was avoiding. "unknown" is now SAID, and said in a form
* that cannot be skimmed as a negative.
*
* `undefined` (the field was never populated at all) is still omitted — that is a
* different thing from a probe that ran and could not decide.
*/
function triLabel(state) {
if (state === "installed")
return "installed";
if (state === "not-installed")
return "not installed";
if (state === "unknown")
return "UNKNOWN (probe could not verify — NOT a negative)";
return undefined;
}
/**
* Render the compact ENVIRONMENT block. Returns "" when nothing useful is known
* (so the caller appends nothing). Unknown fields are omitted cleanly.
*/
export function formatEnvBlock(caps) {
const parts = [];
if (caps.os)
parts.push(`OS ${caps.os}`);
if (caps.gpu) {
const vram = typeof caps.vramTotalGb === "number" ? ` (${caps.vramTotalGb} GB VRAM)` : "";
parts.push(`GPU ${caps.gpu}${vram}`);
}
if (typeof caps.ramGb === "number")
parts.push(`${caps.ramGb} GB RAM`);
if (caps.cuda)
parts.push(`CUDA ${caps.cuda}`);
if (caps.torch)
parts.push(`torch ${caps.torch}`);
if (caps.python)
parts.push(`python ${caps.python}`);
if (caps.comfyui || caps.location) {
const loc = caps.location ? ` (${caps.location})` : "";
// Drop the version segment cleanly when it's unknown → "ComfyUI (LOCAL)".
const ver = caps.comfyui ? ` ${caps.comfyui}` : "";
parts.push(`ComfyUI${ver}${loc}`);
}
if (caps.manager === "v4") {
parts.push("Manager v4");
}
else if (caps.manager === "legacy") {
// The agent should relay this to the user when node/model operations
// degrade — comfyui-mcp is optimized for Manager v4+.
parts.push("Manager 3.x LEGACY (partial feature set — recommend upgrading to ComfyUI-Manager v4+; comfyui-mcp is optimized for v4)");
}
const triton = triLabel(caps.triton);
if (triton)
parts.push(`Triton: ${triton}`);
const sage = triLabel(caps.sageattention);
if (sage)
parts.push(`SageAttention: ${sage}`);
if (caps.kitchen)
parts.push(caps.kitchen);
if (caps.backend) {
// With three providers the specific "other" isn't single-valued, so name them
// generically when an alternative is resolvable.
const otherClause = caps.otherBackendAvailable ? "; other providers available" : "";
parts.push(`Backend: ${caps.backend}${otherClause}`);
}
// Our own build versions — so the agent can stamp them into bug reports without
// digging (report-bug skill requires both).
//
// #846: the version named FIRST is the one this process is RUNNING, because that
// is the build whose behaviour the agent is about to describe in a bug report. An
// upgrade that landed while the orchestrator was up does not change that, and
// silently reporting the newer on-disk number instead would pin every issue to
// code nobody executed. So the drift is DISCLOSED, with the action that resolves
// it — a restart is the only thing that makes the two agree.
const upgradeClause = caps.mcpVersion &&
caps.mcpVersionInstalled &&
caps.mcpVersionInstalled !== caps.mcpVersion
? ` (this process is RUNNING ${caps.mcpVersion}; ${caps.mcpVersionInstalled} is now installed on disk` +
" — restart the orchestrator to load it, and report bugs against the RUNNING version)"
: "";
const versions = [
caps.mcpVersion && `comfyui-mcp ${caps.mcpVersion}${upgradeClause}`,
caps.panelVersion && `panel ${caps.panelVersion}`,
].filter(Boolean);
if (versions.length)
parts.push(versions.join(" · "));
if (parts.length === 0)
return "";
const head = `ENVIRONMENT (live, this machine): ${parts.join(" · ")}.`;
const guidance = " Use this for model/precision/VRAM choices, OS-correct install commands, and the" +
" acceleration decision — default to sdpa + no torch.compile (see the" +
" triton-sageattention skill) and offer to install ONLY where this line says" +
" \"not installed\". \"UNKNOWN\" means the probe could not verify which interpreter the" +
" running ComfyUI uses; it is NOT a report of absence. Never disable Triton/" +
`SageAttention or edit a user's workflow on an UNKNOWN — call install_comfyui (action:"environment")` +
" (python_probe_reason says what could not be established) or ask the user first.";
return head + guidance;
}
// ---------------------------------------------------------------------------
// buildPanelSystemAppend — PREPEND the env block to the existing static prompt
// ---------------------------------------------------------------------------
/**
* Surgical builder: returns the existing static panel prompt with a compact env
* block PREPENDED. If the env block is empty (probe failed/timed out entirely),
* returns the static prompt unchanged — the prompt MUST always build.
*/
export function buildPanelSystemAppend(staticPrompt, caps) {
const block = caps ? formatEnvBlock(caps) : "";
if (!block)
return staticPrompt;
return `${block}\n\n${staticPrompt}`;
}
//# sourceMappingURL=env-capabilities.js.map