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.
- package/README.md +17 -1
- package/dist/config.js +27 -1
- package/dist/config.js.map +1 -1
- package/dist/orchestrator/agent-backend.js +42 -0
- package/dist/orchestrator/agent-backend.js.map +1 -1
- package/dist/orchestrator/ai-proposer.js +106 -0
- package/dist/orchestrator/ai-proposer.js.map +1 -0
- package/dist/orchestrator/backend-readiness.js +78 -1
- package/dist/orchestrator/backend-readiness.js.map +1 -1
- package/dist/orchestrator/chatgpt-oauth-backend.js +349 -0
- package/dist/orchestrator/chatgpt-oauth-backend.js.map +1 -0
- package/dist/orchestrator/claude-backend.js +8 -0
- package/dist/orchestrator/claude-backend.js.map +1 -1
- package/dist/orchestrator/copilot-backend.js +265 -0
- package/dist/orchestrator/copilot-backend.js.map +1 -0
- package/dist/orchestrator/glm-backend.js +20 -0
- package/dist/orchestrator/glm-backend.js.map +1 -0
- package/dist/orchestrator/grok-backend.js +1470 -0
- package/dist/orchestrator/grok-backend.js.map +1 -0
- package/dist/orchestrator/history.js +181 -0
- package/dist/orchestrator/history.js.map +1 -0
- package/dist/orchestrator/index.js +620 -138
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/kimi-backend.js +26 -0
- package/dist/orchestrator/kimi-backend.js.map +1 -0
- package/dist/orchestrator/oauth-bridge.js +92 -0
- package/dist/orchestrator/oauth-bridge.js.map +1 -0
- package/dist/orchestrator/oauth-bridge.test.js +129 -0
- package/dist/orchestrator/oauth-bridge.test.js.map +1 -0
- package/dist/orchestrator/ollama-backend.js +71 -8
- package/dist/orchestrator/ollama-backend.js.map +1 -1
- package/dist/orchestrator/panel-agent.js +25 -0
- package/dist/orchestrator/panel-agent.js.map +1 -1
- package/dist/orchestrator/panel-console-http.js +470 -0
- package/dist/orchestrator/panel-console-http.js.map +1 -0
- package/dist/orchestrator/panel-mcp-http.js +2 -2
- package/dist/orchestrator/panel-mcp-http.js.map +1 -1
- package/dist/orchestrator/panel-tools.js +71 -7
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/a2ui-spec.js +207 -0
- package/dist/services/a2ui-spec.js.map +1 -0
- package/dist/services/a2ui-spec.test.js +174 -0
- package/dist/services/a2ui-spec.test.js.map +1 -0
- package/dist/services/civitai-lookup.js +5 -0
- package/dist/services/civitai-lookup.js.map +1 -1
- package/dist/services/civitai-resolver.js +160 -3
- package/dist/services/civitai-resolver.js.map +1 -1
- package/dist/services/code-provider-auth-redact.test.js +83 -0
- package/dist/services/code-provider-auth-redact.test.js.map +1 -0
- package/dist/services/code-provider-auth.js +561 -0
- package/dist/services/code-provider-auth.js.map +1 -0
- package/dist/services/comfy-cli.js +276 -0
- package/dist/services/comfy-cli.js.map +1 -0
- package/dist/services/download-cache.js +7 -1
- package/dist/services/download-cache.js.map +1 -1
- package/dist/services/lora-catalog.js +329 -0
- package/dist/services/lora-catalog.js.map +1 -0
- package/dist/services/model-resolver.js +65 -4
- package/dist/services/model-resolver.js.map +1 -1
- package/dist/services/node-management.js +36 -53
- package/dist/services/node-management.js.map +1 -1
- package/dist/services/oauth-flow.js +314 -0
- package/dist/services/oauth-flow.js.map +1 -0
- package/dist/services/oauth-flow.test.js +179 -0
- package/dist/services/oauth-flow.test.js.map +1 -0
- package/dist/services/oauth-landing.test.js +45 -0
- package/dist/services/oauth-landing.test.js.map +1 -0
- package/dist/services/panel-secrets.js +132 -1
- package/dist/services/panel-secrets.js.map +1 -1
- package/dist/services/prompt-overrides.js +103 -0
- package/dist/services/prompt-overrides.js.map +1 -0
- package/dist/services/ui-bridge.js +52 -2
- package/dist/services/ui-bridge.js.map +1 -1
- package/dist/services/workflow-health.js +13 -0
- package/dist/services/workflow-health.js.map +1 -1
- package/dist/services/workflow-target-store.js +64 -0
- package/dist/services/workflow-target-store.js.map +1 -0
- package/dist/tools/comfy-cli.js +257 -0
- package/dist/tools/comfy-cli.js.map +1 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/model-explorer.js +114 -0
- package/dist/tools/model-explorer.js.map +1 -0
- package/dist/tools/model-extras.js +146 -3
- package/dist/tools/model-extras.js.map +1 -1
- package/dist/tools/model-management.js +11 -2
- package/dist/tools/model-management.js.map +1 -1
- package/dist/tools/node-management.js +21 -10
- package/dist/tools/node-management.js.map +1 -1
- package/dist/tools/prompt-director.js +32 -0
- package/dist/tools/prompt-director.js.map +1 -0
- package/dist/tools/workflow-compose.js +67 -5
- package/dist/tools/workflow-compose.js.map +1 -1
- package/dist/utils/html-to-markdown.js +86 -0
- package/dist/utils/html-to-markdown.js.map +1 -0
- package/package.json +1 -1
- package/packs/ltx23-distill-3stage/install-runpod.sh +58 -0
- package/packs/ltx23-distill-3stage/install-windows.bat +63 -0
- package/packs/ltx23-distill-3stage/manifest.yaml +63 -0
- package/packs/ltx23-distill-3stage/pack.yaml +37 -0
- package/packs/ltx23-distill-3stage/workflow.json +906 -0
- package/plugin/.mcp.json +4 -8
- package/plugin/hooks/vram-check.mjs +52 -9
- package/plugin/skills/civitai/SKILL.md +52 -78
- package/plugin/skills/comfyui-core/SKILL.md +2 -2
- package/plugin/skills/local-llm-free/SKILL.md +2 -2
- package/scripts/check-pack-models.mjs +11 -1
- package/scripts/gen-tool-docs.ts +2 -0
- package/scripts/mock-panel.mjs +92 -52
- package/scripts/runpod-release.mjs +18 -2
- 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
|