comfyui-mcp 0.18.0 → 0.19.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 (76) hide show
  1. package/README.md +58 -2
  2. package/dist/config.js +57 -3
  3. package/dist/config.js.map +1 -1
  4. package/dist/orchestrator/agent-backend.js +33 -0
  5. package/dist/orchestrator/agent-backend.js.map +1 -0
  6. package/dist/orchestrator/claude-backend.js +475 -0
  7. package/dist/orchestrator/claude-backend.js.map +1 -0
  8. package/dist/orchestrator/codex-backend.js +1086 -0
  9. package/dist/orchestrator/codex-backend.js.map +1 -0
  10. package/dist/orchestrator/index.js +312 -16
  11. package/dist/orchestrator/index.js.map +1 -1
  12. package/dist/orchestrator/panel-agent.js +349 -346
  13. package/dist/orchestrator/panel-agent.js.map +1 -1
  14. package/dist/orchestrator/panel-mcp-http.js +190 -0
  15. package/dist/orchestrator/panel-mcp-http.js.map +1 -0
  16. package/dist/orchestrator/panel-tools.js +809 -359
  17. package/dist/orchestrator/panel-tools.js.map +1 -1
  18. package/dist/orchestrator/session-store.js +71 -0
  19. package/dist/orchestrator/session-store.js.map +1 -0
  20. package/dist/services/api-nodes.js +109 -0
  21. package/dist/services/api-nodes.js.map +1 -1
  22. package/dist/services/crash-log.js +222 -0
  23. package/dist/services/crash-log.js.map +1 -0
  24. package/dist/services/env-capabilities.js +404 -0
  25. package/dist/services/env-capabilities.js.map +1 -0
  26. package/dist/services/model-resolver.js +25 -3
  27. package/dist/services/model-resolver.js.map +1 -1
  28. package/dist/services/workflow-slicer.js +123 -0
  29. package/dist/services/workflow-slicer.js.map +1 -0
  30. package/dist/tools/index.js +2 -0
  31. package/dist/tools/index.js.map +1 -1
  32. package/dist/tools/model-extras.js +6 -2
  33. package/dist/tools/model-extras.js.map +1 -1
  34. package/dist/tools/model-management.js +12 -2
  35. package/dist/tools/model-management.js.map +1 -1
  36. package/dist/tools/skills-access.js +480 -0
  37. package/dist/tools/skills-access.js.map +1 -0
  38. package/dist/tools/workflow-library.js +140 -0
  39. package/dist/tools/workflow-library.js.map +1 -1
  40. package/package.json +14 -2
  41. package/packs/anima/workflow.json +27 -27
  42. package/packs/anima-img2img/workflow.json +12 -12
  43. package/packs/anima-inpaint/workflow.json +6 -6
  44. package/packs/anima-txt2img/workflow.json +12 -12
  45. package/packs/cozy-flow/workflow.json +1 -1
  46. package/packs/ideogram/workflow.json +16 -16
  47. package/packs/ideogram-img2img/workflow.json +9 -9
  48. package/packs/ideogram-txt2img/workflow.json +9 -9
  49. package/packs/krea2/workflow.json +4265 -0
  50. package/packs/krea2-txt2img/install-runpod.sh +40 -0
  51. package/packs/krea2-txt2img/install-windows.bat +45 -0
  52. package/packs/krea2-txt2img/manifest.yaml +29 -0
  53. package/packs/krea2-txt2img/pack.yaml +32 -0
  54. package/packs/krea2-txt2img/workflow.json +1871 -0
  55. package/packs/ltx-2.3-extender/workflow.json +9 -9
  56. package/packs/ltx-2.3-extender-no-audio/workflow.json +9 -9
  57. package/packs/ltx-2.3-flf/workflow.json +13 -13
  58. package/packs/ltx-2.3-xy-plot/workflow.json +27 -27
  59. package/packs/wan-pusa-extend/manifest.yaml +43 -0
  60. package/packs/wan-pusa-extend/pack.yaml +51 -0
  61. package/packs/wan-pusa-extend/workflow.json +1 -0
  62. package/plugin/skills/krea2-txt2img/SKILL.md +83 -0
  63. package/plugin/skills/triton-sageattention/SKILL.md +363 -0
  64. package/plugin/skills/video-extend/SKILL.md +505 -0
  65. package/plugin/skills/video-upscale/SKILL.md +329 -0
  66. package/plugin/skills/wan-flf-video/SKILL.md +26 -1
  67. package/plugin/skills/wan-t2v-video/SKILL.md +12 -0
  68. package/plugin/skills/workflow-layout/SKILL.md +14 -5
  69. package/scripts/codex-comfyui-mcp-check.mjs +53 -0
  70. package/scripts/codex-knowledge-parity-smoke.mjs +202 -0
  71. package/scripts/codex-parity-smoke.mjs +177 -0
  72. package/scripts/gen-tool-docs.ts +33 -2
  73. package/scripts/mock-panel.mjs +34 -3
  74. package/scripts/panel-load-workflow-smoke.mjs +210 -0
  75. package/scripts/slice-pipeline.mjs +15 -61
  76. package/scripts/test-agent.mjs +108 -13
