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.
- package/README.md +58 -2
- package/dist/config.js +57 -3
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +33 -0
- package/dist/orchestrator/agent-backend.js.map +1 -0
- package/dist/orchestrator/claude-backend.js +475 -0
- package/dist/orchestrator/claude-backend.js.map +1 -0
- package/dist/orchestrator/codex-backend.js +1086 -0
- package/dist/orchestrator/codex-backend.js.map +1 -0
- package/dist/orchestrator/index.js +312 -16
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +349 -346
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-mcp-http.js +190 -0
- package/dist/orchestrator/panel-mcp-http.js.map +1 -0
- package/dist/orchestrator/panel-tools.js +809 -359
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/session-store.js +71 -0
- package/dist/orchestrator/session-store.js.map +1 -0
- package/dist/services/api-nodes.js +109 -0
- package/dist/services/api-nodes.js.map +1 -1
- package/dist/services/crash-log.js +222 -0
- package/dist/services/crash-log.js.map +1 -0
- package/dist/services/env-capabilities.js +404 -0
- package/dist/services/env-capabilities.js.map +1 -0
- package/dist/services/model-resolver.js +25 -3
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/workflow-slicer.js +123 -0
- package/dist/services/workflow-slicer.js.map +1 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/model-extras.js +6 -2
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +12 -2
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/skills-access.js +480 -0
- package/dist/tools/skills-access.js.map +1 -0
- package/dist/tools/workflow-library.js +140 -0
- package/dist/tools/workflow-library.js.map +1 -1
- package/package.json +14 -2
- package/packs/anima/workflow.json +27 -27
- package/packs/anima-img2img/workflow.json +12 -12
- package/packs/anima-inpaint/workflow.json +6 -6
- package/packs/anima-txt2img/workflow.json +12 -12
- package/packs/cozy-flow/workflow.json +1 -1
- package/packs/ideogram/workflow.json +16 -16
- package/packs/ideogram-img2img/workflow.json +9 -9
- package/packs/ideogram-txt2img/workflow.json +9 -9
- package/packs/krea2/workflow.json +4265 -0
- package/packs/krea2-txt2img/install-runpod.sh +40 -0
- package/packs/krea2-txt2img/install-windows.bat +45 -0
- package/packs/krea2-txt2img/manifest.yaml +29 -0
- package/packs/krea2-txt2img/pack.yaml +32 -0
- package/packs/krea2-txt2img/workflow.json +1871 -0
- package/packs/ltx-2.3-extender/workflow.json +9 -9
- package/packs/ltx-2.3-extender-no-audio/workflow.json +9 -9
- package/packs/ltx-2.3-flf/workflow.json +13 -13
- package/packs/ltx-2.3-xy-plot/workflow.json +27 -27
- package/packs/wan-pusa-extend/manifest.yaml +43 -0
- package/packs/wan-pusa-extend/pack.yaml +51 -0
- package/packs/wan-pusa-extend/workflow.json +1 -0
- package/plugin/skills/krea2-txt2img/SKILL.md +83 -0
- package/plugin/skills/triton-sageattention/SKILL.md +363 -0
- package/plugin/skills/video-extend/SKILL.md +505 -0
- package/plugin/skills/video-upscale/SKILL.md +329 -0
- package/plugin/skills/wan-flf-video/SKILL.md +26 -1
- package/plugin/skills/wan-t2v-video/SKILL.md +12 -0
- package/plugin/skills/workflow-layout/SKILL.md +14 -5
- package/scripts/codex-comfyui-mcp-check.mjs +53 -0
- package/scripts/codex-knowledge-parity-smoke.mjs +202 -0
- package/scripts/codex-parity-smoke.mjs +177 -0
- package/scripts/gen-tool-docs.ts +33 -2
- package/scripts/mock-panel.mjs +34 -3
- package/scripts/panel-load-workflow-smoke.mjs +210 -0
- package/scripts/slice-pipeline.mjs +15 -61
- 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
|