comfyui-mcp 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/config.js +159 -11
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/ai-proposer.js +5 -0
- package/dist/orchestrator/ai-proposer.js.map +1 -1
- package/dist/orchestrator/antigravity-backend.js +394 -52
- package/dist/orchestrator/antigravity-backend.js.map +1 -1
- package/dist/orchestrator/claude-backend.js +13 -0
- package/dist/orchestrator/claude-backend.js.map +1 -1
- package/dist/orchestrator/codex-backend.js +12 -1
- package/dist/orchestrator/codex-backend.js.map +1 -1
- package/dist/orchestrator/gemini-backend.js +8 -1
- package/dist/orchestrator/gemini-backend.js.map +1 -1
- package/dist/orchestrator/grok-backend.js +9 -5
- package/dist/orchestrator/grok-backend.js.map +1 -1
- package/dist/orchestrator/index.js +603 -50
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +21 -5
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-console-http.js +1 -0
- package/dist/orchestrator/panel-console-http.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +140 -9
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/ai-toolkit.js +260 -3
- package/dist/services/ai-toolkit.js.map +1 -1
- package/dist/services/download-progress.js +177 -7
- package/dist/services/download-progress.js.map +1 -1
- package/dist/services/missing-models.js +295 -0
- package/dist/services/missing-models.js.map +1 -0
- package/dist/services/openai-provider-registry.js +20 -1
- package/dist/services/openai-provider-registry.js.map +1 -1
- package/dist/services/panel-secrets.js +58 -7
- package/dist/services/panel-secrets.js.map +1 -1
- package/dist/services/process-control.js +154 -4
- package/dist/services/process-control.js.map +1 -1
- package/dist/services/runpod-client.js +157 -9
- package/dist/services/runpod-client.js.map +1 -1
- package/dist/services/runpod-ssh.js +594 -0
- package/dist/services/runpod-ssh.js.map +1 -0
- package/dist/services/runpod-watch.js +192 -13
- package/dist/services/runpod-watch.js.map +1 -1
- package/dist/services/secure-bridge.js +22 -6
- package/dist/services/secure-bridge.js.map +1 -1
- package/dist/services/trainer-bootstrap.js +142 -0
- package/dist/services/trainer-bootstrap.js.map +1 -0
- package/dist/services/training-config.js +4 -1
- package/dist/services/training-config.js.map +1 -1
- package/dist/services/training-jobs.js +1332 -253
- package/dist/services/training-jobs.js.map +1 -1
- package/dist/tools/apps.js +222 -0
- package/dist/tools/apps.js.map +1 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/missing-models.js +151 -0
- package/dist/tools/missing-models.js.map +1 -0
- package/dist/tools/process-control.js +1 -1
- package/dist/tools/process-control.js.map +1 -1
- package/dist/tools/runpod.js +205 -17
- package/dist/tools/runpod.js.map +1 -1
- package/dist/tools/train.js +138 -5
- package/dist/tools/train.js.map +1 -1
- package/docker/trainer/Dockerfile +4 -2
- package/package.json +1 -1
- package/scripts/gen-tool-docs.ts +43 -4
|
@@ -26,27 +26,46 @@
|
|
|
26
26
|
// `agy models` answers (the connect ack's model probe). An unauthenticated CLI
|
|
27
27
|
// gets an actionable "run `agy` once and sign in" message.
|
|
28
28
|
//
|
|
29
|
-
// MCP / ComfyUI tools
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// `
|
|
33
|
-
//
|
|
34
|
-
//
|
|
29
|
+
// MCP / ComfyUI tools — VERIFIED LIVE against agy 1.1.5 (2026-07-21): the CLI
|
|
30
|
+
// honors ONLY the global shared `~/.gemini/config/mcp_config.json`; the
|
|
31
|
+
// workspace `.agents/mcp_config.json` path in third-party docs is IDE-only and
|
|
32
|
+
// silently ignored by `agy -p`. Before the first turn we MERGE our two servers —
|
|
33
|
+
// the headless `comfyui` stdio MCP and the per-tab `panel` HTTP MCP — into that
|
|
34
|
+
// file under our own names, preserving every other entry byte-for-byte.
|
|
35
|
+
// Merge-safe and reversible (delete the two keys); global rather than
|
|
36
|
+
// workspace-scoped only because the CLI offers nothing narrower.
|
|
35
37
|
//
|
|
36
38
|
// LIMITS (flagged honestly, mirrors the issue's "reduced capabilities" ask):
|
|
37
39
|
// - `-p` prints plain text: no structured tool-call events, so the panel shows
|
|
38
40
|
// no per-tool progress for agy turns (onActivity still fires on every stdout
|
|
39
41
|
// chunk, keeping the idle watchdog armed).
|
|
40
42
|
// - `--continue` binds to the account's LATEST conversation; a user running
|
|
41
|
-
// `agy` interactively in parallel
|
|
42
|
-
//
|
|
43
|
+
// `agy` interactively in parallel — or a SECOND antigravity panel tab —
|
|
44
|
+
// can steal continuity (tab A's next turn continues tab B's thread). The
|
|
45
|
+
// supported surface offers no way to read the new conversation's id from
|
|
46
|
+
// -p output; revisit if a future agy prints one.
|
|
47
|
+
// - The prompt rides argv, so it is visible in the OS process list for the
|
|
48
|
+
// child's lifetime (same-host processes can read the conversation) and is
|
|
49
|
+
// capped at ~32K chars on Windows (preflighted with a legible error).
|
|
43
50
|
// - No documented image-input path for -p → vision=false (image refs are
|
|
44
51
|
// already named in the turn text as a fallback).
|
|
45
52
|
import { spawn, spawnSync } from "node:child_process";
|
|
46
|
-
|
|
53
|
+
// SECURITY: agy is Google's LLM-vendor CLI — its subprocess must NOT inherit the
|
|
54
|
+
// user's TOOL secrets (RunPod/CivitAI/HF/RunComfy/Registry tokens, AND the
|
|
55
|
+
// GEMINI/GOOGLE keys, which panel-secrets classifies as tool secrets). The
|
|
56
|
+
// official Antigravity CLI docs (antigravity.google/docs/cli) say agy
|
|
57
|
+
// authenticates via the native system keyring + browser/SSH OAuth — it does NOT
|
|
58
|
+
// document any API-key env auth — so there is NO confirmed need to re-admit the
|
|
59
|
+
// GEMINI/GOOGLE keys, and doing so would expose tool credentials to the agy
|
|
60
|
+
// subprocess. We therefore strip EVERY tool-only secret (no keep-list).
|
|
61
|
+
// NOTE: if a future agy version documents API-key env auth (e.g. GEMINI_API_KEY),
|
|
62
|
+
// re-admit that specific key deliberately here via buildAgentSpawnEnv(..., { keep })
|
|
63
|
+
// with a justification — do not blanket-keep.
|
|
64
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
47
65
|
import { homedir } from "node:os";
|
|
48
|
-
import { join } from "node:path";
|
|
66
|
+
import { dirname, join } from "node:path";
|
|
49
67
|
import { logger } from "../utils/logger.js";
|
|
68
|
+
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
|
|
50
69
|
import { ANTIGRAVITY_CAPABILITIES, } from "./agent-backend.js";
|
|
51
70
|
function msgOf(err) {
|
|
52
71
|
return err instanceof Error ? err.message : String(err);
|
|
@@ -126,6 +145,13 @@ export function resolveAgyBin(home = homedir()) {
|
|
|
126
145
|
}
|
|
127
146
|
return null;
|
|
128
147
|
}
|
|
148
|
+
/** Parse a Go-style duration ("45m", "5m0s", "1h30m") to ms; null when unparseable. */
|
|
149
|
+
export function parseGoDurationMs(s) {
|
|
150
|
+
const m = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(s.trim());
|
|
151
|
+
if (!m || (!m[1] && !m[2] && !m[3]))
|
|
152
|
+
return null;
|
|
153
|
+
return (Number(m[1] ?? 0) * 3600 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 1000;
|
|
154
|
+
}
|
|
129
155
|
/** Strip ANSI escape sequences (agy's TUI heritage may color even -p output). */
|
|
130
156
|
function stripAnsi(s) {
|
|
131
157
|
// eslint-disable-next-line no-control-regex
|
|
@@ -185,8 +211,17 @@ export function parseAgyModels(output) {
|
|
|
185
211
|
export function mergeAgyMcpConfig(existingText, servers) {
|
|
186
212
|
let root = {};
|
|
187
213
|
try {
|
|
188
|
-
if (existingText?.trim())
|
|
189
|
-
|
|
214
|
+
if (existingText?.trim()) {
|
|
215
|
+
const parsed = JSON.parse(existingText);
|
|
216
|
+
// Parseable-but-non-object (array/scalar): named keys set on an array are
|
|
217
|
+
// silently dropped by stringify, and spreading a scalar makes garbage —
|
|
218
|
+
// neither is a valid mcp config, so start from a fresh wrapper instead.
|
|
219
|
+
// (ensureMcpConfig refuses to touch such a file at all; this guard keeps
|
|
220
|
+
// the pure function safe for direct callers.)
|
|
221
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
222
|
+
root = parsed;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
190
225
|
}
|
|
191
226
|
catch {
|
|
192
227
|
// Unparseable user file: do NOT clobber it — merge into a fresh wrapper and
|
|
@@ -202,14 +237,101 @@ export function mergeAgyMcpConfig(existingText, servers) {
|
|
|
202
237
|
? root
|
|
203
238
|
: (root.mcpServers ?? {});
|
|
204
239
|
for (const [name, spec] of Object.entries(servers)) {
|
|
205
|
-
target[name] =
|
|
206
|
-
spec.transport === "stdio"
|
|
207
|
-
? { command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }
|
|
208
|
-
: { serverUrl: spec.url };
|
|
240
|
+
target[name] = specToAgyEntry(spec);
|
|
209
241
|
}
|
|
210
242
|
const merged = bareLayout ? target : { ...root, mcpServers: target };
|
|
211
243
|
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
212
244
|
}
|
|
245
|
+
/** Remove OUR server entries from an agy mcp_config.json, preserving everything
|
|
246
|
+
* else. OWNERSHIP-AWARE (codex review): an entry is deleted only when its
|
|
247
|
+
* current content deep-equals the snapshot WE wrote — a same-named entry
|
|
248
|
+
* overwritten meanwhile by another tab/process is someone else's live config
|
|
249
|
+
* and is left alone. Returns the new text, or null when there is nothing to
|
|
250
|
+
* change (missing/unparseable/non-object file, or no owned keys present). */
|
|
251
|
+
export function removeAgyMcpServers(existingText, written) {
|
|
252
|
+
if (!existingText?.trim())
|
|
253
|
+
return null;
|
|
254
|
+
let root;
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(existingText);
|
|
257
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
258
|
+
return null;
|
|
259
|
+
root = parsed;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const hasWrapper = typeof root.mcpServers === "object" && root.mcpServers !== null && !Array.isArray(root.mcpServers);
|
|
265
|
+
const target = hasWrapper ? root.mcpServers : root;
|
|
266
|
+
let changed = false;
|
|
267
|
+
for (const [name, snapshot] of Object.entries(written)) {
|
|
268
|
+
if (name in target && JSON.stringify(target[name]) === snapshot) {
|
|
269
|
+
delete target[name];
|
|
270
|
+
changed = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (!changed)
|
|
274
|
+
return null;
|
|
275
|
+
return `${JSON.stringify(root, null, 2)}\n`;
|
|
276
|
+
}
|
|
277
|
+
/** May this env var be written to disk? STRICT ALLOWLIST (codex review: a
|
|
278
|
+
* denylist can't satisfy a "no secrets" guarantee — PRIVATE_KEY, AUTHORIZATION,
|
|
279
|
+
* DB_PASS, cookies, connection strings all slip past name patterns): only
|
|
280
|
+
* our own operational namespace goes to disk, and even there anything
|
|
281
|
+
* credential-shaped (COMFYUI_AUTH_TOKEN…) is excluded. Everything else is
|
|
282
|
+
* withheld — the comfyui MCP server re-reads every stored token itself at boot
|
|
283
|
+
* (~/.comfyui-mcp/.env + panel secrets, the same canonical store the
|
|
284
|
+
* orchestrator's spawn-env merge draws from), so withholding loses nothing. */
|
|
285
|
+
function isDiskSafeEnvKey(key) {
|
|
286
|
+
if (!/^COMFYUI(_MCP)?_[A-Z0-9_]+$/.test(key) && key !== "COMFYUI_URL" && key !== "COMFYUI_PATH")
|
|
287
|
+
return false;
|
|
288
|
+
return !/(API_?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH|COOKIE|PRIVATE)/i.test(key);
|
|
289
|
+
}
|
|
290
|
+
/** Reduce a spec's env to disk-safe keys before it is written. Returns the
|
|
291
|
+
* sanitized servers plus the withheld key names (for an honest log line).
|
|
292
|
+
* Exported for tests. */
|
|
293
|
+
export function sanitizeMcpServersForDisk(servers) {
|
|
294
|
+
const out = {};
|
|
295
|
+
const withheld = [];
|
|
296
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
297
|
+
if (spec.transport !== "stdio" || !spec.env) {
|
|
298
|
+
out[name] = spec;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const env = {};
|
|
302
|
+
for (const [k, v] of Object.entries(spec.env)) {
|
|
303
|
+
if (isDiskSafeEnvKey(k))
|
|
304
|
+
env[k] = v;
|
|
305
|
+
else
|
|
306
|
+
withheld.push(k);
|
|
307
|
+
}
|
|
308
|
+
out[name] = { ...spec, env };
|
|
309
|
+
}
|
|
310
|
+
return { servers: out, withheld };
|
|
311
|
+
}
|
|
312
|
+
/** The exact JSON entry written for a spec (shared by merge + the ownership
|
|
313
|
+
* snapshot so the close()-time content compare can never drift). */
|
|
314
|
+
export function specToAgyEntry(spec) {
|
|
315
|
+
return spec.transport === "stdio"
|
|
316
|
+
? { command: spec.command, args: spec.args ?? [], env: spec.env ?? {} }
|
|
317
|
+
: { serverUrl: spec.url };
|
|
318
|
+
}
|
|
319
|
+
/** Atomic write: tmp file + rename, so a crash mid-write can never leave the
|
|
320
|
+
* user's global config truncated (the unparseable-file guard would then
|
|
321
|
+
* refuse to touch it forever). */
|
|
322
|
+
function writeFileAtomic(file, text) {
|
|
323
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
324
|
+
writeFileSync(tmp, text, "utf8");
|
|
325
|
+
renameSync(tmp, file);
|
|
326
|
+
}
|
|
327
|
+
/** The MCP config file agy 1.1.5 actually honors — VERIFIED LIVE 2026-07-21:
|
|
328
|
+
* the workspace `.agents/mcp_config.json` and `~/.gemini/antigravity-cli/mcp/`
|
|
329
|
+
* paths circulating in third-party docs are IGNORED by the CLI; only the
|
|
330
|
+
* global shared `~/.gemini/config/mcp_config.json` attaches servers to a
|
|
331
|
+
* `-p` session (health_check round-trip confirmed against a live ComfyUI). */
|
|
332
|
+
export function agyMcpConfigPath(home = homedir()) {
|
|
333
|
+
return join(home, ".gemini", "config", "mcp_config.json");
|
|
334
|
+
}
|
|
213
335
|
/** Sentinel session id: agy owns conversation storage and -p output carries no
|
|
214
336
|
* conversation id, so "resume" means `--continue` (the account's latest). */
|
|
215
337
|
const AGY_SESSION_SENTINEL = "antigravity-latest";
|
|
@@ -226,12 +348,31 @@ export class AntigravityBackend {
|
|
|
226
348
|
/** The in-flight per-turn child (interrupt/close kill its tree). */
|
|
227
349
|
child = null;
|
|
228
350
|
interrupted = false;
|
|
351
|
+
/** True from runTurn entry until settle — a turn is definitely in flight. */
|
|
352
|
+
turnActive = false;
|
|
353
|
+
/** Expiry for an interrupt armed while NO turn was active. Two truths clash:
|
|
354
|
+
* a Stop pressed right after Send must survive the pre-spawn window (the
|
|
355
|
+
* 03080bc bug — the turn is coming, just not visible to us yet), but a stray
|
|
356
|
+
* Stop while genuinely idle must NOT stay armed and cancel the user's next
|
|
357
|
+
* unrelated message minutes later. The backend can't see PanelAgent's queue
|
|
358
|
+
* to tell the two apart, so an idle-armed interrupt self-expires after a
|
|
359
|
+
* short grace: an imminent turn (Stop-after-Send) starts within it and
|
|
360
|
+
* consumes the flag; nothing starting means it was a stray Stop. */
|
|
361
|
+
idleInterruptExpiry = null;
|
|
229
362
|
disposed = false;
|
|
230
363
|
/** False until the first turn of a FRESH conversation completes; resumed
|
|
231
364
|
* sessions start true so every turn continues the latest conversation. */
|
|
232
365
|
continueNext = false;
|
|
233
366
|
needsSystemPreamble = false;
|
|
234
367
|
mcpConfigWritten = false;
|
|
368
|
+
/** The file ensureMcpConfig actually wrote to (cleanup target for close()). */
|
|
369
|
+
mcpConfigFile = null;
|
|
370
|
+
/** Per-entry snapshot of what we wrote (name -> JSON), for ownership-aware
|
|
371
|
+
* removal on close(). */
|
|
372
|
+
mcpWrittenEntries = null;
|
|
373
|
+
/** Reasoning effort for turns (`--effort low|medium|high`), from opts.effort
|
|
374
|
+
* when it is on agy's scale; other providers' scales are ignored. */
|
|
375
|
+
effort;
|
|
235
376
|
constructor(deps = {}) {
|
|
236
377
|
this.deps = deps;
|
|
237
378
|
this.model = deps.model;
|
|
@@ -255,15 +396,22 @@ export class AntigravityBackend {
|
|
|
255
396
|
throw new Error("antigravity backend is closed.");
|
|
256
397
|
this.resolveBin();
|
|
257
398
|
}
|
|
258
|
-
/** Merge our MCP servers into
|
|
259
|
-
*
|
|
399
|
+
/** Merge our MCP servers into agy's global mcp_config.json (once per backend
|
|
400
|
+
* instance) — the ONLY location the CLI honors (verified live; see
|
|
401
|
+
* agyMcpConfigPath). Merge-safe: every entry we don't own is preserved
|
|
402
|
+
* byte-for-byte, and removal = deleting our two keys. Being a GLOBAL file,
|
|
403
|
+
* the per-tab `panel` entry is last-writer-wins across concurrent
|
|
404
|
+
* antigravity tabs (each run() rewrites it) — an accepted edge for now.
|
|
260
405
|
* Failure is non-fatal: the agent still answers, just without ComfyUI tools —
|
|
261
406
|
* logged so the gap is visible. */
|
|
262
|
-
ensureMcpConfig(
|
|
263
|
-
|
|
407
|
+
ensureMcpConfig() {
|
|
408
|
+
// Re-merge on EVERY run(), not once per instance: another tab's close() (or
|
|
409
|
+
// the user) may have removed/overwritten our entries since — a session must
|
|
410
|
+
// start from a config that actually contains ITS servers (codex review).
|
|
411
|
+
if (!this.deps.mcpServers || !Object.keys(this.deps.mcpServers).length)
|
|
264
412
|
return;
|
|
265
|
-
const
|
|
266
|
-
const
|
|
413
|
+
const file = this.deps.mcpConfigPath ?? agyMcpConfigPath();
|
|
414
|
+
const dir = dirname(file);
|
|
267
415
|
try {
|
|
268
416
|
let existing = null;
|
|
269
417
|
try {
|
|
@@ -273,37 +421,88 @@ export class AntigravityBackend {
|
|
|
273
421
|
existing = null;
|
|
274
422
|
}
|
|
275
423
|
if (existing) {
|
|
424
|
+
let parsed;
|
|
276
425
|
try {
|
|
277
|
-
JSON.parse(existing);
|
|
426
|
+
parsed = JSON.parse(existing);
|
|
278
427
|
}
|
|
279
428
|
catch {
|
|
280
429
|
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)`);
|
|
281
430
|
return;
|
|
282
431
|
}
|
|
432
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
433
|
+
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)`);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
283
436
|
}
|
|
437
|
+
// NO SECRETS AT REST: strip credential-looking env keys before the write —
|
|
438
|
+
// the comfyui MCP server re-reads its tokens from ~/.comfyui-mcp at boot.
|
|
439
|
+
const { servers, withheld } = sanitizeMcpServersForDisk(this.deps.mcpServers);
|
|
284
440
|
mkdirSync(dir, { recursive: true });
|
|
285
|
-
|
|
441
|
+
writeFileAtomic(file, mergeAgyMcpConfig(existing, servers));
|
|
286
442
|
this.mcpConfigWritten = true;
|
|
287
|
-
|
|
443
|
+
this.mcpConfigFile = file;
|
|
444
|
+
// Ownership snapshot: exactly what we wrote, per entry — close() deletes
|
|
445
|
+
// an entry only while its content still matches this (another tab's
|
|
446
|
+
// overwrite is theirs to keep).
|
|
447
|
+
this.mcpWrittenEntries = Object.fromEntries(Object.entries(servers).map(([n, s]) => [n, JSON.stringify(specToAgyEntry(s))]));
|
|
448
|
+
logger.info(`[antigravity-backend] merged comfyui/panel MCP servers into ${file}` +
|
|
449
|
+
(withheld.length ? ` (withheld secret env keys: ${withheld.join(", ")} — the MCP server reloads them from its own store)` : ""));
|
|
288
450
|
}
|
|
289
451
|
catch (err) {
|
|
290
452
|
logger.warn(`[antigravity-backend] could not write ${file}: ${msgOf(err)}`);
|
|
291
453
|
}
|
|
292
454
|
}
|
|
455
|
+
/** Undo ensureMcpConfig on dispose: delete OUR keys from the global file so a
|
|
456
|
+
* later interactive `agy` run doesn't keep spawning our tool server against a
|
|
457
|
+
* possibly-stale ComfyUI URL and a dead per-tab panel URL. Everything else in
|
|
458
|
+
* the file is preserved; re-merged on the next run(). Best-effort. */
|
|
459
|
+
removeMcpConfig() {
|
|
460
|
+
if (!this.mcpConfigWritten || !this.mcpConfigFile || !this.mcpWrittenEntries)
|
|
461
|
+
return;
|
|
462
|
+
const file = this.mcpConfigFile;
|
|
463
|
+
this.mcpConfigWritten = false;
|
|
464
|
+
try {
|
|
465
|
+
let existing = null;
|
|
466
|
+
try {
|
|
467
|
+
existing = readFileSync(file, "utf8");
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
const cleaned = removeAgyMcpServers(existing, this.mcpWrittenEntries);
|
|
473
|
+
if (cleaned !== null) {
|
|
474
|
+
writeFileAtomic(file, cleaned);
|
|
475
|
+
logger.info(`[antigravity-backend] removed our MCP server entries from ${file}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
logger.debug(`[antigravity-backend] mcp_config cleanup skipped: ${msgOf(err)}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
293
482
|
/**
|
|
294
483
|
* Drive the session: one `agy -p` child per neutral turn, continuity via
|
|
295
484
|
* `--continue`. The channel async-iteration IS the turn gate (PanelAgent
|
|
296
485
|
* releases one batch per turn).
|
|
297
486
|
*/
|
|
298
487
|
async *run(opts) {
|
|
299
|
-
// Panel may pass the Claude panel model unconditionally — only honor a model
|
|
300
|
-
// that plausibly belongs to this provider (anything not Claude-shaped; the
|
|
301
|
-
// real catalog is the account's own `agy models`).
|
|
302
|
-
if (opts.model && !/^claude/i.test(opts.model))
|
|
303
|
-
this.model = opts.model;
|
|
304
488
|
await this.prepare();
|
|
489
|
+
// Panel may pass the CLAUDE panel model unconditionally in opts.model — but a
|
|
490
|
+
// simple "reject claude-*" filter is WRONG here: Antigravity's own catalog
|
|
491
|
+
// legitimately offers Claude models (verified live: claude-sonnet-4-6,
|
|
492
|
+
// claude-opus-4-6-thinking ride the Google subscription). So validate
|
|
493
|
+
// claude-shaped ids against the account's real `agy models` catalog and only
|
|
494
|
+
// drop the ones that aren't in it (the panel's Anthropic-side default).
|
|
495
|
+
// Non-claude ids are honored directly — they can only have come from our own
|
|
496
|
+
// picker (fed by listModels) or explicit config.
|
|
497
|
+
if (opts.model)
|
|
498
|
+
this.model = (await this.acceptableModel(opts.model)) ?? this.model;
|
|
499
|
+
// agy has a real --effort flag (low|medium|high). Honor opts.effort when it
|
|
500
|
+
// is on that scale; other providers' scales (e.g. Codex xhigh) are ignored.
|
|
501
|
+
if (opts.effort && /^(low|medium|high)$/i.test(opts.effort)) {
|
|
502
|
+
this.effort = opts.effort.toLowerCase();
|
|
503
|
+
}
|
|
305
504
|
const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
|
|
306
|
-
this.ensureMcpConfig(
|
|
505
|
+
this.ensureMcpConfig();
|
|
307
506
|
const resuming = !!(opts.resume ?? opts.sessionId);
|
|
308
507
|
this.continueNext = resuming;
|
|
309
508
|
this.needsSystemPreamble = !resuming && !!this.deps.systemAppend;
|
|
@@ -343,13 +542,37 @@ export class AntigravityBackend {
|
|
|
343
542
|
// block on an interactive prompt that nothing will ever answer.
|
|
344
543
|
"--dangerously-skip-permissions",
|
|
345
544
|
...(this.model ? ["--model", this.model] : []),
|
|
545
|
+
...(this.effort ? ["--effort", this.effort] : []),
|
|
346
546
|
];
|
|
347
547
|
// NOTE: `interrupted` is deliberately NOT cleared here. A Stop can land
|
|
348
548
|
// while this turn is still starting up (run() awaits prepare() and the
|
|
349
549
|
// channel before reaching runTurn), and clearing at turn START silently
|
|
350
550
|
// discarded it — the turn then ran with nothing able to stop it. The flag is
|
|
351
551
|
// cleared when a turn SETTLES instead, so it survives setup and the child
|
|
352
|
-
// assignment below acts on it.
|
|
552
|
+
// assignment below acts on it. A pending idle-armed interrupt is now
|
|
553
|
+
// definitely aimed at THIS turn — cancel its expiry so it's consumed here.
|
|
554
|
+
this.turnActive = true;
|
|
555
|
+
if (this.idleInterruptExpiry) {
|
|
556
|
+
clearTimeout(this.idleInterruptExpiry);
|
|
557
|
+
this.idleInterruptExpiry = null;
|
|
558
|
+
}
|
|
559
|
+
// Windows caps the whole CreateProcess command line at 32,767 chars, and the
|
|
560
|
+
// prompt rides argv. The panel system preamble alone is ~29K on the first
|
|
561
|
+
// turn, so a transcript replay / pasted workflow can blow past it — spawn
|
|
562
|
+
// then fails with a cryptic EINVAL/E2BIG. Preflight with a legible error.
|
|
563
|
+
if (process.platform === "win32") {
|
|
564
|
+
const cmdLen = bin.length + args.reduce((n, a) => n + a.length + 3, 0);
|
|
565
|
+
if (cmdLen > 30_000) {
|
|
566
|
+
this.turnActive = false;
|
|
567
|
+
yield {
|
|
568
|
+
type: "error",
|
|
569
|
+
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). ` +
|
|
570
|
+
"Send a shorter message, or start a fresh chat to drop replayed context.",
|
|
571
|
+
};
|
|
572
|
+
yield { type: "result", ok: false, subtype: "error" };
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
353
576
|
const queue = [];
|
|
354
577
|
let wake = null;
|
|
355
578
|
let done = false;
|
|
@@ -370,7 +593,7 @@ export class AntigravityBackend {
|
|
|
370
593
|
try {
|
|
371
594
|
child = spawn(bin, args, {
|
|
372
595
|
cwd,
|
|
373
|
-
env:
|
|
596
|
+
env: buildAgentSpawnEnv(), // no keep-list: strip ALL tool-only secrets (agy uses OAuth/keyring)
|
|
374
597
|
stdio: ["ignore", "pipe", "pipe"],
|
|
375
598
|
windowsHide: true,
|
|
376
599
|
detached: process.platform !== "win32",
|
|
@@ -396,6 +619,26 @@ export class AntigravityBackend {
|
|
|
396
619
|
}
|
|
397
620
|
}, 15_000);
|
|
398
621
|
heartbeat.unref?.();
|
|
622
|
+
// HARD CEILING: the heartbeat above defers turn-length policing to agy's
|
|
623
|
+
// own --print-timeout — but a frozen agy that never honors it would then
|
|
624
|
+
// hang the turn unbounded. Enforce the same budget ourselves, +2min grace:
|
|
625
|
+
// if the child is still alive past it, kill the tree; settle() then ends
|
|
626
|
+
// the turn with a legible timeout error instead of a generic exit code.
|
|
627
|
+
let ceilingHit = false;
|
|
628
|
+
let ceilingForce = null;
|
|
629
|
+
const ceilingMs = (parseGoDurationMs(timeout) ?? 45 * 60_000) + 120_000;
|
|
630
|
+
const ceiling = setTimeout(() => {
|
|
631
|
+
ceilingHit = true;
|
|
632
|
+
killProcessTree(child.pid);
|
|
633
|
+
// INDEPENDENT finalization (codex review): if the kill fails or the child
|
|
634
|
+
// never emits exit/error, nothing else would ever settle the turn — the
|
|
635
|
+
// generator (and PanelAgent's gate) would block forever. Give the kill a
|
|
636
|
+
// beat to surface the natural exit event, then force-settle; settle() is
|
|
637
|
+
// idempotent, so a late real exit becomes a no-op.
|
|
638
|
+
ceilingForce = setTimeout(() => settle(null), 10_000);
|
|
639
|
+
ceilingForce.unref?.();
|
|
640
|
+
}, ceilingMs);
|
|
641
|
+
ceiling.unref?.();
|
|
399
642
|
child.stdout.setEncoding("utf8");
|
|
400
643
|
child.stderr.setEncoding("utf8");
|
|
401
644
|
child.stdout.on("data", (chunk) => {
|
|
@@ -430,6 +673,9 @@ export class AntigravityBackend {
|
|
|
430
673
|
return;
|
|
431
674
|
settled = true;
|
|
432
675
|
clearInterval(heartbeat);
|
|
676
|
+
clearTimeout(ceiling);
|
|
677
|
+
if (ceilingForce)
|
|
678
|
+
clearTimeout(ceilingForce);
|
|
433
679
|
if (this.child === child)
|
|
434
680
|
this.child = null;
|
|
435
681
|
if (streamOpen)
|
|
@@ -440,6 +686,14 @@ export class AntigravityBackend {
|
|
|
440
686
|
push({ type: "assistant", text: finalText });
|
|
441
687
|
push({ type: "result", ok: false, subtype: "cancelled" });
|
|
442
688
|
}
|
|
689
|
+
else if (ceilingHit) {
|
|
690
|
+
push({
|
|
691
|
+
type: "error",
|
|
692
|
+
message: `agy did not finish within ${timeout} (+2m grace) and was terminated — its own --print-timeout never fired, which usually means a frozen CLI. ` +
|
|
693
|
+
"Try again; raise COMFYUI_MCP_ANTIGRAVITY_PRINT_TIMEOUT for genuinely long jobs.",
|
|
694
|
+
});
|
|
695
|
+
push({ type: "result", ok: false, subtype: "timeout" });
|
|
696
|
+
}
|
|
443
697
|
else if (spawnErr || code !== 0) {
|
|
444
698
|
const stderrTail = stripAnsi(errOut).trim().split(/\r?\n/).slice(-3).join(" ").trim();
|
|
445
699
|
const authish = /sign.?in|log.?in|auth|credential|unauthoriz/i.test(stderrTail + finalText);
|
|
@@ -462,8 +716,10 @@ export class AntigravityBackend {
|
|
|
462
716
|
}
|
|
463
717
|
// Clear the interrupt HERE (turn end), not at turn start — see the note
|
|
464
718
|
// where the queue is set up. The flag has now been consumed to pick this
|
|
465
|
-
// turn's result, so the next turn starts clean.
|
|
719
|
+
// turn's result, so the next turn starts clean. turnActive closes with it:
|
|
720
|
+
// interrupts landing from now until the next runTurn are idle no-ops.
|
|
466
721
|
this.interrupted = false;
|
|
722
|
+
this.turnActive = false;
|
|
467
723
|
finish();
|
|
468
724
|
};
|
|
469
725
|
child.on("error", (err) => settle(null, err));
|
|
@@ -475,18 +731,41 @@ export class AntigravityBackend {
|
|
|
475
731
|
// never settles (exactly what hung the interrupt test).
|
|
476
732
|
if (this.interrupted)
|
|
477
733
|
killProcessTree(child.pid);
|
|
478
|
-
// Drain the bridged queue until the child settles.
|
|
479
|
-
|
|
734
|
+
// Drain the bridged queue until the child settles. The finally guards the
|
|
735
|
+
// ABANDONMENT path: a consumer that drops this generator mid-turn without
|
|
736
|
+
// close() (generator .return() runs finally blocks) must not leave the
|
|
737
|
+
// child running for up to 45m with the heartbeat bumping a dead turn.
|
|
738
|
+
try {
|
|
739
|
+
while (true) {
|
|
740
|
+
while (queue.length)
|
|
741
|
+
yield queue.shift();
|
|
742
|
+
if (done)
|
|
743
|
+
break;
|
|
744
|
+
await new Promise((resolve) => {
|
|
745
|
+
wake = resolve;
|
|
746
|
+
});
|
|
747
|
+
}
|
|
480
748
|
while (queue.length)
|
|
481
749
|
yield queue.shift();
|
|
482
|
-
if (done)
|
|
483
|
-
break;
|
|
484
|
-
await new Promise((resolve) => {
|
|
485
|
-
wake = resolve;
|
|
486
|
-
});
|
|
487
750
|
}
|
|
488
|
-
|
|
489
|
-
|
|
751
|
+
finally {
|
|
752
|
+
if (!settled) {
|
|
753
|
+
// ABANDONED mid-turn (consumer dropped the generator without close()).
|
|
754
|
+
// Mark settled FIRST: the kill below triggers the child's exit event,
|
|
755
|
+
// and without this it would run settle() against shared state that may
|
|
756
|
+
// already belong to a subsequent turn (codex review).
|
|
757
|
+
settled = true;
|
|
758
|
+
clearInterval(heartbeat);
|
|
759
|
+
clearTimeout(ceiling);
|
|
760
|
+
if (ceilingForce)
|
|
761
|
+
clearTimeout(ceilingForce);
|
|
762
|
+
killProcessTree(child.pid);
|
|
763
|
+
if (this.child === child)
|
|
764
|
+
this.child = null;
|
|
765
|
+
this.turnActive = false;
|
|
766
|
+
this.interrupted = false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
490
769
|
}
|
|
491
770
|
/** Stop the current turn: kill the in-flight child's process tree. The next
|
|
492
771
|
* turn continues the conversation (`-c`) with whatever agy recorded. */
|
|
@@ -494,22 +773,76 @@ export class AntigravityBackend {
|
|
|
494
773
|
// Set the flag FIRST and UNCONDITIONALLY. `this.child` is only assigned
|
|
495
774
|
// after spawn() returns, so an interrupt landing in that window used to hit
|
|
496
775
|
// the `if (!child) return` below, leave `interrupted` false, and never kill
|
|
497
|
-
// anything — the turn then ran to completion with nothing able to stop it
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
// this flag the moment it assigns the child, so a kill is never lost.
|
|
776
|
+
// anything — the turn then ran to completion with nothing able to stop it
|
|
777
|
+
// (the original 03080bc bug). run() re-checks this flag the moment it
|
|
778
|
+
// assigns the child, so a kill is never lost.
|
|
501
779
|
this.interrupted = true;
|
|
780
|
+
if (this.idleInterruptExpiry)
|
|
781
|
+
clearTimeout(this.idleInterruptExpiry);
|
|
782
|
+
this.idleInterruptExpiry = null;
|
|
783
|
+
if (!this.turnActive) {
|
|
784
|
+
// Armed while NO turn was in flight: either a Stop racing its own turn's
|
|
785
|
+
// startup (honor it — a turn starts within ms and consumes the flag) or a
|
|
786
|
+
// stray idle Stop (PanelAgent interrupts unconditionally, e.g. rewind).
|
|
787
|
+
// Self-expire so the stray case can't linger and spuriously cancel the
|
|
788
|
+
// user's NEXT message (the post-03080bc poisoning regression).
|
|
789
|
+
const t = setTimeout(() => {
|
|
790
|
+
this.idleInterruptExpiry = null;
|
|
791
|
+
if (!this.turnActive)
|
|
792
|
+
this.interrupted = false;
|
|
793
|
+
}, 500);
|
|
794
|
+
t.unref?.();
|
|
795
|
+
this.idleInterruptExpiry = t;
|
|
796
|
+
}
|
|
502
797
|
const child = this.child;
|
|
503
798
|
if (!child)
|
|
504
799
|
return; // spawn window — run() kills it as soon as it exists
|
|
505
800
|
killProcessTree(child.pid);
|
|
506
801
|
}
|
|
802
|
+
/** The account's model catalog, fetched lazily via `agy models` and cached for
|
|
803
|
+
* the backend's lifetime. Null when the probe fails (unauthenticated etc.) —
|
|
804
|
+
* callers then fall back to the claude-prefix heuristic. */
|
|
805
|
+
catalog = null;
|
|
806
|
+
/** When the last catalog probe FAILED (ms epoch) — negative-cached so an
|
|
807
|
+
* unauthenticated CLI doesn't eat a fresh 30s `agy models` spawn on every
|
|
808
|
+
* claude-shaped model check (each connect re-runs run()). */
|
|
809
|
+
catalogFailedAt = 0;
|
|
810
|
+
static CATALOG_RETRY_MS = 60_000;
|
|
811
|
+
async ensureCatalog() {
|
|
812
|
+
if (this.catalog)
|
|
813
|
+
return this.catalog;
|
|
814
|
+
if (Date.now() - this.catalogFailedAt < AntigravityBackend.CATALOG_RETRY_MS)
|
|
815
|
+
return null;
|
|
816
|
+
try {
|
|
817
|
+
this.catalog = new Set((await this.listModels()).map((m) => m.id));
|
|
818
|
+
}
|
|
819
|
+
catch (err) {
|
|
820
|
+
logger.debug(`[antigravity-backend] catalog probe failed: ${msgOf(err)}`);
|
|
821
|
+
this.catalog = null;
|
|
822
|
+
this.catalogFailedAt = Date.now();
|
|
823
|
+
}
|
|
824
|
+
return this.catalog;
|
|
825
|
+
}
|
|
826
|
+
/** Decide whether `model` is usable for THIS provider: non-claude ids pass
|
|
827
|
+
* (they come from our own picker/config); claude-shaped ids are checked
|
|
828
|
+
* against the live catalog — Antigravity DOES serve some claude-* models, but
|
|
829
|
+
* the panel also leaks its Anthropic-side default (e.g. claude-opus-4-8)
|
|
830
|
+
* through opts.model, which must be dropped. Returns the model when
|
|
831
|
+
* acceptable, undefined when not. Catalog unavailable → drop claude ids
|
|
832
|
+
* (conservative: a wrong --model makes every turn fail). */
|
|
833
|
+
async acceptableModel(model) {
|
|
834
|
+
if (!/^claude/i.test(model))
|
|
835
|
+
return model;
|
|
836
|
+
const catalog = await this.ensureCatalog();
|
|
837
|
+
return catalog?.has(model) ? model : undefined;
|
|
838
|
+
}
|
|
507
839
|
/** Switch the model for subsequent turns (--model is per-spawn, so this is
|
|
508
|
-
* free — no respawn dance).
|
|
840
|
+
* free — no respawn dance). Drops ids the account's catalog can't serve
|
|
841
|
+
* (see acceptableModel). */
|
|
509
842
|
async setModel(model) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
843
|
+
const ok = await this.acceptableModel(model);
|
|
844
|
+
if (ok)
|
|
845
|
+
this.model = ok;
|
|
513
846
|
}
|
|
514
847
|
/**
|
|
515
848
|
* Enumerate the account's models via `agy models` (per #262: parse the live
|
|
@@ -526,7 +859,7 @@ export class AntigravityBackend {
|
|
|
526
859
|
try {
|
|
527
860
|
child = spawn(bin, ["models"], {
|
|
528
861
|
cwd: this.deps.cwd ?? process.cwd(),
|
|
529
|
-
env:
|
|
862
|
+
env: buildAgentSpawnEnv(), // no keep-list: strip ALL tool-only secrets (agy uses OAuth/keyring)
|
|
530
863
|
stdio: ["ignore", "pipe", "pipe"],
|
|
531
864
|
windowsHide: true,
|
|
532
865
|
});
|
|
@@ -570,6 +903,15 @@ export class AntigravityBackend {
|
|
|
570
903
|
this.interrupted = true;
|
|
571
904
|
killProcessTree(child.pid);
|
|
572
905
|
}
|
|
906
|
+
if (this.idleInterruptExpiry) {
|
|
907
|
+
clearTimeout(this.idleInterruptExpiry);
|
|
908
|
+
this.idleInterruptExpiry = null;
|
|
909
|
+
}
|
|
910
|
+
// Leave no standing footprint: our entries in the global mcp_config.json
|
|
911
|
+
// would otherwise make every later interactive `agy` run spawn our tool
|
|
912
|
+
// server (stale COMFYUI_URL, dead per-tab panel URL) — and the file is
|
|
913
|
+
// read by another product. Re-merged by the next session's run().
|
|
914
|
+
this.removeMcpConfig();
|
|
573
915
|
}
|
|
574
916
|
}
|
|
575
917
|
//# sourceMappingURL=antigravity-backend.js.map
|