@@ -0,0 +1,1086 @@
1
+ // OpenAI Codex backend — the provider-specific adapter behind the AgentBackend
2
+ // port, driving Codex over the `codex app-server` JSON-RPC protocol (NOT
3
+ // `codex exec` string-scraping). This mirrors how our own `openai-codex` plugin
4
+ // drives the app-server (see the plugin's scripts/lib/app-server.mjs +
5
+ // codex.mjs); the protocol mapping is commented inline below.
6
+ //
7
+ // PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
8
+ // push, self-restart) and drives this backend via
9
+ // `for await (const ev of backend.run({...}))`. See
10
+ // docs/design/agent-backend-injection.md.
11
+ //
12
+ // PROTOCOL MAPPING (port → app-server):
13
+ // - session = a Codex THREAD (`thread/start` new | `thread/resume` by id)
14
+ // - run() loop turn = `turn/start` (one turn per neutral channel batch);
15
+ // images ride as `localImage` input items (file paths)
16
+ // - assistant_delta ← `item/agentMessage/delta` ({itemId, delta})
17
+ // - assistant_delta(th) ← `item/reasoning/{text,summaryText}Delta` (thinking)
18
+ // - assistant (commit) ← `item/completed` for an `agentMessage` item
19
+ // - result ← `turn/completed` ({threadId, turn:{status}})
20
+ // - error ← `error` notification ({error:{message}})
21
+ // - interrupt() → `turn/interrupt` ({threadId, turnId})
22
+ // - listModels() ← `config/read` (or a sensible static fallback)
23
+ //
24
+ // FULL PARITY with Claude: the Codex backend now drives the live ComfyUI canvas
25
+ // AND the headless comfyui MCP, with the panel system prompt — everything Claude
26
+ // can do. Two MCP servers are declared to the app-server at launch via `-c`
27
+ // overrides:
28
+ // - `comfyui` (stdio): the headless comfyui MCP (this build's dist/index.js),
29
+ // mirroring the env the Claude path passes (COMFYUI_URL / COMFYUI_PATH / …).
30
+ // - `panel` (http) : the orchestrator-hosted loopback HTTP MCP that exposes
31
+ // the SHARED panel_* live-graph tools, routed by tab id
32
+ // (http://127.0.0.1:<port>/<tabId>). See panel-mcp-http.ts + panel-tools.ts.
33
+ // The app-server can only host CONFIG-DECLARED MCP servers (not an in-process SDK
34
+ // server), which is exactly why panel_* is exposed over HTTP for this backend.
35
+ // The panel system prompt is prepended to the FIRST turn (the app-server's
36
+ // thread/start has no instructions field).
37
+ import { spawn, spawnSync } from "node:child_process";
38
+ import { createRequire } from "node:module";
39
+ import { promises as fsp } from "node:fs";
40
+ import os from "node:os";
41
+ import path from "node:path";
42
+ import readline from "node:readline";
43
+ import { logger } from "../utils/logger.js";
44
+ import { CODEX_CAPABILITIES, } from "./agent-backend.js";
45
+ function msgOf(err) {
46
+ return err instanceof Error ? err.message : String(err);
47
+ }
48
+ /**
49
+ * Kill an entire process tree, not just the direct child. On the Windows
50
+ * PATH/shell fallback the direct child is a cmd.exe/shim whose grandchild is the
51
+ * real `codex` node process — killing only the shell leaves the tree alive. Use
52
+ * `taskkill /T /F` (mirrors the reference client's terminateProcessTree). On
53
+ * POSIX, signal the process group (negative pid) so a shell + its child both die,
54
+ * falling back to the single pid. Best-effort + swallows errors: it runs during
55
+ * teardown and must never throw into the host process.
56
+ */
57
+ function killProcessTree(pid) {
58
+ if (!Number.isFinite(pid))
59
+ return;
60
+ const p = pid;
61
+ if (process.platform === "win32") {
62
+ try {
63
+ spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
64
+ }
65
+ catch {
66
+ try {
67
+ process.kill(p);
68
+ }
69
+ catch {
70
+ // already gone
71
+ }
72
+ }
73
+ return;
74
+ }
75
+ try {
76
+ process.kill(-p, "SIGTERM"); // process group (we spawn detached on POSIX)
77
+ }
78
+ catch {
79
+ try {
80
+ process.kill(p, "SIGTERM");
81
+ }
82
+ catch {
83
+ // already gone
84
+ }
85
+ }
86
+ }
87
+ class AppServerClient {
88
+ bin;
89
+ cwd;
90
+ env;
91
+ extraArgs;
92
+ proc = null;
93
+ rl = null;
94
+ pending = new Map();
95
+ nextId = 1;
96
+ closed = false;
97
+ exitResolved = false;
98
+ /** The error that ended the connection (null = clean exit). Readable so a turn
99
+ * can surface a meaningful message when the child dies mid-turn. */
100
+ exitError = null;
101
+ stderr = "";
102
+ notificationHandler = null;
103
+ resolveExit;
104
+ /** Resolves when the app-server process exits or errors (P0-2): runTurn() races
105
+ * its per-turn drain against this so a child that dies after turn/start resolved
106
+ * but before turn/completed doesn't deadlock the turn forever. */
107
+ exitPromise;
108
+ constructor(bin, cwd, env,
109
+ // Extra `-c key=value` config overrides appended after `app-server` (used to
110
+ // declare the comfyui + panel MCP servers — full Codex/Claude tool parity).
111
+ extraArgs = []) {
112
+ this.bin = bin;
113
+ this.cwd = cwd;
114
+ this.env = env;
115
+ this.extraArgs = extraArgs;
116
+ this.exitPromise = new Promise((resolve) => {
117
+ this.resolveExit = resolve;
118
+ });
119
+ }
120
+ /** Spawn `codex app-server`, perform the initialize handshake, and return. */
121
+ async initialize(clientInfo) {
122
+ // On Windows the bundled bin is a node launcher script; spawn it via the
123
+ // current node so we don't depend on a `codex` shim being on PATH. When `bin`
124
+ // is a plain "codex" (PATH fallback) we still spawn it directly.
125
+ const isJs = /\.(c|m)?js$/i.test(this.bin);
126
+ const cmd = isJs ? process.execPath : this.bin;
127
+ // `-c` overrides go AFTER the `app-server` subcommand (they're app-server
128
+ // flags). They declare the comfyui (stdio) + panel (http) MCP servers.
129
+ const baseArgs = isJs ? [this.bin, "app-server"] : ["app-server"];
130
+ const args = [...baseArgs, ...this.extraArgs];
131
+ // When falling back to a `codex` on PATH on Windows, the resolvable entry is a
132
+ // `.cmd`/`.ps1` shim — spawn without a shell can't find it (ENOENT). Use a
133
+ // shell in that case (mirrors the plugin's client). The bundled-dep lane runs
134
+ // the `.js` launcher via node directly, so it never needs a shell.
135
+ const useShell = !isJs && process.platform === "win32";
136
+ this.proc = spawn(cmd, args, {
137
+ cwd: this.cwd,
138
+ env: this.env,
139
+ stdio: ["pipe", "pipe", "pipe"],
140
+ windowsHide: true,
141
+ shell: useShell,
142
+ // On POSIX, put the child in its OWN process group so close() can kill the
143
+ // whole tree (shell + grandchild) with a single negative-pid signal. On
144
+ // Windows we use taskkill /T instead, so detached isn't needed there.
145
+ detached: process.platform !== "win32",
146
+ });
147
+ this.proc.stdout.setEncoding("utf8");
148
+ this.proc.stderr.setEncoding("utf8");
149
+ this.proc.stderr.on("data", (chunk) => {
150
+ this.stderr += chunk;
151
+ });
152
+ // Swallow stream errors on the child's pipes. When the app-server child dies
153
+ // mid-turn, the NEXT write to stdin (or a read on stdout) raises an async
154
+ // 'error' event (EPIPE on Windows) — with no listener Node treats it as an
155
+ // uncaughtException and the orchestrator's handler would exit the whole
156
+ // process. Route them through handleExit instead so the turn rejects cleanly
157
+ // (P0-2) and the host survives. (P0-2)
158
+ this.proc.stdin.on("error", (error) => this.handleExit(error));
159
+ this.proc.stdout.on("error", (error) => this.handleExit(error));
160
+ this.proc.on("error", (error) => this.handleExit(error));
161
+ this.proc.on("exit", (code, signal) => {
162
+ const detail = code === 0
163
+ ? null
164
+ : new Error(`codex app-server exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${this.stderr ? ` ${this.stderr.trim().split(/\r?\n/).slice(-2).join(" ")}` : ""}`);
165
+ this.handleExit(detail);
166
+ });
167
+ this.rl = readline.createInterface({ input: this.proc.stdout });
168
+ this.rl.on("line", (line) => this.handleLine(line));
169
+ // JSON-RPC handshake: initialize (request) then initialized (notification).
170
+ // We opt IN to the delta notifications (by NOT opting out) so we can stream
171
+ // assistant + reasoning text token-by-token; the plugin opts them out because
172
+ // it only captures final messages.
173
+ await this.request("initialize", {
174
+ clientInfo,
175
+ capabilities: { experimentalApi: false, optOutNotificationMethods: [] },
176
+ });
177
+ this.notify("initialized", {});
178
+ }
179
+ request(method, params) {
180
+ if (this.closed)
181
+ return Promise.reject(new Error("codex app-server client is closed."));
182
+ const id = this.nextId++;
183
+ return new Promise((resolve, reject) => {
184
+ this.pending.set(id, { resolve: resolve, reject, method });
185
+ this.send({ id, method, params });
186
+ });
187
+ }
188
+ notify(method, params = {}) {
189
+ if (this.closed)
190
+ return;
191
+ // Fire-and-forget: a write failure (dead child) must not throw into the caller
192
+ // — handleExit already records it and rejects pending requests.
193
+ try {
194
+ this.send({ method, params });
195
+ }
196
+ catch {
197
+ // connection gone; pending requests already rejected via handleExit
198
+ }
199
+ }
200
+ send(message) {
201
+ const stdin = this.proc?.stdin;
202
+ // Stream gone / destroyed (child died) — surface as a connection exit so any
203
+ // pending request rejects, rather than throwing an unhandled error from a
204
+ // fire-and-forget notify() (P0-2).
205
+ if (!stdin || stdin.destroyed || stdin.writableEnded) {
206
+ this.handleExit(this.exitError ?? new Error("codex app-server stdin is not available."));
207
+ throw this.exitError ?? new Error("codex app-server stdin is not available.");
208
+ }
209
+ try {
210
+ stdin.write(`${JSON.stringify(message)}\n`);
211
+ }
212
+ catch (err) {
213
+ // Synchronous write failure (EPIPE) on a child that just died.
214
+ this.handleExit(err instanceof Error ? err : new Error(String(err)));
215
+ throw err;
216
+ }
217
+ }
218
+ /**
219
+ * The auto-approve RESULT for a server→client approval/permission/elicitation
220
+ * request, or null if the request isn't an approval we should auto-grant.
221
+ *
222
+ * Decision shapes differ per request method (from the app-server protocol):
223
+ * - execCommandApproval / applyPatchApproval → { decision: ReviewDecision }
224
+ * where ReviewDecision = "approved" | "denied" | …
225
+ * - item/commandExecution/requestApproval, item/fileChange/requestApproval,
226
+ * item/permissions/requestApproval → { decision: "accept" | … }
227
+ * - mcpServer/elicitation/request → an MCP elicitation result
228
+ * ({ action: "accept", content: {} }).
229
+ * We grant the affirmative for each so the headless background agent (same
230
+ * isolation posture as Claude's bypassPermissions) is never blocked.
231
+ */
232
+ autoApproveDecision(method) {
233
+ switch (method) {
234
+ case "execCommandApproval":
235
+ case "applyPatchApproval":
236
+ return { decision: "approved" };
237
+ case "item/commandExecution/requestApproval":
238
+ case "item/fileChange/requestApproval":
239
+ case "item/permissions/requestApproval":
240
+ return { decision: "accept" };
241
+ case "mcpServer/elicitation/request":
242
+ return { action: "accept", content: {} };
243
+ default:
244
+ return null;
245
+ }
246
+ }
247
+ handleLine(line) {
248
+ if (!line.trim())
249
+ return;
250
+ let message;
251
+ try {
252
+ message = JSON.parse(line);
253
+ }
254
+ catch (error) {
255
+ this.handleExit(new Error(`Failed to parse codex app-server JSONL: ${msgOf(error)}`));
256
+ return;
257
+ }
258
+ // Server→client request. The app-server asks the client to approve commands,
259
+ // file edits, MCP tool elicitations, and permission requests. The panel agent
260
+ // is an ISOLATED background agent (same posture as the Claude path's
261
+ // bypassPermissions), so we AUTO-APPROVE these to keep the live-graph work
262
+ // flowing — otherwise a panel_* MCP tool call hangs on an approval prompt the
263
+ // headless orchestrator can't surface. Anything we don't recognize still gets
264
+ // a method-not-found so the protocol keeps moving.
265
+ if (message.id !== undefined && message.method) {
266
+ const decision = this.autoApproveDecision(message.method);
267
+ if (decision) {
268
+ logger.debug(`[codex-backend] auto-approving server request ${message.method}`);
269
+ this.send({ id: message.id, result: decision });
270
+ }
271
+ else {
272
+ logger.debug(`[codex-backend] unsupported server request ${message.method} — replying method-not-found`);
273
+ this.send({ id: message.id, error: { code: -32601, message: `Unsupported server request: ${message.method}` } });
274
+ }
275
+ return;
276
+ }
277
+ // Response to one of our requests.
278
+ if (message.id !== undefined) {
279
+ const p = this.pending.get(message.id);
280
+ if (!p)
281
+ return;
282
+ this.pending.delete(message.id);
283
+ if (message.error)
284
+ p.reject(new Error(message.error.message ?? `codex app-server ${p.method} failed.`));
285
+ else
286
+ p.resolve(message.result ?? {});
287
+ return;
288
+ }
289
+ // Notification.
290
+ if (message.method)
291
+ this.notificationHandler?.(message);
292
+ }
293
+ handleExit(error) {
294
+ if (this.exitResolved)
295
+ return;
296
+ this.exitResolved = true;
297
+ this.exitError = error;
298
+ for (const p of this.pending.values())
299
+ p.reject(error ?? new Error("codex app-server connection closed."));
300
+ this.pending.clear();
301
+ this.resolveExit();
302
+ }
303
+ async close() {
304
+ if (this.closed) {
305
+ await this.exitPromise;
306
+ return;
307
+ }
308
+ this.closed = true;
309
+ // Drop the notification handler so a late notification can't re-enter a
310
+ // torn-down turn during shutdown.
311
+ this.notificationHandler = null;
312
+ this.rl?.close();
313
+ this.rl = null;
314
+ if (this.proc && this.proc.exitCode === null) {
315
+ try {
316
+ this.proc.stdin.end();
317
+ }
318
+ catch {
319
+ // already gone
320
+ }
321
+ const proc = this.proc;
322
+ // Give a graceful stdin-EOF shutdown a beat, then KILL THE WHOLE TREE — on
323
+ // the Windows shell fallback the direct child is a shim whose grandchild is
324
+ // the real codex node process, so proc.kill() alone would orphan it.
325
+ setTimeout(() => {
326
+ if (proc.exitCode === null)
327
+ killProcessTree(proc.pid);
328
+ }, 50).unref?.();
329
+ }
330
+ await this.exitPromise;
331
+ this.proc = null;
332
+ }
333
+ }
334
+ // ---- model fallback ----
335
+ // config/read does not enumerate a model CATALOG (it reports the active provider
336
+ // + model), so when we can't derive a list we fall back to the current Codex
337
+ // model family. The panel picker degrades gracefully on an empty list.
338
+ const CODEX_FALLBACK_MODELS = [
339
+ { id: "gpt-5.5", label: "GPT-5.5" },
340
+ { id: "gpt-5.5-codex", label: "GPT-5.5 Codex" },
341
+ ];
342
+ /** Does this id look like an OpenAI/Codex model (vs. a Claude panel model)? Used
343
+ * to ignore the Claude panel model PanelAgent unconditionally passes as
344
+ * opts.model, so the configured Codex model wins (P1-1). Anthropic ids start with
345
+ * "claude"/"anthropic"; Codex ids are gpt-, o-series, codex-, or chatgpt-. */
346
+ function isCodexModel(id) {
347
+ const m = id.toLowerCase();
348
+ if (m.startsWith("claude") || m.startsWith("anthropic"))
349
+ return false;
350
+ return /^(gpt-|o\d|codex|chatgpt)/.test(m) || m.includes("codex");
351
+ }
352
+ // ---- reasoning effort mapping ----
353
+ // Codex's reasoning-effort scale differs from Claude's: Codex accepts
354
+ // none|minimal|low|medium|high|xhigh (the app-server `turn/start` `effort` field;
355
+ // see the reference openai-codex plugin's codex.mjs), while the panel/Claude
356
+ // scale is low|medium|high|xhigh|max. The shared levels map 1:1; the only
357
+ // off-scale source value is Claude "max", which has no Codex equivalent and maps
358
+ // to the nearest valid level (xhigh). Unknown/empty → null (app-server default).
359
+ const CODEX_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"];
360
+ function toCodexEffort(effort) {
361
+ if (!effort)
362
+ return null;
363
+ const e = effort.toLowerCase();
364
+ if (CODEX_EFFORTS.includes(e))
365
+ return e;
366
+ if (e === "max")
367
+ return "xhigh"; // Claude's top level → Codex's nearest valid
368
+ return null; // unknown level → let the app-server pick its default
369
+ }
370
+ /**
371
+ * Derive a display name for a Codex app-server `item` (from item/started and
372
+ * item/completed) when it represents a TOOL-like action — an MCP tool call, a
373
+ * shell command, a file change, a web search, etc. Returns null for non-tool
374
+ * items (agentMessage / reasoning), which the delta/commit paths already handle,
375
+ * so the caller skips emitting a tool_call for them. Best-effort + defensive: the
376
+ * exact item shape varies by app-server version, so we probe the common name
377
+ * fields and fall back to the item `type`.
378
+ */
379
+ export function toolNameOf(item) {
380
+ if (!item || typeof item !== "object")
381
+ return null;
382
+ const type = typeof item.type === "string" ? item.type : undefined;
383
+ // These item types are text/reasoning, not tools — they're surfaced via the
384
+ // assistant_delta / assistant commit paths, so don't double-report them.
385
+ if (type === "agentMessage" || type === "reasoning")
386
+ return null;
387
+ // MCP tool calls carry a tool name (and often a server) — prefer the most
388
+ // specific identifier available, then fall back to the item type.
389
+ const pick = (...keys) => {
390
+ for (const k of keys) {
391
+ const v = item[k];
392
+ if (typeof v === "string" && v)
393
+ return v;
394
+ }
395
+ return undefined;
396
+ };
397
+ const server = pick("server", "serverName");
398
+ const tool = pick("tool", "toolName", "name", "command");
399
+ if (tool)
400
+ return server ? `${server}.${tool}` : tool;
401
+ // No explicit name field — use the item type as the label (commandExecution,
402
+ // fileChange, webSearch, …) so the panel at least shows that a tool ran.
403
+ return type ?? null;
404
+ }
405
+ /**
406
+ * Build the `-c key=value` CLI overrides that declare the given MCP servers to
407
+ * `codex app-server`. Values are TOML literals: strings are JSON-quoted, arrays
408
+ * are JSON arrays (valid TOML). Mirrors `codex mcp add` / the config.toml format.
409
+ */
410
+ export function buildMcpConfigArgs(servers) {
411
+ const args = [];
412
+ const lit = (s) => JSON.stringify(s); // safe TOML string literal
413
+ for (const [name, spec] of Object.entries(servers)) {
414
+ if (spec.transport === "stdio") {
415
+ args.push("-c", `mcp_servers.${name}.command=${lit(spec.command)}`);
416
+ if (spec.args && spec.args.length) {
417
+ args.push("-c", `mcp_servers.${name}.args=${JSON.stringify(spec.args)}`);
418
+ }
419
+ for (const [k, v] of Object.entries(spec.env ?? {})) {
420
+ args.push("-c", `mcp_servers.${name}.env.${k}=${lit(v)}`);
421
+ }
422
+ }
423
+ else {
424
+ // Streamable HTTP MCP server — `url` is what `codex mcp add --url` sets.
425
+ args.push("-c", `mcp_servers.${name}.url=${lit(spec.url)}`);
426
+ }
427
+ }
428
+ return args;
429
+ }
430
+ /**
431
+ * The Codex app-server adapter. One instance per PanelAgent; it holds the live
432
+ * app-server client + current thread/turn ids and re-opens on each `run()`.
433
+ */
434
+ export class CodexBackend {
435
+ id = "codex";
436
+ capabilities = CODEX_CAPABILITIES;
437
+ deps;
438
+ client = null;
439
+ /** The client currently being spun up by an in-flight prepare(), tracked so a
440
+ * concurrent close() can tear it down even before it's published to
441
+ * this.client (P0-A: close() racing prepare() must never leak the child). */
442
+ preparingClient = null;
443
+ /** Set once close() runs — a hard tripwire so an in-flight prepare() that wakes
444
+ * up after close() disposes its local client instead of publishing it (P0-A). */
445
+ disposed = false;
446
+ /** Cached resolved path to the codex binary/launcher (set in prepare()). */
447
+ bin = null;
448
+ /** The live thread + turn ids — used for `turn/interrupt`. */
449
+ threadId = null;
450
+ turnId = null;
451
+ /** The model requested for new turns (mutable for a future live setModel). */
452
+ model;
453
+ /** The Codex reasoning effort for new turns, already mapped to a valid Codex
454
+ * level (null = let the app-server choose). Captured from run(opts.effort). */
455
+ effort = null;
456
+ /** True until the panel system prompt has been prepended to a turn. The
457
+ * app-server's thread/start has no instructions field, so the persona rides on
458
+ * the FIRST turn's input; reset whenever a NEW thread starts (run()). */
459
+ needsSystemPreamble = false;
460
+ /** Temp image files written for delivered turn images (the app-server's
461
+ * `localImage` input item takes a PATH, so /view bytes are spilled to disk).
462
+ * Tracked so each turn cleans up its own files, and close() sweeps any
463
+ * stragglers. */
464
+ tempImageFiles = new Set();
465
+ constructor(deps = {}) {
466
+ this.deps = deps;
467
+ this.model = deps.model;
468
+ }
469
+ /**
470
+ * Resolve the codex binary: prefer the bundled `@openai/codex` launcher (via
471
+ * require.resolve of its package bin) so no separate install is needed; fall
472
+ * back to a `codex` on PATH. Throws a clear message if neither is available.
473
+ */
474
+ resolveBin() {
475
+ if (this.bin)
476
+ return this.bin;
477
+ try {
478
+ const require = createRequire(import.meta.url);
479
+ // The package exposes bin/codex.js; resolve its package.json then derive the
480
+ // bin path relative to the package dir (works regardless of OS separators).
481
+ const pkgPath = require.resolve("@openai/codex/package.json");
482
+ const pkg = require("@openai/codex/package.json");
483
+ const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.codex;
484
+ if (binRel) {
485
+ const sep = pkgPath.includes("\\") ? "\\" : "/";
486
+ const pkgDir = pkgPath.replace(/[\\/]package\.json$/, "");
487
+ this.bin = `${pkgDir}${sep}${binRel.replace(/^\.[\\/]/, "")}`;
488
+ }
489
+ }
490
+ catch {
491
+ // bundled package not installed — fall through to PATH.
492
+ }
493
+ if (!this.bin)
494
+ this.bin = "codex"; // PATH fallback (a `codex` on PATH)
495
+ return this.bin;
496
+ }
497
+ /**
498
+ * Fetch a ComfyUI image (/view) and spill the bytes to a temp file, returning
499
+ * its absolute path — or null on any failure (the text reference still names the
500
+ * image as a fallback). The app-server `turn/start` `localImage` input item takes
501
+ * a FILE PATH (mirrors the codex CLI `-i, --image <FILE>`), so unlike Claude
502
+ * (inline base64) we must write the bytes to disk. Mirrors
503
+ * ClaudeBackend.fetchImageBlock's source/size guards. Each written path is
504
+ * tracked in tempImageFiles for per-turn + close() cleanup.
505
+ */
506
+ async fetchImageFile(ref) {
507
+ if (!this.deps.comfyuiUrl || !ref?.filename)
508
+ return null;
509
+ try {
510
+ const u = new URL("/view", this.deps.comfyuiUrl);
511
+ u.searchParams.set("filename", ref.filename);
512
+ u.searchParams.set("type", ref.type || "input");
513
+ if (ref.subfolder)
514
+ u.searchParams.set("subfolder", ref.subfolder);
515
+ const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
516
+ if (!res.ok)
517
+ return null;
518
+ const mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
519
+ const buf = Buffer.from(await res.arrayBuffer());
520
+ if (buf.length > 12 * 1024 * 1024)
521
+ return null; // keep context sane (parity with Claude)
522
+ // Preserve a recognizable extension so the model/app-server treat it as the
523
+ // right image type; default to .png (ComfyUI outputs are PNG by default).
524
+ const extFromMime = {
525
+ "image/png": ".png",
526
+ "image/jpeg": ".jpg",
527
+ "image/gif": ".gif",
528
+ "image/webp": ".webp",
529
+ };
530
+ // Recognized content-type → its extension; otherwise only trust the source
531
+ // filename extension if it's a known image type, else default to .png (don't
532
+ // preserve an arbitrary suffix — parity with Claude mapping unknowns to png).
533
+ const allowedExt = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
534
+ const fileExt = path.extname(ref.filename).toLowerCase();
535
+ const ext = extFromMime[mt] ?? (allowedExt.has(fileExt) ? fileExt : ".png");
536
+ const file = path.join(os.tmpdir(), `comfyui-codex-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
537
+ await fsp.writeFile(file, buf);
538
+ this.tempImageFiles.add(file);
539
+ return file;
540
+ }
541
+ catch {
542
+ return null;
543
+ }
544
+ }
545
+ /** Delete the given temp image files (best-effort) and drop them from tracking. */
546
+ async cleanupTempImages(files) {
547
+ for (const f of files) {
548
+ this.tempImageFiles.delete(f);
549
+ try {
550
+ await fsp.unlink(f);
551
+ }
552
+ catch {
553
+ // already gone / never created — best-effort
554
+ }
555
+ }
556
+ }
557
+ /**
558
+ * Preflight: resolve + spawn `codex app-server`, perform the JSON-RPC
559
+ * handshake, and verify the account is logged in (ChatGPT login / CODEX_API_KEY
560
+ * — keyless, like the Claude OAuth lane). Fails fast with a clear reject so a
561
+ * missing binary or signed-out state surfaces immediately instead of being
562
+ * retried as a dropped session. Idempotent — reuses the live client.
563
+ */
564
+ async prepare() {
565
+ if (this.disposed)
566
+ throw new Error("codex backend is closed.");
567
+ if (this.client)
568
+ return;
569
+ const bin = this.resolveBin();
570
+ const cwd = this.deps.cwd ?? process.cwd();
571
+ // Declare the comfyui + panel MCP servers as `-c` overrides so Codex has the
572
+ // same tool surface as Claude (full parity).
573
+ const extraArgs = this.deps.mcpServers ? buildMcpConfigArgs(this.deps.mcpServers) : [];
574
+ const client = new AppServerClient(bin, cwd, process.env, extraArgs);
575
+ // Publish the in-flight client BEFORE the startup awaits so a concurrent
576
+ // close() can find and kill it instead of seeing this.client === null and
577
+ // returning early — which would orphan the spawning app-server child (P0-A).
578
+ this.preparingClient = client;
579
+ // After EVERY await below, re-check disposed: close() may have run during the
580
+ // await and torn down our client out from under us. If so, dispose the local
581
+ // client and bail without publishing it.
582
+ const abortIfDisposed = async () => {
583
+ if (!this.disposed)
584
+ return;
585
+ if (this.preparingClient === client)
586
+ this.preparingClient = null;
587
+ await client.close().catch(() => { });
588
+ throw new Error("codex backend was closed during prepare().");
589
+ };
590
+ try {
591
+ try {
592
+ await client.initialize({ title: "comfyui-mcp panel", name: "comfyui-mcp", version: "0.16.0" });
593
+ }
594
+ catch (err) {
595
+ await client.close().catch(() => { });
596
+ throw new Error(`Could not start the Codex app-server (codex backend). Install the optional dependency with: npm i @openai/codex (or ensure \`codex\` is on PATH). Details: ${msgOf(err)}`);
597
+ }
598
+ await abortIfDisposed();
599
+ // Auth check: account/read tells us whether a ChatGPT login or API key is
600
+ // present. Mirror the plugin's app-server auth probe.
601
+ try {
602
+ const account = await client.request("account/read", { refreshToken: false });
603
+ const loggedIn = !!account?.account || account?.requiresOpenaiAuth === false;
604
+ if (!loggedIn) {
605
+ await client.close().catch(() => { });
606
+ throw new Error("Codex is not logged in. Run `codex login` (ChatGPT login) or set CODEX_API_KEY, then reconnect.");
607
+ }
608
+ }
609
+ catch (err) {
610
+ await client.close().catch(() => { });
611
+ throw err instanceof Error ? err : new Error(msgOf(err));
612
+ }
613
+ await abortIfDisposed();
614
+ this.client = client;
615
+ logger.info("[codex-backend] app-server ready (logged in)");
616
+ }
617
+ finally {
618
+ // Either we published it onto this.client, or an error/abort path already
619
+ // closed it — in all cases stop tracking it as in-flight.
620
+ if (this.preparingClient === client)
621
+ this.preparingClient = null;
622
+ }
623
+ }
624
+ /**
625
+ * Open/continue a Codex thread and yield canonical AgentEvents. The user
626
+ * channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
627
+ * neutral batch becomes a `turn/start`, whose streamed notifications are
628
+ * normalized to AgentEvents, and only after the turn completes do we read the
629
+ * next batch (the channel async-iteration IS the turn-gate).
630
+ */
631
+ async *run(opts) {
632
+ await this.prepare();
633
+ const client = this.client;
634
+ if (!client)
635
+ throw new Error("codex app-server not initialized");
636
+ const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
637
+ // MODEL PRECEDENCE (P1-1): PanelAgent.start() always passes opts.model = the
638
+ // CLAUDE panel model (e.g. claude-opus-4-8), which is NOT a valid Codex model.
639
+ // The Codex model configured at construction (deps.model, from
640
+ // COMFYUI_MCP_CODEX_MODEL) must win. Only honor opts.model if it actually looks
641
+ // like a Codex model (so a future Codex-aware picker can still switch live);
642
+ // otherwise ignore it and keep the configured Codex model (or the account
643
+ // default when neither is set — model:null lets the app-server choose).
644
+ if (opts.model && isCodexModel(opts.model))
645
+ this.model = opts.model;
646
+ // Map the panel/Claude effort scale onto Codex's and apply it to every turn in
647
+ // this session (the panel restarts run() on an effort change, so capturing it
648
+ // here is enough — each new turn reads this.effort). Without this the session
649
+ // ran at the app-server default regardless of the picker (the effort was
650
+ // previously hardcoded to null on turn/start).
651
+ this.effort = toCodexEffort(opts.effort);
652
+ // forkAtAnchor is false (CODEX_CAPABILITIES) → ignore opts.rewindAnchor; we
653
+ // only do whole-thread resume.
654
+ const resumeId = opts.resume ?? opts.sessionId ?? null;
655
+ let threadModel;
656
+ if (resumeId) {
657
+ // thread/resume continues an existing conversation by id.
658
+ const res = await client.request("thread/resume", {
659
+ threadId: resumeId,
660
+ cwd,
661
+ model: this.model ?? null,
662
+ approvalPolicy: "never",
663
+ sandbox: "workspace-write",
664
+ });
665
+ this.threadId = res.thread.id;
666
+ threadModel = res.model;
667
+ // A resumed thread already received the persona on its original first turn —
668
+ // don't repeat it.
669
+ this.needsSystemPreamble = false;
670
+ }
671
+ else {
672
+ // thread/start opens a fresh conversation.
673
+ const res = await client.request("thread/start", {
674
+ cwd,
675
+ model: this.model ?? null,
676
+ approvalPolicy: "never",
677
+ sandbox: "workspace-write",
678
+ ephemeral: false,
679
+ });
680
+ this.threadId = res.thread.id;
681
+ threadModel = res.model;
682
+ // Fresh thread → prepend the panel persona to the first turn's input.
683
+ this.needsSystemPreamble = !!this.deps.systemAppend;
684
+ }
685
+ // The thread id is our session id (PanelAgent persists it for resume).
686
+ yield {
687
+ type: "session",
688
+ sessionId: this.threadId,
689
+ ...(threadModel ? { model: threadModel } : {}),
690
+ };
691
+ // Process the neutral channel one turn at a time. onActivity is the LIVENESS
692
+ // signal — every raw app-server notification for the active turn re-arms
693
+ // PanelAgent's idle watchdog so a long, quiet generation doesn't falsely trip.
694
+ for await (const turn of opts.channel) {
695
+ yield* this.runTurn(client, turn, opts.onActivity);
696
+ }
697
+ }
698
+ /** Run ONE turn: turn/start + stream its notifications → AgentEvents, resolving
699
+ * when `turn/completed`/`error` for this thread+turn arrives, OR when the
700
+ * app-server child exits mid-turn (P0-2 — never deadlock). Notifications are
701
+ * buffered until the turnId is known and then filtered by belongsToTurn (P1-3)
702
+ * so a stale/interleaved same-thread notification can't complete the wrong turn. */
703
+ async *runTurn(client, turn, onActivity) {
704
+ const threadId = this.threadId;
705
+ // Event queue bridging the push-based notification handler to this pull-based
706
+ // async generator. The handler enqueues normalized AgentEvents; we drain.
707
+ const queue = [];
708
+ let wake = null;
709
+ let done = false;
710
+ const push = (ev) => {
711
+ queue.push(ev);
712
+ wake?.();
713
+ wake = null;
714
+ };
715
+ const finish = () => {
716
+ done = true;
717
+ wake?.();
718
+ wake = null;
719
+ };
720
+ // EVERY terminal path of a turn MUST emit exactly one `result` so PanelAgent's
721
+ // turn-gate advances (it only calls completeTurn() on a result event; a missing
722
+ // result parks its channel forever — panel-agent.ts ~438). This single
723
+ // idempotent helper guarantees that: it emits an `error` event + a single
724
+ // `{type:"result", ok:false}` and finishes, and is a no-op if a result was
725
+ // already emitted (so the error notification, the exit watcher, and the
726
+ // turn/start rejection can all call it without double-finishing) (P0-B).
727
+ let finishedResult = false;
728
+ const emitTerminalError = (message) => {
729
+ if (finishedResult)
730
+ return;
731
+ finishedResult = true;
732
+ closeStream();
733
+ push({ type: "error", message });
734
+ push({ type: "result", ok: false, subtype: "error" });
735
+ finish();
736
+ };
737
+ // ---- turn-id state machine (mirrors the reference captureTurn) ----
738
+ // The turnId isn't known until the turn/start response resolves, and some
739
+ // notifications can arrive BEFORE it. Buffer those, then replay only the ones
740
+ // that belong to this turn once we know the id. After that, filter live.
741
+ let activeTurnId = null;
742
+ let turnIdKnown = false;
743
+ const buffered = [];
744
+ const belongsToTurn = (msg) => {
745
+ const params = (msg.params ?? {});
746
+ const msgThreadId = params.threadId;
747
+ // Wrong thread → not ours.
748
+ if (msgThreadId && msgThreadId !== threadId)
749
+ return false;
750
+ const t = params.turn;
751
+ const msgTurnId = params.turnId ?? t?.id ?? null;
752
+ // No active turn id yet (shouldn't happen post-buffer) or the notification
753
+ // carries no turn id → accept; otherwise require an exact match.
754
+ return activeTurnId === null || msgTurnId === null || msgTurnId === activeTurnId;
755
+ };
756
+ // Track the streamed item id so deltas + the final commit share one bubble id
757
+ // (the panel reconciles by id, like the Claude stream path). Reasoning and
758
+ // reply text each open/close their own stream (P2-1: reasoning was previously
759
+ // emitted without a stream_start, so the panel dropped early thinking deltas).
760
+ let streamOpen = false;
761
+ let streamKind = null;
762
+ let interrupted = false;
763
+ const openStream = (id, kind) => {
764
+ if (streamOpen && streamKind === kind)
765
+ return;
766
+ if (streamOpen)
767
+ push({ type: "stream_end" }); // switch kinds → close the old one
768
+ streamOpen = true;
769
+ streamKind = kind;
770
+ push({ type: "stream_start", id });
771
+ };
772
+ const closeStream = () => {
773
+ if (streamOpen) {
774
+ push({ type: "stream_end" });
775
+ streamOpen = false;
776
+ streamKind = null;
777
+ }
778
+ };
779
+ // Normalize ONE notification (already confirmed to belong to this turn) into
780
+ // canonical AgentEvents. Pulled out so it can be applied to both live and
781
+ // buffered (replayed) notifications.
782
+ const apply = (msg) => {
783
+ // Once ANY terminal result has fired (success turn/completed OR a terminal
784
+ // error via emitTerminalError), the turn is done — drop every later
785
+ // notification so a racing/buffered turn/completed can't push a SECOND result
786
+ // (double-completing PanelAgent's gate) or enqueue deltas into a closing
787
+ // iterator. This is the "exactly one result" invariant (P0-B).
788
+ if (finishedResult)
789
+ return;
790
+ const params = (msg.params ?? {});
791
+ switch (msg.method) {
792
+ case "turn/started": {
793
+ const t = params.turn;
794
+ if (t?.id) {
795
+ this.turnId = t.id;
796
+ if (!activeTurnId)
797
+ activeTurnId = t.id;
798
+ }
799
+ break;
800
+ }
801
+ case "item/agentMessage/delta": {
802
+ const delta = params.delta;
803
+ const itemId = params.itemId;
804
+ if (typeof delta === "string" && delta) {
805
+ openStream(itemId ?? null, "text");
806
+ push({ type: "assistant_delta", text: delta });
807
+ }
808
+ break;
809
+ }
810
+ case "item/reasoning/textDelta":
811
+ case "item/reasoning/summaryTextDelta": {
812
+ // Extended-thinking streaming (raw reasoning + the model's summary).
813
+ // Open a reasoning stream on the FIRST delta (P2-1) so PanelAgent — which
814
+ // drops assistant_delta when no stream is open — renders early thinking.
815
+ const delta = params.delta;
816
+ const itemId = params.itemId;
817
+ if (typeof delta === "string" && delta) {
818
+ openStream(itemId ?? null, "thinking");
819
+ push({ type: "assistant_delta", text: delta, thinking: true });
820
+ }
821
+ break;
822
+ }
823
+ case "item/started": {
824
+ // A non-message item began (a tool/command/MCP call or file change). Emit
825
+ // a tool_call(start) AgentEvent so the panel has TOOL VISIBILITY (the
826
+ // documented P2 gap — Codex previously surfaced no tool activity at all)
827
+ // and the watchdog re-arms on a translated event too. agentMessage /
828
+ // reasoning items aren't "tools" — they're handled by the delta/commit
829
+ // paths above — so skip them here.
830
+ const item = params.item;
831
+ const name = toolNameOf(item);
832
+ if (name)
833
+ push({ type: "tool_call", name, phase: "start", detail: item });
834
+ break;
835
+ }
836
+ case "item/completed": {
837
+ // The authoritative commit for a finished item. Close any open stream
838
+ // (reasoning OR reply) then emit the canonical event: `assistant` for an
839
+ // agentMessage, or tool_call(end) for a finished tool/command/MCP item.
840
+ const item = params.item;
841
+ const itemType = item?.type;
842
+ closeStream();
843
+ if (itemType === "agentMessage") {
844
+ const text = (item?.text ?? "").trim();
845
+ const id = item?.id;
846
+ push({
847
+ type: "assistant",
848
+ text,
849
+ ...(id ? { id } : {}),
850
+ // No per-turn rewind anchor for Codex (forkAtAnchor=false) — omit uuid.
851
+ });
852
+ }
853
+ else {
854
+ const name = toolNameOf(item);
855
+ if (name)
856
+ push({ type: "tool_call", name, phase: "end", detail: item });
857
+ }
858
+ break;
859
+ }
860
+ case "error": {
861
+ // A terminal `error` notification ends the turn: emit it AND finish, so a
862
+ // turn that errors out (no following turn/completed) doesn't hang (P0-2).
863
+ // Routed through the single idempotent terminal-error helper so it can
864
+ // never double-emit with the exit watcher / turn-start rejection (P0-B).
865
+ const e = (params.error ?? {});
866
+ emitTerminalError(e.message ?? "Codex error");
867
+ break;
868
+ }
869
+ case "turn/completed": {
870
+ closeStream();
871
+ const t = params.turn;
872
+ // Mark a result emitted so a racing terminal-error path stays a no-op.
873
+ finishedResult = true;
874
+ push({ type: "result", ok: t?.status === "completed", ...(t?.status ? { subtype: t.status } : {}) });
875
+ finish();
876
+ break;
877
+ }
878
+ default:
879
+ break;
880
+ }
881
+ };
882
+ const prev = client.notificationHandler;
883
+ client.notificationHandler = (msg) => {
884
+ // LIVENESS (watchdog re-arm): ANY notification received while this turn is
885
+ // in flight is a sign the app-server is alive and working — fire onActivity
886
+ // BEFORE buffering/filtering/translating, so even raw notifications that
887
+ // produce NO AgentEvent (a long MCP tool call running a multi-minute ComfyUI
888
+ // generation: item/started, item/updated, tool/exec progress, …) keep
889
+ // PanelAgent's idle watchdog armed. Without this a HEALTHY long generation
890
+ // looks idle and the watchdog falsely trips. A genuine zero-event freeze
891
+ // (the app-server emits nothing at all) never reaches here, so the real
892
+ // freeze-catch is preserved. Cheap + best-effort — never let it throw into
893
+ // the JSON-RPC reader.
894
+ try {
895
+ onActivity?.();
896
+ }
897
+ catch {
898
+ // a watchdog bump must never break the protocol reader
899
+ }
900
+ // Until the turnId is known, buffer everything (we can't yet tell which
901
+ // turn a notification belongs to). Replayed after turn/start resolves.
902
+ if (!turnIdKnown) {
903
+ buffered.push(msg);
904
+ return;
905
+ }
906
+ if (!belongsToTurn(msg)) {
907
+ prev?.(msg); // stale / other-turn / other-thread → pass through
908
+ return;
909
+ }
910
+ apply(msg);
911
+ };
912
+ // Watch for the app-server child dying mid-turn: reject/finish the turn so the
913
+ // local drain below is woken instead of waiting forever (P0-2). Crucially this
914
+ // ALWAYS routes through emitTerminalError so even a child that dies while
915
+ // turn/start is still pending leaves the turn with a terminal `result` — the
916
+ // turn-start .catch() running first (rejecting the pending request) no longer
917
+ // lets this watcher finish() without a result and hang the gate (P0-B).
918
+ void client.exitPromise.then(() => {
919
+ if (done)
920
+ return;
921
+ // emitTerminalError is a no-op if a result already fired, so it's safe to
922
+ // call alongside the turn-start .catch() (which may run first when the child
923
+ // dies while turn/start is pending) — it guarantees the turn still ends with
924
+ // exactly one terminal result and never hangs the gate (P0-B).
925
+ emitTerminalError(client.exitError ? msgOf(client.exitError) : "codex app-server connection closed.");
926
+ });
927
+ // FIRST-TURN PERSONA: the app-server has no thread-level instructions field,
928
+ // so the panel system prompt is prepended to the first turn's input as a
929
+ // clearly-marked system/context preamble (later turns send plain text).
930
+ let turnText = turn.text;
931
+ if (this.needsSystemPreamble && this.deps.systemAppend) {
932
+ turnText =
933
+ `<system>\n${this.deps.systemAppend}\n</system>\n\n` +
934
+ `The user's first message follows.\n\n${turn.text}`;
935
+ this.needsSystemPreamble = false;
936
+ }
937
+ // IMAGE DELIVERY (vision parity with Claude): fetch each ComfyUI image ref's
938
+ // bytes from /view, spill to a temp file, and add a `localImage` input item
939
+ // (which takes a FILE PATH — the app-server's path-based image variant, mirror
940
+ // of the codex CLI `-i, --image <FILE>`). The text item stays first so the
941
+ // prompt context is preserved; images follow. Falls back to text-only when
942
+ // there are no images (or none resolve). The files written for THIS turn are
943
+ // tracked locally so the finally block cleans them up after the turn ends.
944
+ const turnInput = [
945
+ { type: "text", text: turnText, text_elements: [] },
946
+ ];
947
+ const turnTempFiles = [];
948
+ for (const ref of turn.images ?? []) {
949
+ const file = await this.fetchImageFile(ref);
950
+ if (file) {
951
+ turnTempFiles.push(file);
952
+ turnInput.push({ type: "localImage", path: file });
953
+ }
954
+ }
955
+ try {
956
+ // turn/start delivers the user text plus any resolved image input items.
957
+ client
958
+ .request("turn/start", {
959
+ threadId,
960
+ input: turnInput,
961
+ model: this.model ?? null,
962
+ // Forward the session's mapped Codex effort (null = app-server default).
963
+ effort: this.effort,
964
+ outputSchema: null,
965
+ })
966
+ .then((res) => {
967
+ // Set the active turn id, flush the buffer (replaying only this turn's
968
+ // notifications), then switch the handler to live filtering.
969
+ if (res.turn?.id) {
970
+ this.turnId = res.turn.id;
971
+ activeTurnId = res.turn.id;
972
+ }
973
+ turnIdKnown = true;
974
+ for (const msg of buffered) {
975
+ if (belongsToTurn(msg))
976
+ apply(msg);
977
+ else
978
+ prev?.(msg);
979
+ }
980
+ buffered.length = 0;
981
+ })
982
+ .catch((err) => {
983
+ // A failed turn/start (or an interrupt rejecting it) ends the turn. Also
984
+ // mark the id known so any post-failure notifications stop buffering.
985
+ turnIdKnown = true;
986
+ // CRITICAL (P0-B): when the child dies mid-turn, handleExit() rejects this
987
+ // pending request BEFORE resolving exitPromise — so this .catch() runs
988
+ // first. It MUST end the turn with a terminal `result` (not just an
989
+ // `error` + bare finish()), or the exit watcher then sees done===true and
990
+ // returns without one, hanging PanelAgent's gate forever. Route through
991
+ // the idempotent helper so it always emits exactly one result.
992
+ if (interrupted) {
993
+ // Deliberate teardown (interrupt restored/closed the turn): still end
994
+ // with a result so the gate advances, but no user-facing error.
995
+ emitTerminalError("codex turn interrupted.");
996
+ }
997
+ else {
998
+ emitTerminalError(msgOf(err));
999
+ }
1000
+ });
1001
+ // Drain the bridged queue until the turn completes.
1002
+ while (true) {
1003
+ while (queue.length) {
1004
+ yield queue.shift();
1005
+ }
1006
+ if (done)
1007
+ break;
1008
+ await new Promise((resolve) => {
1009
+ wake = resolve;
1010
+ });
1011
+ }
1012
+ // Flush any trailing events queued between the last drain and done.
1013
+ while (queue.length)
1014
+ yield queue.shift();
1015
+ }
1016
+ finally {
1017
+ // Mark interrupted so a late turn/start rejection / exit doesn't surface as
1018
+ // a spurious error after we've already torn the turn down.
1019
+ interrupted = true;
1020
+ // Restore the prior handler ONLY if it's still ours (close() may have nulled
1021
+ // it during shutdown — don't resurrect a stale handler onto a dead client).
1022
+ if (client.notificationHandler && !client.exitError)
1023
+ client.notificationHandler = prev ?? null;
1024
+ this.turnId = null;
1025
+ // Sweep this turn's temp image files now that the app-server has consumed
1026
+ // them (the bytes were read at turn/start). Best-effort + non-blocking on the
1027
+ // generator's teardown.
1028
+ if (turnTempFiles.length)
1029
+ void this.cleanupTempImages(turnTempFiles);
1030
+ }
1031
+ }
1032
+ /** Stop the current turn without ending the thread → `turn/interrupt`. */
1033
+ async interrupt() {
1034
+ const client = this.client;
1035
+ if (!client || !this.threadId || !this.turnId)
1036
+ return;
1037
+ try {
1038
+ await client.request("turn/interrupt", { threadId: this.threadId, turnId: this.turnId });
1039
+ }
1040
+ catch (err) {
1041
+ logger.debug(`[codex-backend] interrupt: ${msgOf(err)}`);
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Codex model enumeration. config/read reports the active provider/model rather
1046
+ * than a catalog, so we surface a sensible static set (the current Codex model
1047
+ * family). Returns [] only if even that can't be determined.
1048
+ */
1049
+ async listModels() {
1050
+ return CODEX_FALLBACK_MODELS;
1051
+ }
1052
+ /** Permanently dispose of the backend (AgentBackend.close): kill the app-server
1053
+ * process TREE (Windows shell-fallback grandchild included), remove listeners,
1054
+ * null the client. Called by PanelAgent.stop() and every agent-replacement path
1055
+ * (reset / effort restart / stopAll / shutdown). Idempotent + safe when never
1056
+ * prepared (client is null). Without this the codex app-server child is orphaned
1057
+ * because interrupt() is a no-op when the turn is idle (P0-1). */
1058
+ async close() {
1059
+ // Tripwire FIRST: an in-flight prepare() re-checks this after each await and
1060
+ // disposes its local client rather than publishing it (P0-A).
1061
+ this.disposed = true;
1062
+ const client = this.client;
1063
+ // Also tear down any client a concurrent prepare() is mid-spawn on but hasn't
1064
+ // published yet — without this, close() would return while that child is still
1065
+ // coming up and orphan it (P0-A).
1066
+ const preparing = this.preparingClient;
1067
+ this.client = null;
1068
+ this.preparingClient = null;
1069
+ this.threadId = null;
1070
+ this.turnId = null;
1071
+ if (client) {
1072
+ client.notificationHandler = null;
1073
+ await client.close().catch(() => { });
1074
+ }
1075
+ if (preparing && preparing !== client) {
1076
+ preparing.notificationHandler = null;
1077
+ await preparing.close().catch(() => { });
1078
+ }
1079
+ // Sweep any temp image files a turn didn't get to clean up (e.g. close() raced
1080
+ // an in-flight turn). Snapshot first — cleanupTempImages mutates the set.
1081
+ if (this.tempImageFiles.size) {
1082
+ await this.cleanupTempImages([...this.tempImageFiles]).catch(() => { });
1083
+ }
1084
+ }
1085
+ }
1086
+ //# sourceMappingURL=codex-backend.js.map