comfyui-mcp 0.30.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +17 -1
  2. package/dist/config.js +27 -1
  3. package/dist/config.js.map +1 -1
  4. package/dist/orchestrator/agent-backend.js +42 -0
  5. package/dist/orchestrator/agent-backend.js.map +1 -1
  6. package/dist/orchestrator/ai-proposer.js +106 -0
  7. package/dist/orchestrator/ai-proposer.js.map +1 -0
  8. package/dist/orchestrator/backend-readiness.js +78 -1
  9. package/dist/orchestrator/backend-readiness.js.map +1 -1
  10. package/dist/orchestrator/chatgpt-oauth-backend.js +349 -0
  11. package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -0
  12. package/dist/orchestrator/claude-backend.js +8 -0
  13. package/dist/orchestrator/claude-backend.js.map +1 -1
  14. package/dist/orchestrator/copilot-backend.js +265 -0
  15. package/dist/orchestrator/copilot-backend.js.map +1 -0
  16. package/dist/orchestrator/glm-backend.js +20 -0
  17. package/dist/orchestrator/glm-backend.js.map +1 -0
  18. package/dist/orchestrator/grok-backend.js +1470 -0
  19. package/dist/orchestrator/grok-backend.js.map +1 -0
  20. package/dist/orchestrator/history.js +181 -0
  21. package/dist/orchestrator/history.js.map +1 -0
  22. package/dist/orchestrator/index.js +620 -138
  23. package/dist/orchestrator/index.js.map +1 -1
  24. package/dist/orchestrator/kimi-backend.js +26 -0
  25. package/dist/orchestrator/kimi-backend.js.map +1 -0
  26. package/dist/orchestrator/oauth-bridge.js +92 -0
  27. package/dist/orchestrator/oauth-bridge.js.map +1 -0
  28. package/dist/orchestrator/oauth-bridge.test.js +129 -0
  29. package/dist/orchestrator/oauth-bridge.test.js.map +1 -0
  30. package/dist/orchestrator/ollama-backend.js +71 -8
  31. package/dist/orchestrator/ollama-backend.js.map +1 -1
  32. package/dist/orchestrator/panel-agent.js +25 -0
  33. package/dist/orchestrator/panel-agent.js.map +1 -1
  34. package/dist/orchestrator/panel-console-http.js +470 -0
  35. package/dist/orchestrator/panel-console-http.js.map +1 -0
  36. package/dist/orchestrator/panel-mcp-http.js +2 -2
  37. package/dist/orchestrator/panel-mcp-http.js.map +1 -1
  38. package/dist/orchestrator/panel-tools.js +71 -7
  39. package/dist/orchestrator/panel-tools.js.map +1 -1
  40. package/dist/services/a2ui-spec.js +207 -0
  41. package/dist/services/a2ui-spec.js.map +1 -0
  42. package/dist/services/a2ui-spec.test.js +174 -0
  43. package/dist/services/a2ui-spec.test.js.map +1 -0
  44. package/dist/services/civitai-lookup.js +5 -0
  45. package/dist/services/civitai-lookup.js.map +1 -1
  46. package/dist/services/civitai-resolver.js +160 -3
  47. package/dist/services/civitai-resolver.js.map +1 -1
  48. package/dist/services/code-provider-auth-redact.test.js +83 -0
  49. package/dist/services/code-provider-auth-redact.test.js.map +1 -0
  50. package/dist/services/code-provider-auth.js +561 -0
  51. package/dist/services/code-provider-auth.js.map +1 -0
  52. package/dist/services/comfy-cli.js +276 -0
  53. package/dist/services/comfy-cli.js.map +1 -0
  54. package/dist/services/download-cache.js +7 -1
  55. package/dist/services/download-cache.js.map +1 -1
  56. package/dist/services/lora-catalog.js +329 -0
  57. package/dist/services/lora-catalog.js.map +1 -0
  58. package/dist/services/model-resolver.js +65 -4
  59. package/dist/services/model-resolver.js.map +1 -1
  60. package/dist/services/node-management.js +36 -53
  61. package/dist/services/node-management.js.map +1 -1
  62. package/dist/services/oauth-flow.js +314 -0
  63. package/dist/services/oauth-flow.js.map +1 -0
  64. package/dist/services/oauth-flow.test.js +179 -0
  65. package/dist/services/oauth-flow.test.js.map +1 -0
  66. package/dist/services/oauth-landing.test.js +45 -0
  67. package/dist/services/oauth-landing.test.js.map +1 -0
  68. package/dist/services/panel-secrets.js +132 -1
  69. package/dist/services/panel-secrets.js.map +1 -1
  70. package/dist/services/prompt-overrides.js +103 -0
  71. package/dist/services/prompt-overrides.js.map +1 -0
  72. package/dist/services/ui-bridge.js +52 -2
  73. package/dist/services/ui-bridge.js.map +1 -1
  74. package/dist/services/workflow-health.js +13 -0
  75. package/dist/services/workflow-health.js.map +1 -1
  76. package/dist/services/workflow-target-store.js +64 -0
  77. package/dist/services/workflow-target-store.js.map +1 -0
  78. package/dist/tools/comfy-cli.js +257 -0
  79. package/dist/tools/comfy-cli.js.map +1 -0
  80. package/dist/tools/index.js +6 -0
  81. package/dist/tools/index.js.map +1 -1
  82. package/dist/tools/model-explorer.js +114 -0
  83. package/dist/tools/model-explorer.js.map +1 -0
  84. package/dist/tools/model-extras.js +146 -3
  85. package/dist/tools/model-extras.js.map +1 -1
  86. package/dist/tools/model-management.js +11 -2
  87. package/dist/tools/model-management.js.map +1 -1
  88. package/dist/tools/node-management.js +21 -10
  89. package/dist/tools/node-management.js.map +1 -1
  90. package/dist/tools/prompt-director.js +32 -0
  91. package/dist/tools/prompt-director.js.map +1 -0
  92. package/dist/tools/workflow-compose.js +67 -5
  93. package/dist/tools/workflow-compose.js.map +1 -1
  94. package/dist/utils/html-to-markdown.js +86 -0
  95. package/dist/utils/html-to-markdown.js.map +1 -0
  96. package/package.json +1 -1
  97. package/packs/ltx23-distill-3stage/install-runpod.sh +58 -0
  98. package/packs/ltx23-distill-3stage/install-windows.bat +63 -0
  99. package/packs/ltx23-distill-3stage/manifest.yaml +63 -0
  100. package/packs/ltx23-distill-3stage/pack.yaml +37 -0
  101. package/packs/ltx23-distill-3stage/workflow.json +906 -0
  102. package/plugin/.mcp.json +4 -8
  103. package/plugin/hooks/vram-check.mjs +52 -9
  104. package/plugin/skills/civitai/SKILL.md +52 -78
  105. package/plugin/skills/comfyui-core/SKILL.md +2 -2
  106. package/plugin/skills/local-llm-free/SKILL.md +2 -2
  107. package/scripts/check-pack-models.mjs +11 -1
  108. package/scripts/gen-tool-docs.ts +2 -0
  109. package/scripts/mock-panel.mjs +92 -52
  110. package/scripts/runpod-release.mjs +18 -2
  111. package/scripts/test-generate.mjs +61 -0
