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.
Files changed (64) hide show
  1. package/README.md +2 -2
  2. package/dist/config.js +159 -11
  3. package/dist/config.js.map +1 -1
  4. package/dist/orchestrator/ai-proposer.js +5 -0
  5. package/dist/orchestrator/ai-proposer.js.map +1 -1
  6. package/dist/orchestrator/antigravity-backend.js +394 -52
  7. package/dist/orchestrator/antigravity-backend.js.map +1 -1
  8. package/dist/orchestrator/claude-backend.js +13 -0
  9. package/dist/orchestrator/claude-backend.js.map +1 -1
  10. package/dist/orchestrator/codex-backend.js +12 -1
  11. package/dist/orchestrator/codex-backend.js.map +1 -1
  12. package/dist/orchestrator/gemini-backend.js +8 -1
  13. package/dist/orchestrator/gemini-backend.js.map +1 -1
  14. package/dist/orchestrator/grok-backend.js +9 -5
  15. package/dist/orchestrator/grok-backend.js.map +1 -1
  16. package/dist/orchestrator/index.js +603 -50
  17. package/dist/orchestrator/index.js.map +1 -1
  18. package/dist/orchestrator/panel-agent.js +21 -5
  19. package/dist/orchestrator/panel-agent.js.map +1 -1
  20. package/dist/orchestrator/panel-console-http.js +1 -0
  21. package/dist/orchestrator/panel-console-http.js.map +1 -1
  22. package/dist/orchestrator/panel-tools.js +140 -9
  23. package/dist/orchestrator/panel-tools.js.map +1 -1
  24. package/dist/services/ai-toolkit.js +260 -3
  25. package/dist/services/ai-toolkit.js.map +1 -1
  26. package/dist/services/download-progress.js +177 -7
  27. package/dist/services/download-progress.js.map +1 -1
  28. package/dist/services/missing-models.js +295 -0
  29. package/dist/services/missing-models.js.map +1 -0
  30. package/dist/services/openai-provider-registry.js +20 -1
  31. package/dist/services/openai-provider-registry.js.map +1 -1
  32. package/dist/services/panel-secrets.js +58 -7
  33. package/dist/services/panel-secrets.js.map +1 -1
  34. package/dist/services/process-control.js +154 -4
  35. package/dist/services/process-control.js.map +1 -1
  36. package/dist/services/runpod-client.js +157 -9
  37. package/dist/services/runpod-client.js.map +1 -1
  38. package/dist/services/runpod-ssh.js +594 -0
  39. package/dist/services/runpod-ssh.js.map +1 -0
  40. package/dist/services/runpod-watch.js +192 -13
  41. package/dist/services/runpod-watch.js.map +1 -1
  42. package/dist/services/secure-bridge.js +22 -6
  43. package/dist/services/secure-bridge.js.map +1 -1
  44. package/dist/services/trainer-bootstrap.js +142 -0
  45. package/dist/services/trainer-bootstrap.js.map +1 -0
  46. package/dist/services/training-config.js +4 -1
  47. package/dist/services/training-config.js.map +1 -1
  48. package/dist/services/training-jobs.js +1332 -253
  49. package/dist/services/training-jobs.js.map +1 -1
  50. package/dist/tools/apps.js +222 -0
  51. package/dist/tools/apps.js.map +1 -0
  52. package/dist/tools/index.js +4 -0
  53. package/dist/tools/index.js.map +1 -1
  54. package/dist/tools/missing-models.js +151 -0
  55. package/dist/tools/missing-models.js.map +1 -0
  56. package/dist/tools/process-control.js +1 -1
  57. package/dist/tools/process-control.js.map +1 -1
  58. package/dist/tools/runpod.js +205 -17
  59. package/dist/tools/runpod.js.map +1 -1
  60. package/dist/tools/train.js +138 -5
  61. package/dist/tools/train.js.map +1 -1
  62. package/docker/trainer/Dockerfile +4 -2
  63. package/package.json +1 -1
  64. 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: `agy` reads MCP servers from a per-workspace
30
- // `.agents/mcp_config.json` (and a global ~/.gemini/config/mcp_config.json we
31
- // NEVER touch). Before the first turn we MERGE our two servers — the headless
32
- // `comfyui` stdio MCP and the per-tab `panel` HTTP MCP — into the workspace file
33
- // under our own names, preserving every other entry byte-for-byte. Scoped,
34
- // merge-safe, reversible (delete the two keys) — per #262's requirements.
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 can steal continuity. The supported
42
- // surface offers no way to read the new conversation's id from -p output.
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
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- root = JSON.parse(existingText);
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 the workspace .agents/mcp_config.json (once per
259
- * backend instance). Never touches the global ~/.gemini/config/mcp_config.json.
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(cwd) {
263
- if (this.mcpConfigWritten || !this.deps.mcpServers || !Object.keys(this.deps.mcpServers).length)
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 dir = join(cwd, ".agents");
266
- const file = join(dir, "mcp_config.json");
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
- writeFileSync(file, mergeAgyMcpConfig(existing, this.deps.mcpServers), "utf8");
441
+ writeFileAtomic(file, mergeAgyMcpConfig(existing, servers));
286
442
  this.mcpConfigWritten = true;
287
- logger.info(`[antigravity-backend] merged comfyui/panel MCP servers into ${file}`);
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(cwd);
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: process.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
- while (true) {
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
- while (queue.length)
489
- yield queue.shift();
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
- // In the panel that is "hit Stop right after Send and the turn hangs
499
- // forever"; in CI it was a 5s timeout in the interrupt test. run() re-checks
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). Ignores Claude-shaped ids the panel may pass. */
840
+ * free — no respawn dance). Drops ids the account's catalog can't serve
841
+ * (see acceptableModel). */
509
842
  async setModel(model) {
510
- if (/^claude/i.test(model))
511
- return;
512
- this.model = model;
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: process.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