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),
918 lines • 44.6 kB
JavaScript
// Google Antigravity CLI backend (issue #262) — the provider adapter behind the
// AgentBackend port for `agy`, the official Antigravity CLI that replaced the
// individual-tier Gemini CLI subscription path on 2026-06-18 (Google AI Pro /
// Ultra / free tiers moved to Antigravity; Gemini CLI remains for enterprise +
// API-key users, and keeps its own separate `gemini` backend here).
//
// Unlike the ACP/app-server adapters (gemini/codex), `agy` exposes NO documented
// machine-readable streaming protocol — only a plain-text non-interactive print
// mode. So this adapter is a SPAWN-PER-TURN driver over the supported public
// surface, exactly as requested in #262 ("start with final-text responses"):
//
// turn N=1 agy -p "<prompt>" [--model m] --dangerously-skip-permissions
// turn N>1 agy -c -p "<prompt>" ... (--continue = latest conversation)
// resume same as N>1 (`-c`); `agy` owns conversation storage
// interrupt() kill the in-flight child process tree (Windows taskkill /T /F)
// listModels() parse `agy models` (live account catalog — no static list)
//
// stdout is streamed to the panel as assistant deltas as it arrives (agy prints
// the final answer progressively in -p mode; if a build buffers, the deltas just
// land at once — still correct). There is no per-turn rewind anchor and no
// in-process MCP: forkAtAnchor=false, inProcessMcp=false.
//
// AUTH: the CLI owns authentication (system keyring + Google Sign-In). Per #262
// we deliberately do NOT read tokens from the keyring or Antigravity state files
// — readiness is "the executable exists", and the real auth signal is whether
// `agy models` answers (the connect ack's model probe). An unauthenticated CLI
// gets an actionable "run `agy` once and sign in" message.
//
// MCP / ComfyUI tools — VERIFIED LIVE against agy 1.1.5 (2026-07-21): the CLI
// honors ONLY the global shared `~/.gemini/config/mcp_config.json`; the
// workspace `.agents/mcp_config.json` path in third-party docs is IDE-only and
// silently ignored by `agy -p`. Before the first turn we MERGE our two servers —
// the headless `comfyui` stdio MCP and the per-tab `panel` HTTP MCP — into that
// file under our own names, preserving every other entry byte-for-byte.
// Merge-safe and reversible (delete the two keys); global rather than
// workspace-scoped only because the CLI offers nothing narrower.
//
// LIMITS (flagged honestly, mirrors the issue's "reduced capabilities" ask):
// - `-p` prints plain text: no structured tool-call events, so the panel shows
// no per-tool progress for agy turns (onActivity still fires on every stdout
// chunk, keeping the idle watchdog armed).
// - `--continue` binds to the account's LATEST conversation; a user running
// `agy` interactively in parallel — or a SECOND antigravity panel tab —
// can steal continuity (tab A's next turn continues tab B's thread). The
// supported surface offers no way to read the new conversation's id from
// -p output; revisit if a future agy prints one.
// - The prompt rides argv, so it is visible in the OS process list for the
// child's lifetime (same-host processes can read the conversation) and is
// capped at ~32K chars on Windows (preflighted with a legible error).
// - No documented image-input path for -p → vision=false (image refs are
// already named in the turn text as a fallback).
import { spawn, spawnSync } from "node:child_process";
// SECURITY: agy is Google's LLM-vendor CLI — its subprocess must NOT inherit the
// user's TOOL secrets (RunPod/CivitAI/HF/RunComfy/Registry tokens, AND the
// GEMINI/GOOGLE keys, which panel-secrets classifies as tool secrets). The
// official Antigravity CLI docs (antigravity.google/docs/cli) say agy
// authenticates via the native system keyring + browser/SSH OAuth — it does NOT
// document any API-key env auth — so there is NO confirmed need to re-admit the
// GEMINI/GOOGLE keys, and doing so would expose tool credentials to the agy
// subprocess. We therefore strip EVERY tool-only secret (no keep-list).
// NOTE: if a future agy version documents API-key env auth (e.g. GEMINI_API_KEY),
// re-admit that specific key deliberately here via buildAgentSpawnEnv(..., { keep })
// with a justification — do not blanket-keep.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { logger } from "../utils/logger.js";
import { errorText, promptText } from "./error-text.js";
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
import { ANTIGRAVITY_CAPABILITIES, stampTurn, } from "./agent-backend.js";
function msgOf(err) {
return errorText(err);
}
/** Kill an entire process tree (identical posture to gemini/codex-backend):
* Windows taskkill /T /F; POSIX negative-pid process-group signal with a
* single-pid fallback. Best-effort — teardown must never throw. */
function killProcessTree(pid) {
if (!Number.isFinite(pid))
return;
const p = pid;
if (process.platform === "win32") {
try {
spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
}
catch {
try {
process.kill(p);
}
catch {
// already gone
}
}
return;
}
try {
process.kill(-p, "SIGTERM");
}
catch {
try {
process.kill(p, "SIGTERM");
}
catch {
// already gone
}
}
}
/**
* Resolve the `agy` executable. Order: COMFYUI_MCP_ANTIGRAVITY_PATH (explicit
* escape hatch) → PATH (agy / agy.exe) → the official installers' well-known
* locations (%LOCALAPPDATA%\agy\bin on Windows, ~/.local/bin elsewhere).
* Returns an absolute path when found on disk, the bare name as a last resort,
* or null when clearly absent. We never spawn through a shell — the prompt is
* user data, and the official installers ship a real executable (a `.cmd` npm
* shim is NOT supported; Node refuses shell-less .cmd spawns post-CVE-2024-27980,
* and shell quoting of arbitrary prompt text would be an injection risk).
*/
export function resolveAgyBin(home = homedir()) {
const override = process.env.COMFYUI_MCP_ANTIGRAVITY_PATH?.trim();
if (override)
return override;
const names = process.platform === "win32" ? ["agy.exe", "agy"] : ["agy"];
const sep = process.platform === "win32" ? ";" : ":";
for (const dir of (process.env.PATH || "").split(sep).filter(Boolean)) {
for (const name of names) {
try {
const full = join(dir, name);
if (existsSync(full))
return full;
}
catch {
// unreadable PATH entry — skip
}
}
}
const wellKnown = process.platform === "win32"
? [join(process.env.LOCALAPPDATA || join(home, "AppData", "Local"), "agy", "bin", "agy.exe")]
: [join(home, ".local", "bin", "agy"), "/usr/local/bin/agy", "/opt/homebrew/bin/agy"];
for (const p of wellKnown) {
try {
if (existsSync(p))
return p;
}
catch {
// skip
}
}
return null;
}
/** Parse a Go-style duration ("45m", "5m0s", "1h30m") to ms; null when unparseable. */
export function parseGoDurationMs(s) {
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s.trim());
if (!m || (!m[1] && !m[2] && !m[3]))
return null;
return (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 1000;
}
/** Strip ANSI escape sequences (agy's TUI heritage may color even -p output). */
function stripAnsi(s) {
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "");
}
/**
* Parse `agy models` output into ModelChoices. The format is not a documented
* contract, so this is deliberately TOLERANT: strip ANSI, take each non-empty
* line, drop obvious headers/rules/prose, strip list markers and a
* "(default)"/"(current)" tag, and keep the first token when it looks like a
* model id. A `*`-marked or "(default)" row is surfaced first. Returns [] when
* nothing parses (the panel degrades gracefully on an empty list).
*/
export function parseAgyModels(output) {
const out = [];
let defaultId = null;
for (const raw of stripAnsi(output).split(/\r?\n/)) {
const line = raw.trim();
if (!line)
continue;
// Header / separator / prose rows.
if (/^[-=─│+|]+$/.test(line))
continue;
if (/^(available|models?|name|id|description|usage|use )\b/i.test(line) && !/^[a-z0-9][\w.:/-]*$/i.test(line))
continue;
const marked = /^[*✓✔>]/.test(line);
const cleaned = line.replace(/^[*✓✔>•\-\s]+/, "");
const isDefault = marked || /\((default|current|active)\)/i.test(cleaned);
const token = cleaned.split(/\s+/)[0]?.replace(/[,;]$/, "") ?? "";
// A plausible model id: starts alphanumeric, id-ish charset, has a digit or
// dash (filters prose words like "Run"), not an obvious column header.
if (!/^[a-z0-9][\w.:/-]*$/i.test(token))
continue;
if (!/[\d-]/.test(token))
continue;
if (/^(name|model|models|id)$/i.test(token))
continue;
if (out.some((m) => m.id === token))
continue;
out.push({ id: token });
if (isDefault && !defaultId)
defaultId = token;
}
if (defaultId) {
out.sort((a, b) => (a.id === defaultId ? -1 : b.id === defaultId ? 1 : 0));
}
return out;
}
/**
* Merge our MCP servers into a workspace `.agents/mcp_config.json`, preserving
* every entry we don't own. Antigravity's schema (per the official docs): local
* servers are { command, args, env: {K:V} }, remote ones { serverUrl }. Handles
* both the bare { name: {...} } layout and a { mcpServers: { name: {...} } }
* wrapper, preferring whichever the existing file already uses (wrapper when
* creating fresh). Returns the merged JSON text it wrote (for tests).
*/
export function mergeAgyMcpConfig(existingText, servers) {
let root = {};
try {
if (existingText?.trim()) {
const parsed = JSON.parse(existingText);
// Parseable-but-non-object (array/scalar): named keys set on an array are
// silently dropped by stringify, and spreading a scalar makes garbage —
// neither is a valid mcp config, so start from a fresh wrapper instead.
// (ensureMcpConfig refuses to touch such a file at all; this guard keeps
// the pure function safe for direct callers.)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
root = parsed;
}
}
}
catch {
// Unparseable user file: do NOT clobber it — merge into a fresh wrapper and
// let the caller decide; we keep the unparseable original under a backup key
// is worse than refusing. Treat as empty but log at the call site.
root = {};
}
const hasWrapper = typeof root.mcpServers === "object" && root.mcpServers !== null && !Array.isArray(root.mcpServers);
// If the file is bare-layout (top-level server entries, no mcpServers key and
// at least one object value), keep that layout; otherwise use the wrapper.
const bareLayout = !hasWrapper && Object.values(root).some((v) => typeof v === "object" && v !== null);
const target = bareLayout
? root
: (root.mcpServers ?? {});
for (const [name, spec] of Object.entries(servers)) {
target[name] = specToAgyEntry(spec);
}
const merged = bareLayout ? target : { ...root, mcpServers: target };
return `${JSON.stringify(merged, null, 2)}\n`;
}
/** Remove OUR server entries from an agy mcp_config.json, preserving everything
* else. OWNERSHIP-AWARE (codex review): an entry is deleted only when its
* current content deep-equals the snapshot WE wrote — a same-named entry
* overwritten meanwhile by another tab/process is someone else's live config
* and is left alone. Returns the new text, or null when there is nothing to
* change (missing/unparseable/non-object file, or no owned keys present). */
export function removeAgyMcpServers(existingText, written) {
if (!existingText?.trim())
return null;
let root;
try {
const parsed = JSON.parse(existingText);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return null;
root = parsed;
}
catch {
return null;
}
const hasWrapper = typeof root.mcpServers === "object" && root.mcpServers !== null && !Array.isArray(root.mcpServers);
const target = hasWrapper ? root.mcpServers : root;
let changed = false;
for (const [name, snapshot] of Object.entries(written)) {
if (name in target && JSON.stringify(target[name]) === snapshot) {
delete target[name];
changed = true;
}
}
if (!changed)
return null;
return `${JSON.stringify(root, null, 2)}\n`;
}
/** May this env var be written to disk? STRICT ALLOWLIST (codex review: a
* denylist can't satisfy a "no secrets" guarantee — PRIVATE_KEY, AUTHORIZATION,
* DB_PASS, cookies, connection strings all slip past name patterns): only
* our own operational namespace goes to disk, and even there anything
* credential-shaped (COMFYUI_AUTH_TOKEN…) is excluded. Everything else is
* withheld — the comfyui MCP server re-reads every stored token itself at boot
* (~/.comfyui-mcp/.env + panel secrets, the same canonical store the
* orchestrator's spawn-env merge draws from), so withholding loses nothing. */
function isDiskSafeEnvKey(key) {
if (!/^COMFYUI(_MCP)?_[A-Z0-9_]+$/.test(key) && key !== "COMFYUI_URL" && key !== "COMFYUI_PATH")
return false;
return !/(API_?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH|COOKIE|PRIVATE)/i.test(key);
}
/** Reduce a spec's env to disk-safe keys before it is written. Returns the
* sanitized servers plus the withheld key names (for an honest log line).
* Exported for tests. */
export function sanitizeMcpServersForDisk(servers) {
const out = {};
const withheld = [];
for (const [name, spec] of Object.entries(servers)) {
if (spec.transport !== "stdio" || !spec.env) {
out[name] = spec;
continue;
}
const env = {};
for (const [k, v] of Object.entries(spec.env)) {
if (isDiskSafeEnvKey(k))
env[k] = v;
else
withheld.push(k);
}
out[name] = { ...spec, env };
}
return { servers: out, withheld };
}
/** The exact JSON entry written for a spec (shared by merge + the ownership
* snapshot so the close()-time content compare can never drift). */
export function specToAgyEntry(spec) {
return spec.transport === "stdio"
? { command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }
: { serverUrl: spec.url };
}
/** Atomic write: tmp file + rename, so a crash mid-write can never leave the
* user's global config truncated (the unparseable-file guard would then
* refuse to touch it forever). */
function writeFileAtomic(file, text) {
const tmp = `${file}.tmp-${process.pid}`;
writeFileSync(tmp, text, "utf8");
renameSync(tmp, file);
}
/** The MCP config file agy 1.1.5 actually honors — VERIFIED LIVE 2026-07-21:
* the workspace `.agents/mcp_config.json` and `~/.gemini/antigravity-cli/mcp/`
* paths circulating in third-party docs are IGNORED by the CLI; only the
* global shared `~/.gemini/config/mcp_config.json` attaches servers to a
* `-p` session (get_system_stats (action:"health") round-trip confirmed against a live ComfyUI). */
export function agyMcpConfigPath(home = homedir()) {
return join(home, ".gemini", "config", "mcp_config.json");
}
/** Sentinel session id: agy owns conversation storage and -p output carries no
* conversation id, so "resume" means `--continue` (the account's latest). */
const AGY_SESSION_SENTINEL = "antigravity-latest";
/**
* The Antigravity CLI adapter. One instance per PanelAgent; spawn-per-turn, so
* there is no persistent child between turns — only the in-flight one.
*/
export class AntigravityBackend {
id = "antigravity";
capabilities = ANTIGRAVITY_CAPABILITIES;
deps;
model;
bin; // undefined = not yet resolved
/** The in-flight per-turn child (interrupt/close kill its tree). */
child = null;
interrupted = false;
/** True from runTurn entry until settle — a turn is definitely in flight. */
turnActive = false;
/** Expiry for an interrupt armed while NO turn was active. Two truths clash:
* a Stop pressed right after Send must survive the pre-spawn window (the
* 03080bc bug — the turn is coming, just not visible to us yet), but a stray
* Stop while genuinely idle must NOT stay armed and cancel the user's next
* unrelated message minutes later. The backend can't see PanelAgent's queue
* to tell the two apart, so an idle-armed interrupt self-expires after a
* short grace: an imminent turn (Stop-after-Send) starts within it and
* consumes the flag; nothing starting means it was a stray Stop. */
idleInterruptExpiry = null;
disposed = false;
/** False until the first turn of a FRESH conversation completes; resumed
* sessions start true so every turn continues the latest conversation. */
continueNext = false;
needsSystemPreamble = false;
mcpConfigWritten = false;
/** The file ensureMcpConfig actually wrote to (cleanup target for close()). */
mcpConfigFile = null;
/** Per-entry snapshot of what we wrote (name -> JSON), for ownership-aware
* removal on close(). */
mcpWrittenEntries = null;
/** Reasoning effort for turns (`--effort low|medium|high`), from opts.effort
* when it is on agy's scale; other providers' scales are ignored. */
effort;
constructor(deps = {}) {
this.deps = deps;
this.model = deps.model;
}
resolveBin() {
if (this.bin === undefined)
this.bin = resolveAgyBin();
if (!this.bin) {
throw new Error("Antigravity CLI (`agy`) not found. Install it from https://antigravity.google " +
"(Windows: `irm https://antigravity.google/cli/install.ps1 | iex`; macOS/Linux: " +
"`curl -fsSL https://antigravity.google/cli/install.sh | bash`), run `agy` once to " +
"sign in with your Google account, then reconnect. You can also point " +
"COMFYUI_MCP_ANTIGRAVITY_PATH at the executable.");
}
return this.bin;
}
/** One-time preflight: resolve the executable so a missing install fails fast
* with the actionable message above (mirrors the other CLI backends). */
async prepare() {
if (this.disposed)
throw new Error("antigravity backend is closed.");
this.resolveBin();
}
/** Merge our MCP servers into agy's global mcp_config.json (once per backend
* instance) — the ONLY location the CLI honors (verified live; see
* agyMcpConfigPath). Merge-safe: every entry we don't own is preserved
* byte-for-byte, and removal = deleting our two keys. Being a GLOBAL file,
* the per-tab `panel` entry is last-writer-wins across concurrent
* antigravity tabs (each run() rewrites it) — an accepted edge for now.
* Failure is non-fatal: the agent still answers, just without ComfyUI tools —
* logged so the gap is visible. */
ensureMcpConfig() {
// Re-merge on EVERY run(), not once per instance: another tab's close() (or
// the user) may have removed/overwritten our entries since — a session must
// start from a config that actually contains ITS servers (codex review).
if (!this.deps.mcpServers || !Object.keys(this.deps.mcpServers).length)
return;
const file = this.deps.mcpConfigPath ?? agyMcpConfigPath();
const dir = dirname(file);
try {
let existing = null;
try {
existing = readFileSync(file, "utf8");
}
catch {
existing = null;
}
if (existing) {
let parsed;
try {
parsed = JSON.parse(existing);
}
catch {
logger.warn(`[antigravity-backend] ${file} exists but is not valid JSON — leaving it untouched (ComfyUI MCP tools will be unavailable to agy until it parses)`);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
logger.warn(`[antigravity-backend] ${file} is valid JSON but not an object — leaving it untouched (ComfyUI MCP tools will be unavailable to agy until it is an object)`);
return;
}
}
// NO SECRETS AT REST: strip credential-looking env keys before the write —
// the comfyui MCP server re-reads its tokens from ~/.comfyui-mcp at boot.
const { servers, withheld } = sanitizeMcpServersForDisk(this.deps.mcpServers);
mkdirSync(dir, { recursive: true });
writeFileAtomic(file, mergeAgyMcpConfig(existing, servers));
this.mcpConfigWritten = true;
this.mcpConfigFile = file;
// Ownership snapshot: exactly what we wrote, per entry — close() deletes
// an entry only while its content still matches this (another tab's
// overwrite is theirs to keep).
this.mcpWrittenEntries = Object.fromEntries(Object.entries(servers).map(([n, s]) => [n, JSON.stringify(specToAgyEntry(s))]));
logger.info(`[antigravity-backend] merged comfyui/panel MCP servers into ${file}` +
(withheld.length ? ` (withheld secret env keys: ${withheld.join(", ")} — the MCP server reloads them from its own store)` : ""));
}
catch (err) {
logger.warn(`[antigravity-backend] could not write ${file}: ${msgOf(err)}`);
}
}
/** Undo ensureMcpConfig on dispose: delete OUR keys from the global file so a
* later interactive `agy` run doesn't keep spawning our tool server against a
* possibly-stale ComfyUI URL and a dead per-tab panel URL. Everything else in
* the file is preserved; re-merged on the next run(). Best-effort. */
removeMcpConfig() {
if (!this.mcpConfigWritten || !this.mcpConfigFile || !this.mcpWrittenEntries)
return;
const file = this.mcpConfigFile;
this.mcpConfigWritten = false;
try {
let existing = null;
try {
existing = readFileSync(file, "utf8");
}
catch {
return;
}
const cleaned = removeAgyMcpServers(existing, this.mcpWrittenEntries);
if (cleaned !== null) {
writeFileAtomic(file, cleaned);
logger.info(`[antigravity-backend] removed our MCP server entries from ${file}`);
}
}
catch (err) {
logger.debug(`[antigravity-backend] mcp_config cleanup skipped: ${msgOf(err)}`);
}
}
/**
* Drive the session: one `agy -p` child per neutral turn, continuity via
* `--continue`. The channel async-iteration IS the turn gate (PanelAgent
* releases one batch per turn).
*/
async *run(opts) {
await this.prepare();
// Panel may pass the CLAUDE panel model unconditionally in opts.model — but a
// simple "reject claude-*" filter is WRONG here: Antigravity's own catalog
// legitimately offers Claude models (verified live: claude-sonnet-4-6,
// claude-opus-4-6-thinking ride the Google subscription). So validate
// claude-shaped ids against the account's real `agy models` catalog and only
// drop the ones that aren't in it (the panel's Anthropic-side default).
// Non-claude ids are honored directly — they can only have come from our own
// picker (fed by listModels) or explicit config.
if (opts.model)
this.model = (await this.acceptableModel(opts.model)) ?? this.model;
// agy has a real --effort flag (low|medium|high). Honor opts.effort when it
// is on that scale; other providers' scales (e.g. Codex xhigh) are ignored.
if (opts.effort && /^(low|medium|high)$/i.test(opts.effort)) {
this.effort = opts.effort.toLowerCase();
}
const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
this.ensureMcpConfig();
const resuming = !!(opts.resume ?? opts.sessionId);
this.continueNext = resuming;
this.needsSystemPreamble = !resuming && !!this.deps.systemAppend;
yield {
type: "session",
sessionId: AGY_SESSION_SENTINEL,
...(this.model ? { model: this.model } : {}),
};
let turnSeq = 0;
for await (const turn of opts.channel) {
yield* stampTurn(this.runTurn(turn, cwd, opts.onActivity), ++turnSeq);
}
}
/** Run ONE turn = one `agy -p` child. stdout streams as assistant deltas;
* exit 0 commits the accumulated text and a successful result; a non-zero
* exit (or spawn failure) surfaces an error + failed result. Exactly one
* terminal result per turn (PanelAgent's gate depends on it). */
async *runTurn(turn, cwd, onActivity) {
let text = promptText(turn.text);
if (this.needsSystemPreamble && this.deps.systemAppend) {
text =
`<system>\n${this.deps.systemAppend}\n</system>\n\n` +
`The user's first message follows.\n\n${text}`;
this.needsSystemPreamble = false;
}
// Image refs: no documented -p image input — the refs are already named in
// the turn text (vision=false tells the panel up front).
const bin = this.resolveBin();
const timeout = process.env.COMFYUI_MCP_ANTIGRAVITY_PRINT_TIMEOUT?.trim() || "45m";
const args = [
...(this.continueNext ? ["-c"] : []),
"-p",
text,
"--print-timeout",
timeout,
// The panel agent is an isolated background agent (same posture as
// Claude bypassPermissions / the ACP auto-approve): tool calls must not
// block on an interactive prompt that nothing will ever answer.
"--dangerously-skip-permissions",
...(this.model ? ["--model", this.model] : []),
...(this.effort ? ["--effort", this.effort] : []),
];
// NOTE: `interrupted` is deliberately NOT cleared here. A Stop can land
// while this turn is still starting up (run() awaits prepare() and the
// channel before reaching runTurn), and clearing at turn START silently
// discarded it — the turn then ran with nothing able to stop it. The flag is
// cleared when a turn SETTLES instead, so it survives setup and the child
// assignment below acts on it. A pending idle-armed interrupt is now
// definitely aimed at THIS turn — cancel its expiry so it's consumed here.
this.turnActive = true;
if (this.idleInterruptExpiry) {
clearTimeout(this.idleInterruptExpiry);
this.idleInterruptExpiry = null;
}
// Windows caps the whole CreateProcess command line at 32,767 chars, and the
// prompt rides argv. The panel system preamble alone is ~29K on the first
// turn, so a transcript replay / pasted workflow can blow past it — spawn
// then fails with a cryptic EINVAL/E2BIG. Preflight with a legible error.
if (process.platform === "win32") {
const cmdLen = bin.length + args.reduce((n, a) => n + a.length + 3, 0);
if (cmdLen > 30_000) {
this.turnActive = false;
yield {
type: "error",
message: `This message is too large for the Antigravity CLI (${cmdLen} chars; Windows caps a command line at ~32K, and agy takes the prompt as an argument). ` +
"Send a shorter message, or start a fresh chat to drop replayed context.",
};
yield { type: "result", ok: false, subtype: "error" };
return;
}
}
const queue = [];
let wake = null;
let done = false;
const push = (ev) => {
queue.push(ev);
wake?.();
wake = null;
};
const finish = () => {
done = true;
wake?.();
wake = null;
};
let out = "";
let errOut = "";
let streamOpen = false;
let child;
try {
child = spawn(bin, args, {
cwd,
env: buildAgentSpawnEnv(), // no keep-list: strip ALL tool-only secrets (agy uses OAuth/keyring)
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
detached: process.platform !== "win32",
});
}
catch (err) {
yield { type: "error", message: `Could not start agy: ${msgOf(err)}` };
yield { type: "result", ok: false, subtype: "error" };
return;
}
this.child = child;
// LIVENESS heartbeat: agy -p may print NOTHING for minutes while it works
// (tool calls, long generations) — there is no event stream to observe. The
// child process being alive is the only honest liveness signal, so bump the
// idle watchdog on an interval while it runs. A truly hung child is bounded
// by agy's own --print-timeout, which ends the turn with a non-zero exit.
const heartbeat = setInterval(() => {
try {
onActivity?.();
}
catch {
// watchdog bump must never throw
}
}, 15_000);
heartbeat.unref?.();
// HARD CEILING: the heartbeat above defers turn-length policing to agy's
// own --print-timeout — but a frozen agy that never honors it would then
// hang the turn unbounded. Enforce the same budget ourselves, +2min grace:
// if the child is still alive past it, kill the tree; settle() then ends
// the turn with a legible timeout error instead of a generic exit code.
let ceilingHit = false;
let ceilingForce = null;
const ceilingMs = (parseGoDurationMs(timeout) ?? 45 * 60_000) + 120_000;
const ceiling = setTimeout(() => {
ceilingHit = true;
killProcessTree(child.pid);
// INDEPENDENT finalization (codex review): if the kill fails or the child
// never emits exit/error, nothing else would ever settle the turn — the
// generator (and PanelAgent's gate) would block forever. Give the kill a
// beat to surface the natural exit event, then force-settle; settle() is
// idempotent, so a late real exit becomes a no-op.
ceilingForce = setTimeout(() => settle(null), 10_000);
ceilingForce.unref?.();
}, ceilingMs);
ceiling.unref?.();
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
try {
onActivity?.();
}
catch {
// watchdog bump must never break the reader
}
const clean = stripAnsi(chunk);
if (!clean)
return;
out += clean;
if (!streamOpen) {
streamOpen = true;
push({ type: "stream_start", id: null });
}
push({ type: "assistant_delta", text: clean });
});
child.stderr.on("data", (chunk) => {
try {
onActivity?.();
}
catch {
// ignore
}
errOut += chunk;
});
let settled = false;
const settle = (code, spawnErr) => {
if (settled)
return;
settled = true;
clearInterval(heartbeat);
clearTimeout(ceiling);
if (ceilingForce)
clearTimeout(ceilingForce);
if (this.child === child)
this.child = null;
if (streamOpen)
push({ type: "stream_end" });
const finalText = out.trim();
if (this.interrupted) {
if (finalText)
push({ type: "assistant", text: finalText });
push({ type: "result", ok: false, subtype: "cancelled" });
}
else if (ceilingHit) {
push({
type: "error",
message: `agy did not finish within ${timeout} (+2m grace) and was terminated — its own --print-timeout never fired, which usually means a frozen CLI. ` +
"Try again; raise COMFYUI_MCP_ANTIGRAVITY_PRINT_TIMEOUT for genuinely long jobs.",
});
push({ type: "result", ok: false, subtype: "timeout" });
}
else if (spawnErr || code !== 0) {
const stderrTail = stripAnsi(errOut).trim().split(/\r?\n/).slice(-3).join(" ").trim();
const authish = /sign.?in|log.?in|auth|credential|unauthoriz/i.test(stderrTail + finalText);
const message = spawnErr
? /ENOENT/i.test(spawnErr.message)
? "Antigravity CLI (`agy`) could not be launched — it may have been uninstalled. Reinstall from https://antigravity.google and reconnect."
: `agy failed to start: ${spawnErr.message}`
: authish
? `Antigravity CLI is not signed in. Run \`agy\` once in a terminal, complete the Google Sign-In, then send your message again.${stderrTail ? ` (agy: ${stderrTail})` : ""}`
: `agy exited with code ${code}.${stderrTail ? ` ${stderrTail}` : ""}`;
push({ type: "error", message });
push({ type: "result", ok: false, subtype: "error" });
}
else {
if (finalText)
push({ type: "assistant", text: finalText });
// From here on, continue the conversation agy just recorded.
this.continueNext = true;
push({ type: "result", ok: true, subtype: "end_turn" });
}
// Clear the interrupt HERE (turn end), not at turn start — see the note
// where the queue is set up. The flag has now been consumed to pick this
// turn's result, so the next turn starts clean. turnActive closes with it:
// interrupts landing from now until the next runTurn are idle no-ops.
this.interrupted = false;
this.turnActive = false;
finish();
};
child.on("error", (err) => settle(null, err));
child.on("exit", (code) => settle(code));
// An interrupt can arrive while spawn() is in flight — `this.child` was
// still null then, so interrupt() could only raise the flag. Honor it now.
// This MUST come after the exit/error listeners above: killing earlier can
// deliver the child's death before anything is listening, and then the turn
// never settles (exactly what hung the interrupt test).
if (this.interrupted)
killProcessTree(child.pid);
// Drain the bridged queue until the child settles. The finally guards the
// ABANDONMENT path: a consumer that drops this generator mid-turn without
// close() (generator .return() runs finally blocks) must not leave the
// child running for up to 45m with the heartbeat bumping a dead turn.
try {
while (true) {
while (queue.length)
yield queue.shift();
if (done)
break;
await new Promise((resolve) => {
wake = resolve;
});
}
while (queue.length)
yield queue.shift();
}
finally {
if (!settled) {
// ABANDONED mid-turn (consumer dropped the generator without close()).
// Mark settled FIRST: the kill below triggers the child's exit event,
// and without this it would run settle() against shared state that may
// already belong to a subsequent turn (codex review).
settled = true;
clearInterval(heartbeat);
clearTimeout(ceiling);
if (ceilingForce)
clearTimeout(ceilingForce);
killProcessTree(child.pid);
if (this.child === child)
this.child = null;
this.turnActive = false;
this.interrupted = false;
}
}
}
/** Stop the current turn: kill the in-flight child's process tree. The next
* turn continues the conversation (`-c`) with whatever agy recorded. */
async interrupt() {
// Set the flag FIRST and UNCONDITIONALLY. `this.child` is only assigned
// after spawn() returns, so an interrupt landing in that window used to hit
// the `if (!child) return` below, leave `interrupted` false, and never kill
// anything — the turn then ran to completion with nothing able to stop it
// (the original 03080bc bug). run() re-checks this flag the moment it
// assigns the child, so a kill is never lost.
this.interrupted = true;
if (this.idleInterruptExpiry)
clearTimeout(this.idleInterruptExpiry);
this.idleInterruptExpiry = null;
if (!this.turnActive) {
// Armed while NO turn was in flight: either a Stop racing its own turn's
// startup (honor it — a turn starts within ms and consumes the flag) or a
// stray idle Stop (PanelAgent interrupts unconditionally, e.g. rewind).
// Self-expire so the stray case can't linger and spuriously cancel the
// user's NEXT message (the post-03080bc poisoning regression).
const t = setTimeout(() => {
this.idleInterruptExpiry = null;
if (!this.turnActive)
this.interrupted = false;
}, 500);
t.unref?.();
this.idleInterruptExpiry = t;
}
const child = this.child;
if (!child)
return; // spawn window — run() kills it as soon as it exists
killProcessTree(child.pid);
}
/** The account's model catalog, fetched lazily via `agy models` and cached for
* the backend's lifetime. Null when the probe fails (unauthenticated etc.) —
* callers then fall back to the claude-prefix heuristic. */
catalog = null;
/** When the last catalog probe FAILED (ms epoch) — negative-cached so an
* unauthenticated CLI doesn't eat a fresh 30s `agy models` spawn on every
* claude-shaped model check (each connect re-runs run()). */
catalogFailedAt = 0;
static CATALOG_RETRY_MS = 60_000;
async ensureCatalog() {
if (this.catalog)
return this.catalog;
if (Date.now() - this.catalogFailedAt < AntigravityBackend.CATALOG_RETRY_MS)
return null;
try {
this.catalog = new Set((await this.listModels()).map((m) => m.id));
}
catch (err) {
logger.debug(`[antigravity-backend] catalog probe failed: ${msgOf(err)}`);
this.catalog = null;
this.catalogFailedAt = Date.now();
}
return this.catalog;
}
/** Decide whether `model` is usable for THIS provider: non-claude ids pass
* (they come from our own picker/config); claude-shaped ids are checked
* against the live catalog — Antigravity DOES serve some claude-* models, but
* the panel also leaks its Anthropic-side default (e.g. claude-opus-5)
* through opts.model, which must be dropped. Returns the model when
* acceptable, undefined when not. Catalog unavailable → drop claude ids
* (conservative: a wrong --model makes every turn fail). */
async acceptableModel(model) {
if (!/^claude/i.test(model))
return model;
const catalog = await this.ensureCatalog();
return catalog?.has(model) ? model : undefined;
}
/** Switch the model for subsequent turns (--model is per-spawn, so this is
* free — no respawn dance). Drops ids the account's catalog can't serve
* (see acceptableModel). */
async setModel(model) {
const ok = await this.acceptableModel(model);
if (ok)
this.model = ok;
}
/**
* Enumerate the account's models via `agy models` (per #262: parse the live
* catalog, never ship a static list). Also serves as the safe auth probe: an
* unauthenticated CLI fails here → the connect ack degrades with sign-in
* guidance instead of greeting ready.
*/
async listModels() {
const bin = this.resolveBin();
return await new Promise((resolve, reject) => {
let out = "";
let err = "";
let child;
try {
child = spawn(bin, ["models"], {
cwd: this.deps.cwd ?? process.cwd(),
env: buildAgentSpawnEnv(), // no keep-list: strip ALL tool-only secrets (agy uses OAuth/keyring)
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
}
catch (e) {
reject(new Error(`Could not run \`agy models\`: ${msgOf(e)}`));
return;
}
const timer = setTimeout(() => {
killProcessTree(child.pid);
reject(new Error("`agy models` timed out after 30s."));
}, 30_000);
timer.unref?.();
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (c) => (out += c));
child.stderr.on("data", (c) => (err += c));
child.on("error", (e) => {
clearTimeout(timer);
reject(new Error(`Could not run \`agy models\`: ${e.message}`));
});
child.on("exit", (code) => {
clearTimeout(timer);
if (code === 0) {
resolve(parseAgyModels(out));
}
else {
const tail = stripAnsi(err || out).trim().split(/\r?\n/).slice(-2).join(" ");
reject(new Error(`\`agy models\` exited with code ${code}${tail ? ` (${tail})` : ""}. ` +
"If you haven't signed in yet, run `agy` once and complete the Google Sign-In."));
}
});
});
}
/** Dispose: kill any in-flight child tree. Idempotent, safe when never started. */
async close() {
this.disposed = true;
const child = this.child;
this.child = null;
if (child) {
this.interrupted = true;
killProcessTree(child.pid);
}
if (this.idleInterruptExpiry) {
clearTimeout(this.idleInterruptExpiry);
this.idleInterruptExpiry = null;
}
// Leave no standing footprint: our entries in the global mcp_config.json
// would otherwise make every later interactive `agy` run spawn our tool
// server (stale COMFYUI_URL, dead per-tab panel URL) — and the file is
// read by another product. Re-merged by the next session's run().
this.removeMcpConfig();
}
}
//# sourceMappingURL=antigravity-backend.js.map