@@ -0,0 +1,1470 @@
1
+ // Grok (xAI) backend — the provider-specific adapter behind the AgentBackend
2
+ // port, driving the Grok CLI over its **ACP (Agent Client Protocol)** mode
3
+ // (`grok agent stdio`), a JSON-RPC 2.0 client over stdio. This is a faithful
4
+ // MIRROR of grok-backend.ts: same self-contained line-framed JSON-RPC client,
5
+ // same per-turn event-queue bridge, same terminal-result invariant, same
6
+ // Windows/POSIX process-tree kill.
7
+ //
8
+ // PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
9
+ // push, self-restart) and drives this backend via
10
+ // `for await (const ev of backend.run({...}))`. See
11
+ // docs/design/agent-backend-injection.md.
12
+ //
13
+ // PROTOCOL MAPPING (AgentBackend ↔ ACP, per agentclientprotocol.com + the
14
+ // Gemini CLI docs/cli/acp-mode.md):
15
+ // - prepare() = spawn `grok agent` + `initialize` handshake
16
+ // - session = an ACP SESSION (`session/new` new | `session/load` resume)
17
+ // - run() loop turn = `session/prompt` (ONE request per neutral channel batch);
18
+ // the request RESOLVES with a `stopReason` at turn end
19
+ // (unlike Codex, where turn/start returns immediately
20
+ // and a separate turn/completed notification ends it).
21
+ // Images ride inline as base64 `image` ContentBlocks.
22
+ // - assistant_delta ← `session/update` { update.sessionUpdate:"agent_message_chunk",
23
+ // content:{type:"text",text} }
24
+ // - assistant_delta(th) ← `session/update` { ...:"agent_thought_chunk", ... } (thinking)
25
+ // - assistant (commit) ← the accumulated agent_message_chunk text, emitted once
26
+ // when the session/prompt request resolves (ACP has no
27
+ // separate "final message" notification — the chunks ARE
28
+ // the message; the prompt response is the turn boundary)
29
+ // - tool_call(start) ← `session/update` { ...:"tool_call", toolCallId, title, kind }
30
+ // - tool_call(end) ← `session/update` { ...:"tool_call_update", status:
31
+ // "completed"|"failed" }
32
+ // - result ← the `session/prompt` response { stopReason }
33
+ // - error ← a failed `session/prompt` / the child dying mid-turn
34
+ // - interrupt() → `session/cancel` (notification); the in-flight prompt
35
+ // then resolves with stopReason:"cancelled"
36
+ // - listModels() ← a static catalog (gemini-2.5-pro / -flash) — ACP exposes
37
+ // no model enumeration; the model is selected at SPAWN via
38
+ // the CLI `--model` flag (see resolveBin/spawn below)
39
+ //
40
+ // AUTH (NO API KEY — the CLI owns auth): Gemini CLI authenticates itself via
41
+ // Google OAuth / Code Assist (the user runs `gemini` once to sign in). This
42
+ // backend NEVER passes an API key; it just spawns the already-authenticated CLI.
43
+ // If the CLI is signed out, `session/new` returns an `auth_required` error — we
44
+ // attempt one `authenticate` with the first advertised auth method, then surface
45
+ // a clear "run `gemini` and sign in" message (the OAuth browser flow itself is
46
+ // owned by the CLI and cannot be completed headlessly).
47
+ //
48
+ // PARITY with Codex/Claude: the Gemini backend gets the SAME tool surface — the
49
+ // headless `comfyui` stdio MCP plus the `panel` HTTP MCP for live-graph panel_*
50
+ // tools — declared to `session/new` as ACP McpServers. The panel system prompt
51
+ // is prepended to the FIRST turn's prompt (ACP `session/new` has no system /
52
+ // instructions field, mirroring the Codex app-server's thread/start).
53
+ //
54
+ // ASSUMPTIONS we could NOT verify without the live `gemini` CLI (flagged inline,
55
+ // see also the PR body): the exact ACP McpServer http variant shape, the
56
+ // session/load resume semantics, the auth_required retry, and live model
57
+ // switching (we set the model at spawn via --model since ACP exposes no standard
58
+ // per-session model setter). Each is the closest faithful mapping to the
59
+ // documented ACP spec.
60
+ import { spawn, spawnSync } from "node:child_process";
61
+ import readline from "node:readline";
62
+ import { randomUUID } from "node:crypto";
63
+ import { existsSync } from "node:fs";
64
+ import { logger } from "../utils/logger.js";
65
+ import { GROK_CAPABILITIES, } from "./agent-backend.js";
66
+ import { resolveGrokOAuth, } from "../services/code-provider-auth.js";
67
+ import { OAUTH_PROVIDERS, assertAllowedTokenHost, grokTokenFile, redactTokens } from "../services/oauth-flow.js";
68
+ import { OllamaBackend } from "./ollama-backend.js";
69
+ function msgOf(err) {
70
+ return err instanceof Error ? err.message : String(err);
71
+ }
72
+ /**
73
+ * Kill an entire process tree, not just the direct child. On the Windows
74
+ * PATH/shell fallback the direct child is a cmd.exe/`.cmd` shim whose grandchild
75
+ * is the real `gemini` node process — killing only the shell leaves the tree
76
+ * alive. Use `taskkill /T /F`. On POSIX, signal the process group (negative pid)
77
+ * so a shell + its child both die, falling back to the single pid. Best-effort +
78
+ * swallows errors: it runs during teardown and must never throw into the host.
79
+ * (Identical to codex-backend's killProcessTree — same spawn posture.)
80
+ */
81
+ function killProcessTree(pid) {
82
+ if (!Number.isFinite(pid))
83
+ return;
84
+ const p = pid;
85
+ if (process.platform === "win32") {
86
+ try {
87
+ spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
88
+ }
89
+ catch {
90
+ try {
91
+ process.kill(p);
92
+ }
93
+ catch {
94
+ // already gone
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ try {
100
+ process.kill(-p, "SIGTERM"); // process group (we spawn detached on POSIX)
101
+ }
102
+ catch {
103
+ try {
104
+ process.kill(p, "SIGTERM");
105
+ }
106
+ catch {
107
+ // already gone
108
+ }
109
+ }
110
+ }
111
+ /** An Error carrying the JSON-RPC error `data` so the auth_required reason
112
+ * survives the request rejection (used to drive the authenticate retry). */
113
+ class RpcError extends Error {
114
+ code;
115
+ data;
116
+ constructor(message, code, data) {
117
+ super(message);
118
+ this.code = code;
119
+ this.data = data;
120
+ }
121
+ }
122
+ class AcpClient {
123
+ cmd;
124
+ args;
125
+ cwd;
126
+ env;
127
+ useShell;
128
+ proc = null;
129
+ rl = null;
130
+ pending = new Map();
131
+ nextId = 1;
132
+ closed = false;
133
+ exitResolved = false;
134
+ /** The error that ended the connection (null = clean exit). */
135
+ exitError = null;
136
+ stderr = "";
137
+ notificationHandler = null;
138
+ resolveExit;
139
+ /** Resolves when the `gemini` process exits or errors — runTurn() races its
140
+ * per-turn drain against this so a child that dies mid-prompt never deadlocks
141
+ * the turn forever (mirrors codex P0-2). */
142
+ exitPromise;
143
+ constructor(cmd, args, cwd, env, useShell) {
144
+ this.cmd = cmd;
145
+ this.args = args;
146
+ this.cwd = cwd;
147
+ this.env = env;
148
+ this.useShell = useShell;
149
+ this.exitPromise = new Promise((resolve) => {
150
+ this.resolveExit = resolve;
151
+ });
152
+ }
153
+ /** Spawn `grok agent` and perform the ACP `initialize` handshake. Returns the
154
+ * agent's initialize result (capabilities + authMethods). NOTE: ACP has NO
155
+ * `initialized` notification (that's MCP, not ACP) — initialize is a plain
156
+ * request/response, after which we go straight to session/new. */
157
+ async initialize(clientInfo) {
158
+ this.proc = spawn(this.cmd, this.args, {
159
+ cwd: this.cwd,
160
+ env: this.env,
161
+ stdio: ["pipe", "pipe", "pipe"],
162
+ windowsHide: true,
163
+ shell: this.useShell,
164
+ // POSIX: own process group so close() can kill the whole tree with one
165
+ // negative-pid signal. Windows uses taskkill /T instead.
166
+ detached: process.platform !== "win32",
167
+ });
168
+ this.proc.stdout.setEncoding("utf8");
169
+ this.proc.stderr.setEncoding("utf8");
170
+ this.proc.stderr.on("data", (chunk) => {
171
+ this.stderr += chunk;
172
+ });
173
+ // Route pipe errors (EPIPE when the child dies mid-turn) through handleExit so
174
+ // the turn rejects cleanly instead of crashing the host as an uncaught error.
175
+ this.proc.stdin.on("error", (error) => this.handleExit(error));
176
+ this.proc.stdout.on("error", (error) => this.handleExit(error));
177
+ this.proc.on("error", (error) => this.handleExit(error));
178
+ this.proc.on("exit", (code, signal) => {
179
+ const detail = code === 0
180
+ ? null
181
+ : new Error(`grok agent exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${this.stderr ? ` ${this.stderr.trim().split(/\r?\n/).slice(-2).join(" ")}` : ""}`);
182
+ this.handleExit(detail);
183
+ });
184
+ this.rl = readline.createInterface({ input: this.proc.stdout });
185
+ this.rl.on("line", (line) => this.handleLine(line));
186
+ // ACP initialize: negotiate protocol version + advertise client capabilities.
187
+ // We do NOT implement the client fs/terminal methods, so advertise them false
188
+ // (the agent then won't issue fs/read_text_file, terminal/*, etc.).
189
+ const result = await this.request("initialize", {
190
+ protocolVersion: 1,
191
+ clientCapabilities: {
192
+ fs: { readTextFile: false, writeTextFile: false },
193
+ terminal: false,
194
+ },
195
+ clientInfo,
196
+ });
197
+ return result;
198
+ }
199
+ request(method, params) {
200
+ if (this.closed)
201
+ return Promise.reject(new Error("grok agent client is closed."));
202
+ const id = this.nextId++;
203
+ return new Promise((resolve, reject) => {
204
+ this.pending.set(id, { resolve: resolve, reject, method });
205
+ this.send({ id, method, params });
206
+ });
207
+ }
208
+ notify(method, params = {}) {
209
+ if (this.closed)
210
+ return;
211
+ // Fire-and-forget: a write failure (dead child) must not throw into the caller.
212
+ try {
213
+ this.send({ method, params });
214
+ }
215
+ catch {
216
+ // connection gone; pending requests already rejected via handleExit
217
+ }
218
+ }
219
+ send(message) {
220
+ const stdin = this.proc?.stdin;
221
+ if (!stdin || stdin.destroyed || stdin.writableEnded) {
222
+ this.handleExit(this.exitError ?? new Error("grok agent stdin is not available."));
223
+ throw this.exitError ?? new Error("grok agent stdin is not available.");
224
+ }
225
+ // ACP is strict JSON-RPC 2.0 — every outbound frame carries `jsonrpc:"2.0"`.
226
+ const framed = { jsonrpc: "2.0", ...message };
227
+ try {
228
+ stdin.write(`${JSON.stringify(framed)}\n`);
229
+ }
230
+ catch (err) {
231
+ this.handleExit(err instanceof Error ? err : new Error(String(err)));
232
+ throw err;
233
+ }
234
+ }
235
+ /**
236
+ * The auto-approve RESULT for a server→client request, or null if it isn't one
237
+ * we should auto-grant. The panel agent is an ISOLATED background agent (same
238
+ * posture as Claude's bypassPermissions / Codex's auto-approve), so we grant
239
+ * tool-permission requests to keep the live-graph work flowing.
240
+ *
241
+ * ACP permission flow: the agent sends `session/request_permission`
242
+ * ({ sessionId, toolCall, options:[{ optionId, name, kind }] }) and expects
243
+ * { outcome: { outcome:"selected", optionId } }. We pick the most-permissive
244
+ * "allow" option (allow_always > allow_once); if none is offered we cancel.
245
+ */
246
+ autoApproveResult(msg) {
247
+ if (msg.method !== "session/request_permission")
248
+ return null;
249
+ const params = (msg.params ?? {});
250
+ const options = Array.isArray(params.options) ? params.options : [];
251
+ const pick = options.find((o) => o.kind === "allow_always") ??
252
+ options.find((o) => o.kind === "allow_once") ??
253
+ // Fall back to any option whose id/kind reads as an allow.
254
+ options.find((o) => /allow/i.test(o.kind ?? "") || /allow/i.test(o.optionId ?? ""));
255
+ if (pick?.optionId) {
256
+ return { outcome: { outcome: "selected", optionId: pick.optionId } };
257
+ }
258
+ // No allow option offered → decline gracefully so the agent moves on.
259
+ return { outcome: { outcome: "cancelled" } };
260
+ }
261
+ handleLine(line) {
262
+ if (!line.trim())
263
+ return;
264
+ let message;
265
+ try {
266
+ message = JSON.parse(line);
267
+ }
268
+ catch (error) {
269
+ this.handleExit(new Error(`Failed to parse grok agent JSONL: ${msgOf(error)}`));
270
+ return;
271
+ }
272
+ // Server→client request (id + method). Auto-approve permission prompts; reply
273
+ // method-not-found to anything else so the protocol keeps moving (we declared
274
+ // no fs/terminal client capabilities, so those shouldn't arrive).
275
+ if (message.id !== undefined && message.method) {
276
+ const result = this.autoApproveResult(message);
277
+ if (result) {
278
+ logger.debug(`[grok-backend] auto-approving server request ${message.method}`);
279
+ this.send({ id: message.id, result });
280
+ }
281
+ else {
282
+ logger.debug(`[grok-backend] unsupported server request ${message.method} — replying method-not-found`);
283
+ this.send({
284
+ id: message.id,
285
+ error: { code: -32601, message: `Unsupported server request: ${message.method}` },
286
+ });
287
+ }
288
+ return;
289
+ }
290
+ // Response to one of our requests.
291
+ if (message.id !== undefined) {
292
+ const p = this.pending.get(message.id);
293
+ if (!p)
294
+ return;
295
+ this.pending.delete(message.id);
296
+ if (message.error) {
297
+ p.reject(new RpcError(message.error.message ?? `grok agent ${p.method} failed.`, message.error.code, message.error.data));
298
+ }
299
+ else {
300
+ p.resolve(message.result ?? {});
301
+ }
302
+ return;
303
+ }
304
+ // Notification.
305
+ if (message.method)
306
+ this.notificationHandler?.(message);
307
+ }
308
+ handleExit(error) {
309
+ if (this.exitResolved)
310
+ return;
311
+ this.exitResolved = true;
312
+ this.exitError = error;
313
+ for (const p of this.pending.values())
314
+ p.reject(error ?? new Error("grok agent connection closed."));
315
+ this.pending.clear();
316
+ this.resolveExit();
317
+ }
318
+ async close() {
319
+ if (this.closed) {
320
+ await this.exitPromise;
321
+ return;
322
+ }
323
+ this.closed = true;
324
+ this.notificationHandler = null;
325
+ this.rl?.close();
326
+ this.rl = null;
327
+ if (this.proc && this.proc.exitCode === null) {
328
+ try {
329
+ this.proc.stdin.end();
330
+ }
331
+ catch {
332
+ // already gone
333
+ }
334
+ const proc = this.proc;
335
+ // Give a graceful stdin-EOF shutdown a beat, then KILL THE WHOLE TREE — on
336
+ // the Windows shell fallback the direct child is a shim whose grandchild is
337
+ // the real gemini node process, so proc.kill() alone would orphan it.
338
+ setTimeout(() => {
339
+ if (proc.exitCode === null)
340
+ killProcessTree(proc.pid);
341
+ }, 50).unref?.();
342
+ }
343
+ await this.exitPromise;
344
+ this.proc = null;
345
+ }
346
+ }
347
+ // ---- model catalog ----
348
+ // ACP exposes no model enumeration, and the model is fixed at SPAWN via the CLI
349
+ // `--model` flag — so we surface a static catalog of the current Gemini family.
350
+ // Gemini's "thinking" is a token BUDGET, not a discrete effort scale, so we do
351
+ // NOT advertise supportsEffort/supportedEffortLevels: the panel's normalizeModels
352
+ // then hides the effort dropdown (omission is the documented "no effort control"
353
+ // signal). gemini-2.5-pro is the default.
354
+ const GROK_MODELS = [
355
+ { id: "grok-composer-2.5-fast", label: "Grok Composer 2.5 Fast" },
356
+ { id: "grok-build", label: "Grok Build" },
357
+ ];
358
+ const GROK_DEFAULT_MODEL = "grok-composer-2.5-fast";
359
+ /** Does this id look like a Grok model (vs. the Claude panel model PanelAgent
360
+ * unconditionally passes as opts.model)? Used so the configured Grok model
361
+ * wins — mirrors codex-backend's isCodexModel guard (P1-1). */
362
+ function isGrokModel(id) {
363
+ return /^grok[-/]/i.test(id);
364
+ }
365
+ /**
366
+ * Convert our MCP server specs into the ACP `session/new` `mcpServers` array.
367
+ * ACP stdio McpServer: { name, command, args, env:[{name,value}] }. Streamable
368
+ * HTTP MCP uses the SSE variant ({ type:"sse", name, url, headers:[] }) — the
369
+ * live Grok/Gemini CLIs reject { type:"http" } with Invalid params.
370
+ */
371
+ export function buildAcpMcpServers(servers) {
372
+ const out = [];
373
+ for (const [name, spec] of Object.entries(servers)) {
374
+ if (spec.transport === "stdio") {
375
+ out.push({
376
+ name,
377
+ command: spec.command,
378
+ args: spec.args ?? [],
379
+ env: Object.entries(spec.env ?? {}).map(([k, v]) => ({ name: k, value: v })),
380
+ });
381
+ }
382
+ else {
383
+ out.push({ type: "sse", name, url: spec.url, headers: [] });
384
+ }
385
+ }
386
+ return out;
387
+ }
388
+ /**
389
+ * The Gemini CLI ACP adapter. One instance per PanelAgent; it holds the live ACP
390
+ * client + current session id and re-opens on each `run()`.
391
+ *
392
+ * DIRECT-TOKEN SELECTION (Task 6): this class is also the public facade used
393
+ * everywhere (`new GrokBackend(deps)`, wired in orchestrator/index.ts). On the
394
+ * FIRST call to any AgentBackend method it decides — once, cached for the life of
395
+ * this instance — whether `~/.grok/auth.json` holds a usable OAuth token
396
+ * (`resolveGrokOAuth`, mirroring `resolveOpenAICodexOAuth`'s resolve+refresh). If
397
+ * so, EVERY call delegates to an internal `GrokDirectBackend` (a Responses-style
398
+ * HTTP adapter hitting `https://api.x.ai/v1`, reusing the same 6-tool router as
399
+ * Ollama/ChatGPT — see below). If the token file is absent, unreadable, or its
400
+ * refresh fails, the decision is "no" and this class's OWN ACP/CLI body below
401
+ * runs exactly as before — NO behavior change on that path. The Grok CLI thus
402
+ * becomes optional: only needed when no in-panel OAuth sign-in has happened.
403
+ */
404
+ export class GrokBackend {
405
+ id = "grok";
406
+ deps;
407
+ /** Memoized mode decision: resolves to a live GrokDirectBackend when a usable
408
+ * OAuth token was found, or `null` to mean "use this class's own ACP body".
409
+ * Decided at most once per instance (see the class doc above). */
410
+ modePromise = null;
411
+ /** Synchronous mirror of the resolved mode for the `capabilities` getter:
412
+ * `undefined` = not yet decided, a backend = direct mode, `null` = ACP mode.
413
+ * Set by resolveMode() the instant its probe settles. */
414
+ resolvedDirect = undefined;
415
+ client = null;
416
+ /** The client an in-flight prepare() is spinning up, tracked so a concurrent
417
+ * close() can tear it down before it's published (P0-A). */
418
+ preparingClient = null;
419
+ /** Set once close() runs — a tripwire so an in-flight prepare() disposes its
420
+ * local client instead of publishing it (P0-A). */
421
+ disposed = false;
422
+ /** Cached resolved spawn command/args/shell (set in prepare()). */
423
+ spawnSpec = null;
424
+ /** The live ACP session id — used for session/prompt + session/cancel. */
425
+ sessionId = null;
426
+ /** The model requested for new sessions (applied at SPAWN via --model). */
427
+ model;
428
+ /** The model the LIVE `grok agent` child was actually spawned with. Gemini
429
+ * pins the model at spawn, so when this drifts from `this.model` (a live
430
+ * setModel) the run loop respawns the CLI before the next turn (P1). */
431
+ spawnedModel;
432
+ /** Capabilities the agent advertised at initialize (loadSession / image / http). */
433
+ agentCaps = undefined;
434
+ authMethods = [];
435
+ /** True until the panel system prompt has been prepended to a turn. Reset
436
+ * whenever a NEW session starts (run()). */
437
+ needsSystemPreamble = false;
438
+ constructor(deps = {}) {
439
+ this.deps = deps;
440
+ this.model = deps.model;
441
+ }
442
+ /**
443
+ * Capabilities MUST reflect the mode that will actually serve turns — never
444
+ * advertise one this backend won't honor (over-reporting `vision` would make a
445
+ * caller send images the direct 6-tool-router path silently drops = data loss).
446
+ * `capabilities` is a synchronous `readonly` on the AgentBackend port, but the
447
+ * mode is resolved lazily/async — so:
448
+ * - once the mode is KNOWN → report the real descriptor (direct = vision:false,
449
+ * ACP = the full GROK_CAPABILITIES incl. vision:true).
450
+ * - while UNKNOWN but the direct path is REACHABLE (a `~/.grok/auth.json`
451
+ * exists, or a resolveGrokOAuth seam is injected) → report conservatively
452
+ * with `vision:false`. Under-reporting is safe (the panel just won't offer
453
+ * images); over-reporting is not.
454
+ * - while UNKNOWN and the direct path is NOT reachable (no token file) → the
455
+ * ACP path is the only possibility, so report its full capabilities.
456
+ * The xAI vision contract is unverified (Task 8), so the direct path never
457
+ * claims vision until that's confirmed. */
458
+ get capabilities() {
459
+ if (this.resolvedDirect !== undefined) {
460
+ return this.resolvedDirect ? this.resolvedDirect.capabilities : GROK_CAPABILITIES;
461
+ }
462
+ const directReachable = !!this.deps.resolveGrokOAuth || existsSync(grokTokenFile);
463
+ return directReachable ? { ...GROK_CAPABILITIES, vision: false } : GROK_CAPABILITIES;
464
+ }
465
+ /**
466
+ * Decide ONCE (memoized) whether a direct OAuth token is usable: probes
467
+ * `resolveGrokOAuth` (or the injected test seam) and, on success, builds the
468
+ * `GrokDirectBackend` that every AgentBackend method below will delegate to.
469
+ * On ANY failure (no `~/.grok/auth.json`, unreadable, expired-with-no-refresh,
470
+ * refresh network error, wrong-shape file) it caches `null`, meaning "fall
471
+ * back to this class's own ACP/CLI body" — the probe itself does no spawning
472
+ * and no network call beyond the token endpoint, so a missing/foreign
473
+ * `~/.grok/auth.json` (e.g. the Grok CLI's OWN native login, a different JSON
474
+ * shape) is indistinguishable from "not signed in" and correctly falls back.
475
+ */
476
+ resolveMode() {
477
+ if (!this.modePromise) {
478
+ const resolveOAuth = this.deps.resolveGrokOAuth ?? resolveGrokOAuth;
479
+ this.modePromise = resolveOAuth()
480
+ .then((creds) => {
481
+ // Thread the ALREADY-resolved credentials into the direct backend so its
482
+ // prepare() reuses them instead of probing resolveGrokOAuth a second time
483
+ // (single-probe on the success path — the task's explicit requirement).
484
+ const direct = new GrokDirectBackend({
485
+ cwd: this.deps.cwd,
486
+ model: this.deps.model,
487
+ comfyuiUrl: this.deps.comfyuiUrl,
488
+ mcpServers: this.deps.mcpServers,
489
+ systemAppend: this.deps.systemAppend,
490
+ resolveGrokOAuth: this.deps.resolveGrokOAuth,
491
+ initialCredentials: creds,
492
+ });
493
+ this.resolvedDirect = direct;
494
+ return direct;
495
+ })
496
+ .catch(() => {
497
+ this.resolvedDirect = null;
498
+ return null;
499
+ });
500
+ }
501
+ return this.modePromise;
502
+ }
503
+ /**
504
+ * Resolve how to spawn `grok agent --always-approve stdio`. The Grok CLI must
505
+ * be on PATH (installed via Grok Build / xAI). The `--model` flag pins the
506
+ * model at spawn (ACP has no standard per-session model setter). On Windows,
507
+ * `grok` may resolve to a `.cmd` shim — spawn with a shell when needed.
508
+ */
509
+ resolveSpawn() {
510
+ if (this.spawnSpec)
511
+ return this.spawnSpec;
512
+ const modelArgs = this.model ? ["--model", this.model] : [];
513
+ const cmd = "grok";
514
+ const args = ["agent", ...modelArgs, "--always-approve", "stdio"];
515
+ const useShell = process.platform === "win32";
516
+ this.spawnSpec = { cmd, args, useShell };
517
+ return this.spawnSpec;
518
+ }
519
+ /**
520
+ * Fetch a ComfyUI image (/view) and return it as an ACP base64 `image`
521
+ * ContentBlock ({ type:"image", mimeType, data }) — or null on any failure (the
522
+ * text reference still names the image as a fallback). Unlike Codex (which
523
+ * spills to a temp file for its path-based localImage item), ACP takes inline
524
+ * base64, so this mirrors ClaudeBackend.fetchImageBlock exactly (only the key
525
+ * names differ: ACP uses `mimeType`/`data`).
526
+ */
527
+ async fetchImageBlock(ref) {
528
+ if (!this.deps.comfyuiUrl || !ref?.filename)
529
+ return null;
530
+ try {
531
+ const u = new URL("/view", this.deps.comfyuiUrl);
532
+ u.searchParams.set("filename", ref.filename);
533
+ u.searchParams.set("type", ref.type || "input");
534
+ if (ref.subfolder)
535
+ u.searchParams.set("subfolder", ref.subfolder);
536
+ const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
537
+ if (!res.ok)
538
+ return null;
539
+ let mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
540
+ if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mt)) {
541
+ mt = "image/png"; // ComfyUI outputs are PNG by default
542
+ }
543
+ const buf = Buffer.from(await res.arrayBuffer());
544
+ if (buf.length > 12 * 1024 * 1024)
545
+ return null; // keep context sane (parity with Claude)
546
+ return { type: "image", mimeType: mt, data: buf.toString("base64") };
547
+ }
548
+ catch {
549
+ return null;
550
+ }
551
+ }
552
+ /**
553
+ * Preflight: resolve + spawn `grok agent` and perform the ACP `initialize`
554
+ * handshake. Fails fast with a clear reject so a missing binary surfaces
555
+ * immediately instead of being retried as a dropped session. Idempotent —
556
+ * reuses the live client. NOTE: the actual login (Google OAuth) is verified
557
+ * lazily at `session/new` (ACP returns auth_required there) — see run() — since
558
+ * ACP has no pre-session account probe; flagged in the PR body.
559
+ */
560
+ async prepare() {
561
+ if (this.disposed)
562
+ throw new Error("grok backend is closed.");
563
+ const direct = await this.resolveMode();
564
+ if (direct) {
565
+ await direct.prepare?.();
566
+ return;
567
+ }
568
+ if (this.client)
569
+ return;
570
+ const { cmd, args, useShell } = this.resolveSpawn();
571
+ const cwd = this.deps.cwd ?? process.cwd();
572
+ const client = new AcpClient(cmd, args, cwd, process.env, useShell);
573
+ // Publish the in-flight client BEFORE the startup awaits so a concurrent
574
+ // close() can find and kill it (P0-A).
575
+ this.preparingClient = client;
576
+ const abortIfDisposed = async () => {
577
+ if (!this.disposed)
578
+ return;
579
+ if (this.preparingClient === client)
580
+ this.preparingClient = null;
581
+ await client.close().catch(() => { });
582
+ throw new Error("grok backend was closed during prepare().");
583
+ };
584
+ try {
585
+ let init;
586
+ try {
587
+ init = await client.initialize({
588
+ name: "comfyui-mcp",
589
+ title: "comfyui-mcp panel",
590
+ version: "0.16.0",
591
+ });
592
+ }
593
+ catch (err) {
594
+ await client.close().catch(() => { });
595
+ throw new Error(`Could not start the Grok CLI in ACP mode (grok backend). Install the Grok CLI (Grok Build / xAI) and ensure \`grok\` is on PATH, then sign in with \`grok\`. Details: ${msgOf(err)}`);
596
+ }
597
+ await abortIfDisposed();
598
+ this.agentCaps = init.agentCapabilities;
599
+ this.authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
600
+ this.client = client;
601
+ // Record what the live child was spawned with so a later setModel can detect
602
+ // the model drifted and respawn (the model is spawn-pinned via --model) (P1).
603
+ this.spawnedModel = this.model;
604
+ logger.info(`[grok-backend] ACP ready (protocol ${init.protocolVersion ?? "?"}, agent ${init.agentInfo?.name ?? "grok"}${this.authMethods.length ? `, ${this.authMethods.length} auth method(s)` : ""})`);
605
+ }
606
+ finally {
607
+ if (this.preparingClient === client)
608
+ this.preparingClient = null;
609
+ }
610
+ }
611
+ /** Ensure a live ACP session exists, creating (session/new) or resuming
612
+ * (session/load) one. Handles an `auth_required` error from session/new by
613
+ * attempting a single `authenticate` with the first advertised method, then
614
+ * retrying — surfacing a clear sign-in message if it still fails. Returns the
615
+ * session id. */
616
+ async ensureSession(client, cwd, resumeId) {
617
+ const mcpServers = this.deps.mcpServers ? buildAcpMcpServers(this.deps.mcpServers) : [];
618
+ const canLoad = this.agentCaps?.loadSession === true;
619
+ // RESUME (session/load) — whole-session only (forkAtAnchor=false). Only if the
620
+ // agent advertised loadSession; otherwise fall through to a fresh session.
621
+ if (resumeId && canLoad) {
622
+ try {
623
+ await client.request("session/load", { sessionId: resumeId, cwd, mcpServers });
624
+ this.sessionId = resumeId;
625
+ this.needsSystemPreamble = false; // persona already delivered on the original first turn
626
+ return resumeId;
627
+ }
628
+ catch (err) {
629
+ logger.warn(`[grok-backend] session/load failed (${msgOf(err)}) — starting a fresh session`);
630
+ }
631
+ }
632
+ // NEW session, with one auth_required retry.
633
+ const createNew = async () => {
634
+ const res = await client.request("session/new", { cwd, mcpServers });
635
+ if (!res?.sessionId)
636
+ throw new Error("grok agent session/new returned no sessionId.");
637
+ return res.sessionId;
638
+ };
639
+ try {
640
+ this.sessionId = await createNew();
641
+ }
642
+ catch (err) {
643
+ if (this.isAuthRequired(err) && this.authMethods[0]?.id) {
644
+ // The CLI owns auth (Google OAuth). Try the first advertised method once;
645
+ // if the CLI isn't already signed in this cannot complete headlessly.
646
+ try {
647
+ await client.request("authenticate", { methodId: this.authMethods[0].id });
648
+ this.sessionId = await createNew();
649
+ }
650
+ catch {
651
+ throw new Error("Grok CLI is not signed in. Run `grok` once and complete the xAI sign-in, then reconnect.");
652
+ }
653
+ }
654
+ else if (this.isAuthRequired(err)) {
655
+ throw new Error("Grok CLI is not signed in. Run `grok` once and complete the xAI sign-in, then reconnect.");
656
+ }
657
+ else {
658
+ throw err;
659
+ }
660
+ }
661
+ this.needsSystemPreamble = !!this.deps.systemAppend; // fresh session → persona on first turn
662
+ return this.sessionId;
663
+ }
664
+ /** Does this error look like an ACP `auth_required`? Reads the JSON-RPC error
665
+ * data.reason carried by RpcError, falling back to the message text. */
666
+ isAuthRequired(err) {
667
+ if (err instanceof RpcError) {
668
+ const data = err.data;
669
+ if (data?.reason === "auth_required")
670
+ return true;
671
+ }
672
+ return /auth.?required|authenticat|not.*(logged|signed).*in/i.test(msgOf(err));
673
+ }
674
+ /**
675
+ * Open/continue an ACP session and yield canonical AgentEvents. The user
676
+ * channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
677
+ * neutral batch becomes a `session/prompt`, whose streamed session/update
678
+ * notifications are normalized to AgentEvents, and only after the prompt
679
+ * resolves (stopReason) do we read the next batch (the channel async-iteration
680
+ * IS the turn-gate).
681
+ */
682
+ async *run(opts) {
683
+ const direct = await this.resolveMode();
684
+ if (direct) {
685
+ yield* direct.run(opts);
686
+ return;
687
+ }
688
+ // MODEL PRECEDENCE (P1): apply the panel-selected model BEFORE prepare() so the
689
+ // FIRST spawn uses it (the model is spawn-pinned via `--model`; preparing first
690
+ // would spawn the wrong model). PanelAgent.start() usually passes opts.model =
691
+ // the CLAUDE panel model, which is NOT a valid Gemini model — so the configured
692
+ // Gemini model (deps.model, from COMFYUI_MCP_GEMINI_MODEL) wins; only honor
693
+ // opts.model when it actually looks like a Gemini model (e.g. the user picked
694
+ // one in the panel, which arrives as opts.model on a fresh spawn).
695
+ if (opts.model && isGrokModel(opts.model))
696
+ this.model = opts.model;
697
+ await this.prepare();
698
+ if (!this.client)
699
+ throw new Error("grok agent not initialized");
700
+ const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
701
+ // forkAtAnchor is false → ignore opts.rewindAnchor; whole-session resume only.
702
+ const resumeId = opts.resume ?? opts.sessionId ?? null;
703
+ let sessionId = await this.ensureSession(this.client, cwd, resumeId);
704
+ // The session id is our session id (PanelAgent persists it for resume).
705
+ yield {
706
+ type: "session",
707
+ sessionId,
708
+ ...(this.model ? { model: this.model } : {}),
709
+ };
710
+ // Process the neutral channel one turn at a time.
711
+ for await (const turn of opts.channel) {
712
+ // LIVE MODEL SWITCH (P1): PanelAgent treats setModel as live and does NOT
713
+ // restart run() for a model-only change, so the persistent loop adopts it
714
+ // here. The model is spawn-pinned, so a switch means respawning the CLI with
715
+ // the new --model — which necessarily starts a FRESH session (a model swap
716
+ // can't carry the old session forward). Done transparently before the turn;
717
+ // we emit a new `session` event so PanelAgent persists the new id.
718
+ if (this.spawnedModel !== this.model) {
719
+ await this.respawnForModelChange();
720
+ if (!this.client)
721
+ throw new Error("grok agent respawn failed");
722
+ sessionId = await this.ensureSession(this.client, cwd, null);
723
+ yield {
724
+ type: "session",
725
+ sessionId,
726
+ ...(this.model ? { model: this.model } : {}),
727
+ };
728
+ }
729
+ yield* this.runTurn(this.client, turn, opts.onActivity);
730
+ }
731
+ }
732
+ /** Tear down the live `grok agent` child (process-tree kill) and re-spawn it
733
+ * with the current `this.model`'s `--model` flag, so a live setModel takes
734
+ * effect. The model is spawn-pinned, so this is the only way to switch it. The
735
+ * caller then opens a fresh session on the new child. */
736
+ async respawnForModelChange() {
737
+ const old = this.client;
738
+ this.client = null;
739
+ this.sessionId = null;
740
+ if (old) {
741
+ old.notificationHandler = null;
742
+ await old.close().catch(() => { });
743
+ }
744
+ this.spawnSpec = null; // force resolveSpawn to rebuild argv with the new --model
745
+ logger.info(`[grok-backend] model switch → respawning grok agent with --model ${this.model ?? "(default)"}`);
746
+ await this.prepare(); // spawns with this.model; records spawnedModel
747
+ }
748
+ /** Run ONE turn: send session/prompt + stream its session/update notifications →
749
+ * AgentEvents, resolving when the prompt request returns a stopReason, OR when
750
+ * the child exits mid-turn (never deadlock). ACP's prompt request IS the turn
751
+ * boundary, so — unlike Codex — there is no separate completion notification and
752
+ * no turn-id buffering: the sessionId is known before the prompt is sent. */
753
+ async *runTurn(client, turn, onActivity) {
754
+ const sessionId = this.sessionId;
755
+ // Event queue bridging the push-based notification handler to this pull-based
756
+ // async generator (identical pattern to codex-backend).
757
+ const queue = [];
758
+ let wake = null;
759
+ let done = false;
760
+ const push = (ev) => {
761
+ queue.push(ev);
762
+ wake?.();
763
+ wake = null;
764
+ };
765
+ const finish = () => {
766
+ done = true;
767
+ wake?.();
768
+ wake = null;
769
+ };
770
+ // Accumulate the assistant reply text across agent_message_chunk so we can emit
771
+ // ONE authoritative `assistant` commit when the turn ends (ACP has no separate
772
+ // final-message notification). messageId (when present) groups the deltas + the
773
+ // commit under one bubble id, mirroring the Claude/Codex stream reconciliation.
774
+ let assistantText = "";
775
+ let messageId = null;
776
+ // Stream bubble state (reasoning vs reply each open/close their own stream).
777
+ let streamOpen = false;
778
+ let streamKind = null;
779
+ const openStream = (id, kind) => {
780
+ if (streamOpen && streamKind === kind)
781
+ return;
782
+ if (streamOpen)
783
+ push({ type: "stream_end" }); // switch kinds → close the old one
784
+ streamOpen = true;
785
+ streamKind = kind;
786
+ push({ type: "stream_start", id });
787
+ };
788
+ const closeStream = () => {
789
+ if (streamOpen) {
790
+ push({ type: "stream_end" });
791
+ streamOpen = false;
792
+ streamKind = null;
793
+ }
794
+ };
795
+ // EXACTLY ONE terminal `result` (PanelAgent's turn-gate only advances on a
796
+ // result; a missing one parks the channel forever). This idempotent helper
797
+ // emits an `error` + `{result, ok:false}` and finishes; no-op once a result
798
+ // has fired (so the prompt rejection AND the exit watcher can both call it).
799
+ let finishedResult = false;
800
+ const emitTerminalError = (message) => {
801
+ if (finishedResult)
802
+ return;
803
+ finishedResult = true;
804
+ closeStream();
805
+ push({ type: "error", message });
806
+ push({ type: "result", ok: false, subtype: "error" });
807
+ finish();
808
+ };
809
+ let interrupted = false;
810
+ // tool_call carries the title/kind; tool_call_update (ACP) repeats only the
811
+ // toolCallId — so remember each call's display name to label its end event.
812
+ const toolNames = new Map();
813
+ // Normalize ONE session/update notification into canonical AgentEvents.
814
+ const apply = (msg) => {
815
+ if (finishedResult)
816
+ return;
817
+ const params = (msg.params ?? {});
818
+ // Only our session's updates.
819
+ if (params.sessionId && params.sessionId !== sessionId)
820
+ return;
821
+ const update = (params.update ?? {});
822
+ const kind = update.sessionUpdate;
823
+ switch (kind) {
824
+ case "agent_message_chunk": {
825
+ const content = update.content;
826
+ const text = content?.type === "text" ? content.text : undefined;
827
+ const id = update.messageId ?? null;
828
+ if (typeof text === "string" && text) {
829
+ if (id)
830
+ messageId = id;
831
+ openStream(messageId, "text");
832
+ assistantText += text;
833
+ push({ type: "assistant_delta", text });
834
+ }
835
+ break;
836
+ }
837
+ case "agent_thought_chunk": {
838
+ // Extended-thinking streaming. Open a reasoning stream on the FIRST delta
839
+ // so PanelAgent (which drops assistant_delta when no stream is open)
840
+ // renders early thinking, mirroring codex P2-1.
841
+ const content = update.content;
842
+ const text = content?.type === "text" ? content.text : undefined;
843
+ if (typeof text === "string" && text) {
844
+ openStream(messageId, "thinking");
845
+ push({ type: "assistant_delta", text, thinking: true });
846
+ }
847
+ break;
848
+ }
849
+ case "tool_call": {
850
+ // A tool call was requested — emit tool_call(start) for panel visibility.
851
+ const id = update.toolCallId;
852
+ const name = update.title ||
853
+ update.kind ||
854
+ id ||
855
+ "tool";
856
+ if (id)
857
+ toolNames.set(id, name);
858
+ push({ type: "tool_call", name, phase: "start", detail: update });
859
+ break;
860
+ }
861
+ case "tool_call_update": {
862
+ // Progress + completion of a tool call. Emit tool_call(end) only on a
863
+ // TERMINAL status; intermediate in_progress updates just keep the
864
+ // watchdog armed (onActivity already fired for them). ACP's update
865
+ // repeats only the toolCallId, so reuse the remembered title for the name.
866
+ const status = update.status;
867
+ if (status === "completed" || status === "failed") {
868
+ const id = update.toolCallId;
869
+ const name = update.title ||
870
+ (id ? toolNames.get(id) : undefined) ||
871
+ update.kind ||
872
+ id ||
873
+ "tool";
874
+ push({ type: "tool_call", name, phase: "end", detail: update });
875
+ }
876
+ break;
877
+ }
878
+ // plan / available_commands_update / session_info_update / current_mode_update
879
+ // carry no AgentEvent — onActivity (below) already re-armed the watchdog.
880
+ default:
881
+ break;
882
+ }
883
+ };
884
+ const prev = client.notificationHandler;
885
+ client.notificationHandler = (msg) => {
886
+ // LIVENESS: ANY notification while this turn is in flight means the agent is
887
+ // alive — fire onActivity BEFORE filtering/translating so even updates that
888
+ // produce no AgentEvent (a long MCP tool call mid-generation) keep
889
+ // PanelAgent's idle watchdog armed. A genuine zero-event freeze never
890
+ // reaches here, so the real freeze-catch is preserved.
891
+ try {
892
+ onActivity?.();
893
+ }
894
+ catch {
895
+ // a watchdog bump must never break the protocol reader
896
+ }
897
+ if (msg.method === "session/update")
898
+ apply(msg);
899
+ else
900
+ prev?.(msg); // anything else (other methods) → pass through
901
+ };
902
+ // Watch for the child dying mid-turn: end the turn with a terminal result so
903
+ // the local drain is woken instead of waiting forever. emitTerminalError is a
904
+ // no-op if a result already fired, so it's safe alongside the prompt rejection.
905
+ void client.exitPromise.then(() => {
906
+ if (done)
907
+ return;
908
+ emitTerminalError(client.exitError ? msgOf(client.exitError) : "grok agent connection closed.");
909
+ });
910
+ // FIRST-TURN PERSONA: ACP session/new has no instructions field, so the panel
911
+ // system prompt is prepended to the first turn's prompt as a clearly-marked
912
+ // system/context preamble (later turns send plain text). Mirrors codex.
913
+ let turnText = turn.text;
914
+ if (this.needsSystemPreamble && this.deps.systemAppend) {
915
+ turnText =
916
+ `<system>\n${this.deps.systemAppend}\n</system>\n\n` +
917
+ `The user's first message follows.\n\n${turn.text}`;
918
+ this.needsSystemPreamble = false;
919
+ }
920
+ // Build the prompt ContentBlock[]: the text block first (preserves prompt
921
+ // context), then any resolved inline base64 image blocks (vision parity).
922
+ // Images are only attached when the agent advertised promptCapabilities.image
923
+ // (default-allow when the capability is unknown).
924
+ const prompt = [{ type: "text", text: turnText }];
925
+ const imagesAllowed = this.agentCaps?.promptCapabilities?.image !== false;
926
+ if (imagesAllowed) {
927
+ for (const ref of turn.images ?? []) {
928
+ const block = await this.fetchImageBlock(ref);
929
+ if (block)
930
+ prompt.push(block);
931
+ }
932
+ }
933
+ try {
934
+ // session/prompt is a REQUEST that RESOLVES with a stopReason at turn end.
935
+ client
936
+ .request("session/prompt", { sessionId, prompt })
937
+ .then((res) => {
938
+ if (finishedResult)
939
+ return;
940
+ finishedResult = true;
941
+ closeStream();
942
+ const stop = res?.stopReason;
943
+ // Commit the accumulated assistant text (if any) as the authoritative
944
+ // turn-ending message — no per-turn rewind anchor (forkAtAnchor=false).
945
+ const text = assistantText.trim();
946
+ if (text)
947
+ push({ type: "assistant", text, ...(messageId ? { id: messageId } : {}) });
948
+ // end_turn / max_tokens / max_turn_requests = a real completion; cancelled
949
+ // (user interrupt) and refusal are not "ok".
950
+ const ok = !!stop && stop !== "cancelled" && stop !== "refusal";
951
+ push({ type: "result", ok, ...(stop ? { subtype: stop } : {}) });
952
+ finish();
953
+ })
954
+ .catch((err) => {
955
+ // A failed prompt ends the turn. When the child dies mid-turn handleExit
956
+ // rejects this BEFORE exitPromise resolves, so this .catch runs first —
957
+ // it MUST end with a terminal result (the idempotent helper guarantees
958
+ // exactly one), or the exit watcher then sees done and hangs the gate.
959
+ if (interrupted)
960
+ emitTerminalError("grok turn interrupted.");
961
+ else
962
+ emitTerminalError(msgOf(err));
963
+ });
964
+ // Drain the bridged queue until the turn completes.
965
+ while (true) {
966
+ while (queue.length) {
967
+ yield queue.shift();
968
+ }
969
+ if (done)
970
+ break;
971
+ await new Promise((resolve) => {
972
+ wake = resolve;
973
+ });
974
+ }
975
+ while (queue.length)
976
+ yield queue.shift();
977
+ }
978
+ finally {
979
+ // Mark interrupted so a late prompt rejection doesn't surface a spurious
980
+ // error after teardown.
981
+ interrupted = true;
982
+ // Restore the prior handler ONLY if it's still ours (close() may have nulled
983
+ // it during shutdown — don't resurrect a stale handler onto a dead client).
984
+ if (client.notificationHandler && !client.exitError)
985
+ client.notificationHandler = prev ?? null;
986
+ }
987
+ }
988
+ /** Stop the current turn without ending the session → `session/cancel`
989
+ * (notification). The in-flight session/prompt then resolves with
990
+ * stopReason:"cancelled", which the run-turn path turns into a terminal result. */
991
+ async interrupt() {
992
+ const direct = await this.resolveMode();
993
+ if (direct) {
994
+ await direct.interrupt();
995
+ return;
996
+ }
997
+ const client = this.client;
998
+ if (!client || !this.sessionId)
999
+ return;
1000
+ try {
1001
+ client.notify("session/cancel", { sessionId: this.sessionId });
1002
+ }
1003
+ catch (err) {
1004
+ logger.debug(`[grok-backend] interrupt: ${msgOf(err)}`);
1005
+ }
1006
+ }
1007
+ /** Switch the model live. ACP pins the model at spawn (`--model`), so this can't
1008
+ * reconfigure a running child — instead it marks the model dirty (this.model !=
1009
+ * this.spawnedModel) and invalidates the cached spawn spec. The persistent run()
1010
+ * loop then RESPAWNS the `grok agent` CLI with the new --model (and a fresh
1011
+ * session) transparently before the next turn (see run()'s live-switch branch).
1012
+ * If the backend hasn't spawned yet, the first prepare() simply uses the new
1013
+ * model. Ignores non-Gemini ids (PanelAgent may pass the Claude panel model). */
1014
+ async setModel(model) {
1015
+ const direct = await this.resolveMode();
1016
+ if (direct) {
1017
+ await direct.setModel?.(model);
1018
+ return;
1019
+ }
1020
+ if (!isGrokModel(model))
1021
+ return;
1022
+ this.model = model;
1023
+ this.spawnSpec = null; // next spawn rebuilds argv with the new --model
1024
+ }
1025
+ /**
1026
+ * Model enumeration. In direct-token mode this delegates to GrokDirectBackend
1027
+ * (a live GET /v1/models probe). In ACP mode, ACP exposes no model catalog, so
1028
+ * we surface a static set (the current Grok CLI composer family); the panel
1029
+ * picker degrades gracefully on an empty list. No effort metadata (Grok's ACP
1030
+ * mode has no discrete effort scale) → the panel hides the effort dropdown.
1031
+ */
1032
+ async listModels() {
1033
+ const direct = await this.resolveMode();
1034
+ if (direct)
1035
+ return direct.listModels();
1036
+ return GROK_MODELS;
1037
+ }
1038
+ /** Permanently dispose of the backend (AgentBackend.close): kill the gemini
1039
+ * process TREE (Windows shell-fallback grandchild included), remove listeners,
1040
+ * null the client. Idempotent + safe when never prepared. Mirrors codex (P0-1):
1041
+ * interrupt() is a no-op when idle, so without this the child is orphaned. */
1042
+ async close() {
1043
+ this.disposed = true; // tripwire FIRST (an in-flight prepare() bails) (P0-A)
1044
+ const direct = await this.resolveMode();
1045
+ if (direct) {
1046
+ await direct.close?.();
1047
+ return;
1048
+ }
1049
+ const client = this.client;
1050
+ const preparing = this.preparingClient;
1051
+ this.client = null;
1052
+ this.preparingClient = null;
1053
+ this.sessionId = null;
1054
+ if (client) {
1055
+ client.notificationHandler = null;
1056
+ await client.close().catch(() => { });
1057
+ }
1058
+ if (preparing && preparing !== client) {
1059
+ preparing.notificationHandler = null;
1060
+ await preparing.close().catch(() => { });
1061
+ }
1062
+ }
1063
+ }
1064
+ // ---------------------------------------------------------------------------
1065
+ // Direct-token Grok (xAI) backend — Task 6 of the in-panel OAuth plan.
1066
+ //
1067
+ // Hits `https://api.x.ai/v1/responses` with the OAuth bearer resolved from
1068
+ // `~/.grok/auth.json` (resolveGrokOAuth), instead of driving the Grok CLI over
1069
+ // ACP. This reuses the SAME Responses-style SSE adapter SHAPE as
1070
+ // chatgpt-oauth-backend.ts's Codex adapter (prior research judged xAI's public
1071
+ // API to be codex_responses-adapter compatible) and the SAME 6-tool router as
1072
+ // Ollama/ChatGPT (OllamaBackend.dispatch/connectTools/buildModelTools) — a
1073
+ // small model-agnostic HTTP endpoint has no agent harness of its own, so this
1074
+ // backend owns the whole tool loop the way Ollama/ChatGPT already do.
1075
+ //
1076
+ // DIVERGENCES from the Codex adapter (xAI is NOT a re-skinned ChatGPT):
1077
+ // - No `chatgpt-account-id` header — that's OpenAI/ChatGPT account-routing
1078
+ // plumbing with no xAI equivalent; the bearer token alone identifies the
1079
+ // account.
1080
+ // - Every outbound request is host-allowlist-checked (assertAllowedTokenHost
1081
+ // against OAUTH_PROVIDERS.grok.apiHostAllowlist, i.e. `x.ai`/`*.x.ai`)
1082
+ // before the bearer is attached, and any error response body is REDACTED
1083
+ // (redactTokens, from oauth-flow.ts — the single shared redactor) before
1084
+ // it can reach a thrown message or a log line — the access token itself
1085
+ // is never logged anywhere in this path.
1086
+ // - Model slug + exact endpoint sub-path are UNVERIFIED against the live xAI
1087
+ // API (no network access at authoring time, and no Task-1 research
1088
+ // artifact survived for this session to consult). GROK_XAI_DEFAULT_MODEL
1089
+ // reuses the ACP CLI's existing composer alias as a placeholder rather
1090
+ // than inventing a new model string; listModels() prefers a live
1091
+ // `GET /v1/models` probe over any hardcoded catalog. CONFIRM both against
1092
+ // xAI's docs (or override via COMFYUI_MCP_GROK_XAI_MODEL) before relying
1093
+ // on this path in production — see the task-6 report for the flagged risk.
1094
+ // ---------------------------------------------------------------------------
1095
+ export const GROK_XAI_API_BASE = "https://api.x.ai/v1";
1096
+ // The exact Responses sub-path is UNVERIFIED against the live xAI API (Task 8's
1097
+ // smoke test confirms it). Made overridable via COMFYUI_MCP_GROK_XAI_RESPONSES_URL
1098
+ // so if the real path differs it's a config flip, not a code change — whatever URL
1099
+ // is used still passes assertAllowedTokenHost (x.ai/*.x.ai) before any bearer.
1100
+ export const GROK_XAI_RESPONSES_URL = process.env.COMFYUI_MCP_GROK_XAI_RESPONSES_URL?.trim() || `${GROK_XAI_API_BASE}/responses`;
1101
+ const GROK_XAI_MODELS_URL = `${GROK_XAI_API_BASE}/models`;
1102
+ const GROK_MAX_TOOL_ROUNDS = 32;
1103
+ /** `x.ai` / `*.x.ai` — the same allowlist the OAuth engine already enforces for
1104
+ * the Grok token endpoint (oauth-flow.ts), reused here for the DATA API calls. */
1105
+ function grokApiHostAllowlist() {
1106
+ return OAUTH_PROVIDERS.grok?.apiHostAllowlist ?? ["x.ai"];
1107
+ }
1108
+ // UNVERIFIED against the live xAI model catalog (see the divergences note
1109
+ // above) — reuses the ACP CLI's default composer alias as a safe, non-invented
1110
+ // placeholder rather than guessing a raw-API model slug. Override with
1111
+ // COMFYUI_MCP_GROK_XAI_MODEL once confirmed, or rely on listModels()'s live
1112
+ // /v1/models probe to surface the account's real slugs.
1113
+ export const GROK_XAI_DEFAULT_MODEL = process.env.COMFYUI_MCP_GROK_XAI_MODEL?.trim() || GROK_DEFAULT_MODEL;
1114
+ export const GROK_XAI_SYSTEM_PROMPT = [
1115
+ "You are the ComfyUI agent in a sidebar panel. Answer in Markdown.",
1116
+ "",
1117
+ "You have exactly six tools:",
1118
+ "- list_tools / describe_tool / call_tool — headless ComfyUI server.",
1119
+ "- panel_list_tools / panel_describe_tool / panel_call_tool — live canvas.",
1120
+ "",
1121
+ "Describe a tool before its first call. Finish tasks by running tools, not inventing results.",
1122
+ ].join("\n");
1123
+ /** OpenAI-tool-def → Responses `tools[]` shape (identical to Codex's toResponsesTools). */
1124
+ function grokToolsToResponses(tools) {
1125
+ return tools.map((t) => {
1126
+ const fn = (t.function ?? t);
1127
+ return {
1128
+ type: "function",
1129
+ name: fn.name,
1130
+ description: fn.description ?? "",
1131
+ parameters: fn.parameters ?? { type: "object", properties: {} },
1132
+ };
1133
+ });
1134
+ }
1135
+ /** In-memory turn history → Responses `input[]` items (identical shaping to
1136
+ * Codex's historyToCodexInput — same Responses `input` schema). */
1137
+ function grokHistoryToResponsesInput(messages) {
1138
+ const items = [];
1139
+ for (const m of messages) {
1140
+ if (m.role === "user") {
1141
+ items.push({
1142
+ type: "message",
1143
+ role: "user",
1144
+ content: [{ type: "input_text", text: m.content }],
1145
+ });
1146
+ continue;
1147
+ }
1148
+ if (m.role === "assistant") {
1149
+ if (m.tool_calls?.length) {
1150
+ for (const tc of m.tool_calls) {
1151
+ items.push({
1152
+ type: "function_call",
1153
+ call_id: tc.id,
1154
+ name: tc.name,
1155
+ arguments: tc.arguments || "{}",
1156
+ });
1157
+ }
1158
+ }
1159
+ if (m.content.trim()) {
1160
+ items.push({
1161
+ type: "message",
1162
+ role: "assistant",
1163
+ content: [{ type: "output_text", text: m.content }],
1164
+ });
1165
+ }
1166
+ continue;
1167
+ }
1168
+ if (m.role === "tool" && m.tool_call_id) {
1169
+ items.push({
1170
+ type: "function_call_output",
1171
+ call_id: m.tool_call_id,
1172
+ output: m.content,
1173
+ });
1174
+ }
1175
+ }
1176
+ return items;
1177
+ }
1178
+ const GROK_DIRECT_CAPABILITIES = {
1179
+ persistentChannel: true,
1180
+ streamingDeltas: true,
1181
+ interruptMidTurn: true,
1182
+ forkAtAnchor: false,
1183
+ inProcessMcp: false,
1184
+ modelEnumeration: true, // live GET /v1/models probe
1185
+ slashCommands: false,
1186
+ hooks: false,
1187
+ vision: false, // the 6-tool router is text-only (mirrors Ollama/ChatGPT); NeutralTurn.images unused here
1188
+ };
1189
+ /** Direct-token Grok (xAI) backend — see the module-level comment above for the
1190
+ * full rationale and the divergences from the Codex adapter it mirrors. */
1191
+ export class GrokDirectBackend extends OllamaBackend {
1192
+ id = "grok";
1193
+ capabilities = GROK_DIRECT_CAPABILITIES;
1194
+ accessToken = "";
1195
+ grokTurnHistory = [];
1196
+ grokSessionId = null;
1197
+ resolveOAuth;
1198
+ /** Credentials `GrokBackend.resolveMode()` ALREADY resolved to pick the mode,
1199
+ * handed straight to prepare() so the initial preflight does NOT re-probe
1200
+ * `resolveGrokOAuth` (single-probe on the success path). Consumed once, then
1201
+ * cleared; `resolveOAuth` remains for any future mid-session re-resolve. */
1202
+ initialCredentials;
1203
+ constructor(deps = {}) {
1204
+ super({
1205
+ cwd: deps.cwd,
1206
+ comfyuiUrl: deps.comfyuiUrl,
1207
+ mcpServers: deps.mcpServers,
1208
+ systemAppend: deps.systemAppend,
1209
+ backendId: "grok",
1210
+ api: "openai",
1211
+ host: "https://unused", // never dialed — every request goes straight to GROK_XAI_RESPONSES_URL
1212
+ model: deps.model ?? GROK_XAI_DEFAULT_MODEL,
1213
+ });
1214
+ this.model = deps.model ?? GROK_XAI_DEFAULT_MODEL;
1215
+ this.resolveOAuth = deps.resolveGrokOAuth ?? resolveGrokOAuth;
1216
+ this.initialCredentials = deps.initialCredentials;
1217
+ }
1218
+ async prepare() {
1219
+ if (this.disposed)
1220
+ throw new Error("grok backend is closed.");
1221
+ if (this.prepared)
1222
+ return;
1223
+ // Prefer the credentials the facade already resolved to pick this mode — the
1224
+ // initial preflight must NOT re-probe resolveGrokOAuth (single-probe on the
1225
+ // success path). Only re-resolve if none were threaded in (e.g. a direct
1226
+ // instantiation in a test or a future caller).
1227
+ const creds = this.initialCredentials ?? (await this.resolveOAuth());
1228
+ this.initialCredentials = undefined; // consumed
1229
+ this.accessToken = creds.accessToken;
1230
+ await this.connectTools();
1231
+ this.prepared = true;
1232
+ logger.info(`[grok-backend] ready (direct xAI OAuth, model ${this.model}, ${this.comfyTools.length} comfyui meta-tools, ${this.panelTools.length} panel tools behind the router)`);
1233
+ }
1234
+ async *xaiResponsesStream(instructions, input, tools, signal, onActivity) {
1235
+ assertAllowedTokenHost(GROK_XAI_RESPONSES_URL, grokApiHostAllowlist());
1236
+ const keepalive = onActivity ? setInterval(onActivity, 5000) : null;
1237
+ let res;
1238
+ try {
1239
+ res = await fetch(GROK_XAI_RESPONSES_URL, {
1240
+ method: "POST",
1241
+ headers: {
1242
+ "content-type": "application/json",
1243
+ accept: "text/event-stream",
1244
+ authorization: `Bearer ${this.accessToken}`,
1245
+ },
1246
+ body: JSON.stringify({
1247
+ model: this.model,
1248
+ instructions,
1249
+ input,
1250
+ tools: grokToolsToResponses(tools),
1251
+ stream: true,
1252
+ store: false,
1253
+ }),
1254
+ signal,
1255
+ });
1256
+ }
1257
+ finally {
1258
+ if (keepalive)
1259
+ clearInterval(keepalive);
1260
+ }
1261
+ if (!res.ok || !res.body) {
1262
+ const bodyText = await res.text().catch(() => "");
1263
+ throw new Error(`xAI Responses http ${res.status}: ${redactTokens(bodyText).slice(0, 400)}`);
1264
+ }
1265
+ let content = "";
1266
+ const partial = new Map();
1267
+ let usage;
1268
+ let streamOpen = false;
1269
+ const streamId = randomUUID();
1270
+ let buffer = "";
1271
+ const reader = res.body.getReader();
1272
+ const decoder = new TextDecoder();
1273
+ for (;;) {
1274
+ const { done, value } = await reader.read();
1275
+ if (done)
1276
+ break;
1277
+ onActivity?.();
1278
+ buffer += decoder.decode(value, { stream: true });
1279
+ let sep;
1280
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
1281
+ const block = buffer.slice(0, sep);
1282
+ buffer = buffer.slice(sep + 2);
1283
+ let eventType = "";
1284
+ let data = "";
1285
+ for (const line of block.split("\n")) {
1286
+ if (line.startsWith("event:"))
1287
+ eventType = line.slice(6).trim();
1288
+ else if (line.startsWith("data:"))
1289
+ data += line.slice(5).trim();
1290
+ }
1291
+ if (!data || data === "[DONE]")
1292
+ continue;
1293
+ let payload;
1294
+ try {
1295
+ payload = JSON.parse(data);
1296
+ }
1297
+ catch {
1298
+ continue;
1299
+ }
1300
+ const type = eventType || String(payload.type ?? "");
1301
+ if (type === "response.output_text.delta") {
1302
+ const delta = String(payload.delta ?? "");
1303
+ if (delta) {
1304
+ if (!streamOpen) {
1305
+ streamOpen = true;
1306
+ yield { type: "stream_start", id: streamId };
1307
+ }
1308
+ content += delta;
1309
+ yield { type: "assistant_delta", text: delta };
1310
+ }
1311
+ }
1312
+ if (type === "response.function_call_arguments.delta") {
1313
+ const itemId = String(payload.item_id ?? payload.call_id ?? "call");
1314
+ const slot = partial.get(itemId) ?? { id: itemId, name: "", args: "" };
1315
+ if (payload.name)
1316
+ slot.name = String(payload.name);
1317
+ if (payload.arguments)
1318
+ slot.args += String(payload.arguments);
1319
+ partial.set(itemId, slot);
1320
+ }
1321
+ if (type === "response.output_item.done") {
1322
+ const item = (payload.item ?? payload);
1323
+ if (item.type === "function_call") {
1324
+ const id = String(item.call_id ?? item.id ?? randomUUID());
1325
+ partial.set(id, {
1326
+ id,
1327
+ name: String(item.name ?? ""),
1328
+ args: String(item.arguments ?? "{}"),
1329
+ });
1330
+ }
1331
+ }
1332
+ if (type === "response.completed") {
1333
+ const u = payload.response?.usage ??
1334
+ payload.usage;
1335
+ if (u) {
1336
+ usage = {
1337
+ input_tokens: Number(u.input_tokens ?? 0),
1338
+ output_tokens: Number(u.output_tokens ?? 0),
1339
+ };
1340
+ }
1341
+ }
1342
+ }
1343
+ }
1344
+ if (streamOpen)
1345
+ yield { type: "stream_end" };
1346
+ const toolCalls = [...partial.values()]
1347
+ .filter((t) => t.name)
1348
+ .map((t) => ({ id: t.id, name: t.name, arguments: t.args || "{}" }));
1349
+ return { content, toolCalls, usage, streamId: streamOpen ? streamId : null };
1350
+ }
1351
+ async *run(opts) {
1352
+ await this.prepare();
1353
+ if (opts.model && isGrokModel(opts.model))
1354
+ this.model = opts.model;
1355
+ const fresh = !this.grokSessionId || (opts.resume && opts.resume !== this.grokSessionId);
1356
+ this.grokSessionId = opts.resume ?? this.grokSessionId ?? `grok-${randomUUID()}`;
1357
+ if (fresh)
1358
+ this.grokTurnHistory = [];
1359
+ yield { type: "session", sessionId: this.grokSessionId, model: this.model };
1360
+ // NOTE: upstream (MichaelDanCurtis fork) routes this through the editable
1361
+ // prompt registry (services/prompt-overrides). That registry is not ported
1362
+ // here, so the built-in prompt is used directly.
1363
+ const instructions = [GROK_XAI_SYSTEM_PROMPT, this.deps.systemAppend]
1364
+ .filter(Boolean)
1365
+ .join("\n\n");
1366
+ for await (const turn of opts.channel) {
1367
+ yield* this.runGrokTurn(turn, instructions, opts);
1368
+ }
1369
+ }
1370
+ async *runGrokTurn(turn, instructions, opts) {
1371
+ const abort = new AbortController();
1372
+ this.turnAbort = abort;
1373
+ const tools = this.buildModelTools();
1374
+ this.grokTurnHistory.push({ role: "user", content: turn.text });
1375
+ let resultEmitted = false;
1376
+ try {
1377
+ for (let round = 0; round < GROK_MAX_TOOL_ROUNDS; round++) {
1378
+ const stream = this.xaiResponsesStream(instructions, grokHistoryToResponsesInput(this.grokTurnHistory), tools, abort.signal, opts.onActivity);
1379
+ let content = "";
1380
+ let toolCalls = [];
1381
+ let usage;
1382
+ let streamId = null;
1383
+ for (;;) {
1384
+ const r = await stream.next();
1385
+ if (r.done) {
1386
+ ({ content, toolCalls, usage, streamId } = r.value);
1387
+ break;
1388
+ }
1389
+ yield r.value;
1390
+ }
1391
+ if (!toolCalls.length) {
1392
+ this.grokTurnHistory.push({ role: "assistant", content });
1393
+ yield { type: "assistant", text: content, id: streamId ?? undefined, usage };
1394
+ yield { type: "result", ok: true, usage };
1395
+ resultEmitted = true;
1396
+ return;
1397
+ }
1398
+ this.grokTurnHistory.push({ role: "assistant", content, tool_calls: toolCalls });
1399
+ for (const tc of toolCalls) {
1400
+ if (abort.signal.aborted)
1401
+ throw new Error("interrupted");
1402
+ yield { type: "tool_call", name: tc.name, phase: "start", detail: tc.arguments };
1403
+ const { text, isError } = await this.dispatch(tc.name, tc.arguments);
1404
+ opts.onActivity?.();
1405
+ yield { type: "tool_call", name: tc.name, phase: "end", detail: { isError } };
1406
+ this.grokTurnHistory.push({
1407
+ role: "tool",
1408
+ tool_call_id: tc.id,
1409
+ content: text.slice(0, 16000),
1410
+ });
1411
+ }
1412
+ }
1413
+ yield {
1414
+ type: "assistant",
1415
+ text: "(stopped: too many tool rounds in one turn — ask me to continue)",
1416
+ };
1417
+ yield { type: "result", ok: false, subtype: "max_tool_rounds" };
1418
+ resultEmitted = true;
1419
+ }
1420
+ catch (err) {
1421
+ const interrupted = abort.signal.aborted;
1422
+ if (!interrupted) {
1423
+ logger.warn(`[grok-backend] direct-token turn failed: ${msgOf(err)}`);
1424
+ yield { type: "error", message: `grok backend: ${msgOf(err)}` };
1425
+ yield {
1426
+ type: "assistant",
1427
+ text: `⚠️ The model request failed: ${msgOf(err).slice(0, 400)}`,
1428
+ };
1429
+ }
1430
+ if (!resultEmitted) {
1431
+ yield { type: "result", ok: false, subtype: interrupted ? "interrupted" : "error" };
1432
+ }
1433
+ }
1434
+ finally {
1435
+ if (this.turnAbort === abort)
1436
+ this.turnAbort = null;
1437
+ }
1438
+ }
1439
+ async setModel(model) {
1440
+ if (isGrokModel(model))
1441
+ this.model = model;
1442
+ }
1443
+ /** Prefer a live account probe over any hardcoded catalog (the exact xAI
1444
+ * model slugs are unverified — see the module-level comment). Falls back to
1445
+ * just the configured model id if the probe fails (offline, wrong scope, …). */
1446
+ async listModels() {
1447
+ try {
1448
+ assertAllowedTokenHost(GROK_XAI_MODELS_URL, grokApiHostAllowlist());
1449
+ const res = await fetch(GROK_XAI_MODELS_URL, {
1450
+ headers: { authorization: `Bearer ${this.accessToken}` },
1451
+ signal: AbortSignal.timeout(8000),
1452
+ });
1453
+ if (!res.ok)
1454
+ return [{ id: this.model, label: this.model }];
1455
+ const data = (await res.json());
1456
+ const ids = (data.data ?? []).map((m) => m.id).filter((id) => !!id);
1457
+ if (!ids.length)
1458
+ return [{ id: this.model, label: this.model }];
1459
+ const rest = ids.filter((id) => id !== this.model).slice(0, 40);
1460
+ return [this.model, ...rest].map((id) => ({ id, label: id }));
1461
+ }
1462
+ catch {
1463
+ return [{ id: this.model, label: this.model }];
1464
+ }
1465
+ }
1466
+ }
1467
+ // Expose the default model id for the orchestrator wiring (COMFYUI_MCP_GEMINI_MODEL
1468
+ // fallback) without duplicating the literal.
1469
+ export { GROK_DEFAULT_MODEL };
1470
+ //# sourceMappingURL=grok-backend.js.map