comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
1,518 lines • 73.3 kB
JavaScript
// Grok (xAI) backend — the provider-specific adapter behind the AgentBackend
// port, driving the Grok CLI over its **ACP (Agent Client Protocol)** mode
// (`grok agent stdio`), a JSON-RPC 2.0 client over stdio. This is a faithful
// MIRROR of grok-backend.ts: same self-contained line-framed JSON-RPC client,
// same per-turn event-queue bridge, same terminal-result invariant, same
// Windows/POSIX process-tree kill.
//
// PanelAgent keeps all provider-agnostic orchestration (queue, turn-gate, bridge
// push, self-restart) and drives this backend via
// `for await (const ev of backend.run({...}))`. See
// design/agent-backend-injection.md.
//
// PROTOCOL MAPPING (AgentBackend ↔ ACP, per agentclientprotocol.com + the
// Gemini CLI docs/cli/acp-mode.md):
// - prepare() = spawn `grok agent` + `initialize` handshake
// - session = an ACP SESSION (`session/new` new | `session/load` resume)
// - run() loop turn = `session/prompt` (ONE request per neutral channel batch);
// the request RESOLVES with a `stopReason` at turn end
// (unlike Codex, where turn/start returns immediately
// and a separate turn/completed notification ends it).
// Images ride inline as base64 `image` ContentBlocks.
// - assistant_delta ← `session/update` { update.sessionUpdate:"agent_message_chunk",
// content:{type:"text",text} }
// - assistant_delta(th) ← `session/update` { ...:"agent_thought_chunk", ... } (thinking)
// - assistant (commit) ← the accumulated agent_message_chunk text, emitted once
// when the session/prompt request resolves (ACP has no
// separate "final message" notification — the chunks ARE
// the message; the prompt response is the turn boundary)
// - tool_call(start) ← `session/update` { ...:"tool_call", toolCallId, title, kind }
// - tool_call(end) ← `session/update` { ...:"tool_call_update", status:
// "completed"|"failed" }
// - result ← the `session/prompt` response { stopReason }
// - error ← a failed `session/prompt` / the child dying mid-turn
// - interrupt() → `session/cancel` (notification); the in-flight prompt
// then resolves with stopReason:"cancelled"
// - listModels() ← a static catalog (gemini-2.5-pro / -flash) — ACP exposes
// no model enumeration; the model is selected at SPAWN via
// the CLI `--model` flag (see resolveBin/spawn below)
//
// AUTH (NO API KEY — the CLI owns auth): Gemini CLI authenticates itself via
// Google OAuth / Code Assist (the user runs `gemini` once to sign in). This
// backend NEVER passes an API key; it just spawns the already-authenticated CLI.
// If the CLI is signed out, `session/new` returns an `auth_required` error — we
// attempt one `authenticate` with the first advertised auth method, then surface
// a clear "run `gemini` and sign in" message (the OAuth browser flow itself is
// owned by the CLI and cannot be completed headlessly).
//
// PARITY with Codex/Claude: the Gemini backend gets the SAME tool surface — the
// headless `comfyui` stdio MCP plus the `panel` HTTP MCP for live-graph panel_*
// tools — declared to `session/new` as ACP McpServers. The panel system prompt
// is prepended to the FIRST turn's prompt (ACP `session/new` has no system /
// instructions field, mirroring the Codex app-server's thread/start).
//
// ASSUMPTIONS we could NOT verify without the live `gemini` CLI (flagged inline,
// see also the PR body): the exact ACP McpServer http variant shape, the
// session/load resume semantics, the auth_required retry, and live model
// switching (we set the model at spawn via --model since ACP exposes no standard
// per-session model setter). Each is the closest faithful mapping to the
// documented ACP spec.
import { spawn, spawnSync } from "node:child_process";
import readline from "node:readline";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { logger } from "../utils/logger.js";
import { errorText, promptText } from "./error-text.js";
import { buildAgentSpawnEnv } from "../services/panel-secrets.js";
import { GROK_CAPABILITIES, stampTurn, } from "./agent-backend.js";
import { resolveGrokOAuth, } from "../services/code-provider-auth.js";
import { OAUTH_PROVIDERS, assertAllowedTokenHost, grokTokenFile, redactTokens } from "../services/oauth-flow.js";
import { OllamaBackend } from "./ollama-backend.js";
import { asRateLimitError, sendWithRateLimitRetry } from "./rate-limit.js";
function msgOf(err) {
return errorText(err);
}
/**
* Kill an entire process tree, not just the direct child. On the Windows
* PATH/shell fallback the direct child is a cmd.exe/`.cmd` shim whose grandchild
* is the real `gemini` node process — killing only the shell leaves the tree
* alive. Use `taskkill /T /F`. On POSIX, signal the process group (negative pid)
* so a shell + its child both die, falling back to the single pid. Best-effort +
* swallows errors: it runs during teardown and must never throw into the host.
* (Identical to codex-backend's killProcessTree — same spawn posture.)
*/
function killProcessTree(pid) {
if (!Number.isFinite(pid))
return;
const p = pid;
if (process.platform === "win32") {
try {
spawnSync("taskkill", ["/PID", String(p), "/T", "/F"], { windowsHide: true });
}
catch {
try {
process.kill(p);
}
catch {
// already gone
}
}
return;
}
try {
process.kill(-p, "SIGTERM"); // process group (we spawn detached on POSIX)
}
catch {
try {
process.kill(p, "SIGTERM");
}
catch {
// already gone
}
}
}
/** An Error carrying the JSON-RPC error `data` so the auth_required reason
* survives the request rejection (used to drive the authenticate retry). */
class RpcError extends Error {
code;
data;
constructor(message, code, data) {
super(message);
this.code = code;
this.data = data;
}
}
class AcpClient {
cmd;
args;
cwd;
env;
useShell;
proc = null;
rl = null;
pending = new Map();
nextId = 1;
closed = false;
exitResolved = false;
/** The error that ended the connection (null = clean exit). */
exitError = null;
stderr = "";
notificationHandler = null;
resolveExit;
/** Resolves when the `gemini` process exits or errors — runTurn() races its
* per-turn drain against this so a child that dies mid-prompt never deadlocks
* the turn forever (mirrors codex P0-2). */
exitPromise;
constructor(cmd, args, cwd, env, useShell) {
this.cmd = cmd;
this.args = args;
this.cwd = cwd;
this.env = env;
this.useShell = useShell;
this.exitPromise = new Promise((resolve) => {
this.resolveExit = resolve;
});
}
/** Spawn `grok agent` and perform the ACP `initialize` handshake. Returns the
* agent's initialize result (capabilities + authMethods). NOTE: ACP has NO
* `initialized` notification (that's MCP, not ACP) — initialize is a plain
* request/response, after which we go straight to session/new. */
async initialize(clientInfo) {
this.proc = spawn(this.cmd, this.args, {
cwd: this.cwd,
env: this.env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
shell: this.useShell,
// POSIX: own process group so close() can kill the whole tree with one
// negative-pid signal. Windows uses taskkill /T instead.
detached: process.platform !== "win32",
});
this.proc.stdout.setEncoding("utf8");
this.proc.stderr.setEncoding("utf8");
this.proc.stderr.on("data", (chunk) => {
this.stderr += chunk;
});
// Route pipe errors (EPIPE when the child dies mid-turn) through handleExit so
// the turn rejects cleanly instead of crashing the host as an uncaught error.
this.proc.stdin.on("error", (error) => this.handleExit(error));
this.proc.stdout.on("error", (error) => this.handleExit(error));
this.proc.on("error", (error) => this.handleExit(error));
this.proc.on("exit", (code, signal) => {
const detail = code === 0
? null
: new Error(`grok agent exited unexpectedly (${signal ? `signal ${signal}` : `exit ${code}`}).${this.stderr ? ` ${this.stderr.trim().split(/\r?\n/).slice(-2).join(" ")}` : ""}`);
this.handleExit(detail);
});
this.rl = readline.createInterface({ input: this.proc.stdout });
this.rl.on("line", (line) => this.handleLine(line));
// ACP initialize: negotiate protocol version + advertise client capabilities.
// We do NOT implement the client fs/terminal methods, so advertise them false
// (the agent then won't issue fs/read_text_file, terminal/*, etc.).
const result = await this.request("initialize", {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
},
clientInfo,
});
return result;
}
request(method, params) {
if (this.closed)
return Promise.reject(new Error("grok agent client is closed."));
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve: resolve, reject, method });
this.send({ id, method, params });
});
}
notify(method, params = {}) {
if (this.closed)
return;
// Fire-and-forget: a write failure (dead child) must not throw into the caller.
try {
this.send({ method, params });
}
catch {
// connection gone; pending requests already rejected via handleExit
}
}
send(message) {
const stdin = this.proc?.stdin;
if (!stdin || stdin.destroyed || stdin.writableEnded) {
this.handleExit(this.exitError ?? new Error("grok agent stdin is not available."));
throw this.exitError ?? new Error("grok agent stdin is not available.");
}
// ACP is strict JSON-RPC 2.0 — every outbound frame carries `jsonrpc:"2.0"`.
const framed = { jsonrpc: "2.0", ...message };
try {
stdin.write(`${JSON.stringify(framed)}\n`);
}
catch (err) {
this.handleExit(err instanceof Error ? err : new Error(String(err)));
throw err;
}
}
/**
* The auto-approve RESULT for a server→client request, or null if it isn't one
* we should auto-grant. The panel agent is an ISOLATED background agent (same
* posture as Claude's bypassPermissions / Codex's auto-approve), so we grant
* tool-permission requests to keep the live-graph work flowing.
*
* ACP permission flow: the agent sends `session/request_permission`
* ({ sessionId, toolCall, options:[{ optionId, name, kind }] }) and expects
* { outcome: { outcome:"selected", optionId } }. We pick the most-permissive
* "allow" option (allow_always > allow_once); if none is offered we cancel.
*/
autoApproveResult(msg) {
if (msg.method !== "session/request_permission")
return null;
const params = (msg.params ?? {});
const options = Array.isArray(params.options) ? params.options : [];
const pick = options.find((o) => o.kind === "allow_always") ??
options.find((o) => o.kind === "allow_once") ??
// Fall back to any option whose id/kind reads as an allow.
options.find((o) => /allow/i.test(o.kind ?? "") || /allow/i.test(o.optionId ?? ""));
if (pick?.optionId) {
return { outcome: { outcome: "selected", optionId: pick.optionId } };
}
// No allow option offered → decline gracefully so the agent moves on.
return { outcome: { outcome: "cancelled" } };
}
handleLine(line) {
if (!line.trim())
return;
let message;
try {
message = JSON.parse(line);
}
catch (error) {
this.handleExit(new Error(`Failed to parse grok agent JSONL: ${msgOf(error)}`));
return;
}
// Server→client request (id + method). Auto-approve permission prompts; reply
// method-not-found to anything else so the protocol keeps moving (we declared
// no fs/terminal client capabilities, so those shouldn't arrive).
if (message.id !== undefined && message.method) {
const result = this.autoApproveResult(message);
if (result) {
logger.debug(`[grok-backend] auto-approving server request ${message.method}`);
this.send({ id: message.id, result });
}
else {
logger.debug(`[grok-backend] unsupported server request ${message.method} — replying method-not-found`);
this.send({
id: message.id,
error: { code: -32601, message: `Unsupported server request: ${message.method}` },
});
}
return;
}
// Response to one of our requests.
if (message.id !== undefined) {
const p = this.pending.get(message.id);
if (!p)
return;
this.pending.delete(message.id);
if (message.error) {
p.reject(new RpcError(message.error.message ?? `grok agent ${p.method} failed.`, message.error.code, message.error.data));
}
else {
p.resolve(message.result ?? {});
}
return;
}
// Notification.
if (message.method)
this.notificationHandler?.(message);
}
handleExit(error) {
if (this.exitResolved)
return;
this.exitResolved = true;
this.exitError = error;
for (const p of this.pending.values())
p.reject(error ?? new Error("grok agent connection closed."));
this.pending.clear();
this.resolveExit();
}
async close() {
if (this.closed) {
await this.exitPromise;
return;
}
this.closed = true;
this.notificationHandler = null;
this.rl?.close();
this.rl = null;
if (this.proc && this.proc.exitCode === null) {
try {
this.proc.stdin.end();
}
catch {
// already gone
}
const proc = this.proc;
// Give a graceful stdin-EOF shutdown a beat, then KILL THE WHOLE TREE — on
// the Windows shell fallback the direct child is a shim whose grandchild is
// the real gemini node process, so proc.kill() alone would orphan it.
setTimeout(() => {
if (proc.exitCode === null)
killProcessTree(proc.pid);
}, 50).unref?.();
}
await this.exitPromise;
this.proc = null;
}
}
// ---- model catalog ----
// ACP exposes no model enumeration, and the model is fixed at SPAWN via the CLI
// `--model` flag — so we surface a static catalog matching the current Grok CLI.
// Grok's reasoning control is not exposed as a discrete effort scale here, so we do
// NOT advertise supportsEffort/supportedEffortLevels: the panel's normalizeModels
// then hides the effort dropdown (omission is the documented "no effort control"
// signal). grok-4.6 is the current CLI default: xAI shipped it on 2026-08-12 and
// Grok Build now defaults to it. Verified against docs.x.ai/developers/grok-4-6 on
// 2026-08-14 — it is a real id in BOTH namespaces this file spans (the CLI's
// `--model` flag and the raw xAI `/v1` API), which matters because
// GROK_DEFAULT_MODEL below also seeds GROK_XAI_DEFAULT_MODEL on the direct-token
// path. The older ids stay listed so a session pinned to one keeps resolving.
const GROK_MODELS = [
{ id: "grok-4.6", label: "Grok 4.6" },
{ id: "grok-4.5", label: "Grok 4.5" },
{ id: "grok-composer-2.5-fast", label: "Grok Composer 2.5 Fast" },
{ id: "grok-build", label: "Grok Build" },
];
const GROK_DEFAULT_MODEL = "grok-4.6";
/** Does this id look like a Grok model (vs. the Claude panel model PanelAgent
* unconditionally passes as opts.model)? Used so the configured Grok model
* wins — mirrors codex-backend's isCodexModel guard (P1-1). */
function isGrokModel(id) {
return /^grok[-/]/i.test(id);
}
/**
* Convert our MCP server specs into the ACP `session/new` `mcpServers` array.
* ACP stdio McpServer: { name, command, args, env:[{name,value}] }. Streamable
* HTTP MCP uses the SSE variant ({ type:"sse", name, url, headers:[] }) — the
* live Grok/Gemini CLIs reject { type:"http" } with Invalid params.
*/
export function buildAcpMcpServers(servers) {
const out = [];
for (const [name, spec] of Object.entries(servers)) {
if (spec.transport === "stdio") {
out.push({
name,
command: spec.command,
args: spec.args ?? [],
env: Object.entries(spec.env ?? {}).map(([k, v]) => ({ name: k, value: v })),
});
}
else {
out.push({ type: "sse", name, url: spec.url, headers: [] });
}
}
return out;
}
/**
* The Gemini CLI ACP adapter. One instance per PanelAgent; it holds the live ACP
* client + current session id and re-opens on each `run()`.
*
* DIRECT-TOKEN SELECTION (Task 6): this class is also the public facade used
* everywhere (`new GrokBackend(deps)`, wired in orchestrator/index.ts). On the
* FIRST call to any AgentBackend method it decides — once, cached for the life of
* this instance — whether `~/.grok/auth.json` holds a usable OAuth token
* (`resolveGrokOAuth`, mirroring `resolveOpenAICodexOAuth`'s resolve+refresh). If
* so, EVERY call delegates to an internal `GrokDirectBackend` (a Responses-style
* HTTP adapter hitting `https://api.x.ai/v1`, reusing the same 6-tool router as
* Ollama/ChatGPT — see below). If the token file is absent, unreadable, or its
* refresh fails, the decision is "no" and this class's OWN ACP/CLI body below
* runs exactly as before — NO behavior change on that path. The Grok CLI thus
* becomes optional: only needed when no in-panel OAuth sign-in has happened.
*/
export class GrokBackend {
id = "grok";
deps;
/** Memoized mode decision: resolves to a live GrokDirectBackend when a usable
* OAuth token was found, or `null` to mean "use this class's own ACP body".
* Decided at most once per instance (see the class doc above). */
modePromise = null;
/** Synchronous mirror of the resolved mode for the `capabilities` getter:
* `undefined` = not yet decided, a backend = direct mode, `null` = ACP mode.
* Set by resolveMode() the instant its probe settles. */
resolvedDirect = undefined;
client = null;
/** The client an in-flight prepare() is spinning up, tracked so a concurrent
* close() can tear it down before it's published (P0-A). */
preparingClient = null;
/** Set once close() runs — a tripwire so an in-flight prepare() disposes its
* local client instead of publishing it (P0-A). */
disposed = false;
/** Cached resolved spawn command/args/shell (set in prepare()). */
spawnSpec = null;
/** The live ACP session id — used for session/prompt + session/cancel. */
sessionId = null;
/** The model requested for new sessions (applied at SPAWN via --model). */
model;
/** The model the LIVE `grok agent` child was actually spawned with. Gemini
* pins the model at spawn, so when this drifts from `this.model` (a live
* setModel) the run loop respawns the CLI before the next turn (P1). */
spawnedModel;
/** Capabilities the agent advertised at initialize (loadSession / image / http). */
agentCaps = undefined;
authMethods = [];
/** True until the panel system prompt has been prepended to a turn. Reset
* whenever a NEW session starts (run()). */
needsSystemPreamble = false;
constructor(deps = {}) {
this.deps = deps;
this.model = deps.model;
}
/**
* Capabilities MUST reflect the mode that will actually serve turns — never
* advertise one this backend won't honor (over-reporting `vision` would make a
* caller send images the direct 6-tool-router path silently drops = data loss).
* `capabilities` is a synchronous `readonly` on the AgentBackend port, but the
* mode is resolved lazily/async — so:
* - once the mode is KNOWN → report the real descriptor (direct = vision:false,
* ACP = the full GROK_CAPABILITIES incl. vision:true).
* - while UNKNOWN but the direct path is REACHABLE (a `~/.grok/auth.json`
* exists, or a resolveGrokOAuth seam is injected) → report conservatively
* with `vision:false`. Under-reporting is safe (the panel just won't offer
* images); over-reporting is not.
* - while UNKNOWN and the direct path is NOT reachable (no token file) → the
* ACP path is the only possibility, so report its full capabilities.
* The xAI vision contract is unverified (Task 8), so the direct path never
* claims vision until that's confirmed. */
get capabilities() {
if (this.resolvedDirect !== undefined) {
return this.resolvedDirect ? this.resolvedDirect.capabilities : GROK_CAPABILITIES;
}
const directReachable = !!this.deps.resolveGrokOAuth || existsSync(grokTokenFile);
return directReachable ? { ...GROK_CAPABILITIES, vision: false, audio: false } : GROK_CAPABILITIES;
}
/**
* Decide ONCE (memoized) whether a direct OAuth token is usable: probes
* `resolveGrokOAuth` (or the injected test seam) and, on success, builds the
* `GrokDirectBackend` that every AgentBackend method below will delegate to.
* On ANY failure (no `~/.grok/auth.json`, unreadable, expired-with-no-refresh,
* refresh network error, wrong-shape file) it caches `null`, meaning "fall
* back to this class's own ACP/CLI body" — the probe itself does no spawning
* and no network call beyond the token endpoint, so a missing/foreign
* `~/.grok/auth.json` (e.g. the Grok CLI's OWN native login, a different JSON
* shape) is indistinguishable from "not signed in" and correctly falls back.
*/
resolveMode() {
if (!this.modePromise) {
const resolveOAuth = this.deps.resolveGrokOAuth ?? resolveGrokOAuth;
this.modePromise = resolveOAuth()
.then((creds) => {
// Thread the ALREADY-resolved credentials into the direct backend so its
// prepare() reuses them instead of probing resolveGrokOAuth a second time
// (single-probe on the success path — the task's explicit requirement).
const direct = new GrokDirectBackend({
cwd: this.deps.cwd,
model: this.deps.model,
comfyuiUrl: this.deps.comfyuiUrl,
mcpServers: this.deps.mcpServers,
systemAppend: this.deps.systemAppend,
resolveGrokOAuth: this.deps.resolveGrokOAuth,
initialCredentials: creds,
});
this.resolvedDirect = direct;
return direct;
})
.catch(() => {
this.resolvedDirect = null;
return null;
});
}
return this.modePromise;
}
/**
* Resolve how to spawn `grok agent --always-approve stdio`. The Grok CLI must
* be on PATH (installed via Grok Build / xAI). The `--model` flag pins the
* model at spawn (ACP has no standard per-session model setter). On Windows,
* `grok` may resolve to a `.cmd` shim — spawn with a shell when needed.
*/
resolveSpawn() {
if (this.spawnSpec)
return this.spawnSpec;
const modelArgs = this.model ? ["--model", this.model] : [];
const cmd = "grok";
const args = ["agent", ...modelArgs, "--always-approve", "stdio"];
const useShell = process.platform === "win32";
this.spawnSpec = { cmd, args, useShell };
return this.spawnSpec;
}
/**
* Fetch a ComfyUI image (/view) and return it as an ACP base64 `image`
* ContentBlock ({ type:"image", mimeType, data }) — or null on any failure (the
* text reference still names the image as a fallback). Unlike Codex (which
* spills to a temp file for its path-based localImage item), ACP takes inline
* base64, so this mirrors ClaudeBackend.fetchImageBlock exactly (only the key
* names differ: ACP uses `mimeType`/`data`).
*/
async fetchImageBlock(ref) {
if (!this.deps.comfyuiUrl || !ref?.filename)
return null;
try {
const u = new URL("/view", this.deps.comfyuiUrl);
u.searchParams.set("filename", ref.filename);
u.searchParams.set("type", ref.type || "input");
if (ref.subfolder)
u.searchParams.set("subfolder", ref.subfolder);
const res = await fetch(u, { signal: AbortSignal.timeout(15000) });
if (!res.ok)
return null;
let mt = (res.headers.get("content-type") || "").split(";")[0].trim().toLowerCase();
if (!["image/png", "image/jpeg", "image/gif", "image/webp"].includes(mt)) {
mt = "image/png"; // ComfyUI outputs are PNG by default
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length > 12 * 1024 * 1024)
return null; // keep context sane (parity with Claude)
return { type: "image", mimeType: mt, data: buf.toString("base64") };
}
catch {
return null;
}
}
/**
* Preflight: resolve + spawn `grok agent` and perform the ACP `initialize`
* handshake. Fails fast with a clear reject so a missing binary surfaces
* immediately instead of being retried as a dropped session. Idempotent —
* reuses the live client. NOTE: the actual login (Google OAuth) is verified
* lazily at `session/new` (ACP returns auth_required there) — see run() — since
* ACP has no pre-session account probe; flagged in the PR body.
*/
async prepare() {
if (this.disposed)
throw new Error("grok backend is closed.");
const direct = await this.resolveMode();
if (direct) {
await direct.prepare?.();
return;
}
if (this.client)
return;
const { cmd, args, useShell } = this.resolveSpawn();
const cwd = this.deps.cwd ?? process.cwd();
// SECURITY: spawn with the agent env — process.env MINUS tool-only secrets
// (RunPod/HF/CivitAI… tokens; they belong only to the comfyui tool child).
const client = new AcpClient(cmd, args, cwd, buildAgentSpawnEnv(), useShell);
// Publish the in-flight client BEFORE the startup awaits so a concurrent
// close() can find and kill it (P0-A).
this.preparingClient = client;
const abortIfDisposed = async () => {
if (!this.disposed)
return;
if (this.preparingClient === client)
this.preparingClient = null;
await client.close().catch(() => { });
throw new Error("grok backend was closed during prepare().");
};
try {
let init;
try {
init = await client.initialize({
name: "comfyui-mcp",
title: "comfyui-mcp panel",
version: "0.16.0",
});
}
catch (err) {
await client.close().catch(() => { });
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)}`);
}
await abortIfDisposed();
this.agentCaps = init.agentCapabilities;
this.authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
this.client = client;
// Record what the live child was spawned with so a later setModel can detect
// the model drifted and respawn (the model is spawn-pinned via --model) (P1).
this.spawnedModel = this.model;
logger.info(`[grok-backend] ACP ready (protocol ${init.protocolVersion ?? "?"}, agent ${init.agentInfo?.name ?? "grok"}${this.authMethods.length ? `, ${this.authMethods.length} auth method(s)` : ""})`);
}
finally {
if (this.preparingClient === client)
this.preparingClient = null;
}
}
/** Ensure a live ACP session exists, creating (session/new) or resuming
* (session/load) one. Handles an `auth_required` error from session/new by
* attempting a single `authenticate` with the first advertised method, then
* retrying — surfacing a clear sign-in message if it still fails. Returns the
* session id. */
async ensureSession(client, cwd, resumeId) {
const mcpServers = this.deps.mcpServers ? buildAcpMcpServers(this.deps.mcpServers) : [];
const canLoad = this.agentCaps?.loadSession === true;
// RESUME (session/load) — whole-session only (forkAtAnchor=false). Only if the
// agent advertised loadSession; otherwise fall through to a fresh session.
if (resumeId && canLoad) {
try {
await client.request("session/load", { sessionId: resumeId, cwd, mcpServers });
this.sessionId = resumeId;
this.needsSystemPreamble = false; // persona already delivered on the original first turn
return resumeId;
}
catch (err) {
logger.warn(`[grok-backend] session/load failed (${msgOf(err)}) — starting a fresh session`);
}
}
// NEW session, with one auth_required retry.
const createNew = async () => {
const res = await client.request("session/new", { cwd, mcpServers });
if (!res?.sessionId)
throw new Error("grok agent session/new returned no sessionId.");
return res.sessionId;
};
try {
this.sessionId = await createNew();
}
catch (err) {
if (this.isAuthRequired(err) && this.authMethods[0]?.id) {
// The CLI owns auth (Google OAuth). Try the first advertised method once;
// if the CLI isn't already signed in this cannot complete headlessly.
try {
await client.request("authenticate", { methodId: this.authMethods[0].id });
this.sessionId = await createNew();
}
catch {
throw new Error("Grok CLI is not signed in. Run `grok` once and complete the xAI sign-in, then reconnect.");
}
}
else if (this.isAuthRequired(err)) {
throw new Error("Grok CLI is not signed in. Run `grok` once and complete the xAI sign-in, then reconnect.");
}
else {
throw err;
}
}
this.needsSystemPreamble = !!this.deps.systemAppend; // fresh session → persona on first turn
return this.sessionId;
}
/** Does this error look like an ACP `auth_required`? Reads the JSON-RPC error
* data.reason carried by RpcError, falling back to the message text. */
isAuthRequired(err) {
if (err instanceof RpcError) {
const data = err.data;
if (data?.reason === "auth_required")
return true;
}
return /auth.?required|authenticat|not.*(logged|signed).*in/i.test(msgOf(err));
}
/**
* Open/continue an ACP session and yield canonical AgentEvents. The user
* channel (PanelAgent's gated queue) is consumed ONE turn at a time: each
* neutral batch becomes a `session/prompt`, whose streamed session/update
* notifications are normalized to AgentEvents, and only after the prompt
* resolves (stopReason) do we read the next batch (the channel async-iteration
* IS the turn-gate).
*/
async *run(opts) {
const direct = await this.resolveMode();
if (direct) {
yield* direct.run(opts);
return;
}
// MODEL PRECEDENCE (P1): apply the panel-selected model BEFORE prepare() so the
// FIRST spawn uses it (the model is spawn-pinned via `--model`; preparing first
// would spawn the wrong model). PanelAgent.start() usually passes opts.model =
// the CLAUDE panel model, which is NOT a valid Gemini model — so the configured
// Gemini model (deps.model, from COMFYUI_MCP_GEMINI_MODEL) wins; only honor
// opts.model when it actually looks like a Gemini model (e.g. the user picked
// one in the panel, which arrives as opts.model on a fresh spawn).
if (opts.model && isGrokModel(opts.model))
this.model = opts.model;
await this.prepare();
if (!this.client)
throw new Error("grok agent not initialized");
const cwd = opts.cwd ?? this.deps.cwd ?? process.cwd();
// forkAtAnchor is false → ignore opts.rewindAnchor; whole-session resume only.
const resumeId = opts.resume ?? opts.sessionId ?? null;
let sessionId = await this.ensureSession(this.client, cwd, resumeId);
// The session id is our session id (PanelAgent persists it for resume).
yield {
type: "session",
sessionId,
...(this.model ? { model: this.model } : {}),
};
// Process the neutral channel one turn at a time.
let turnSeq = 0;
for await (const turn of opts.channel) {
// LIVE MODEL SWITCH (P1): PanelAgent treats setModel as live and does NOT
// restart run() for a model-only change, so the persistent loop adopts it
// here. The model is spawn-pinned, so a switch means respawning the CLI with
// the new --model — which necessarily starts a FRESH session (a model swap
// can't carry the old session forward). Done transparently before the turn;
// we emit a new `session` event so PanelAgent persists the new id.
if (this.spawnedModel !== this.model) {
await this.respawnForModelChange();
if (!this.client)
throw new Error("grok agent respawn failed");
sessionId = await this.ensureSession(this.client, cwd, null);
yield {
type: "session",
sessionId,
...(this.model ? { model: this.model } : {}),
};
}
yield* stampTurn(this.runTurn(this.client, turn, opts.onActivity), ++turnSeq);
}
}
/** Tear down the live `grok agent` child (process-tree kill) and re-spawn it
* with the current `this.model`'s `--model` flag, so a live setModel takes
* effect. The model is spawn-pinned, so this is the only way to switch it. The
* caller then opens a fresh session on the new child. */
async respawnForModelChange() {
const old = this.client;
this.client = null;
this.sessionId = null;
if (old) {
old.notificationHandler = null;
await old.close().catch(() => { });
}
this.spawnSpec = null; // force resolveSpawn to rebuild argv with the new --model
logger.info(`[grok-backend] model switch → respawning grok agent with --model ${this.model ?? "(default)"}`);
await this.prepare(); // spawns with this.model; records spawnedModel
}
/** Run ONE turn: send session/prompt + stream its session/update notifications →
* AgentEvents, resolving when the prompt request returns a stopReason, OR when
* the child exits mid-turn (never deadlock). ACP's prompt request IS the turn
* boundary, so — unlike Codex — there is no separate completion notification and
* no turn-id buffering: the sessionId is known before the prompt is sent. */
async *runTurn(client, turn, onActivity) {
const sessionId = this.sessionId;
// Event queue bridging the push-based notification handler to this pull-based
// async generator (identical pattern to codex-backend).
const queue = [];
let wake = null;
let done = false;
const push = (ev) => {
queue.push(ev);
wake?.();
wake = null;
};
const finish = () => {
done = true;
wake?.();
wake = null;
};
// Accumulate the assistant reply text across agent_message_chunk so we can emit
// ONE authoritative `assistant` commit when the turn ends (ACP has no separate
// final-message notification). messageId (when present) groups the deltas + the
// commit under one bubble id, mirroring the Claude/Codex stream reconciliation.
let assistantText = "";
let messageId = null;
// Stream bubble state (reasoning vs reply each open/close their own stream).
let streamOpen = false;
let streamKind = null;
const openStream = (id, kind) => {
if (streamOpen && streamKind === kind)
return;
if (streamOpen)
push({ type: "stream_end" }); // switch kinds → close the old one
streamOpen = true;
streamKind = kind;
push({ type: "stream_start", id });
};
const closeStream = () => {
if (streamOpen) {
push({ type: "stream_end" });
streamOpen = false;
streamKind = null;
}
};
// EXACTLY ONE terminal `result` (PanelAgent's turn-gate only advances on a
// result; a missing one parks the channel forever). This idempotent helper
// emits an `error` + `{result, ok:false}` and finishes; no-op once a result
// has fired (so the prompt rejection AND the exit watcher can both call it).
let finishedResult = false;
const emitTerminalError = (message) => {
if (finishedResult)
return;
finishedResult = true;
closeStream();
push({ type: "error", message });
push({ type: "result", ok: false, subtype: "error" });
finish();
};
let interrupted = false;
// tool_call carries the title/kind; tool_call_update (ACP) repeats only the
// toolCallId — so remember each call's display name to label its end event.
const toolNames = new Map();
// Normalize ONE session/update notification into canonical AgentEvents.
const apply = (msg) => {
if (finishedResult)
return;
const params = (msg.params ?? {});
// Only our session's updates.
if (params.sessionId && params.sessionId !== sessionId)
return;
const update = (params.update ?? {});
const kind = update.sessionUpdate;
switch (kind) {
case "agent_message_chunk": {
const content = update.content;
const text = content?.type === "text" ? content.text : undefined;
const id = update.messageId ?? null;
if (typeof text === "string" && text) {
if (id)
messageId = id;
openStream(messageId, "text");
assistantText += text;
push({ type: "assistant_delta", text });
}
break;
}
case "agent_thought_chunk": {
// Extended-thinking streaming. Open a reasoning stream on the FIRST delta
// so PanelAgent (which drops assistant_delta when no stream is open)
// renders early thinking, mirroring codex P2-1.
const content = update.content;
const text = content?.type === "text" ? content.text : undefined;
if (typeof text === "string" && text) {
openStream(messageId, "thinking");
push({ type: "assistant_delta", text, thinking: true });
}
break;
}
case "tool_call": {
// A tool call was requested — emit tool_call(start) for panel visibility.
const id = update.toolCallId;
const name = update.title ||
update.kind ||
id ||
"tool";
if (id)
toolNames.set(id, name);
push({ type: "tool_call", name, phase: "start", detail: update });
break;
}
case "tool_call_update": {
// Progress + completion of a tool call. Emit tool_call(end) only on a
// TERMINAL status; intermediate in_progress updates just keep the
// watchdog armed (onActivity already fired for them). ACP's update
// repeats only the toolCallId, so reuse the remembered title for the name.
const status = update.status;
if (status === "completed" || status === "failed") {
const id = update.toolCallId;
const name = update.title ||
(id ? toolNames.get(id) : undefined) ||
update.kind ||
id ||
"tool";
push({ type: "tool_call", name, phase: "end", detail: update });
}
break;
}
// plan / available_commands_update / session_info_update / current_mode_update
// carry no AgentEvent — onActivity (below) already re-armed the watchdog.
default:
break;
}
};
const prev = client.notificationHandler;
client.notificationHandler = (msg) => {
// LIVENESS: ANY notification while this turn is in flight means the agent is
// alive — fire onActivity BEFORE filtering/translating so even updates that
// produce no AgentEvent (a long MCP tool call mid-generation) keep
// PanelAgent's idle watchdog armed. A genuine zero-event freeze never
// reaches here, so the real freeze-catch is preserved.
try {
onActivity?.();
}
catch {
// a watchdog bump must never break the protocol reader
}
if (msg.method === "session/update")
apply(msg);
else
prev?.(msg); // anything else (other methods) → pass through
};
// Watch for the child dying mid-turn: end the turn with a terminal result so
// the local drain is woken instead of waiting forever. emitTerminalError is a
// no-op if a result already fired, so it's safe alongside the prompt rejection.
void client.exitPromise.then(() => {
if (done)
return;
emitTerminalError(client.exitError ? msgOf(client.exitError) : "grok agent connection closed.");
});
// FIRST-TURN PERSONA: ACP session/new has no instructions field, so the panel
// system prompt is prepended to the first turn's prompt as a clearly-marked
// system/context preamble (later turns send plain text). Mirrors codex.
let turnText = promptText(turn.text);
if (this.needsSystemPreamble && this.deps.systemAppend) {
turnText =
`<system>\n${this.deps.systemAppend}\n</system>\n\n` +
`The user's first message follows.\n\n${turnText}`;
this.needsSystemPreamble = false;
}
// Build the prompt ContentBlock[]: the text block first (preserves prompt
// context), then any resolved inline base64 image blocks (vision parity).
// Images are only attached when the agent advertised promptCapabilities.image
// (default-allow when the capability is unknown).
const prompt = [{ type: "text", text: turnText }];
const imagesAllowed = this.agentCaps?.promptCapabilities?.image !== false;
if (imagesAllowed) {
for (const ref of turn.images ?? []) {
const block = await this.fetchImageBlock(ref);
if (block)
prompt.push(block);
}
}
try {
// session/prompt is a REQUEST that RESOLVES with a stopReason at turn end.
client
.request("session/prompt", { sessionId, prompt })
.then((res) => {
if (finishedResult)
return;
finishedResult = true;
closeStream();
const stop = res?.stopReason;
// Commit the accumulated assistant text (if any) as the authoritative
// turn-ending message — no per-turn rewind anchor (forkAtAnchor=false).
const text = assistantText.trim();
if (text)
push({ type: "assistant", text, ...(messageId ? { id: messageId } : {}) });
// end_turn / max_tokens / max_turn_requests = a real completion; cancelled
// (user interrupt) and refusal are not "ok".
const ok = !!stop && stop !== "cancelled" && stop !== "refusal";
push({ type: "result", ok, ...(stop ? { subtype: stop } : {}) });
finish();
})
.catch((err) => {
// A failed prompt ends the turn. When the child dies mid-turn handleExit
// rejects this BEFORE exitPromise resolves, so this .catch runs first —
// it MUST end with a terminal result (the idempotent helper guarantees
// exactly one), or the exit watcher then sees done and hangs the gate.
if (interrupted)
emitTerminalError("grok turn interrupted.");
else
emitTerminalError(msgOf(err));
});
// Drain the bridged queue until the turn completes.
while (true) {
while (queue.length) {
yield queue.shift();
}
if (done)
break;
await new Promise((resolve) => {
wake = resolve;
});
}
while (queue.length)
yield queue.shift();
}
finally {
// Mark interrupted so a late prompt rejection doesn't surface a spurious
// error after teardown.
interrupted = true;
// Restore the prior handler ONLY if it's still ours (close() may have nulled
// it during shutdown — don't resurrect a stale handler onto a dead client).
if (client.notificationHandler && !client.exitError)
client.notificationHandler = prev ?? null;
}
}
/** Stop the current turn without ending the session → `session/cancel`
* (notification). The in-flight session/prompt then resolves with
* stopReason:"cancelled", which the run-turn path turns into a terminal result. */
async interrupt() {
const direct = await this.resolveMode();
if (direct) {
await direct.interrupt();
return;
}
const client = this.client;
if (!client || !this.sessionId)
return;
try {
client.notify("session/cancel", { sessionId: this.sessionId });
}
catch (err) {
logger.debug(`[grok-backend] interrupt: ${msgOf(err)}`);
}
}
/** Switch the model live. ACP pins the model at spawn (`--model`), so this can't
* reconfigure a running child — instead it marks the model dirty (this.model !=
* this.spawnedModel) and invalidates the cached spawn spec. The persistent run()
* loop then RESPAWNS the `grok agent` CLI with the new --model (and a fresh
* session) transparently before the next turn (see run()'s live-switch branch).
* If the backend hasn't spawned yet, the first prepare() simply uses the new
* model. Ignores non-Gemini ids (PanelAgent may pass the Claude panel model). */
async setModel(model) {
const direct = await this.resolveMode();
if (direct) {
await direct.setModel?.(model);
return;
}
if (!isGrokModel(model))
return;
this.model = model;
this.spawnSpec = null; // next spawn rebuilds argv with the new --model
}
/**
* Model enumeration. In direct-token mode this delegates to GrokDirectBackend
* (a live GET /v1/models probe). In ACP mode, ACP exposes no model catalog, so
* we surface a static set (the current Grok CLI composer family); the panel
* picker degrades gracefully on an empty list. No effort metadata (Grok's ACP
* mode has no discrete effort scale) → the panel hides the effort dropdown.
*/
async listModels() {
const direct = await this.resolveMode();
if (direct)
return direct.listModels();
return GROK_MODELS;
}
/** Permanently dispose of the backend (AgentBackend.close): kill the gemini
* process TREE (Windows shell-fallback grandchild included), remove listeners,
* null the client. Idempotent + safe when never prepared. Mirrors codex (P0-1):
* interrupt() is a no-op when idle, so without this the child is orphaned. */
async close() {
this.disposed = true; // tripwire FIRST (an in-flight prepare() bails) (P0-A)
const direct = await this.resolveMode();
if (direct) {
await direct.close?.();
return;
}
const client = this.client;
const preparing = this.preparingClient;
this.client = null;
this.preparingClient = null;
this.sessionId = null;
if (client) {
client.notificationHandler = null;
await client.close().catch(() => { });
}
if (preparing && preparing !== client) {
preparing.notificationHandler = null;
await preparing.close().catch(() => { });
}
}
}
// ---------------------------------------------------------------------------
// Direct-token Grok (xAI) backend — Task 6 of the in-panel OAuth plan.
//
// Hits `https://api.x.ai/v1/responses` with the OAuth bearer resolved from
// `~/.grok/auth.json` (resolveGrokOAuth), instead of driving the Grok CLI over
// ACP. This reuses the SAME Responses-style SSE adapter SHAPE as
// chatgpt-oauth-backend.ts's Codex adapter (prior research judged xAI's public
// API to be codex_responses-adapter compatible) and the SAME 6-tool router as
// Ollama/ChatGPT (OllamaBackend.dispatch/connectTools/buildModelTools) — a
// small model-agnostic HTTP endpoint has no agent harness of its own, so this
// backend owns the whole tool loop the way Ollama/ChatGPT already do.
//
// DIVERGENCES from the Codex adapter (xAI is NOT a re-skinned ChatGPT):
// - No `chatgpt-account-id` header — that's OpenAI/ChatGPT account-routing
// plumbing with no xAI equivalent; the bearer token alone identifies the
// account.
// - Every outbound request is host-allowlist-checked (assertAllowedTokenHost
// against OAUTH_PROVIDERS.grok.apiHostAllowlist, i.e. `x.ai`/`*.x.ai`)
// before the bearer is attached, and any error response body is REDACTED
// (redactTokens, from oauth-flow.ts — the single shared redactor) before
// it can reach a thrown message or a log line — the access token itself
// is never logged anywhere in this path.
// - The exact endpoint SUB-PATH is still UNVERIFIED against the live xAI API
// (no network access at authoring time, and no Task-1 research artifact
// survived for this session to consult); override it via
// COMFYUI_MCP_GROK_XAI_RESPONSES_URL if the real path differs.
// The MODEL SLUG is no longer in that bucket: GROK_XAI_DEFAULT_MODEL
// inherits GROK_DEFAULT_MODEL, which is `grok-4.6` — documented by
// docs.x.ai/developers/grok-4-6 as a real xAI `/v1` API model id (verified
// 2026-08-14). listModels() still prefers a live `GET /v1/models` probe over
// any hardcoded catalog, and COMFYUI_MCP_GROK_XAI_MODEL still overrides.
// ---------------------------------------------------------------------------
export const GROK_XAI_API_BASE = "https://api.x.ai/v1";
// The exact Responses sub-path is UNVERIFIED against the live xAI API (Task 8's
// smoke test confirms it). Made overridable via COMFYUI_MCP_GROK_XAI_RESPONSES_URL
// so if the real path differs it's a config flip, not a code change — whatever URL
// is used still passes assertAllowedTokenHost (x.ai/*.x.ai) before any bearer.
export const GROK_XAI_RESPONSES_URL = process.env.COMFYUI_MCP_GROK_XAI_RESPONSES_URL?.trim() || `${GROK_XAI_API_BASE}/responses`;
const GROK_XAI_MODELS_URL = `${GROK_XAI_API_BASE}/models`;
const GROK_MAX_TOOL_ROUNDS = 32;
/** `x.ai` / `*.x.ai` — the same allowlist the OAuth engine already enforces for
* the Grok token endpoint (oauth-flow.ts), reused here for the DATA API calls. */
function grokApiHostAllowlist() {
return OAUTH_PROVIDERS.grok?.apiHostAllowlist ?? ["x.ai"];
}
// Inherits the ACP CLI default (GROK_DEFAULT_MODEL). That used to be an
// UNVERIFIED placeholder, but as of grok-4.6 the two namespaces coincide:
// docs.x.ai/developers/grok-4-6 documents `grok-4.6` as a real xAI `/v1` API
// model id (verified 2026-08-14), so this fallback is now a valid raw-API slug
// rather than a CLI alias borrowed on faith. The endpoint SUB-PATH is a separate
// question and remains unverified — see the divergences note above. Override the
// model with COMFYUI_MCP_GROK_XAI_MODEL, or rely on listModels()'s live
// /v1/models probe to surface the account's real slugs.
export const GROK_XAI_DEFAULT_MODEL = process.env.COMFYUI_MCP_GROK_XAI_MODEL?.trim() || GROK_DEFAULT_MODEL;
export const GROK_XAI_SYSTEM_PROMPT = [
"You are the ComfyUI agent in a sidebar panel. Answer in Markdown.",
"",
"You have exactly six tools:",
"- list_tools / describe_tool / call_tool — headless ComfyUI server.",
"- panel_list_tools / panel_describe_tool / panel_call_tool — live canvas.",
"",
"Describe a tool before its first call. Finish tasks by running tools, not inventing results.",
].join("\n");
/** OpenAI-tool-def → Responses `tools[]` shape (identical to Codex's toResponsesTools). */
function grokToolsToResponses(tools) {
return tools.map((t) => {
const fn = (t.function ?? t);
return {
type: "function",
name: fn.name,
description: fn.description ?? "",
parameters: fn.parameters ?? { type: "object", properties: {} },
};
});
}
/** In-memory turn history → Responses `input[]` items (identical shaping to
* Codex's historyToCodexInput — same Responses `input` schema). */
function grokHistoryToResponsesInput(messages) {
const items = [];
for (const m of messages) {
if (m.role === "user") {
items.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: m.content }],
});
continue;
}
if (m.role === "assistant") {
if (m.tool_calls?.length) {
for (const tc of m.tool_calls) {
items.push({
type: "function_call",
call_id: tc.id,
name: tc.name,
arguments: tc.arguments || "{}",
});
}
}
if (m.content.trim()) {
items.push({
type: "message",
role: "assistant",
content: [{ type: "output_text", text: m.content }],
});
}
continue;
}
if (m.role === "tool" && m.tool_call_id) {
items.push({
type: "function_call_output",
call_id: m.tool_call_id,
output: m.content,
});
}
}
return items;
}
const GROK_DIRECT_CAPABILITIES = {
persistentChannel: true,
streamingDeltas: true,
interruptMidTurn: true,
forkAtAnchor: false,
inProcessMcp: false,
modelEnumeration: true, // live GET /v1/models probe
slashCommands: false,
hooks: false,
vision: false, // the 6-tool router is text-only (mirrors Ollama/ChatGPT); NeutralTurn.images unused here
audio: false, // xAI's audio-input contract is unverified, and an over-claim here means a SILENTLY unheard attachment (#790)
// This adapter DOES stamp turn markers (see stampTurn in run() below), so it
// must say so: #468's run-completion ack trusts the declaration, and a backend
// that stamps but declares otherwise would let an unmarked straggler ack a
// completion it never carried. Declaration and behavior must not disagree.
turnMarkers: true,
};
/** Direct-token Grok (xAI) backend — see the module-level comment above for the
* full rationale and the divergences from the Codex adapter it mirrors. */
export class GrokDirectBackend extends OllamaBackend {
id = "grok";
capabilities = GROK_DIRECT_CAPABILITIES;
accessToken = "";
grokTurnHistory = [];
grokSessionId = null;
resolveOAuth;
/** Credentials `GrokBackend.resolveMode()` ALREADY resolved to pick the mode,
* handed straight to prepare() so the initial preflight does NOT re-probe
* `resolveGrokOAuth` (single-probe on the success path). Consumed once, then
* cleared; `resolveOAuth` remains for any future mid-session re-resolve. */
initialCredentials;
constructor(deps = {}) {
super({
cwd: deps.cwd,
comfyuiUrl: deps.comfyuiUrl,
mcpServers: deps.mcpServers,
systemAppend: deps.systemAppend,
backendId: "grok",
api: "openai",
host: "https://unused", // never dialed — every request goes straight to GROK_XAI_RESPONSES_URL
model: deps.model ?? GROK_XAI_DEFAULT_MODEL,
});
this.model = deps.model ?? GROK_XAI_DEFAULT_MODEL;
this.resolveOAuth = deps.resolveGrokOAuth ?? resolveGrokOAuth;
this.initialCredentials = deps.initialCredentials;
}
async prepare() {
if (this.disposed)
throw new Error("grok backend is closed.");
if (this.prepared)
return;
// Prefer the credentials the facade already resolved to pick this mode — the
// initial preflight must NOT re-probe resolveGrokOAuth (single-probe on the
// success path). Only re-resolve if none were threaded in (e.g. a direct
// instantiation in a test or a future caller).
const creds = this.initialCredentials ?? (await this.resolveOAuth());
this.initialCredentials = undefined; // consumed
this.accessToken = creds.accessToken;
await this.connectTools();
this.prepared = true;
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)`);
}
async *xaiResponsesStream(instructions, input, tools, signal, onActivity) {
assertAllowedTokenHost(GROK_XAI_RESPONSES_URL, grokApiHostAllowlist());
const keepalive = onActivity ? setInterval(onActivity, 5000) : null;
let res;
try {
// Capture the model BEFORE the async boundary. The thunk below is re-invoked
// after a rate-limit backoff, and re-reading `this.model` there sends the retried
// request to whatever the user switched to during the wait — while the notice they
// already saw names the old one. One turn, two models, no way to tell from the log.
const model = this.model;
// 429s are waited out when xAI names a bounded window; every other status
// falls through to the error below unchanged (orchestrator/rate-limit.ts).
res = yield* sendWithRateLimitRetry(() => fetch(GROK_XAI_RESPONSES_URL, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
authorization: `Bearer ${this.accessToken}`,
},
body: JSON.stringify({
model,
instructions,
input,
tools: grokToolsToResponses(tools),
stream: true,
store: false,
}),
signal,
}), { model, label: "grok-backend", signal, onActivity });
}
finally {
if (keepalive)
clearInterval(keepalive);
}
if (!res.ok || !res.body) {
// Never a 429 — sendWithRateLimitRetry owns that status.
// unknown-ok: "" is interpolated into an ERROR MESSAGE and nothing else — the
// HTTP status is reported either way, so an unreadable body costs detail in the
// text, never a wrong conclusion. Verified there is no branch on this value.
const bodyText = await res.text().catch(() => "");
throw new Error(`xAI Responses http ${res.status}: ${redactTokens(bodyText).slice(0, 400)}`);
}
let content = "";
const partial = new Map();
let usage;
let streamOpen = false;
const streamId = randomUUID();
let buffer = "";
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done)
break;
onActivity?.();
buffer += decoder.decode(value, { stream: true });
let sep;
while ((sep = buffer.indexOf("\n\n")) >= 0) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
let eventType = "";
let data = "";
for (const line of block.split("\n")) {
if (line.startsWith("event:"))
eventType = line.slice(6).trim();
else if (line.startsWith("data:"))
data += line.slice(5).trim();
}
if (!data || data === "[DONE]")
continue;
let payload;
try {
payload = JSON.parse(data);
}
catch {
continue;
}
const type = eventType || String(payload.type ?? "");
if (type === "response.output_text.delta") {
const delta = String(payload.delta ?? "");
if (delta) {
if (!streamOpen) {
streamOpen = true;
yield { type: "stream_start", id: streamId };
}
content += delta;
yield { type: "assistant_delta", text: delta };
}
}
if (type === "response.function_call_arguments.delta") {
const itemId = String(payload.item_id ?? payload.call_id ?? "call");
const slot = partial.get(itemId) ?? { id: itemId, name: "", args: "" };
if (payload.name)
slot.name = String(payload.name);
if (payload.arguments)
slot.args += String(payload.arguments);
partial.set(itemId, slot);
}
if (type === "response.output_item.done") {
const item = (payload.item ?? payload);
if (item.type === "function_call") {
const id = String(item.call_id ?? item.id ?? randomUUID());
partial.set(id, {
id,
name: String(item.name ?? ""),
args: String(item.arguments ?? "{}"),
});
}
}
if (type === "response.completed") {
const u = payload.response?.usage ??
payload.usage;
if (u) {
usage = {
input_tokens: Number(u.input_tokens ?? 0),
output_tokens: Number(u.output_tokens ?? 0),
};
}
}
}
}
if (streamOpen)
yield { type: "stream_end" };
const toolCalls = [...partial.values()]
.filter((t) => t.name)
.map((t) => ({ id: t.id, name: t.name, arguments: t.args || "{}" }));
return { content, toolCalls, usage, streamId: streamOpen ? streamId : null };
}
async *run(opts) {
await this.prepare();
if (opts.model && isGrokModel(opts.model))
this.model = opts.model;
const fresh = !this.grokSessionId || (opts.resume && opts.resume !== this.grokSessionId);
this.grokSessionId = opts.resume ?? this.grokSessionId ?? `grok-${randomUUID()}`;
if (fresh)
this.grokTurnHistory = [];
yield { type: "session", sessionId: this.grokSessionId, model: this.model };
// NOTE: upstream (MichaelDanCurtis fork) routes this through the editable
// prompt registry (services/prompt-overrides). That registry is not ported
// here, so the built-in prompt is used directly.
const instructions = [GROK_XAI_SYSTEM_PROMPT, this.deps.systemAppend]
.filter(Boolean)
.join("\n\n");
let turnSeq = 0;
for await (const turn of opts.channel) {
yield* stampTurn(this.runGrokTurn(turn, instructions, opts), ++turnSeq);
}
}
async *runGrokTurn(turn, instructions, opts) {
const abort = new AbortController();
this.turnAbort = abort;
const tools = this.buildModelTools();
this.grokTurnHistory.push({ role: "user", content: turn.text });
let resultEmitted = false;
try {
for (let round = 0; round < GROK_MAX_TOOL_ROUNDS; round++) {
const stream = this.xaiResponsesStream(instructions, grokHistoryToResponsesInput(this.grokTurnHistory), tools, abort.signal, opts.onActivity);
let content = "";
let toolCalls = [];
let usage;
let streamId = null;
for (;;) {
const r = await stream.next();
if (r.done) {
({ content, toolCalls, usage, streamId } = r.value);
break;
}
yield r.value;
}
if (!toolCalls.length) {
this.grokTurnHistory.push({ role: "assistant", content });
yield { type: "assistant", text: content, id: streamId ?? undefined, usage };
yield { type: "result", ok: true, usage };
resultEmitted = true;
return;
}
this.grokTurnHistory.push({ role: "assistant", content, tool_calls: toolCalls });
for (const tc of toolCalls) {
if (abort.signal.aborted)
throw new Error("interrupted");
yield { type: "tool_call", name: tc.name, phase: "start", detail: tc.arguments };
const { text, isError } = await this.dispatch(tc.name, tc.arguments);
opts.onActivity?.();
yield { type: "tool_call", name: tc.name, phase: "end", detail: { isError } };
this.grokTurnHistory.push({
role: "tool",
tool_call_id: tc.id,
content: text.slice(0, 16000),
});
}
}
yield {
type: "assistant",
text: "(stopped: too many tool rounds in one turn — ask me to continue)",
};
yield { type: "result", ok: false, subtype: "max_tool_rounds" };
resultEmitted = true;
}
catch (err) {
const interrupted = abort.signal.aborted;
const rateLimited = asRateLimitError(err);
if (!interrupted) {
logger.warn(`[grok-backend] direct-token turn failed: ${msgOf(err)}`);
if (rateLimited) {
// Already a finished sentence naming the model, the reason and the
// remedy — it travels alone rather than under a "grok backend:" prefix
// that would blame this adapter for the provider's limit.
yield { type: "error", message: rateLimited.message, rateLimit: true };
}
else {
yield { type: "error", message: `grok backend: ${msgOf(err)}` };
yield {
type: "assistant",
text: `⚠️ The model request failed: ${msgOf(err).slice(0, 400)}`,
};
}
}
if (!resultEmitted) {
yield {
type: "result",
ok: false,
subtype: interrupted ? "interrupted" : rateLimited ? "rate_limit" : "error",
};
}
}
finally {
if (this.turnAbort === abort)
this.turnAbort = null;
}
}
async setModel(model) {
if (isGrokModel(model))
this.model = model;
}
/** Prefer a live account probe over any hardcoded catalog (the exact xAI
* model slugs are unverified — see the module-level comment). Falls back to
* just the configured model id if the probe fails (offline, wrong scope, …). */
async listModels() {
try {
assertAllowedTokenHost(GROK_XAI_MODELS_URL, grokApiHostAllowlist());
const res = await fetch(GROK_XAI_MODELS_URL, {
headers: { authorization: `Bearer ${this.accessToken}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok)
return [{ id: this.model, label: this.model }];
const data = (await res.json());
const ids = (data.data ?? []).map((m) => m.id).filter((id) => !!id);
if (!ids.length)
return [{ id: this.model, label: this.model }];
const rest = ids.filter((id) => id !== this.model).slice(0, 40);
return [this.model, ...rest].map((id) => ({ id, label: id }));
}
catch {
return [{ id: this.model, label: this.model }];
}
}
}
// Expose the default model id for the orchestrator wiring (COMFYUI_MCP_GEMINI_MODEL
// fallback) without duplicating the literal.
export { GROK_DEFAULT_MODEL };
//# sourceMappingURL=grok-backend.js.map