UNPKG

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),

873 lines • 40.9 kB
// pi.dev CLI backend (issue #491, "include pi.dev as an agent") — the provider // adapter behind the AgentBackend port for `pi`, the open-source multi-provider // coding agent from earendil-works (https://pi.dev). Unlike codex/gemini (ACP / // app-server), `pi` exposes a DOCUMENTED machine-readable event stream via its // `--mode json` flag (JSON-lines on stdout), which this adapter parses — so it // gets real streaming deltas, per-tool progress, and a RESUMABLE session id, a // step up from the plain-text antigravity adapter it is otherwise modeled on. // // turn N=1 (fresh) pi --mode json [--model m] [--provider p] < prompt.txt // turn N>1 / resume pi --session <id> --mode json ... < prompt.txt // interrupt() kill the in-flight child process tree // listModels() parse `pi --list-models` (padded text table) // // SESSION: `pi --mode json` prints a session header `{"type":"session","id":...}` // as its first line. We capture that uuid and drive continuity with // `--session <id>` (pi accepts a session file OR id), and REPORT it up the stack // so PanelAgent can persist it and resume across restarts. This is genuine // per-conversation continuity, not antigravity's "latest conversation" sentinel. // // NO MCP — IMPORTANT LIMITATION: pi has NO Model Context Protocol client (verified // against the earendil-works/pi repo tree, CHANGELOG, and extensions docs — pi's // tool surface is its OWN built-in tools (bash/read/write/edit/grep/…) plus // TypeScript extensions registered via `pi.registerTool()`, never external MCP // servers). So unlike every other CLI backend here, pi CANNOT be handed our // `comfyui` (stdio) + `panel` (http) MCP servers — a pi turn drives the local // workspace with pi's own tools but does not get the ComfyUI panel_* / comfyui // tools. This is surfaced honestly in the ready banner. (Full ComfyUI-tool parity // would require shipping a pi EXTENSION that bridges to the panel HTTP MCP — // tracked as follow-up, out of scope for #491's "include pi as an agent".) // // AUTH: pi resolves credentials from (1) `~/.pi/agent/auth.json` (API keys and // OAuth subscriptions written by `/login`), (2) provider env vars (pi's full map // is transcribed in pi-credentials.ts), (3) a `~/.pi/agent/models.json` custom // provider carrying its own apiKey/oauth, and (4) Google Vertex ADC. Detection // of all four — which is what drives readiness, since `--list-models` is not an // auth probe — lives in pi-credentials.ts. We // spawn with buildAgentSpawnEnv() (no keep-list): the ComfyUI TOOL secrets // (RunPod/CivitAI/RunComfy/Registry, and the Google/HF keys that panel-secrets // classifies as tool secrets) are stripped so an LLM-vendor process never sees a // tool credential — pi's own provider keys (ANTHROPIC/OPENAI/XAI/OpenRouter/…) // are NOT tool secrets and pass through untouched, and its subscription auth // lives in auth.json regardless. A pi user who wants the Google/HuggingFace // providers via env should put those keys in ~/.pi/agent/auth.json (their env // vars are ComfyUI-tool-scoped and intentionally withheld from agent subprocesses). // // LIMITS (flagged honestly): // - No ComfyUI MCP tools (see above). // - No documented image-input path for headless mode → vision=false (image // refs are already named in the turn text as a fallback). // - `pi --list-models` prints pi's built-in provider CATALOG (not gated on // which keys are configured), so it proves the CLI launches but is NOT an // auth probe — a keyless pi can greet ready and then fail the first turn with // an actionable provider/credential error. import { spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync } 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 { looksLikeAuthFailure, providerAuthRemedy, qualifySlashCommands } from "./cli-remedy.js"; import { buildAgentSpawnEnv } from "../services/panel-secrets.js"; import { PI_CAPABILITIES, stampTurn, } from "./agent-backend.js"; function msgOf(err) { return errorText(err); } /** Kill an entire process tree (identical posture to the other CLI backends): * 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 an npm shim to the script it runs, so it can be launched WITHOUT a shell. * * #2835 — installing pi from npm gives `pi` (an extensionless bash script), * `pi.cmd` and `pi.ps1`, and no `pi.exe`. On Windows both of the first two are * unspawnable by Node, measured: * * spawn(<extensionless shim>) -> ENOENT * spawn(<.cmd>) -> EINVAL * * The obvious repair is `shell: true`, and it is the wrong one: the prompt reaches * these spawns as an argv element, so routing it through cmd.exe would make every * prompt a potential command line. That is exactly what this module refuses to do, * and the refusal is right. * * npm's shim names its real entry point, so there is a third option that keeps the * no-shell rule intact: * * "%_prog%" "%dp0%\\node_modules\\pi\\bin\\pi.js" %* * * Extract that path and spawn `node <script>` directly. The objection was never the * file extension; it was the shell. * * Returns null unless the extracted target actually exists on disk — a shim we * cannot resolve stays unusable rather than becoming a guess. */ export function resolveNpmShimTarget(shimPath) { let text; try { text = readFileSync(shimPath, "utf8"); } catch { return null; } // The $basedir class excludes quote and space ON PURPOSE. npm's sh shim names // the interpreter and the script on ONE line -- exec "$basedir/node" // "$basedir/node_modules/pi/bin/pi.js" "$@" -- so a permissive `.+?` anchors on // the FIRST $basedir/ and swallows `node" "$basedir/...` into the capture. That // path never exists, so existsSync turned it into null and this branch resolved // NOTHING: measured 0/10 against the real npm shims on a Windows box, 10/10 with // the class below, and the .cmd branch stays 10/10 either way. // `%dp0%` is the shim's own directory. Accept either separator: npm writes // backslashes on Windows, and the .ps1/sh variants use forward slashes. const m = /%dp0%[\\/](.+?[.](?:js|mjs|cjs))/i.exec(text) ?? /[$]basedir[/]([^"\'\s]+?[.](?:js|mjs|cjs))/i.exec(text); if (!m) return null; const rel = m[1].split(String.fromCharCode(92)).join("/"); const target = join(dirname(shimPath), rel); try { return existsSync(target) ? target : null; } catch { return null; } } /** * #2835 — PATH entries as Windows actually writes them. A quoted entry * (`"C:\Program Files\nodejs"`) concatenates into an invalid path, and a dead * UNC entry makes the synchronous read below block for an SMB timeout during * backend startup. `src/services/self-update.ts` already strips wrapping quotes * and skips UNC before its own sync probes; same treatment here. */ function windowsPathDirs() { const out = []; for (const raw of (process.env.PATH || "").split(";")) { const dir = raw.trim().replace(/^"(.*)"$/, "$1").trim(); if (!dir) continue; if (dir.startsWith(String.fromCharCode(92, 92))) continue; // UNC — may hang out.push(dir); } return out; } /** * One candidate, classified: a runnable executable launches as itself, a * resolvable npm shim launches as `node <script>`, anything else is null. Kept * separate so BOTH the launch path and readiness ask the same question. */ function launchFromWindowsCandidate(candidate) { const script = resolveNpmShimTarget(candidate); if (script) return { command: process.execPath, prefixArgs: [script] }; // A .cmd/.bat that named no resolvable script stays unspawnable: Node cannot // run one shell-lessly and we never spawn through a shell (the prompt is user // data). An extensionless file is a POSIX script, never runnable on Windows. if (/[.](cmd|bat)$/i.test(candidate)) return null; if (!/[.][a-z0-9]+$/i.test(candidate)) return null; return existsSync(candidate) ? { command: candidate, prefixArgs: [] } : null; } /** * What to actually spawn for `pi`, including the npm-shim case (#2835). * * A real executable launches as itself. A resolvable npm shim launches as * `node <script>`. Anything else is null, exactly as before. */ export function resolvePiLaunch(home = homedir()) { const isWin = process.platform === "win32"; const override = process.env.COMFYUI_MCP_PI_PATH?.trim(); // On Windows the npm shim comes FIRST, before any PATH result is trusted. // `resolvePiBin` scans for ["pi.exe", "pi"], and `existsSync` happily matches the // EXTENSIONLESS bash script npm writes beside the .cmd — which is not executable // on Windows at all and spawns ENOENT. That is #2835's first symptom, and taking // the PATH answer first would reproduce it here. if (isWin) { // Resolve IN SOURCE ORDER. An earlier revision collected every candidate and // ran the shim pre-pass over all of them, which let a shim anywhere on PATH // beat an explicit COMFYUI_MCP_PI_PATH executable: the override is not a shim, // resolveNpmShimTarget returns null for it, and the loop walked on to PATH. // An override exists precisely to win, so it is decided on its own first. if (override) { const viaOverride = launchFromWindowsCandidate(override) ?? launchFromWindowsCandidate(override + ".cmd"); if (viaOverride) return viaOverride; } for (const dir of windowsPathDirs()) { const found = launchFromWindowsCandidate(join(dir, "pi.exe")) ?? launchFromWindowsCandidate(join(dir, "pi.cmd")) ?? launchFromWindowsCandidate(join(dir, "pi")); if (found) return found; } // Fall THROUGH, do not return null: resolvePiBin also probes the well-known // install locations (%LOCALAPPDATA%/pi/bin/pi.exe, ~/.local/bin/pi.exe) that // are not on PATH. Returning here dropped those and broke 26 existing tests. } const direct = resolvePiBin(home); if (!direct) return null; // A .cmd/.bat that survived to here named no script we could find, so it stays // unspawnable — unchanged from before this existed. if (/[.](cmd|bat)$/i.test(direct)) return null; // Likewise an extensionless file on Windows: executable on POSIX, never here. if (isWin && !/[.][a-z0-9]+$/i.test(direct)) return null; return { command: direct, prefixArgs: [] }; } export function resolvePiBin(home = homedir()) { const override = process.env.COMFYUI_MCP_PI_PATH?.trim(); if (override) { // Apply the SAME .cmd/.bat refusal as PATH discovery below — the override // must NOT be an escape hatch around it. Node cannot shell-lessly spawn a // .cmd/.bat, and we never spawn through a shell (the prompt is user data — // shell-quoting it would be an injection risk). A shim override is ignored // with a warning so the actionable "install pi/pi.exe" path fires. if (/\.(cmd|bat)$/i.test(override)) { logger.warn(`[pi-backend] COMFYUI_MCP_PI_PATH points at a shell shim (${override}); Node cannot spawn a .cmd/.bat shell-lessly — ignoring it. Point COMFYUI_MCP_PI_PATH at the real pi/pi.exe.`); return null; } return override; } // A `.cmd`/`.bat` shim is deliberately NOT accepted for SPAWNING: Node refuses // shell-less `.cmd` spawns post-CVE-2024-27980, and we never spawn through a // shell (the prompt is user data — shell-quoting it would be an injection // risk). The official installer ships a real `pi`/`pi.exe`; a .cmd-only install // reads as "not found" here with actionable guidance (same stance as agy). const names = process.platform === "win32" ? ["pi.exe", "pi"] : ["pi"]; 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"), "pi", "bin", "pi.exe"), join(home, ".local", "bin", "pi.exe"), ] : [join(home, ".local", "bin", "pi"), "/usr/local/bin/pi", "/opt/homebrew/bin/pi"]; for (const p of wellKnown) { try { if (existsSync(p)) return p; } catch { // skip } } return null; } /** Parse a Go/JS-style duration ("45m", "5m0s", "1h30m") to ms; null when * unparseable. (Shared shape with the antigravity adapter.) */ export function parsePiDurationMs(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 (pi's TUI heritage may color even table output). * The ESC byte (\x1b) is part of the match — without it a colored token keeps * its leading control char and fails id validation. */ function stripAnsi(s) { return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ""); } /** * Parse `pi --list-models` output into ModelChoices. The format is a padded text * table with columns `provider model context max-out thinking images` * (whitespace-aligned, no documented machine contract), so this is deliberately * TOLERANT: strip ANSI, drop the header/separator rows, split each data row on * runs of 2+ spaces, and take the provider + model columns. The id is * `provider/model` (pi's `--model` accepts that prefixed form and it disambiguates * the same model offered by multiple providers); the label is the bare model id. * Returns [] when nothing parses (the panel degrades gracefully on an empty list). */ export function parsePiModels(output) { const out = []; const seen = new Set(); for (const raw of stripAnsi(output).split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; // Separator rules (─ === +-| etc.). if (/^[-=─│+|_\s]+$/.test(line)) continue; const cols = line.split(/\s{2,}/).map((c) => c.trim()).filter(Boolean); if (cols.length < 2) continue; const provider = cols[0]; const modelId = cols[1]; // Header row. if (/^provider$/i.test(provider) && /^model$/i.test(modelId)) continue; // Plausible provider + model ids: id-ish charset, model has a digit or dash // (filters prose), neither is an obvious column header word. if (!/^[a-z0-9][\w.:-]*$/i.test(provider)) continue; if (!/^[a-z0-9][\w.:@/-]*$/i.test(modelId)) continue; if (!/[\d-]/.test(modelId)) continue; const id = `${provider}/${modelId}`; if (seen.has(id)) continue; seen.add(id); out.push({ id, label: modelId }); } return out; } /** Sentinel session id used ONLY for the up-front session event of a FRESH * conversation, before pi's `--mode json` header reveals the real uuid. It is * never passed back to `--session` (guarded), so a turn that dies before the * header can't poison resume with a bogus id. */ const PI_SESSION_SENTINEL = "pi-pending"; /** * The pi.dev CLI adapter. One instance per PanelAgent; spawn-per-turn, so there * is no persistent child between turns — only the in-flight one. Continuity is * pi's own session store, addressed by the captured session id. */ export class PiBackend { id = "pi"; capabilities = PI_CAPABILITIES; deps; model; provider; launch; // 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 (see antigravity's * identical rationale: survive a Stop-after-Send race without poisoning the * user's next unrelated message). */ idleInterruptExpiry = null; disposed = false; /** The real pi session id: from the JSON header (fresh) or opts.resume. Drives * `--session` on every turn after it is known, and is REPORTED to PanelAgent * for cross-restart resume. Null until the first header of a fresh session. */ piSessionId = null; needsSystemPreamble = false; constructor(deps = {}) { this.deps = deps; this.model = deps.model; this.provider = deps.provider; } resolveBin() { if (this.launch === undefined) this.launch = resolvePiLaunch(); if (!this.launch) { throw new Error("pi CLI (`pi`) not found. Install it from https://pi.dev " + "(macOS/Linux: `curl -fsSL https://pi.dev/install.sh | sh`), configure a provider " + "(set a provider API key, or run `pi` once and `/login`), then reconnect. You can " + "also point COMFYUI_MCP_PI_PATH at the executable."); } return this.launch; } /** 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("pi backend is closed."); this.resolveBin(); } /** * Drive the session: one `pi --mode json` child per neutral turn, continuity * via `--session <capturedId>`. The channel async-iteration IS the turn gate * (PanelAgent releases one batch per turn). */ async *run(opts) { await this.prepare(); // The panel may pass a Claude id in opts.model (its own default) — that is // NOT a valid pi model. Only honor opts.model when it looks like a pi id // (contains a "/" provider prefix, which our listModels always emits); a bare // claude-* id is dropped so pi uses its configured default instead. if (opts.model && this.acceptableModel(opts.model)) this.model = opts.model; const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd(); const resumeId = (opts.resume ?? opts.sessionId) || null; // Guard against a legacy-persisted bogus sentinel (older builds emitted one // before the header arrived); never resume from it. if (resumeId && resumeId !== PI_SESSION_SENTINEL) this.piSessionId = resumeId; this.needsSystemPreamble = !this.piSessionId && !!this.deps.systemAppend; // Report a session id ONLY when it is REAL: on resume we already hold the // caller's id, so surface it up front; on a FRESH conversation we emit NO // session event until pi's JSON header reveals the real uuid (runTurn pushes // it then). This prevents a pre-header failure (spawn error, over-long // prompt, early exit) from persisting a bogus "pending" id up the stack. if (this.piSessionId) { yield { type: "session", sessionId: this.piSessionId, ...(this.model ? { model: this.model } : {}), }; } let turnSeq = 0; for await (const turn of opts.channel) { yield* stampTurn(this.runTurn(turn, cwd, opts.onActivity), ++turnSeq); } } /** Whether `model` is usable for pi. The one thing we MUST reject is the * panel's Anthropic-side default (a bare `claude-*` id with no provider * prefix) leaking through opts.model — passing it to `pi --model` would fail * every turn (and setting `--provider` alongside it does NOT rescue it, so the * provider flag must NOT whitelist it). Everything else — our own * `provider/model` picker ids, or an explicit id the user configured — passes. */ acceptableModel(model) { if (/^claude/i.test(model) && !model.includes("/")) return false; return true; } /** Run ONE turn = one `pi --mode json` child. Parses the JSON-lines event * stream: text deltas stream as assistant deltas, tool_execution_* map to * tool_call events, the session header reveals the resumable id. Exit 0 commits * the accumulated text + 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). * * RESUME-MISS SELF-HEAL (artokun/comfyui-mcp-panel#1235): a RESUMED turn that * dies with pi's "No session found matching '<id>'" has a dead resume target * (pruned session store, different cwd/home, a foreign id armed from the * panel's hello.resume hint). Retrying the SAME resume would fail every turn * forever — the panel's `/new` workaround exists precisely because nothing * dropped the dead id. So drop it and re-run THIS turn once as a fresh * session (isRetry guards against a loop); the new header re-persists a valid * id up the stack. The claude/codex equivalents self-heal in PanelAgent's * catch (#277); pi yields error EVENTS instead of throwing, so the heal lives * here. */ async *runTurn(turn, cwd, onActivity, isRetry = false) { let text = promptText(turn.text); // System blocks, in order: the heavy panel preamble (FIRST fresh turn only) // then the capability note (EVERY turn — fresh AND resume). The note comes // LAST so, on the first turn, it overrides the preamble's panel_*-tools claim; // on resumed/subsequent turns it is re-asserted alone, so a long-running or // resumed pi session is never left believing it has ComfyUI tools it can't // run (#491 codex P0a-resume). const sysBlocks = []; if (this.needsSystemPreamble && this.deps.systemAppend) { sysBlocks.push(this.deps.systemAppend); this.needsSystemPreamble = false; } if (this.deps.capabilityNote) sysBlocks.push(this.deps.capabilityNote); if (sysBlocks.length) { text = `<system>\n${sysBlocks.join("\n\n")}\n</system>\n\n` + `The user's message follows.\n\n${text}`; } // Image refs: no documented headless image input — the refs are already named // in the turn text (vision=false tells the panel up front). const bin = this.resolveBin(); const resuming = !!this.piSessionId; const args = [ ...(resuming ? ["--session", this.piSessionId] : []), "--mode", "json", ...(this.provider && !(this.model ?? "").includes("/") ? ["--provider", this.provider] : []), ...(this.model ? ["--model", this.model] : []), ]; // A Stop can land while this turn is still starting up — see the antigravity // adapter's note: `interrupted` is cleared on SETTLE, not here, so it survives // setup. A pending idle-armed interrupt is now aimed at THIS turn. this.turnActive = true; if (this.idleInterruptExpiry) { clearTimeout(this.idleInterruptExpiry); this.idleInterruptExpiry = null; } // Pi's documented piped-input path avoids the OS argv limit and keeps // conversation text out of process listings. 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 assistantText = ""; let errOut = ""; let streamOpen = false; let lineBuf = ""; /** Handle one parsed JSON event line from pi's --mode json stream. */ const handleEvent = (ev) => { const t = ev.type; if (t === "session") { // First line: the resumable session uuid. Report it up so PanelAgent // persists the REAL id (not the up-front sentinel) for cross-restart // resume, and drive continuity from it on the next turn. const id = typeof ev.id === "string" ? ev.id : null; if (id && id !== this.piSessionId) { this.piSessionId = id; push({ type: "session", sessionId: id, ...(this.model ? { model: this.model } : {}) }); } return; } if (t === "message_update") { const ame = ev.assistantMessageEvent; const delta = typeof ame?.delta === "string" ? ame.delta : ""; if (!delta) return; const kind = typeof ame?.type === "string" ? ame.type : ""; const thinking = /think|reason/i.test(kind); // Pi also streams tool arguments. Only text/thinking deltas belong in // the assistant stream; tool_execution events render calls separately. const isText = kind === "" || /^text/i.test(kind); if (!isText && !thinking) return; if (isText) assistantText += delta; if (!streamOpen) { streamOpen = true; push({ type: "stream_start", id: null }); } push({ type: "assistant_delta", text: delta, ...(thinking ? { thinking: true } : {}) }); return; } if (t === "tool_execution_start") { const name = typeof ev.toolName === "string" ? ev.toolName : "tool"; push({ type: "tool_call", name, phase: "start", detail: ev.args }); return; } if (t === "tool_execution_end") { const name = typeof ev.toolName === "string" ? ev.toolName : "tool"; push({ type: "tool_call", name, phase: "end", detail: { isError: ev.isError === true } }); return; } // agent_start / turn_start / message_start / message_end / turn_end / // agent_end / queue_update / compaction_* / *_retry_* — no panel event // needed; the process exit is the turn's terminal signal. }; const consumeLines = (chunk) => { lineBuf += chunk; let nl; while ((nl = lineBuf.indexOf("\n")) >= 0) { const line = lineBuf.slice(0, nl).trim(); lineBuf = lineBuf.slice(nl + 1); if (!line) continue; let parsed; try { parsed = JSON.parse(line); } catch { continue; // non-JSON noise (shouldn't happen on stdout in json mode) } if (parsed && typeof parsed === "object") handleEvent(parsed); } }; let child; try { child = spawn(bin.command, [...bin.prefixArgs, ...args], { cwd, env: buildAgentSpawnEnv(), // strip ComfyUI tool secrets; pi's own provider keys pass through stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: process.platform !== "win32", }); } catch (err) { this.turnActive = false; yield { type: "error", message: `Could not start pi: ${msgOf(err)}` }; yield { type: "result", ok: false, subtype: "error" }; return; } this.child = child; // LIVENESS heartbeat: a pi turn may run long tool calls with quiet stretches. const heartbeat = setInterval(() => { try { onActivity?.(); } catch { // watchdog bump must never throw } }, 15_000); heartbeat.unref?.(); // HARD CEILING: bound a frozen child. Configurable; default 45m + 2m grace. const timeout = process.env.COMFYUI_MCP_PI_PRINT_TIMEOUT?.trim() || "45m"; let ceilingHit = false; let ceilingForce = null; const ceilingMs = (parsePiDurationMs(timeout) ?? 45 * 60_000) + 120_000; const ceiling = setTimeout(() => { ceilingHit = true; killProcessTree(child.pid); 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 } consumeLines(chunk); }); child.stderr.on("data", (chunk) => { try { onActivity?.(); } catch { // ignore } errOut += chunk; }); let settled = false; let exitFallback = null; // Set by settle() when the failure IS the resume-miss: the dead id is dropped // and this turn re-runs once as a fresh session (see the runTurn docstring). let resumeMiss = false; const settle = (code, spawnErr) => { if (settled) return; settled = true; clearInterval(heartbeat); clearTimeout(ceiling); if (ceilingForce) clearTimeout(ceilingForce); if (exitFallback) clearTimeout(exitFallback); if (this.child === child) this.child = null; // Flush any trailing buffered line (no final newline). if (lineBuf.trim()) consumeLines("\n"); if (streamOpen) push({ type: "stream_end" }); const finalText = assistantText.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: `pi did not finish within ${timeout} (+2m grace) and was terminated, which usually means a frozen CLI. ` + "Try again; raise COMFYUI_MCP_PI_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(); if (!spawnErr && resuming && !isRetry && /no session found matching/i.test(stderrTail)) { // RESUME-MISS (#1235): drop the dead id and retry fresh below — push NO // error/result, so the turn's single terminal event comes from the retry. resumeMiss = true; } else { const authish = looksLikeAuthFailure(stderrTail + finalText); // #948 — pi's own text says "please run /login", which in a chat panel // reads as "type this here" and sends the user somewhere it cannot work. // Every path that passes the child's words through is qualified, not just // the auth one: a non-auth failure can name a slash command too. const message = spawnErr ? /ENOENT/i.test(spawnErr.message) ? "pi CLI (`pi`) could not be launched — it may have been uninstalled. Reinstall from https://pi.dev and reconnect." : `pi failed to start: ${spawnErr.message}` : authish ? providerAuthRemedy("pi", stderrTail) : `pi exited with code ${code}.${stderrTail ? ` ${qualifySlashCommands(stderrTail, "pi")}` : ""}`; push({ type: "error", message }); push({ type: "result", ok: false, subtype: "error" }); } } else { if (finalText) push({ type: "assistant", text: finalText }); push({ type: "result", ok: true, subtype: "end_turn" }); } // Clear the interrupt HERE (turn end), not at turn start — it was consumed // to pick this turn's result, so the next turn starts clean. this.interrupted = false; this.turnActive = false; finish(); }; child.on("error", (err) => settle(null, err)); // Settle on 'close' (all stdio drained), NOT 'exit': Node permits stdout to // still hold buffered data at 'exit', so settling there could drop a final // JSONL line (the last delta or the session header). 'exit' only arms a short // fallback in case a stray inherited pipe keeps stdout open past the process. child.on("exit", (code) => { if (settled || exitFallback) return; exitFallback = setTimeout(() => settle(code), 2000); exitFallback.unref?.(); }); child.on("close", (code) => settle(code)); // A broken input pipe means the child exited; the listeners above report // that failure. Pi reads to EOF, so close stdin after handing over the text. child.stdin.on("error", () => { }); child.stdin.end(text); // An interrupt can arrive while spawn() is in flight — honor it now (after the // exit/close/error listeners are attached, so the kill's events are observed). if (this.interrupted) killProcessTree(child.pid); 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()). 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; } } if (resumeMiss) { // The dead resume id must NEVER be retried, and the fresh session needs the // panel preamble a resume suppresses. The retried turn re-reads both. logger.warn(`[pi-backend] resume target ${this.piSessionId} is gone (pi: no session found matching) — dropping it and retrying the turn as a fresh session (#1235)`); this.piSessionId = null; this.needsSystemPreamble = !!this.deps.systemAppend; yield* this.runTurn(turn, cwd, onActivity, true); } } /** Stop the current turn: kill the in-flight child's process tree. The next * turn continues the pi session (`--session <id>`) with whatever pi recorded. */ async interrupt() { this.interrupted = true; if (this.idleInterruptExpiry) clearTimeout(this.idleInterruptExpiry); this.idleInterruptExpiry = null; if (!this.turnActive) { 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); } /** Switch the model for subsequent turns (--model is per-spawn, so this is free * — no respawn dance). Rejects ids that don't look like pi ids. */ async setModel(model) { if (this.acceptableModel(model)) this.model = model; } /** * Enumerate pi's provider/model catalog via `pi --list-models` (a padded text * table). Also serves as the "CLI can launch" probe for the connect ack — note * it is NOT an auth probe (pi lists its built-in catalog regardless of which * keys are configured). */ async listModels() { const bin = this.resolveBin(); return await new Promise((resolve, reject) => { let out = ""; let err = ""; let child; try { child = spawn(bin.command, [...bin.prefixArgs, "--list-models"], { cwd: this.deps.cwd ?? process.cwd(), env: buildAgentSpawnEnv(), stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }); } catch (e) { reject(new Error(`Could not run \`pi --list-models\`: ${msgOf(e)}`)); return; } const timer = setTimeout(() => { killProcessTree(child.pid); reject(new Error("`pi --list-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 \`pi --list-models\`: ${e.message}`)); }); // 'close' (stdio drained), not 'exit' — the full table must be captured. child.on("close", (code) => { clearTimeout(timer); if (code === 0) { resolve(parsePiModels(out)); } else { const tail = stripAnsi(err || out).trim().split(/\r?\n/).slice(-2).join(" "); reject(new Error(`\`pi --list-models\` exited with code ${code}${tail ? ` (${tail})` : ""}. ` + "Make sure pi is installed (https://pi.dev) and a provider is configured.")); } }); }); } /** 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; } } } //# sourceMappingURL=pi-backend.js.map