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),
175 lines • 8.79 kB
JavaScript
// Cross-process AGENT IDENTITY channel — WHICH LLM is driving the panel agent.
//
// A bug report filed from the panel is written by whichever model the user has
// selected in the provider/model chips: Claude Opus, a hosted Kimi, a local
// gemma. Report QUALITY tracks that choice, so a report that does not name the
// model cannot be judged — a thin, wrong or hallucinated issue looks exactly
// like a careful one until you know what wrote it.
//
// It CANNOT be self-reported. Asking a model which model it is returns a guess
// (they routinely name a sibling, or the model they were distilled from), and
// the agent's ENVIRONMENT block never carried the model at all — only
// `Backend:`. So the ORCHESTRATOR, which owns the chips and therefore the fact,
// publishes it, and report_issue reads it back.
//
// The transport is the one download-progress.ts already established, with the
// direction reversed: that channel is the comfyui MCP subprocess writing rows
// for the orchestrator to read; this one is the orchestrator writing a single
// small JSON for the subprocess to read. No socket, no new HTTP surface, and —
// like that channel — it no-ops entirely when the env var is unset, i.e. for
// every non-panel use of the MCP (plain stdio, CLI, tests).
//
// WHY A FILE AND NOT THE SPAWN ENV: the model is live-switchable. `setModel`
// deliberately does NOT restart the session (panel-agent.ts), so a value baked
// into the child's env at spawn goes stale the moment the user changes the chip
// — and would then stamp reports with a model that did not write them, which is
// worse than stamping nothing. The orchestrator republishes at every turn
// dispatch instead, so the file always describes the turn that is running.
//
// There is deliberately NO staleness cutoff on a read: the identity is written
// when a turn STARTS, and a turn can legitimately run for an hour before its
// agent files a report. Ageing the file out would drop exactly the long
// sessions most worth attributing.
import { mkdirSync, renameSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
/** Env var naming the identity file for THIS agent's MCP subprocess. Absent for
* every non-panel spawn, which is what makes the whole channel opt-in. */
export const AGENT_IDENTITY_ENV = "COMFYUI_MCP_AGENT_IDENTITY";
/** Machine-readable marker: the form an analysis pass greps for. */
const STAMP_MARKER = "<!-- reporter-agent:";
/**
* What a marker becomes when it arrives INSIDE a caller's body — i.e. quoted
* from some other issue, which is ordinary agent behaviour ("related to #1234,
* whose body reads: …"). Every stamped issue in the tracker carries the marker
* as quotable text, so a body that already contains one says nothing about who
* is filing THIS report.
*
* An earlier version treated any marker as proof this body was already stamped
* and returned it untouched — which filed the report under the QUOTED model's
* name, in a sentence explicitly vouching that the attribution is mechanical.
* That is the exact harm this module exists to prevent, arriving through its own
* guard (fallback merge gate, P1).
*/
const QUOTED_MARKER = "<!-- quoted-reporter-agent:";
/**
* Where the identity for one agent lives.
*
* Keyed by the BRIDGE PORT as well as the agent key for the same reason
* SessionStore is (session-store.ts): the agent key is `orchestrator::<backend>`
* and carries no port, so two ComfyUI instances on one machine would otherwise
* write each other's identity — and a report would name the other instance's
* model.
*/
export function agentIdentityPath(port, agentKey) {
const dir = join(process.env.COMFYUI_MCP_DATA_DIR?.trim() || join(homedir(), ".comfyui-mcp"), "agents");
// Agent keys contain `::` and could in principle contain anything else a
// backend id allows; reduce to a filename-safe token rather than trusting it.
const safe = agentKey.replace(/[^A-Za-z0-9_-]+/g, "_").slice(0, 80) || "agent";
return join(dir, `identity-${port}-${safe}.json`);
}
/**
* Publish the identity for one agent. Best-effort: a failure here must never
* break a turn — the cost of not writing it is an unstamped report, which is
* where we were before.
*
* Written tmp-then-rename so a reader in the subprocess can never observe a
* half-written file (Node's rename replaces an existing destination on Windows
* too, which is where this runs most).
*/
export function publishAgentIdentity(path, identity) {
try {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const tmp = `${path}.${process.pid}.tmp`;
writeFileSync(tmp, JSON.stringify({ ...identity, updated: Date.now() }), "utf8");
renameSync(tmp, path);
return true;
}
catch {
return false;
}
}
/**
* Read the published identity, or undefined when there is none to read: no env
* var (every non-panel spawn), no file yet, unreadable, unparseable, or parsed
* to something without a backend. Every one of those means "we do not know what
* model this is", and an unknown must stay unstamped rather than be guessed at.
*/
export function readAgentIdentity(path = process.env[AGENT_IDENTITY_ENV]) {
if (!path)
return undefined;
try {
const raw = JSON.parse(readFileSync(path, "utf8"));
if (!raw || typeof raw !== "object")
return undefined;
const rec = raw;
const backend = typeof rec.backend === "string" ? rec.backend.trim() : "";
if (!backend)
return undefined;
const str = (v) => {
const s = typeof v === "string" ? v.trim() : "";
return s ? s.slice(0, 120) : undefined;
};
return {
backend: backend.slice(0, 60),
model: str(rec.model),
effort: str(rec.effort),
updated: typeof rec.updated === "number" ? rec.updated : undefined,
};
}
catch {
return undefined;
}
}
/** Human form for the report line: `kimi · model kimi-k2-thinking · effort high`. */
export function formatAgentIdentity(identity) {
const parts = [`**${identity.backend}**`];
// "model unknown" is stated rather than omitted: a provider that exposes no
// model chip is a different fact from a report filed before this shipped, and
// silently dropping the field makes those two read identically.
parts.push(identity.model ? `model \`${identity.model}\`` : "model _unreported_");
if (identity.effort)
parts.push(`effort \`${identity.effort}\``);
return parts.join(" · ");
}
/**
* Append the identity block to an issue body.
*
* The wording says who stamped it, because the whole point is that this line is
* NOT the model's own claim about itself — a reader comparing report quality
* across models has to be able to trust it the way they trust the version line.
*
* ALWAYS appended when the identity is known. There is no "already stamped"
* shortcut, because nothing in the body can establish that: the only marker a
* caller's text can carry is one QUOTED from another issue, and skipping on it
* files the report under someone else's model. The one caller passes the raw
* argument and forwards the stamped result, so a body cannot reach here twice.
*
* A quoted marker is REWRITTEN as it passes through, so exactly one live marker
* survives and it is this report's — an analysis pass counting the full
* `<!-- reporter-agent:` literal across the tracker can never read a quotation as
* a second reporter (a looser grep for `reporter-agent` still matches the quoted
* form, by design — it stays findable). The quoted
* line stays readable; only its machine prefix changes.
*/
export function stampAgentIdentity(body, identity) {
if (!identity)
return body;
body = body.replaceAll(STAMP_MARKER, QUOTED_MARKER);
const machine = [
`backend=${identity.backend}`,
identity.model ? `model=${identity.model}` : "model=unknown",
...(identity.effort ? [`effort=${identity.effort}`] : []),
].join(" ");
return (`${body.replace(/\s+$/, "")}\n\n---\n` +
`Filed from the ComfyUI panel by ${formatAgentIdentity(identity)} ` +
`— stamped by comfyui-mcp from the panel's provider/model selection, not self-reported.\n` +
`${STAMP_MARKER} ${machine} -->\n`);
}
/** The label that makes the filed issues filterable by provider on GitHub
* (`label:agent:ollama`). The MODEL stays in the body — one label per provider
* is a bounded set, one per model is not. */
export function agentLabel(identity) {
return `agent:${identity.backend}`;
}
//# sourceMappingURL=agent-identity.js.map