comfyui-mcp 0.52.13 → 0.52.15
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 +3 -2
- package/dist/boot.js +6 -0
- package/dist/boot.js.map +1 -1
- package/dist/handshake-instructions.js +68 -0
- package/dist/handshake-instructions.js.map +1 -0
- package/dist/orchestrator/agent-backend.js +17 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/backend-readiness.js +43 -0
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/index.js +62 -38
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/panel-mcp-http.js +5 -1
- package/dist/orchestrator/panel-mcp-http.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +105 -5
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/orchestrator/qwen-backend.js +1029 -0
- package/dist/orchestrator/qwen-backend.js.map +1 -0
- package/dist/orchestrator/rgthree-fast-groups-property.js +53 -0
- package/dist/orchestrator/rgthree-fast-groups-property.js.map +1 -0
- package/dist/services/comfy-view-ref.js +236 -4
- package/dist/services/comfy-view-ref.js.map +1 -1
- package/dist/services/panel-launcher.js +5 -1
- package/dist/services/panel-launcher.js.map +1 -1
- package/dist/services/ui-bridge.js +5 -0
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/tools/vocabulary.js +1 -1
- package/docs/design/panel-surface.txt +1 -0
- package/locales/ar/main.json +2 -1
- package/locales/en/main.json +2 -1
- package/locales/es/main.json +2 -1
- package/locales/fa/main.json +2 -1
- package/locales/fr/main.json +2 -1
- package/locales/ja/main.json +2 -1
- package/locales/ko/main.json +2 -1
- package/locales/pt-BR/main.json +2 -1
- package/locales/ru/main.json +2 -1
- package/locales/tr/main.json +2 -1
- package/locales/zh/main.json +2 -1
- package/locales/zh-TW/main.json +2 -1
- package/package.json +1 -1
- package/plugin/.mcp.json +2 -3
- package/plugin/scripts/launch-server.mjs +130 -0
- package/plugin/skills/minimax-h3-video/SKILL.md +270 -0
- package/plugin/skills/model-registry/SKILL.md +16 -0
- package/plugin/skills/rgthree/SKILL.md +26 -1
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
// Qwen Code backend — the provider-specific adapter behind the AgentBackend
|
|
2
|
+
// port, driving the Qwen Code CLI over its **ACP (Agent Client Protocol)** mode
|
|
3
|
+
// (`qwen --acp`), a JSON-RPC 2.0 client over stdio. This is a faithful MIRROR
|
|
4
|
+
// of gemini-backend.ts: same self-contained line-framed JSON-RPC client, same
|
|
5
|
+
// per-turn event-queue bridge, same terminal-result invariant, same
|
|
6
|
+
// Windows/POSIX process-tree kill. Qwen Code was forked from the Gemini CLI, so
|
|
7
|
+
// its ACP surface (`initialize` → `session/new` → `session/prompt`, streamed
|
|
8
|
+
// `session/update` notifications, `session/cancel`) is the same protocol.
|
|
9
|
+
//
|
|
10
|
+
// PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
|
|
11
|
+
// push, self-restart) and drives this backend via
|
|
12
|
+
// `for await (const ev of backend.run({...}))`. See
|
|
13
|
+
// design/agent-backend-injection.md.
|
|
14
|
+
//
|
|
15
|
+
// TRANSPORT CHOICE (issue #1417): Qwen Code ALSO ships an experimental
|
|
16
|
+
// `qwen serve` HTTP/SSE daemon whose northbound API multiplexes ONE agent across
|
|
17
|
+
// many clients — and which itself speaks ACP to a spawned `qwen --acp` child.
|
|
18
|
+
// The Agent Panel wants exactly the opposite (one orchestrator-scoped session
|
|
19
|
+
// per tab), so this backend takes the stdio `qwen --acp` path directly and the
|
|
20
|
+
// daemon is deliberately NOT used (no daemon lifecycle, no SSE replay layer, no
|
|
21
|
+
// bearer-auth surface — `qwen serve` is the transport Qwen itself marks
|
|
22
|
+
// experimental).
|
|
23
|
+
//
|
|
24
|
+
// AUTH (the CLI owns auth): Qwen Code authenticates via `qwen` → `/auth`
|
|
25
|
+
// (Alibaba ModelStudio Coding Plan, third-party providers, or a custom
|
|
26
|
+
// OpenAI/Anthropic/Gemini-compatible endpoint), persisted in
|
|
27
|
+
// ~/.qwen/settings.json, or via env keys (DASHSCOPE_API_KEY,
|
|
28
|
+
// BAILIAN_CODING_PLAN_API_KEY, OPENAI_API_KEY/OPENAI_BASE_URL) — all forwarded
|
|
29
|
+
// through the spawned process.env. The Qwen OAuth free tier was DISCONTINUED
|
|
30
|
+
// 2026-04-15, so a CLI that was previously signed in can carry a stale
|
|
31
|
+
// `qwen-oauth` selectedType + dead cached creds that OUTRANK a valid env key
|
|
32
|
+
// (session/new succeeds, the first prompt then dies) — the exact trap the
|
|
33
|
+
// Gemini backend hit with the retired Google individual login. We therefore
|
|
34
|
+
// authenticate PROACTIVELY with the `openai` auth method when an env key is set
|
|
35
|
+
// (see pickAuthMethod), and otherwise react to an `auth_required` error from
|
|
36
|
+
// session/new with a single `authenticate` retry, surfacing an actionable
|
|
37
|
+
// sign-in message if it still fails.
|
|
38
|
+
//
|
|
39
|
+
// PARITY with Codex/Gemini: the Qwen backend gets the SAME tool surface — the
|
|
40
|
+
// headless `comfyui` stdio MCP plus the `panel` HTTP MCP for live-graph panel_*
|
|
41
|
+
// tools — declared to `session/new` as ACP McpServers. The panel system prompt
|
|
42
|
+
// is prepended to the FIRST turn's prompt (ACP `session/new` has no system /
|
|
43
|
+
// instructions field).
|
|
44
|
+
//
|
|
45
|
+
// WINDOWS: Qwen Code is commonly installed as `qwen.cmd`, which Node cannot
|
|
46
|
+
// spawn shell-lessly — so on win32 we spawn through the shell (mirroring the
|
|
47
|
+
// Gemini PATH fallback) and close() kills the whole process TREE (the direct
|
|
48
|
+
// child is the cmd shim, the grandchild the real qwen node process).
|
|
49
|
+
//
|
|
50
|
+
// ASSUMPTIONS we could NOT verify without the live `qwen` CLI (flagged inline,
|
|
51
|
+
// see also the PR body): the exact ACP McpServer http/sse variant shape (we
|
|
52
|
+
// reuse the `sse` mapping the live Gemini/Grok CLIs require), the session/load
|
|
53
|
+
// resume semantics, the auth_required retry, and live model switching (the
|
|
54
|
+
// model is set at SPAWN via --model since ACP exposes no standard per-session
|
|
55
|
+
// model setter). Each is the closest faithful mapping to the documented ACP
|
|
56
|
+
// spec.
|
|
57
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
58
|
+
import readline from "node:readline";
|
|
59
|
+
import { logger } from "../utils/logger.js";
|
|
60
|
+
import { errorText, promptText } from "./error-text.js";
|
|
61
|
+
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
|
|
62
|
+
import { QWEN_CAPABILITIES, stampTurn, } from "./agent-backend.js";
|
|
63
|
+
function msgOf(err) {
|
|
64
|
+
return errorText(err);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Kill an entire process tree, not just the direct child. On the Windows
|
|
68
|
+
* PATH/shell fallback the direct child is a cmd.exe/`.cmd` shim whose grandchild
|
|
69
|
+
* is the real `qwen` node process — killing only the shell leaves the tree
|
|
70
|
+
* alive. Use `taskkill /T /F`. On POSIX, signal the process group (negative pid)
|
|
71
|
+
* so a shell + its child both die, falling back to the single pid. Best-effort +
|
|
72
|
+
* swallows errors: it runs during teardown and must never throw into the host.
|
|
73
|
+
* (Identical to gemini-backend's killProcessTree — same spawn posture.)
|
|
74
|
+
*/
|
|
75
|
+
function killProcessTree(pid) {
|
|
76
|
+
if (!Number.isFinite(pid))
|
|
77
|
+
return;
|
|
78
|
+
const p = pid;
|
|
79
|
+
if (process.platform === "win32") {
|
|
80
|
+
try {
|
|
81
|
+
spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
try {
|
|
85
|
+
process.kill(p);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// already gone
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
process.kill(-p, "SIGTERM"); // process group (we spawn detached on POSIX)
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(p, "SIGTERM");
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// already gone
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** An Error carrying the JSON-RPC error `data` so the auth_required reason
|
|
106
|
+
* survives the request rejection (used to drive the authenticate retry). */
|
|
107
|
+
class RpcError extends Error {
|
|
108
|
+
code;
|
|
109
|
+
data;
|
|
110
|
+
constructor(message, code, data) {
|
|
111
|
+
super(message);
|
|
112
|
+
this.code = code;
|
|
113
|
+
this.data = data;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
class AcpClient {
|
|
117
|
+
cmd;
|
|
118
|
+
args;
|
|
119
|
+
cwd;
|
|
120
|
+
env;
|
|
121
|
+
useShell;
|
|
122
|
+
proc = null;
|
|
123
|
+
rl = null;
|
|
124
|
+
pending = new Map();
|
|
125
|
+
nextId = 1;
|
|
126
|
+
closed = false;
|
|
127
|
+
exitResolved = false;
|
|
128
|
+
/** The error that ended the connection (null = clean exit). */
|
|
129
|
+
exitError = null;
|
|
130
|
+
stderr = "";
|
|
131
|
+
notificationHandler = null;
|
|
132
|
+
resolveExit;
|
|
133
|
+
/** Resolves when the `qwen` process exits or errors — runTurn() races its
|
|
134
|
+
* per-turn drain against this so a child that dies mid-prompt never deadlocks
|
|
135
|
+
* the turn forever (mirrors codex P0-2). */
|
|
136
|
+
exitPromise;
|
|
137
|
+
constructor(cmd, args, cwd, env, useShell) {
|
|
138
|
+
this.cmd = cmd;
|
|
139
|
+
this.args = args;
|
|
140
|
+
this.cwd = cwd;
|
|
141
|
+
this.env = env;
|
|
142
|
+
this.useShell = useShell;
|
|
143
|
+
this.exitPromise = new Promise((resolve) => {
|
|
144
|
+
this.resolveExit = resolve;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** Spawn `qwen --acp` and perform the ACP `initialize` handshake. Returns the
|
|
148
|
+
* agent's initialize result (capabilities + authMethods). NOTE: ACP has NO
|
|
149
|
+
* `initialized` notification (that's MCP, not ACP) — initialize is a plain
|
|
150
|
+
* request/response, after which we go straight to session/new. */
|
|
151
|
+
async initialize(clientInfo) {
|
|
152
|
+
this.proc = spawn(this.cmd, this.args, {
|
|
153
|
+
cwd: this.cwd,
|
|
154
|
+
env: this.env,
|
|
155
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
156
|
+
windowsHide: true,
|
|
157
|
+
shell: this.useShell,
|
|
158
|
+
// POSIX: own process group so close() can kill the whole tree with one
|
|
159
|
+
// negative-pid signal. Windows uses taskkill /T instead.
|
|
160
|
+
detached: process.platform !== "win32",
|
|
161
|
+
});
|
|
162
|
+
this.proc.stdout.setEncoding("utf8");
|
|
163
|
+
this.proc.stderr.setEncoding("utf8");
|
|
164
|
+
this.proc.stderr.on("data", (chunk) => {
|
|
165
|
+
this.stderr += chunk;
|
|
166
|
+
});
|
|
167
|
+
// Route pipe errors (EPIPE when the child dies mid-turn) through handleExit so
|
|
168
|
+
// the turn rejects cleanly instead of crashing the host as an uncaught error.
|
|
169
|
+
this.proc.stdin.on("error", (error) => this.handleExit(error));
|
|
170
|
+
this.proc.stdout.on("error", (error) => this.handleExit(error));
|
|
171
|
+
this.proc.on("error", (error) => this.handleExit(error));
|
|
172
|
+
this.proc.on("exit", (code, signal) => {
|
|
173
|
+
const detail = code === 0
|
|
174
|
+
? null
|
|
175
|
+
: new Error(`qwen --acp exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${this.stderr ? ` ${this.stderr.trim().split(/\r?\n/).slice(-2).join(" ")}` : ""}`);
|
|
176
|
+
this.handleExit(detail);
|
|
177
|
+
});
|
|
178
|
+
this.rl = readline.createInterface({ input: this.proc.stdout });
|
|
179
|
+
this.rl.on("line", (line) => this.handleLine(line));
|
|
180
|
+
// ACP initialize: negotiate protocol version + advertise client capabilities.
|
|
181
|
+
// We do NOT implement the client fs/terminal methods, so advertise them false
|
|
182
|
+
// (the agent then won't issue fs/read_text_file, terminal/*, etc.).
|
|
183
|
+
const result = await this.request("initialize", {
|
|
184
|
+
protocolVersion: 1,
|
|
185
|
+
clientCapabilities: {
|
|
186
|
+
fs: { readTextFile: false, writeTextFile: false },
|
|
187
|
+
terminal: false,
|
|
188
|
+
},
|
|
189
|
+
clientInfo,
|
|
190
|
+
});
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
request(method, params) {
|
|
194
|
+
if (this.closed)
|
|
195
|
+
return Promise.reject(new Error("qwen --acp client is closed."));
|
|
196
|
+
const id = this.nextId++;
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
this.pending.set(id, { resolve: resolve, reject, method });
|
|
199
|
+
this.send({ id, method, params });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
notify(method, params = {}) {
|
|
203
|
+
if (this.closed)
|
|
204
|
+
return;
|
|
205
|
+
// Fire-and-forget: a write failure (dead child) must not throw into the caller.
|
|
206
|
+
try {
|
|
207
|
+
this.send({ method, params });
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
// connection gone; pending requests already rejected via handleExit
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
send(message) {
|
|
214
|
+
const stdin = this.proc?.stdin;
|
|
215
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded) {
|
|
216
|
+
this.handleExit(this.exitError ?? new Error("qwen --acp stdin is not available."));
|
|
217
|
+
throw this.exitError ?? new Error("qwen --acp stdin is not available.");
|
|
218
|
+
}
|
|
219
|
+
// ACP is strict JSON-RPC 2.0 — every outbound frame carries `jsonrpc:"2.0"`.
|
|
220
|
+
const framed = { jsonrpc: "2.0", ...message };
|
|
221
|
+
try {
|
|
222
|
+
stdin.write(`${JSON.stringify(framed)}\n`);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
this.handleExit(err instanceof Error ? err : new Error(String(err)));
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The auto-approve RESULT for a server→client request, or null if it isn't one
|
|
231
|
+
* we should auto-grant. The panel agent is an ISOLATED background agent (same
|
|
232
|
+
* posture as Claude's bypassPermissions / Codex's auto-approve), so we grant
|
|
233
|
+
* tool-permission requests to keep the live-graph work flowing.
|
|
234
|
+
*
|
|
235
|
+
* ACP permission flow: the agent sends `session/request_permission`
|
|
236
|
+
* ({ sessionId, toolCall, options:[{ optionId, name, kind }] }) and expects
|
|
237
|
+
* { outcome: { outcome:"selected", optionId } }. We pick the most-permissive
|
|
238
|
+
* "allow" option (allow_always > allow_once); if none is offered we cancel.
|
|
239
|
+
*/
|
|
240
|
+
autoApproveResult(msg) {
|
|
241
|
+
if (msg.method !== "session/request_permission")
|
|
242
|
+
return null;
|
|
243
|
+
const params = (msg.params ?? {});
|
|
244
|
+
const options = Array.isArray(params.options) ? params.options : [];
|
|
245
|
+
const pick = options.find((o) => o.kind === "allow_always") ??
|
|
246
|
+
options.find((o) => o.kind === "allow_once") ??
|
|
247
|
+
// Fall back to any option whose id/kind reads as an allow.
|
|
248
|
+
options.find((o) => /allow/i.test(o.kind ?? "") || /allow/i.test(o.optionId ?? ""));
|
|
249
|
+
if (pick?.optionId) {
|
|
250
|
+
return { outcome: { outcome: "selected", optionId: pick.optionId } };
|
|
251
|
+
}
|
|
252
|
+
// No allow option offered → decline gracefully so the agent moves on.
|
|
253
|
+
return { outcome: { outcome: "cancelled" } };
|
|
254
|
+
}
|
|
255
|
+
handleLine(line) {
|
|
256
|
+
if (!line.trim())
|
|
257
|
+
return;
|
|
258
|
+
let message;
|
|
259
|
+
try {
|
|
260
|
+
message = JSON.parse(line);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
this.handleExit(new Error(`Failed to parse qwen --acp JSONL: ${msgOf(error)}`));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Server→client request (id + method). Auto-approve permission prompts; reply
|
|
267
|
+
// method-not-found to anything else so the protocol keeps moving (we declared
|
|
268
|
+
// no fs/terminal client capabilities, so those shouldn't arrive).
|
|
269
|
+
if (message.id !== undefined && message.method) {
|
|
270
|
+
const result = this.autoApproveResult(message);
|
|
271
|
+
if (result) {
|
|
272
|
+
logger.debug(`[qwen-backend] auto-approving server request ${message.method}`);
|
|
273
|
+
this.send({ id: message.id, result });
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
logger.debug(`[qwen-backend] unsupported server request ${message.method} — replying method-not-found`);
|
|
277
|
+
this.send({
|
|
278
|
+
id: message.id,
|
|
279
|
+
error: { code: -32601, message: `Unsupported server request: ${message.method}` },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
// Response to one of our requests.
|
|
285
|
+
if (message.id !== undefined) {
|
|
286
|
+
const p = this.pending.get(message.id);
|
|
287
|
+
if (!p)
|
|
288
|
+
return;
|
|
289
|
+
this.pending.delete(message.id);
|
|
290
|
+
if (message.error) {
|
|
291
|
+
p.reject(new RpcError(message.error.message ?? `qwen --acp ${p.method} failed.`, message.error.code, message.error.data));
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
p.resolve(message.result ?? {});
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
// Notification.
|
|
299
|
+
if (message.method)
|
|
300
|
+
this.notificationHandler?.(message);
|
|
301
|
+
}
|
|
302
|
+
handleExit(error) {
|
|
303
|
+
if (this.exitResolved)
|
|
304
|
+
return;
|
|
305
|
+
this.exitResolved = true;
|
|
306
|
+
this.exitError = error;
|
|
307
|
+
for (const p of this.pending.values())
|
|
308
|
+
p.reject(error ?? new Error("qwen --acp connection closed."));
|
|
309
|
+
this.pending.clear();
|
|
310
|
+
this.resolveExit();
|
|
311
|
+
}
|
|
312
|
+
async close() {
|
|
313
|
+
if (this.closed) {
|
|
314
|
+
await this.exitPromise;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.closed = true;
|
|
318
|
+
this.notificationHandler = null;
|
|
319
|
+
this.rl?.close();
|
|
320
|
+
this.rl = null;
|
|
321
|
+
if (this.proc && this.proc.exitCode === null) {
|
|
322
|
+
try {
|
|
323
|
+
this.proc.stdin.end();
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// already gone
|
|
327
|
+
}
|
|
328
|
+
const proc = this.proc;
|
|
329
|
+
// Give a graceful stdin-EOF shutdown a beat, then KILL THE WHOLE TREE — on
|
|
330
|
+
// the Windows shell fallback the direct child is a shim whose grandchild is
|
|
331
|
+
// the real qwen node process, so proc.kill() alone would orphan it.
|
|
332
|
+
setTimeout(() => {
|
|
333
|
+
if (proc.exitCode === null)
|
|
334
|
+
killProcessTree(proc.pid);
|
|
335
|
+
}, 50).unref?.();
|
|
336
|
+
}
|
|
337
|
+
await this.exitPromise;
|
|
338
|
+
this.proc = null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ---- model catalog ----
|
|
342
|
+
// ACP exposes no model enumeration, and the model is fixed at SPAWN via the CLI
|
|
343
|
+
// `--model` flag — so we surface a static catalog of the current Qwen coding
|
|
344
|
+
// family (the models the Alibaba ModelStudio Coding Plan advertises for
|
|
345
|
+
// `qwen --model`, per qwenlm.github.io/qwen-code-docs). Qwen Code's own default
|
|
346
|
+
// is qwen3-coder-plus; COMFYUI_MCP_QWEN_MODEL overrides, and any id the CLI
|
|
347
|
+
// accepts (a custom modelProviders entry, e.g.) works the same way.
|
|
348
|
+
const QWEN_MODELS = [
|
|
349
|
+
{ id: "qwen3-coder-plus", label: "Qwen3 Coder Plus" },
|
|
350
|
+
{ id: "qwen3-coder-next", label: "Qwen3 Coder Next" },
|
|
351
|
+
{ id: "qwen3.7-plus", label: "Qwen3.7 Plus" },
|
|
352
|
+
];
|
|
353
|
+
const QWEN_DEFAULT_MODEL = "qwen3-coder-plus";
|
|
354
|
+
/** Does this id look like a Qwen model (vs. the Claude panel model PanelAgent
|
|
355
|
+
* unconditionally passes as opts.model)? Used so the configured Qwen model
|
|
356
|
+
* wins — mirrors gemini-backend's isGeminiModel guard (P1-1). */
|
|
357
|
+
function isQwenModel(id) {
|
|
358
|
+
// Qwen ids start with "qwen" with NO separator (qwen3-coder-plus, qwen3.7-plus).
|
|
359
|
+
return /^qwen/i.test(id);
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Convert our MCP server specs into the ACP `session/new` `mcpServers` array.
|
|
363
|
+
* ACP stdio McpServer: { name, command, args, env:[{name,value}] }. Streamable
|
|
364
|
+
* HTTP MCP rides the SSE variant ({ type:"sse", name, url, headers:[] }) — the
|
|
365
|
+
* live Gemini (and Grok) CLIs reject { type:"http" } with Invalid params on the
|
|
366
|
+
* FIRST user message, after a successful connect ack, so the old "http" mapping
|
|
367
|
+
* made a panel-MCP-attached tab look connected but fail on first use.
|
|
368
|
+
* (Identical to gemini-backend's buildAcpMcpServers — same wire shape.)
|
|
369
|
+
*/
|
|
370
|
+
export function buildAcpMcpServers(servers) {
|
|
371
|
+
const out = [];
|
|
372
|
+
for (const [name, spec] of Object.entries(servers)) {
|
|
373
|
+
if (spec.transport === "stdio") {
|
|
374
|
+
out.push({
|
|
375
|
+
name,
|
|
376
|
+
command: spec.command,
|
|
377
|
+
args: spec.args ?? [],
|
|
378
|
+
env: Object.entries(spec.env ?? {}).map(([k, v]) => ({ name: k, value: v })),
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
out.push({ type: "sse", name, url: spec.url, headers: [] });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
/** The env keys that count as a Qwen-side credential for the auth flow: the
|
|
388
|
+
* Dashscope standard key, the Coding Plan key, or a generic OpenAI-compatible
|
|
389
|
+
* key (the CLI's `openai` auth type reads OPENAI_API_KEY/OPENAI_BASE_URL). */
|
|
390
|
+
const QWEN_AUTH_ENV_KEYS = ["DASHSCOPE_API_KEY", "BAILIAN_CODING_PLAN_API_KEY", "OPENAI_API_KEY"];
|
|
391
|
+
function qwenAuthKeyPresent() {
|
|
392
|
+
return QWEN_AUTH_ENV_KEYS.some((k) => !!process.env[k]?.trim());
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The Qwen Code CLI ACP adapter. One instance per PanelAgent; it holds the live
|
|
396
|
+
* ACP client + current session id and re-opens on each `run()`.
|
|
397
|
+
*/
|
|
398
|
+
export class QwenBackend {
|
|
399
|
+
id = "qwen";
|
|
400
|
+
capabilities = QWEN_CAPABILITIES;
|
|
401
|
+
deps;
|
|
402
|
+
client = null;
|
|
403
|
+
/** The client an in-flight prepare() is spinning up, tracked so a concurrent
|
|
404
|
+
* close() can tear it down before it's published (P0-A). */
|
|
405
|
+
preparingClient = null;
|
|
406
|
+
/** Set once close() runs — a tripwire so an in-flight prepare() disposes its
|
|
407
|
+
* local client instead of publishing it (P0-A). */
|
|
408
|
+
disposed = false;
|
|
409
|
+
/** Cached resolved spawn command/args/shell (set in prepare()). */
|
|
410
|
+
spawnSpec = null;
|
|
411
|
+
/** The live ACP session id — used for session/prompt + session/cancel. */
|
|
412
|
+
sessionId = null;
|
|
413
|
+
/** The model requested for new sessions (applied at SPAWN via --model). */
|
|
414
|
+
model;
|
|
415
|
+
/** The model the LIVE `qwen --acp` child was actually spawned with. Qwen Code
|
|
416
|
+
* pins the model at spawn, so when this drifts from `this.model` (a live
|
|
417
|
+
* setModel) the run loop respawns the CLI before the next turn (P1). */
|
|
418
|
+
spawnedModel;
|
|
419
|
+
/** Capabilities the agent advertised at initialize (loadSession / image / http). */
|
|
420
|
+
agentCaps = undefined;
|
|
421
|
+
authMethods = [];
|
|
422
|
+
/** True until the panel system prompt has been prepended to a turn. Reset
|
|
423
|
+
* whenever a NEW session starts (run()). */
|
|
424
|
+
needsSystemPreamble = false;
|
|
425
|
+
constructor(deps = {}) {
|
|
426
|
+
this.deps = deps;
|
|
427
|
+
this.model = deps.model;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Resolve how to spawn `qwen --acp`. Unlike Gemini there is NO bundled npm
|
|
431
|
+
* package in this repo — the Qwen Code CLI is a separate install
|
|
432
|
+
* (`npm i -g @qwen-code/qwen-code`, brew, or the standalone installer), so we
|
|
433
|
+
* resolve `qwen` from PATH. A PATH `qwen` on Windows resolves to a
|
|
434
|
+
* `.cmd`/`.ps1` shim, which spawn can't find WITHOUT a shell — hence
|
|
435
|
+
* useShell on win32 (mirrors gemini-backend's PATH fallback; the tree kill in
|
|
436
|
+
* close() handles the shim's grandchild). The `--model` flag pins the model
|
|
437
|
+
* at spawn (ACP has no standard per-session model setter).
|
|
438
|
+
*/
|
|
439
|
+
resolveSpawn() {
|
|
440
|
+
if (this.spawnSpec)
|
|
441
|
+
return this.spawnSpec;
|
|
442
|
+
const modelArgs = this.model ? ["--model", this.model] : [];
|
|
443
|
+
this.spawnSpec = {
|
|
444
|
+
cmd: "qwen",
|
|
445
|
+
args: ["--acp", ...modelArgs],
|
|
446
|
+
useShell: process.platform === "win32",
|
|
447
|
+
};
|
|
448
|
+
return this.spawnSpec;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Fetch a ComfyUI image (/view) and return it as an ACP base64 `image`
|
|
452
|
+
* ContentBlock ({ type:"image", mimeType, data }) — or null on any failure (the
|
|
453
|
+
* text reference still names the image as a fallback). ACP takes inline base64,
|
|
454
|
+
* so this mirrors GeminiBackend.fetchImageBlock exactly.
|
|
455
|
+
*/
|
|
456
|
+
async fetchImageBlock(ref) {
|
|
457
|
+
if (!this.deps.comfyuiUrl || !ref?.filename)
|
|
458
|
+
return null;
|
|
459
|
+
try {
|
|
460
|
+
const u = new URL("/view", this.deps.comfyuiUrl);
|
|
461
|
+
u.searchParams.set("filename", ref.filename);
|
|
462
|
+
u.searchParams.set("type", ref.type || "input");
|
|
463
|
+
if (ref.subfolder)
|
|
464
|
+
u.searchParams.set("subfolder", ref.subfolder);
|
|
465
|
+
const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
|
|
466
|
+
if (!res.ok)
|
|
467
|
+
return null;
|
|
468
|
+
let mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
|
|
469
|
+
if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mt)) {
|
|
470
|
+
mt = "image/png"; // ComfyUI outputs are PNG by default
|
|
471
|
+
}
|
|
472
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
473
|
+
if (buf.length > 12 * 1024 * 1024)
|
|
474
|
+
return null; // keep context sane (parity with Claude)
|
|
475
|
+
return { type: "image", mimeType: mt, data: buf.toString("base64") };
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Preflight: resolve + spawn `qwen --acp` and perform the ACP `initialize`
|
|
483
|
+
* handshake. Fails fast with a clear reject so a missing binary surfaces
|
|
484
|
+
* immediately instead of being retried as a dropped session. Idempotent —
|
|
485
|
+
* reuses the live client. NOTE: the actual login is verified lazily at
|
|
486
|
+
* `session/new` (ACP returns auth_required there) — see ensureSession() —
|
|
487
|
+
* since ACP has no pre-session account probe; flagged in the PR body.
|
|
488
|
+
*/
|
|
489
|
+
async prepare() {
|
|
490
|
+
if (this.disposed)
|
|
491
|
+
throw new Error("qwen backend is closed.");
|
|
492
|
+
if (this.client)
|
|
493
|
+
return;
|
|
494
|
+
const { cmd, args, useShell } = this.resolveSpawn();
|
|
495
|
+
const cwd = this.deps.cwd ?? process.cwd();
|
|
496
|
+
// SECURITY: spawn with the agent env — process.env MINUS tool-only secrets
|
|
497
|
+
// (RunPod/HF/CivitAI… tokens; they belong only to the comfyui tool child).
|
|
498
|
+
// The Qwen auth env keys (DASHSCOPE_API_KEY & co.) are NOT tool-only, so
|
|
499
|
+
// they pass through unchanged — they are this provider's OWN credential.
|
|
500
|
+
const client = new AcpClient(cmd, args, cwd, buildAgentSpawnEnv(process.env), useShell);
|
|
501
|
+
// Publish the in-flight client BEFORE the startup awaits so a concurrent
|
|
502
|
+
// close() can find and kill it (P0-A).
|
|
503
|
+
this.preparingClient = client;
|
|
504
|
+
const abortIfDisposed = async () => {
|
|
505
|
+
if (!this.disposed)
|
|
506
|
+
return;
|
|
507
|
+
if (this.preparingClient === client)
|
|
508
|
+
this.preparingClient = null;
|
|
509
|
+
await client.close().catch(() => { });
|
|
510
|
+
throw new Error("qwen backend was closed during prepare().");
|
|
511
|
+
};
|
|
512
|
+
try {
|
|
513
|
+
let init;
|
|
514
|
+
try {
|
|
515
|
+
init = await client.initialize({
|
|
516
|
+
name: "comfyui-mcp",
|
|
517
|
+
title: "comfyui-mcp panel",
|
|
518
|
+
version: "0.16.0",
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
await client.close().catch(() => { });
|
|
523
|
+
throw new Error(`Could not start the Qwen Code CLI in ACP mode (qwen backend). Install Qwen Code (npm i -g @qwen-code/qwen-code, or ensure \`qwen\` is on PATH) and sign in (run \`qwen\` once and complete /auth, or set DASHSCOPE_API_KEY / BAILIAN_CODING_PLAN_API_KEY). Details: ${msgOf(err)}`);
|
|
524
|
+
}
|
|
525
|
+
await abortIfDisposed();
|
|
526
|
+
this.agentCaps = init.agentCapabilities;
|
|
527
|
+
this.authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
|
|
528
|
+
this.client = client;
|
|
529
|
+
// Record what the live child was spawned with so a later setModel can detect
|
|
530
|
+
// the model drifted and respawn (the model is spawn-pinned via --model) (P1).
|
|
531
|
+
this.spawnedModel = this.model;
|
|
532
|
+
logger.info(`[qwen-backend] ACP ready (protocol ${init.protocolVersion ?? "?"}, agent ${init.agentInfo?.name ?? "qwen"}${this.authMethods.length ? `, ${this.authMethods.length} auth method(s)` : ""})`);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
if (this.preparingClient === client)
|
|
536
|
+
this.preparingClient = null;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Ensure a live ACP session exists, creating (session/new) or resuming
|
|
540
|
+
* (session/load) one. Handles an `auth_required` error from session/new by
|
|
541
|
+
* attempting a single `authenticate` with the picked method (see
|
|
542
|
+
* pickAuthMethod), then retrying — surfacing a clear sign-in message if it
|
|
543
|
+
* still fails. Returns the session id. */
|
|
544
|
+
async ensureSession(client, cwd, resumeId) {
|
|
545
|
+
const mcpServers = this.deps.mcpServers ? buildAcpMcpServers(this.deps.mcpServers) : [];
|
|
546
|
+
const canLoad = this.agentCaps?.loadSession === true;
|
|
547
|
+
// RESUME (session/load) — whole-session only (forkAtAnchor=false). Only if the
|
|
548
|
+
// agent advertised loadSession; otherwise fall through to a fresh session.
|
|
549
|
+
if (resumeId && canLoad) {
|
|
550
|
+
try {
|
|
551
|
+
await client.request("session/load", { sessionId: resumeId, cwd, mcpServers });
|
|
552
|
+
this.sessionId = resumeId;
|
|
553
|
+
this.needsSystemPreamble = false; // persona already delivered on the original first turn
|
|
554
|
+
return resumeId;
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
logger.warn(`[qwen-backend] session/load failed (${msgOf(err)}) — starting a fresh session`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// NEW session, with one auth_required retry.
|
|
561
|
+
const createNew = async () => {
|
|
562
|
+
const res = await client.request("session/new", { cwd, mcpServers });
|
|
563
|
+
if (!res?.sessionId)
|
|
564
|
+
throw new Error("qwen --acp session/new returned no sessionId.");
|
|
565
|
+
return res.sessionId;
|
|
566
|
+
};
|
|
567
|
+
// PROACTIVE API-key auth. When a Qwen-side env key is set we authenticate up
|
|
568
|
+
// front rather than only reacting to `auth_required`. Why: a CLI that was
|
|
569
|
+
// previously signed in via Qwen OAuth keeps `security.auth.selectedType:
|
|
570
|
+
// "qwen-oauth"` + cached OAuth creds, which OUTRANK the env key — and the
|
|
571
|
+
// Qwen OAuth free tier was discontinued 2026-04-15, so `session/new` can
|
|
572
|
+
// SUCCEED via the dead OAuth without ever raising `auth_required`, then fail
|
|
573
|
+
// at request time. Authenticating with the openai method here switches the
|
|
574
|
+
// CLI onto the env key and clears the stale creds (mirrors gemini-backend's
|
|
575
|
+
// GEMINI_API_KEY proactive auth after the 2026-06-18 Google login sunset).
|
|
576
|
+
if (qwenAuthKeyPresent()) {
|
|
577
|
+
const apiKeyMethod = this.pickAuthMethod();
|
|
578
|
+
if (apiKeyMethod) {
|
|
579
|
+
try {
|
|
580
|
+
await client.request("authenticate", { methodId: apiKeyMethod });
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
logger.warn(`[qwen-backend] proactive API-key authenticate failed (${msgOf(err)}) — continuing to session/new`);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
this.sessionId = await createNew();
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
if (!this.isAuthRequired(err))
|
|
592
|
+
throw err;
|
|
593
|
+
// The CLI owns auth. Pick the openai/api-key method when an env key is set
|
|
594
|
+
// rather than blindly the first advertised method; the key itself already
|
|
595
|
+
// reaches the CLI via the spawned process.env.
|
|
596
|
+
const methodId = this.pickAuthMethod();
|
|
597
|
+
if (!methodId)
|
|
598
|
+
throw new Error(this.authFailureMessage());
|
|
599
|
+
try {
|
|
600
|
+
await client.request("authenticate", { methodId });
|
|
601
|
+
this.sessionId = await createNew();
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
throw new Error(this.authFailureMessage());
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
this.needsSystemPreamble = !!this.deps.systemAppend; // fresh session → persona on first turn
|
|
608
|
+
return this.sessionId;
|
|
609
|
+
}
|
|
610
|
+
/** Choose the ACP auth method for the `authenticate` retry. Prefers the
|
|
611
|
+
* openai / api-key method when a Qwen-side env key is set — blindly taking
|
|
612
|
+
* authMethods[0] could pick the discontinued qwen-oauth flow, which
|
|
613
|
+
* dead-loops headlessly. Falls back to the first advertised method when no
|
|
614
|
+
* key is set or no api-key-ish method is advertised. We match against the
|
|
615
|
+
* CLI's own advertised methods rather than hardcoding the literal, so this
|
|
616
|
+
* survives auth-type value changes across qwen-code versions. */
|
|
617
|
+
pickAuthMethod() {
|
|
618
|
+
const methods = this.authMethods;
|
|
619
|
+
if (methods.length === 0)
|
|
620
|
+
return undefined;
|
|
621
|
+
if (qwenAuthKeyPresent()) {
|
|
622
|
+
const hay = (m) => `${m.id ?? ""} ${m.name ?? ""} ${m.description ?? ""}`.toLowerCase();
|
|
623
|
+
// Strong match: the canonical openai auth-type value (Qwen Code's
|
|
624
|
+
// OpenAI-compatible / Dashscope / Coding Plan path).
|
|
625
|
+
const strong = methods.find((m) => m.id === "openai" || m.id === "use_openai");
|
|
626
|
+
if (strong?.id)
|
|
627
|
+
return strong.id;
|
|
628
|
+
// Loose fallback: an api-key-ish method that is clearly NOT oauth.
|
|
629
|
+
const loose = methods.find((m) => {
|
|
630
|
+
const h = hay(m);
|
|
631
|
+
return /api.?key|openai|dashscope|coding.?plan/.test(h) && !/oauth|sign.?in|log.?in/.test(h);
|
|
632
|
+
});
|
|
633
|
+
if (loose?.id)
|
|
634
|
+
return loose.id;
|
|
635
|
+
}
|
|
636
|
+
return methods[0]?.id;
|
|
637
|
+
}
|
|
638
|
+
/** A clear sign-in error: points key-holders at a likely bad key, and everyone
|
|
639
|
+
* else at the two supported sign-in paths (interactive /auth, or an env key). */
|
|
640
|
+
authFailureMessage() {
|
|
641
|
+
return qwenAuthKeyPresent()
|
|
642
|
+
? "Qwen Code CLI rejected the API key. Ensure DASHSCOPE_API_KEY / BAILIAN_CODING_PLAN_API_KEY / " +
|
|
643
|
+
"OPENAI_API_KEY is valid (and OPENAI_BASE_URL points at the right endpoint for a Coding Plan key), " +
|
|
644
|
+
"then Disconnect → Connect to retry."
|
|
645
|
+
: "Qwen Code CLI is not authenticated. Run `qwen` once and complete /auth (Alibaba ModelStudio Coding " +
|
|
646
|
+
"Plan or a provider API key — the Qwen OAuth free tier ended 2026-04-15), or set DASHSCOPE_API_KEY " +
|
|
647
|
+
"in ~/.comfyui-mcp/.env, then Disconnect → Connect to retry.";
|
|
648
|
+
}
|
|
649
|
+
/** Does this error look like an ACP `auth_required`? Reads the JSON-RPC error
|
|
650
|
+
* data.reason carried by RpcError, falling back to the message text. */
|
|
651
|
+
isAuthRequired(err) {
|
|
652
|
+
if (err instanceof RpcError) {
|
|
653
|
+
const data = err.data;
|
|
654
|
+
if (data?.reason === "auth_required")
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
return /auth.?required|authenticat|not.*(logged|signed).*in/i.test(msgOf(err));
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Open/continue an ACP session and yield canonical AgentEvents. The user
|
|
661
|
+
* channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
|
|
662
|
+
* neutral batch becomes a `session/prompt`, whose streamed session/update
|
|
663
|
+
* notifications are normalized to AgentEvents, and only after the prompt
|
|
664
|
+
* resolves (stopReason) do we read the next batch (the channel async-iteration
|
|
665
|
+
* IS the turn-gate).
|
|
666
|
+
*/
|
|
667
|
+
async *run(opts) {
|
|
668
|
+
// MODEL PRECEDENCE (P1): apply the panel-selected model BEFORE prepare() so the
|
|
669
|
+
// FIRST spawn uses it (the model is spawn-pinned via `--model`; preparing first
|
|
670
|
+
// would spawn the wrong model). PanelAgent.start() usually passes opts.model =
|
|
671
|
+
// the CLAUDE panel model, which is NOT a valid Qwen model — so the configured
|
|
672
|
+
// Qwen model (deps.model, from COMFYUI_MCP_QWEN_MODEL) wins; only honor
|
|
673
|
+
// opts.model when it actually looks like a Qwen model (e.g. the user picked
|
|
674
|
+
// one in the panel, which arrives as opts.model on a fresh spawn).
|
|
675
|
+
if (opts.model && isQwenModel(opts.model))
|
|
676
|
+
this.model = opts.model;
|
|
677
|
+
await this.prepare();
|
|
678
|
+
if (!this.client)
|
|
679
|
+
throw new Error("qwen --acp not initialized");
|
|
680
|
+
const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
|
|
681
|
+
// forkAtAnchor is false → ignore opts.rewindAnchor; whole-session resume only.
|
|
682
|
+
const resumeId = opts.resume ?? opts.sessionId ?? null;
|
|
683
|
+
let sessionId = await this.ensureSession(this.client, cwd, resumeId);
|
|
684
|
+
// The session id is our session id (PanelAgent persists it for resume).
|
|
685
|
+
yield {
|
|
686
|
+
type: "session",
|
|
687
|
+
sessionId,
|
|
688
|
+
...(this.model ? { model: this.model } : {}),
|
|
689
|
+
};
|
|
690
|
+
// Process the neutral channel one turn at a time.
|
|
691
|
+
let turnSeq = 0;
|
|
692
|
+
for await (const turn of opts.channel) {
|
|
693
|
+
// LIVE MODEL SWITCH (P1): PanelAgent treats setModel as live and does NOT
|
|
694
|
+
// restart run() for a model-only change, so the persistent loop adopts it
|
|
695
|
+
// here. The model is spawn-pinned, so a switch means respawning the CLI with
|
|
696
|
+
// the new --model — which necessarily starts a FRESH session (a model swap
|
|
697
|
+
// can't carry the old session forward). Done transparently before the turn;
|
|
698
|
+
// we emit a new `session` event so PanelAgent persists the new id.
|
|
699
|
+
if (this.spawnedModel !== this.model) {
|
|
700
|
+
await this.respawnForModelChange();
|
|
701
|
+
if (!this.client)
|
|
702
|
+
throw new Error("qwen --acp respawn failed");
|
|
703
|
+
sessionId = await this.ensureSession(this.client, cwd, null);
|
|
704
|
+
yield {
|
|
705
|
+
type: "session",
|
|
706
|
+
sessionId,
|
|
707
|
+
...(this.model ? { model: this.model } : {}),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
yield* stampTurn(this.runTurn(this.client, turn, opts.onActivity), ++turnSeq);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
/** Tear down the live `qwen --acp` child (process-tree kill) and re-spawn it
|
|
714
|
+
* with the current `this.model`'s `--model` flag, so a live setModel takes
|
|
715
|
+
* effect. The model is spawn-pinned, so this is the only way to switch it. The
|
|
716
|
+
* caller then opens a fresh session on the new child. */
|
|
717
|
+
async respawnForModelChange() {
|
|
718
|
+
const old = this.client;
|
|
719
|
+
this.client = null;
|
|
720
|
+
this.sessionId = null;
|
|
721
|
+
if (old) {
|
|
722
|
+
old.notificationHandler = null;
|
|
723
|
+
await old.close().catch(() => { });
|
|
724
|
+
}
|
|
725
|
+
this.spawnSpec = null; // force resolveSpawn to rebuild argv with the new --model
|
|
726
|
+
logger.info(`[qwen-backend] model switch → respawning qwen --acp with --model ${this.model ?? "(default)"}`);
|
|
727
|
+
await this.prepare(); // spawns with this.model; records spawnedModel
|
|
728
|
+
}
|
|
729
|
+
/** Run ONE turn: send session/prompt + stream its session/update notifications →
|
|
730
|
+
* AgentEvents, resolving when the prompt request returns a stopReason, OR when
|
|
731
|
+
* the child exits mid-turn (never deadlock). ACP's prompt request IS the turn
|
|
732
|
+
* boundary, so — unlike Codex — there is no separate completion notification and
|
|
733
|
+
* no turn-id buffering: the sessionId is known before the prompt is sent. */
|
|
734
|
+
async *runTurn(client, turn, onActivity) {
|
|
735
|
+
const sessionId = this.sessionId;
|
|
736
|
+
// Event queue bridging the push-based notification handler to this pull-based
|
|
737
|
+
// async generator (identical pattern to gemini-backend).
|
|
738
|
+
const queue = [];
|
|
739
|
+
let wake = null;
|
|
740
|
+
let done = false;
|
|
741
|
+
const push = (ev) => {
|
|
742
|
+
queue.push(ev);
|
|
743
|
+
wake?.();
|
|
744
|
+
wake = null;
|
|
745
|
+
};
|
|
746
|
+
const finish = () => {
|
|
747
|
+
done = true;
|
|
748
|
+
wake?.();
|
|
749
|
+
wake = null;
|
|
750
|
+
};
|
|
751
|
+
// Accumulate the assistant reply text across agent_message_chunk so we can emit
|
|
752
|
+
// ONE authoritative `assistant` commit when the turn ends (ACP has no separate
|
|
753
|
+
// final-message notification). messageId (when present) groups the deltas + the
|
|
754
|
+
// commit under one bubble id, mirroring the Claude/Codex stream reconciliation.
|
|
755
|
+
let assistantText = "";
|
|
756
|
+
let messageId = null;
|
|
757
|
+
// Stream bubble state (reasoning vs reply each open/close their own stream).
|
|
758
|
+
let streamOpen = false;
|
|
759
|
+
let streamKind = null;
|
|
760
|
+
const openStream = (id, kind) => {
|
|
761
|
+
if (streamOpen && streamKind === kind)
|
|
762
|
+
return;
|
|
763
|
+
if (streamOpen)
|
|
764
|
+
push({ type: "stream_end" }); // switch kinds → close the old one
|
|
765
|
+
streamOpen = true;
|
|
766
|
+
streamKind = kind;
|
|
767
|
+
push({ type: "stream_start", id });
|
|
768
|
+
};
|
|
769
|
+
const closeStream = () => {
|
|
770
|
+
if (streamOpen) {
|
|
771
|
+
push({ type: "stream_end" });
|
|
772
|
+
streamOpen = false;
|
|
773
|
+
streamKind = null;
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
// EXACTLY ONE terminal `result` (PanelAgent's turn-gate only advances on a
|
|
777
|
+
// result; a missing one parks the channel forever). This idempotent helper
|
|
778
|
+
// emits an `error` + `{result, ok:false}` and finishes; no-op once a result
|
|
779
|
+
// has fired (so the prompt rejection AND the exit watcher can both call it).
|
|
780
|
+
let finishedResult = false;
|
|
781
|
+
const emitTerminalError = (message) => {
|
|
782
|
+
if (finishedResult)
|
|
783
|
+
return;
|
|
784
|
+
finishedResult = true;
|
|
785
|
+
closeStream();
|
|
786
|
+
push({ type: "error", message });
|
|
787
|
+
push({ type: "result", ok: false, subtype: "error" });
|
|
788
|
+
finish();
|
|
789
|
+
};
|
|
790
|
+
let interrupted = false;
|
|
791
|
+
// tool_call carries the title/kind; tool_call_update (ACP) repeats only the
|
|
792
|
+
// toolCallId — so remember each call's display name to label its end event.
|
|
793
|
+
const toolNames = new Map();
|
|
794
|
+
// Normalize ONE session/update notification into canonical AgentEvents.
|
|
795
|
+
const apply = (msg) => {
|
|
796
|
+
if (finishedResult)
|
|
797
|
+
return;
|
|
798
|
+
const params = (msg.params ?? {});
|
|
799
|
+
// Only our session's updates.
|
|
800
|
+
if (params.sessionId && params.sessionId !== sessionId)
|
|
801
|
+
return;
|
|
802
|
+
const update = (params.update ?? {});
|
|
803
|
+
const kind = update.sessionUpdate;
|
|
804
|
+
switch (kind) {
|
|
805
|
+
case "agent_message_chunk": {
|
|
806
|
+
const content = update.content;
|
|
807
|
+
const text = content?.type === "text" ? content.text : undefined;
|
|
808
|
+
const id = update.messageId ?? null;
|
|
809
|
+
if (typeof text === "string" && text) {
|
|
810
|
+
if (id)
|
|
811
|
+
messageId = id;
|
|
812
|
+
openStream(messageId, "text");
|
|
813
|
+
assistantText += text;
|
|
814
|
+
push({ type: "assistant_delta", text });
|
|
815
|
+
}
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
case "agent_thought_chunk": {
|
|
819
|
+
// Extended-thinking streaming. Open a reasoning stream on the FIRST delta
|
|
820
|
+
// so PanelAgent (which drops assistant_delta when no stream is open)
|
|
821
|
+
// renders early thinking, mirroring codex P2-1.
|
|
822
|
+
const content = update.content;
|
|
823
|
+
const text = content?.type === "text" ? content.text : undefined;
|
|
824
|
+
if (typeof text === "string" && text) {
|
|
825
|
+
openStream(messageId, "thinking");
|
|
826
|
+
push({ type: "assistant_delta", text, thinking: true });
|
|
827
|
+
}
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
case "tool_call": {
|
|
831
|
+
// A tool call was requested — emit tool_call(start) for panel visibility.
|
|
832
|
+
const id = update.toolCallId;
|
|
833
|
+
const name = update.title ||
|
|
834
|
+
update.kind ||
|
|
835
|
+
id ||
|
|
836
|
+
"tool";
|
|
837
|
+
if (id)
|
|
838
|
+
toolNames.set(id, name);
|
|
839
|
+
push({ type: "tool_call", name, phase: "start", detail: update });
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
case "tool_call_update": {
|
|
843
|
+
// Progress + completion of a tool call. Emit tool_call(end) only on a
|
|
844
|
+
// TERMINAL status; intermediate in_progress updates just keep the
|
|
845
|
+
// watchdog armed (onActivity already fired for them). ACP's update
|
|
846
|
+
// repeats only the toolCallId, so reuse the remembered title for the name.
|
|
847
|
+
const status = update.status;
|
|
848
|
+
if (status === "completed" || status === "failed") {
|
|
849
|
+
const id = update.toolCallId;
|
|
850
|
+
const name = update.title ||
|
|
851
|
+
(id ? toolNames.get(id) : undefined) ||
|
|
852
|
+
update.kind ||
|
|
853
|
+
id ||
|
|
854
|
+
"tool";
|
|
855
|
+
push({ type: "tool_call", name, phase: "end", detail: update });
|
|
856
|
+
}
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
// plan / available_commands_update / session_info_update / current_mode_update
|
|
860
|
+
// carry no AgentEvent — onActivity (below) already re-armed the watchdog.
|
|
861
|
+
default:
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
const prev = client.notificationHandler;
|
|
866
|
+
client.notificationHandler = (msg) => {
|
|
867
|
+
// LIVENESS: ANY notification while this turn is in flight means the agent is
|
|
868
|
+
// alive — fire onActivity BEFORE filtering/translating so even updates that
|
|
869
|
+
// produce no AgentEvent (a long MCP tool call mid-generation) keep
|
|
870
|
+
// PanelAgent's idle watchdog armed. A genuine zero-event freeze never
|
|
871
|
+
// reaches here, so the real freeze-catch is preserved.
|
|
872
|
+
try {
|
|
873
|
+
onActivity?.();
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
// a watchdog bump must never break the protocol reader
|
|
877
|
+
}
|
|
878
|
+
if (msg.method === "session/update")
|
|
879
|
+
apply(msg);
|
|
880
|
+
else
|
|
881
|
+
prev?.(msg); // anything else (other methods) → pass through
|
|
882
|
+
};
|
|
883
|
+
// Watch for the child dying mid-turn: end the turn with a terminal result so
|
|
884
|
+
// the local drain is woken instead of waiting forever. emitTerminalError is a
|
|
885
|
+
// no-op if a result already fired, so it's safe alongside the prompt rejection.
|
|
886
|
+
void client.exitPromise.then(() => {
|
|
887
|
+
if (done)
|
|
888
|
+
return;
|
|
889
|
+
emitTerminalError(client.exitError ? msgOf(client.exitError) : "qwen --acp connection closed.");
|
|
890
|
+
});
|
|
891
|
+
// FIRST-TURN PERSONA: ACP session/new has no instructions field, so the panel
|
|
892
|
+
// system prompt is prepended to the first turn's prompt as a clearly-marked
|
|
893
|
+
// system/context preamble (later turns send plain text). Mirrors gemini.
|
|
894
|
+
let turnText = promptText(turn.text);
|
|
895
|
+
if (this.needsSystemPreamble && this.deps.systemAppend) {
|
|
896
|
+
turnText =
|
|
897
|
+
`<system>\n${this.deps.systemAppend}\n</system>\n\n` +
|
|
898
|
+
`The user's first message follows.\n\n${turnText}`;
|
|
899
|
+
this.needsSystemPreamble = false;
|
|
900
|
+
}
|
|
901
|
+
// Build the prompt ContentBlock[]: the text block first (preserves prompt
|
|
902
|
+
// context), then any resolved inline base64 image blocks (vision parity).
|
|
903
|
+
// Images are only attached when the agent advertised promptCapabilities.image
|
|
904
|
+
// (default-allow when the capability is unknown).
|
|
905
|
+
const prompt = [{ type: "text", text: turnText }];
|
|
906
|
+
const imagesAllowed = this.agentCaps?.promptCapabilities?.image !== false;
|
|
907
|
+
if (imagesAllowed) {
|
|
908
|
+
for (const ref of turn.images ?? []) {
|
|
909
|
+
const block = await this.fetchImageBlock(ref);
|
|
910
|
+
if (block)
|
|
911
|
+
prompt.push(block);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
try {
|
|
915
|
+
// session/prompt is a REQUEST that RESOLVES with a stopReason at turn end.
|
|
916
|
+
client
|
|
917
|
+
.request("session/prompt", { sessionId, prompt })
|
|
918
|
+
.then((res) => {
|
|
919
|
+
if (finishedResult)
|
|
920
|
+
return;
|
|
921
|
+
finishedResult = true;
|
|
922
|
+
closeStream();
|
|
923
|
+
const stop = res?.stopReason;
|
|
924
|
+
// Commit the accumulated assistant text (if any) as the authoritative
|
|
925
|
+
// turn-ending message — no per-turn rewind anchor (forkAtAnchor=false).
|
|
926
|
+
const text = assistantText.trim();
|
|
927
|
+
if (text)
|
|
928
|
+
push({ type: "assistant", text, ...(messageId ? { id: messageId } : {}) });
|
|
929
|
+
// end_turn / max_tokens / max_turn_requests = a real completion; cancelled
|
|
930
|
+
// (user interrupt) and refusal are not "ok".
|
|
931
|
+
const ok = !!stop && stop !== "cancelled" && stop !== "refusal";
|
|
932
|
+
push({ type: "result", ok, ...(stop ? { subtype: stop } : {}) });
|
|
933
|
+
finish();
|
|
934
|
+
})
|
|
935
|
+
.catch((err) => {
|
|
936
|
+
// A failed prompt ends the turn. When the child dies mid-turn handleExit
|
|
937
|
+
// rejects this BEFORE exitPromise resolves, so this .catch runs first —
|
|
938
|
+
// it MUST end with a terminal result (the idempotent helper guarantees
|
|
939
|
+
// exactly one), or the exit watcher then sees done and hangs the gate.
|
|
940
|
+
if (interrupted)
|
|
941
|
+
emitTerminalError("qwen turn interrupted.");
|
|
942
|
+
else
|
|
943
|
+
emitTerminalError(msgOf(err));
|
|
944
|
+
});
|
|
945
|
+
// Drain the bridged queue until the turn completes.
|
|
946
|
+
while (true) {
|
|
947
|
+
while (queue.length) {
|
|
948
|
+
yield queue.shift();
|
|
949
|
+
}
|
|
950
|
+
if (done)
|
|
951
|
+
break;
|
|
952
|
+
await new Promise((resolve) => {
|
|
953
|
+
wake = resolve;
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
while (queue.length)
|
|
957
|
+
yield queue.shift();
|
|
958
|
+
}
|
|
959
|
+
finally {
|
|
960
|
+
// Mark interrupted so a late prompt rejection doesn't surface a spurious
|
|
961
|
+
// error after teardown.
|
|
962
|
+
interrupted = true;
|
|
963
|
+
// Restore the prior handler ONLY if it's still ours (close() may have nulled
|
|
964
|
+
// it during shutdown — don't resurrect a stale handler onto a dead client).
|
|
965
|
+
if (client.notificationHandler && !client.exitError)
|
|
966
|
+
client.notificationHandler = prev ?? null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
/** Stop the current turn without ending the session → `session/cancel`
|
|
970
|
+
* (notification). The in-flight session/prompt then resolves with
|
|
971
|
+
* stopReason:"cancelled", which the run-turn path turns into a terminal result. */
|
|
972
|
+
async interrupt() {
|
|
973
|
+
const client = this.client;
|
|
974
|
+
if (!client || !this.sessionId)
|
|
975
|
+
return;
|
|
976
|
+
try {
|
|
977
|
+
client.notify("session/cancel", { sessionId: this.sessionId });
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
980
|
+
logger.debug(`[qwen-backend] interrupt: ${msgOf(err)}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
/** Switch the model live. ACP pins the model at spawn (`--model`), so this can't
|
|
984
|
+
* reconfigure a running child — instead it marks the model dirty (this.model !=
|
|
985
|
+
* this.spawnedModel) and invalidates the cached spawn spec. The persistent run()
|
|
986
|
+
* loop then RESPAWNS the `qwen --acp` CLI with the new --model (and a fresh
|
|
987
|
+
* session) transparently before the next turn (see run()'s live-switch branch).
|
|
988
|
+
* If the backend hasn't spawned yet, the first prepare() simply uses the new
|
|
989
|
+
* model. Ignores non-Qwen ids (PanelAgent may pass the Claude panel model). */
|
|
990
|
+
async setModel(model) {
|
|
991
|
+
if (!isQwenModel(model))
|
|
992
|
+
return;
|
|
993
|
+
this.model = model;
|
|
994
|
+
this.spawnSpec = null; // next spawn rebuilds argv with the new --model
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Qwen model enumeration. ACP exposes no model catalog, so we surface a static
|
|
998
|
+
* set (the current Qwen coding family); the panel picker degrades gracefully on
|
|
999
|
+
* an empty list. No effort metadata (no discrete effort scale) → the panel
|
|
1000
|
+
* hides the effort dropdown.
|
|
1001
|
+
*/
|
|
1002
|
+
async listModels() {
|
|
1003
|
+
return QWEN_MODELS;
|
|
1004
|
+
}
|
|
1005
|
+
/** Permanently dispose of the backend (AgentBackend.close): kill the qwen
|
|
1006
|
+
* process TREE (Windows shell-fallback grandchild included), remove listeners,
|
|
1007
|
+
* null the client. Idempotent + safe when never prepared. Mirrors codex (P0-1):
|
|
1008
|
+
* interrupt() is a no-op when idle, so without this the child is orphaned. */
|
|
1009
|
+
async close() {
|
|
1010
|
+
this.disposed = true; // tripwire FIRST (an in-flight prepare() bails) (P0-A)
|
|
1011
|
+
const client = this.client;
|
|
1012
|
+
const preparing = this.preparingClient;
|
|
1013
|
+
this.client = null;
|
|
1014
|
+
this.preparingClient = null;
|
|
1015
|
+
this.sessionId = null;
|
|
1016
|
+
if (client) {
|
|
1017
|
+
client.notificationHandler = null;
|
|
1018
|
+
await client.close().catch(() => { });
|
|
1019
|
+
}
|
|
1020
|
+
if (preparing && preparing !== client) {
|
|
1021
|
+
preparing.notificationHandler = null;
|
|
1022
|
+
await preparing.close().catch(() => { });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Expose the default model id for the orchestrator wiring (COMFYUI_MCP_QWEN_MODEL
|
|
1027
|
+
// fallback) without duplicating the literal.
|
|
1028
|
+
export { QWEN_DEFAULT_MODEL };
|
|
1029
|
+
//# sourceMappingURL=qwen-backend.js.map
|