1291 lines
42 KiB
JavaScript
1291 lines
42 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Grok Build ACP turn runner — `grok agent … stdio` JSON-RPC client.
|
||
|
|
*
|
||
|
|
* Lifecycle (Agent Client Protocol / xAI docs):
|
||
|
|
* initialize → authenticate (when authMethods exist)
|
||
|
|
* → session/resume | session/load | session/new
|
||
|
|
* → session/prompt
|
||
|
|
* session/update notifications → canonical Netcatty emitter events
|
||
|
|
*
|
||
|
|
* Prefer session/resume (no history replay) over session/load when the agent
|
||
|
|
* advertises it; keep acceptUpdates=false until session/prompt so load replay
|
||
|
|
* never pollutes the current assistant bubble.
|
||
|
|
*
|
||
|
|
* Prefer session-level mcpServers over project `.grok/config.toml` merge.
|
||
|
|
* Keep the headless streaming-json driver as an explicit fallback runtime.
|
||
|
|
*/
|
||
|
|
const path = require("node:path");
|
||
|
|
const { spawn } = require("node:child_process");
|
||
|
|
const { StringDecoder } = require("node:string_decoder");
|
||
|
|
const {
|
||
|
|
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS,
|
||
|
|
createLineBuffer,
|
||
|
|
formatGrokErrorForUser,
|
||
|
|
resolveGrokToolIntegrationFlags,
|
||
|
|
resolveGrokTurnPrompt,
|
||
|
|
extractGrokAcpPromptUsage,
|
||
|
|
emitGrokUsage,
|
||
|
|
normalizeGrokPlanUpdate,
|
||
|
|
applyGrokReasoningFallback,
|
||
|
|
resolveGrokCatalogCurrentModelId,
|
||
|
|
parseGrokModelSelection,
|
||
|
|
shouldReportGrokProcessExitFailure,
|
||
|
|
spawnGrokProcess,
|
||
|
|
} = require("./grokDriver.cjs");
|
||
|
|
|
||
|
|
const GROK_ACP_ABORT_GRACE_MS = 1_500;
|
||
|
|
const MAX_GROK_ACP_LINE_BYTES = 10 * 1024 * 1024;
|
||
|
|
const MAX_GROK_ACP_STDERR_CHARS = 64 * 1024;
|
||
|
|
const ACP_PROTOCOL_VERSION = 1;
|
||
|
|
// The outer SDK model-list request has a 10s deadline. Leave enough time for
|
||
|
|
// the legacy `grok models` fallback when ACP initialize hangs.
|
||
|
|
const GROK_ACP_MODEL_LIST_TIMEOUT_MS = 4_000;
|
||
|
|
const GROK_FALLBACK_REASONING_EFFORTS = ["low", "medium", "high", "xhigh"];
|
||
|
|
const GROK_REASONING_EFFORTS = new Set([
|
||
|
|
"none",
|
||
|
|
"minimal",
|
||
|
|
"low",
|
||
|
|
"medium",
|
||
|
|
"high",
|
||
|
|
"xhigh",
|
||
|
|
"max",
|
||
|
|
]);
|
||
|
|
|
||
|
|
function signalProcessTree(child, signal, forceKillImpl) {
|
||
|
|
if (!child) return;
|
||
|
|
if (typeof forceKillImpl === "function") {
|
||
|
|
try { forceKillImpl(child, signal); } catch { /* ignore */ }
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (process.platform === "win32" && signal === "SIGKILL" && child.pid) {
|
||
|
|
try {
|
||
|
|
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], {
|
||
|
|
stdio: "ignore",
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
killer.on("error", () => {});
|
||
|
|
killer.unref?.();
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
// fall through
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (process.platform !== "win32" && child.pid) {
|
||
|
|
try {
|
||
|
|
process.kill(-child.pid, signal);
|
||
|
|
return;
|
||
|
|
} catch {
|
||
|
|
// fall through
|
||
|
|
}
|
||
|
|
}
|
||
|
|
try { child.kill(signal); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resolve absolute cwd for ACP session lifecycle (protocol requires absolute path).
|
||
|
|
*/
|
||
|
|
function resolveGrokAcpCwd(cwd) {
|
||
|
|
const raw = String(cwd || process.cwd() || ".").trim() || ".";
|
||
|
|
try {
|
||
|
|
return path.resolve(raw);
|
||
|
|
} catch {
|
||
|
|
return raw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build argv for `grok [global flags] agent [agent flags] stdio`.
|
||
|
|
*
|
||
|
|
* Live CLI (grok 0.2.x): `--disallowed-tools` and `--no-auto-update` are
|
||
|
|
* top-level options. Placing them after `agent` fails with:
|
||
|
|
* unexpected argument '--disallowed-tools' found
|
||
|
|
* Agent-local flags (`--always-approve`, `-m`) stay after `agent`.
|
||
|
|
*/
|
||
|
|
function buildGrokAcpSpawnArgs({
|
||
|
|
model,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
} = {}) {
|
||
|
|
// Global top-level flags MUST precede the `agent` subcommand.
|
||
|
|
const args = ["--no-auto-update"];
|
||
|
|
// MCP lockdown is a top-level flag (same as headless `grok -p ...`).
|
||
|
|
args.push(...resolveGrokToolIntegrationFlags(toolIntegrationMode));
|
||
|
|
args.push("agent");
|
||
|
|
const mode = String(permissionMode || "confirm").toLowerCase();
|
||
|
|
// Non-interactive Netcatty turns cannot answer ACP permission prompts.
|
||
|
|
if (mode !== "observer") {
|
||
|
|
args.push("--always-approve");
|
||
|
|
}
|
||
|
|
const selection = parseGrokModelSelection(model);
|
||
|
|
if (selection.model) {
|
||
|
|
args.push("-m", selection.model);
|
||
|
|
}
|
||
|
|
if (selection.effort) {
|
||
|
|
args.push("--reasoning-effort", selection.effort);
|
||
|
|
}
|
||
|
|
args.push("stdio");
|
||
|
|
return args;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Normalize injectMcp env ([{name,value}] or plain object) into Grok ACP's
|
||
|
|
* required pair-array shape. A plain {KEY:VALUE} object is rejected by
|
||
|
|
* `session/new` (`Invalid params` / McpServer enum).
|
||
|
|
*/
|
||
|
|
function toAcpMcpEnvPairs(env) {
|
||
|
|
if (Array.isArray(env)) {
|
||
|
|
return env
|
||
|
|
.filter((pair) => pair && typeof pair.name === "string" && typeof pair.value === "string")
|
||
|
|
.map((pair) => ({ name: pair.name, value: pair.value }));
|
||
|
|
}
|
||
|
|
if (env && typeof env === "object") {
|
||
|
|
const out = [];
|
||
|
|
for (const [name, value] of Object.entries(env)) {
|
||
|
|
if (typeof name === "string" && typeof value === "string") {
|
||
|
|
out.push({ name, value });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert Netcatty injectMcp configs into ACP session/new mcpServers entries.
|
||
|
|
* Grok agent stdio expects stdio servers with env as [{name,value}, ...].
|
||
|
|
*/
|
||
|
|
function toAcpMcpServers(injectedMcpServers) {
|
||
|
|
const out = [];
|
||
|
|
for (const cfg of injectedMcpServers || []) {
|
||
|
|
if (!cfg || !cfg.name || !cfg.command) continue;
|
||
|
|
const entry = {
|
||
|
|
name: String(cfg.name),
|
||
|
|
// Optional for Grok, but documents stdio transport for the McpServer enum.
|
||
|
|
type: "stdio",
|
||
|
|
command: String(cfg.command),
|
||
|
|
args: Array.isArray(cfg.args) ? cfg.args.map(String) : [],
|
||
|
|
env: toAcpMcpEnvPairs(cfg.env),
|
||
|
|
};
|
||
|
|
out.push(entry);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build session/new params including MCP servers and permission meta.
|
||
|
|
*/
|
||
|
|
function buildGrokAcpSessionNewParams({
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
systemContext,
|
||
|
|
} = {}) {
|
||
|
|
const mode = String(permissionMode || "confirm").toLowerCase();
|
||
|
|
const toolMode = String(toolIntegrationMode || "mcp").toLowerCase();
|
||
|
|
const params = {
|
||
|
|
cwd: resolveGrokAcpCwd(cwd),
|
||
|
|
mcpServers: toAcpMcpServers(injectedMcpServers),
|
||
|
|
_meta: {},
|
||
|
|
};
|
||
|
|
if (mode !== "observer") {
|
||
|
|
params._meta.yoloMode = true;
|
||
|
|
} else {
|
||
|
|
// Soft read-oriented path when no interactive approval UI is available.
|
||
|
|
params._meta.autoMode = true;
|
||
|
|
}
|
||
|
|
if (toolMode !== "skills") {
|
||
|
|
params._meta.rules = [
|
||
|
|
"Netcatty MCP mode is active. Do not use local shell, search_replace, or write tools for side effects.",
|
||
|
|
"Operate on remote terminal sessions only through the injected netcatty-remote-hosts MCP server.",
|
||
|
|
`Disallowed local built-ins (policy): ${GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(", ")}.`,
|
||
|
|
].join(" ");
|
||
|
|
}
|
||
|
|
if (systemContext && String(systemContext).trim()) {
|
||
|
|
// Prefer additive rules so Grok keeps its agent profile; overflow goes to rules.
|
||
|
|
const existing = params._meta.rules ? `${params._meta.rules} ` : "";
|
||
|
|
params._meta.rules = `${existing}${String(systemContext).trim()}`.slice(0, 16_000);
|
||
|
|
}
|
||
|
|
return params;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildGrokAcpInitializeParams() {
|
||
|
|
return {
|
||
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
||
|
|
clientCapabilities: {
|
||
|
|
fs: { readTextFile: false, writeTextFile: false },
|
||
|
|
terminal: false,
|
||
|
|
},
|
||
|
|
clientInfo: {
|
||
|
|
name: "netcatty",
|
||
|
|
version: "0.0.0",
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert Grok's ACP initialize modelState into Netcatty's shared model picker
|
||
|
|
* shape. Reasoning levels are model-specific and come from the live catalog;
|
||
|
|
* non-reasoning models intentionally remain plain model rows.
|
||
|
|
*/
|
||
|
|
function parseGrokAcpModelCatalog(initResult) {
|
||
|
|
const modelState = initResult?._meta?.modelState
|
||
|
|
|| initResult?.modelState
|
||
|
|
|| initResult?.agentCapabilities?._meta?.modelState;
|
||
|
|
if (!modelState || typeof modelState !== "object") {
|
||
|
|
return { currentModelId: null, models: [] };
|
||
|
|
}
|
||
|
|
|
||
|
|
const models = [];
|
||
|
|
for (const entry of Array.isArray(modelState.availableModels) ? modelState.availableModels : []) {
|
||
|
|
const id = String(entry?.modelId || entry?.id || "").trim();
|
||
|
|
if (!id) continue;
|
||
|
|
const preset = {
|
||
|
|
id,
|
||
|
|
name: String(entry?.name || id),
|
||
|
|
};
|
||
|
|
if (entry?.description) preset.description = String(entry.description);
|
||
|
|
|
||
|
|
const meta = entry?._meta && typeof entry._meta === "object"
|
||
|
|
? entry._meta
|
||
|
|
: (entry?.meta && typeof entry.meta === "object" ? entry.meta : {});
|
||
|
|
const supportsReasoning = meta.supportsReasoningEffort === true
|
||
|
|
|| meta.supports_reasoning_effort === true;
|
||
|
|
const explicitlyUnsupported = !supportsReasoning && (
|
||
|
|
meta.supportsReasoningEffort === false
|
||
|
|
|| meta.supports_reasoning_effort === false
|
||
|
|
);
|
||
|
|
if (supportsReasoning) {
|
||
|
|
const rawOptions = Array.isArray(meta.reasoningEfforts)
|
||
|
|
? meta.reasoningEfforts
|
||
|
|
: (Array.isArray(meta.reasoning_efforts) ? meta.reasoning_efforts : []);
|
||
|
|
const levels = [];
|
||
|
|
for (const option of rawOptions) {
|
||
|
|
const value = String(option?.value || option?.id || "").trim().toLowerCase();
|
||
|
|
if (GROK_REASONING_EFFORTS.has(value) && !levels.includes(value)) {
|
||
|
|
levels.push(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (levels.length === 0) {
|
||
|
|
const knownFallback = applyGrokReasoningFallback(preset);
|
||
|
|
if (Array.isArray(knownFallback?.thinkingLevels)) {
|
||
|
|
levels.push(...knownFallback.thinkingLevels);
|
||
|
|
} else {
|
||
|
|
levels.push(...GROK_FALLBACK_REASONING_EFFORTS);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
preset.thinkingLevels = levels;
|
||
|
|
|
||
|
|
const advertisedDefault = String(
|
||
|
|
meta.reasoningEffort || meta.reasoning_effort || "",
|
||
|
|
).trim().toLowerCase();
|
||
|
|
const optionDefault = rawOptions.find((option) => option?.default === true);
|
||
|
|
const optionDefaultValue = String(
|
||
|
|
optionDefault?.value || optionDefault?.id || "",
|
||
|
|
).trim().toLowerCase();
|
||
|
|
if (levels.includes(advertisedDefault)) {
|
||
|
|
preset.defaultThinkingLevel = advertisedDefault;
|
||
|
|
} else if (levels.includes(optionDefaultValue)) {
|
||
|
|
preset.defaultThinkingLevel = optionDefaultValue;
|
||
|
|
} else if (levels.includes("high")) {
|
||
|
|
preset.defaultThinkingLevel = "high";
|
||
|
|
} else {
|
||
|
|
preset.defaultThinkingLevel = levels[0];
|
||
|
|
}
|
||
|
|
} else if (!explicitlyUnsupported) {
|
||
|
|
Object.assign(preset, applyGrokReasoningFallback(preset));
|
||
|
|
}
|
||
|
|
models.push(preset);
|
||
|
|
}
|
||
|
|
|
||
|
|
const advertisedCurrentModelId = String(
|
||
|
|
modelState.currentModelId || modelState.current_model_id || "",
|
||
|
|
).trim() || null;
|
||
|
|
const currentModelId = resolveGrokCatalogCurrentModelId(models, advertisedCurrentModelId);
|
||
|
|
return { currentModelId, models };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Read the live Grok model catalog from ACP initialize. Unlike `grok models`,
|
||
|
|
* this response includes per-model reasoning support, available levels, and
|
||
|
|
* the default effort. Authentication is not required for the initialize step.
|
||
|
|
*/
|
||
|
|
async function listGrokAcpModels({
|
||
|
|
binPath,
|
||
|
|
env,
|
||
|
|
spawnImpl,
|
||
|
|
abortController,
|
||
|
|
signal,
|
||
|
|
timeoutMs = GROK_ACP_MODEL_LIST_TIMEOUT_MS,
|
||
|
|
abortGraceMs = GROK_ACP_ABORT_GRACE_MS,
|
||
|
|
forceKillImpl,
|
||
|
|
} = {}) {
|
||
|
|
const cliPath = String(binPath || "").trim();
|
||
|
|
if (!cliPath) return { currentModelId: null, models: [] };
|
||
|
|
const abortSignal = signal || abortController?.signal;
|
||
|
|
if (abortSignal?.aborted) return { currentModelId: null, models: [] };
|
||
|
|
|
||
|
|
return await new Promise((resolve) => {
|
||
|
|
let child;
|
||
|
|
let settled = false;
|
||
|
|
let timer = null;
|
||
|
|
let forceKillTimer = null;
|
||
|
|
let abortHandler = null;
|
||
|
|
let childClosed = false;
|
||
|
|
let terminationStarted = false;
|
||
|
|
const empty = { currentModelId: null, models: [] };
|
||
|
|
|
||
|
|
const terminateChild = () => {
|
||
|
|
if (terminationStarted || childClosed || !child || child.exitCode != null) return;
|
||
|
|
terminationStarted = true;
|
||
|
|
signalProcessTree(child, "SIGTERM", forceKillImpl);
|
||
|
|
forceKillTimer = setTimeout(() => {
|
||
|
|
if (!childClosed && child?.exitCode == null) {
|
||
|
|
signalProcessTree(child, "SIGKILL", forceKillImpl);
|
||
|
|
}
|
||
|
|
}, Math.max(1, abortGraceMs));
|
||
|
|
forceKillTimer.unref?.();
|
||
|
|
};
|
||
|
|
|
||
|
|
const finish = (value, terminate = true) => {
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
clearTimeout(timer);
|
||
|
|
if (abortSignal && abortHandler) {
|
||
|
|
abortSignal.removeEventListener("abort", abortHandler);
|
||
|
|
}
|
||
|
|
if (terminate) terminateChild();
|
||
|
|
try { child?.stdin?.end?.(); } catch { /* ignore */ }
|
||
|
|
resolve(value);
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
child = spawnGrokProcess(spawnImpl, cliPath, ["--no-auto-update", "agent", "stdio"], {
|
||
|
|
env: { ...(env || process.env) },
|
||
|
|
stdio: ["pipe", "pipe", "pipe"],
|
||
|
|
windowsHide: true,
|
||
|
|
detached: process.platform !== "win32",
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
finish(empty, false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const lineBuffer = createLineBuffer((line) => {
|
||
|
|
let message;
|
||
|
|
try {
|
||
|
|
message = JSON.parse(line);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (message?.id !== 1) return;
|
||
|
|
if (message.error) {
|
||
|
|
finish(empty);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
finish(parseGrokAcpModelCatalog(message.result));
|
||
|
|
}, MAX_GROK_ACP_LINE_BYTES);
|
||
|
|
|
||
|
|
child.stdout?.on("data", (chunk) => {
|
||
|
|
if (settled || abortSignal?.aborted) return;
|
||
|
|
try {
|
||
|
|
lineBuffer.push(chunk);
|
||
|
|
} catch {
|
||
|
|
finish(empty);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
child.stdin?.on?.("error", () => finish(empty));
|
||
|
|
child.on("error", () => finish(empty, false));
|
||
|
|
child.on("close", () => {
|
||
|
|
childClosed = true;
|
||
|
|
clearTimeout(forceKillTimer);
|
||
|
|
if (!settled) {
|
||
|
|
try { lineBuffer.flush(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
finish(empty, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
abortHandler = () => finish(empty);
|
||
|
|
if (abortSignal) {
|
||
|
|
if (abortSignal.aborted) {
|
||
|
|
abortHandler();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||
|
|
}
|
||
|
|
timer = setTimeout(() => finish(empty), Math.max(1, timeoutMs));
|
||
|
|
timer.unref?.();
|
||
|
|
|
||
|
|
const request = {
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: 1,
|
||
|
|
method: "initialize",
|
||
|
|
params: buildGrokAcpInitializeParams(),
|
||
|
|
};
|
||
|
|
try {
|
||
|
|
child.stdin?.write?.(`${JSON.stringify(request)}\n`, (err) => {
|
||
|
|
if (err) finish(empty);
|
||
|
|
});
|
||
|
|
} catch {
|
||
|
|
finish(empty);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildGrokAcpPromptParams(sessionId, prompt) {
|
||
|
|
return {
|
||
|
|
sessionId: String(sessionId || ""),
|
||
|
|
prompt: [{ type: "text", text: String(prompt || "") }],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Parse initialize result into resume/load capability flags.
|
||
|
|
* When the agent omits capability fields, treat them as "unknown" so we still
|
||
|
|
* try resume then load (Grok versions vary in what they advertise).
|
||
|
|
*/
|
||
|
|
function parseGrokAcpAgentCapabilities(initResult) {
|
||
|
|
const caps = initResult && typeof initResult === "object"
|
||
|
|
? (initResult.agentCapabilities || {})
|
||
|
|
: {};
|
||
|
|
const sessionCaps = caps.sessionCapabilities && typeof caps.sessionCapabilities === "object"
|
||
|
|
? caps.sessionCapabilities
|
||
|
|
: {};
|
||
|
|
const hasLoadField = Object.prototype.hasOwnProperty.call(caps, "loadSession");
|
||
|
|
const hasResumeField = Object.prototype.hasOwnProperty.call(sessionCaps, "resume");
|
||
|
|
return {
|
||
|
|
loadSession: caps.loadSession === true,
|
||
|
|
resume: sessionCaps.resume != null && sessionCaps.resume !== false,
|
||
|
|
hasCapabilityInfo: hasLoadField || hasResumeField,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Ordered session establish methods for this turn.
|
||
|
|
* Prefer session/resume (no history replay) → session/load → session/new.
|
||
|
|
*/
|
||
|
|
function planGrokAcpSessionEstablish({ resumeSessionId, agentCapabilities } = {}) {
|
||
|
|
if (!resumeSessionId) return ["new"];
|
||
|
|
const caps = agentCapabilities || parseGrokAcpAgentCapabilities(null);
|
||
|
|
const methods = [];
|
||
|
|
if (caps.resume || !caps.hasCapabilityInfo) methods.push("resume");
|
||
|
|
if (caps.loadSession || !caps.hasCapabilityInfo) methods.push("load");
|
||
|
|
methods.push("new");
|
||
|
|
return [...new Set(methods)];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Params for session/resume or session/load (same shape per ACP session-setup).
|
||
|
|
*/
|
||
|
|
function buildGrokAcpSessionResumeOrLoadParams({
|
||
|
|
sessionId,
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
} = {}) {
|
||
|
|
return {
|
||
|
|
sessionId: String(sessionId || ""),
|
||
|
|
cwd: resolveGrokAcpCwd(cwd),
|
||
|
|
mcpServers: toAcpMcpServers(injectedMcpServers),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Select authenticate methodId per xAI ACP sample:
|
||
|
|
* XAI_API_KEY + xai.api_key → xai.api_key; else cached_token; else null.
|
||
|
|
* Empty authMethods → skip (already authenticated / no gate).
|
||
|
|
*/
|
||
|
|
function selectGrokAcpAuthMethodId(initResult, env = {}) {
|
||
|
|
const methods = Array.isArray(initResult?.authMethods) ? initResult.authMethods : [];
|
||
|
|
if (methods.length === 0) return { methodId: null, required: false };
|
||
|
|
const ids = new Set(
|
||
|
|
methods.map((m) => (m && typeof m.id === "string" ? m.id : "")).filter(Boolean),
|
||
|
|
);
|
||
|
|
const hasApiKey = Boolean(String(env.XAI_API_KEY || process.env.XAI_API_KEY || "").trim());
|
||
|
|
if (hasApiKey && ids.has("xai.api_key")) {
|
||
|
|
return { methodId: "xai.api_key", required: true };
|
||
|
|
}
|
||
|
|
if (ids.has("cached_token")) {
|
||
|
|
return { methodId: "cached_token", required: true };
|
||
|
|
}
|
||
|
|
if (ids.has("xai.api_key")) {
|
||
|
|
// Advertised but no key in env — still attempt so Grok can read config;
|
||
|
|
// missing credentials surface as auth errors after authenticate fails.
|
||
|
|
return { methodId: "xai.api_key", required: true };
|
||
|
|
}
|
||
|
|
return { methodId: null, required: true };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Run authenticate when the agent advertises methods (xAI official ACP sample).
|
||
|
|
*/
|
||
|
|
async function authenticateGrokAcp(rpc, initResult, env = {}) {
|
||
|
|
const selected = selectGrokAcpAuthMethodId(initResult, env);
|
||
|
|
if (!selected.methodId) {
|
||
|
|
if (selected.required) {
|
||
|
|
throw new Error("Run `grok login` first, or set XAI_API_KEY.");
|
||
|
|
}
|
||
|
|
return { skipped: true, methodId: null };
|
||
|
|
}
|
||
|
|
await rpc.request(
|
||
|
|
"authenticate",
|
||
|
|
{ methodId: selected.methodId, _meta: { headless: true } },
|
||
|
|
{ timeoutMs: 60_000 },
|
||
|
|
);
|
||
|
|
return { skipped: false, methodId: selected.methodId };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Establish session: try plan methods until one succeeds.
|
||
|
|
* Caller MUST keep acceptUpdates=false until after this returns (load may replay).
|
||
|
|
*/
|
||
|
|
async function establishGrokAcpSession(rpc, {
|
||
|
|
resumeSessionId,
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
systemContext,
|
||
|
|
agentCapabilities,
|
||
|
|
} = {}) {
|
||
|
|
const methods = planGrokAcpSessionEstablish({ resumeSessionId, agentCapabilities });
|
||
|
|
let lastError = null;
|
||
|
|
for (const method of methods) {
|
||
|
|
try {
|
||
|
|
if (method === "new") {
|
||
|
|
const created = await rpc.request(
|
||
|
|
"session/new",
|
||
|
|
buildGrokAcpSessionNewParams({
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
systemContext,
|
||
|
|
}),
|
||
|
|
{ timeoutMs: 30_000 },
|
||
|
|
);
|
||
|
|
const sessionId = created?.sessionId || created?.session_id;
|
||
|
|
if (!sessionId) throw new Error("Grok ACP session/new did not return a sessionId");
|
||
|
|
return { sessionId, method: "new" };
|
||
|
|
}
|
||
|
|
if (method === "resume") {
|
||
|
|
const result = await rpc.request(
|
||
|
|
"session/resume",
|
||
|
|
buildGrokAcpSessionResumeOrLoadParams({
|
||
|
|
sessionId: resumeSessionId,
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
}),
|
||
|
|
{ timeoutMs: 30_000 },
|
||
|
|
);
|
||
|
|
const sessionId = result?.sessionId || result?.session_id || resumeSessionId;
|
||
|
|
return { sessionId, method: "resume" };
|
||
|
|
}
|
||
|
|
if (method === "load") {
|
||
|
|
const result = await rpc.request(
|
||
|
|
"session/load",
|
||
|
|
buildGrokAcpSessionResumeOrLoadParams({
|
||
|
|
sessionId: resumeSessionId,
|
||
|
|
cwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
}),
|
||
|
|
{ timeoutMs: 30_000 },
|
||
|
|
);
|
||
|
|
const sessionId = result?.sessionId || result?.session_id || resumeSessionId;
|
||
|
|
return { sessionId, method: "load" };
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
lastError = err;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
throw lastError || new Error("Grok ACP session establish failed");
|
||
|
|
}
|
||
|
|
|
||
|
|
function resultToText(result) {
|
||
|
|
if (result == null) return "";
|
||
|
|
if (typeof result === "string") return result;
|
||
|
|
if (typeof result === "number" || typeof result === "boolean") return String(result);
|
||
|
|
if (typeof result === "object") {
|
||
|
|
if (typeof result.content === "string") return result.content;
|
||
|
|
if (typeof result.text === "string") return result.text;
|
||
|
|
try { return JSON.stringify(result); } catch { return String(result); }
|
||
|
|
}
|
||
|
|
return String(result);
|
||
|
|
}
|
||
|
|
|
||
|
|
function closeReasoning(state, emitter) {
|
||
|
|
if (state?.reasoningOpen) {
|
||
|
|
emitter.reasoningEnd();
|
||
|
|
state.reasoningOpen = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Map one ACP session/update payload (or x.ai notification) to emitter calls.
|
||
|
|
* @returns {boolean} true when the stream should stop on error
|
||
|
|
*/
|
||
|
|
function translateGrokAcpUpdate(update, emitter, state = {}) {
|
||
|
|
if (!update || typeof update !== "object") return false;
|
||
|
|
const kind = String(
|
||
|
|
update.sessionUpdate
|
||
|
|
|| update.type
|
||
|
|
|| update.kind
|
||
|
|
|| "",
|
||
|
|
);
|
||
|
|
|
||
|
|
switch (kind) {
|
||
|
|
case "agent_message_chunk":
|
||
|
|
case "message_chunk":
|
||
|
|
case "text": {
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
const text = update.content?.text
|
||
|
|
?? update.text
|
||
|
|
?? update.data
|
||
|
|
?? (typeof update.content === "string" ? update.content : "");
|
||
|
|
if (text) {
|
||
|
|
emitter.text(String(text));
|
||
|
|
state.streamedAssistantText = true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "agent_thought_chunk":
|
||
|
|
case "thought_chunk":
|
||
|
|
case "thought": {
|
||
|
|
const text = update.content?.text
|
||
|
|
?? update.text
|
||
|
|
?? update.data
|
||
|
|
?? "";
|
||
|
|
if (text) {
|
||
|
|
emitter.reasoning(String(text));
|
||
|
|
state.reasoningOpen = true;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "tool_call": {
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
const id = update.toolCallId || update.tool_call_id || update.id;
|
||
|
|
if (!id) return false;
|
||
|
|
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
||
|
|
if (!state.toolNames) state.toolNames = new Map();
|
||
|
|
const name = String(
|
||
|
|
update.toolName
|
||
|
|
|| update.tool_name
|
||
|
|
|| update.title
|
||
|
|
|| update.kind
|
||
|
|
|| "tool",
|
||
|
|
);
|
||
|
|
const args = update.rawInput && typeof update.rawInput === "object"
|
||
|
|
? update.rawInput
|
||
|
|
: (update.input && typeof update.input === "object" ? update.input : {});
|
||
|
|
state.toolNames.set(id, name);
|
||
|
|
if (!state.emittedToolCalls.has(id)) {
|
||
|
|
state.emittedToolCalls.add(id);
|
||
|
|
emitter.toolCall(name, args, id);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "tool_call_update": {
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
const id = update.toolCallId || update.tool_call_id || update.id;
|
||
|
|
if (!id) return false;
|
||
|
|
if (!state.emittedToolResults) state.emittedToolResults = new Set();
|
||
|
|
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
||
|
|
if (!state.toolNames) state.toolNames = new Map();
|
||
|
|
const status = String(update.status || "").toLowerCase();
|
||
|
|
const name = state.toolNames.get(id)
|
||
|
|
|| String(update.toolName || update.tool_name || update.title || "tool");
|
||
|
|
if (!state.emittedToolCalls.has(id)) {
|
||
|
|
state.emittedToolCalls.add(id);
|
||
|
|
state.toolNames.set(id, name);
|
||
|
|
emitter.toolCall(name, {}, id);
|
||
|
|
}
|
||
|
|
if (
|
||
|
|
status === "completed"
|
||
|
|
|| status === "failed"
|
||
|
|
|| status === "error"
|
||
|
|
|| status === "cancelled"
|
||
|
|
|| update.rawOutput != null
|
||
|
|
) {
|
||
|
|
if (!state.emittedToolResults.has(id)) {
|
||
|
|
state.emittedToolResults.add(id);
|
||
|
|
emitter.toolResult(
|
||
|
|
id,
|
||
|
|
resultToText(update.rawOutput ?? update.content ?? update.error ?? ""),
|
||
|
|
name,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "plan": {
|
||
|
|
// Canonical activity shape: [{ text, completed }] + "running"|"completed"
|
||
|
|
// (sdkAgentAdapter / AgentActivityGroup read text+completed, not content+status).
|
||
|
|
const plan = normalizeGrokPlanUpdate(Array.isArray(update.entries) ? update.entries : []);
|
||
|
|
if (plan && typeof emitter.planUpdate === "function") {
|
||
|
|
emitter.planUpdate("grok-plan", plan.items, plan.status);
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
case "error": {
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(update.message || update.error || "Grok ACP turn failed"));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
default:
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle a parsed JSON-RPC message from grok agent stdio.
|
||
|
|
*/
|
||
|
|
function handleGrokAcpMessage(message, { emitter, state, pending, onPromptComplete }) {
|
||
|
|
if (!message || typeof message !== "object") return;
|
||
|
|
|
||
|
|
// Response to a request
|
||
|
|
if (Object.prototype.hasOwnProperty.call(message, "id") && message.id != null && !message.method) {
|
||
|
|
const waiter = pending.get(message.id);
|
||
|
|
if (waiter) {
|
||
|
|
pending.delete(message.id);
|
||
|
|
if (message.error) {
|
||
|
|
waiter.reject(new Error(
|
||
|
|
message.error.message || message.error.data || JSON.stringify(message.error),
|
||
|
|
));
|
||
|
|
// Do NOT call onPromptComplete on error — that used to resolve a race
|
||
|
|
// peer and swallow the rejection (async request wrap + microtask hop).
|
||
|
|
} else {
|
||
|
|
// Mark turn completed + emit usage synchronously on successful
|
||
|
|
// session/prompt so teardown/close cannot race past usage emission
|
||
|
|
// (UI would fall back to estimated Token ~1).
|
||
|
|
if (state.promptRequestId != null && message.id === state.promptRequestId) {
|
||
|
|
state.turnCompleted = true;
|
||
|
|
emitGrokUsage(emitter, extractGrokAcpPromptUsage(message.result));
|
||
|
|
onPromptComplete?.(message);
|
||
|
|
}
|
||
|
|
waiter.resolve(message.result);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const method = String(message.method || "");
|
||
|
|
const params = message.params && typeof message.params === "object" ? message.params : {};
|
||
|
|
|
||
|
|
if (method === "session/update" || method === "x.ai/session/update") {
|
||
|
|
// session/load may re-broadcast historical agent_message_chunk events for
|
||
|
|
// client UI rebuild. Those must not be written into the *current* Netcatty
|
||
|
|
// assistant bubble — only accept updates after session/prompt is in flight.
|
||
|
|
if (state.acceptUpdates === false) {
|
||
|
|
if (params.sessionId && !state.sessionId) {
|
||
|
|
state.sessionId = params.sessionId;
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const update = params.update || params.sessionUpdate || params;
|
||
|
|
// Nested: params.update.sessionUpdate
|
||
|
|
const payload = update?.sessionUpdate || update?.type
|
||
|
|
? update
|
||
|
|
: (params.sessionUpdate ? { sessionUpdate: params.sessionUpdate, ...params } : update);
|
||
|
|
if (payload) translateGrokAcpUpdate(payload, emitter, state);
|
||
|
|
if (params.sessionId && !state.sessionId) {
|
||
|
|
state.sessionId = params.sessionId;
|
||
|
|
emitter.sessionId?.(params.sessionId);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (method === "session/request_permission" || method === "request_permission") {
|
||
|
|
// Non-interactive: auto-allow when yolo was requested; otherwise deny.
|
||
|
|
// optionId MUST come from params.options (ACP); do not invent ids.
|
||
|
|
const id = message.id;
|
||
|
|
if (id == null) return;
|
||
|
|
const allow = state.autoAllowPermissions !== false;
|
||
|
|
const result = buildGrokAcpPermissionResponse(params, allow);
|
||
|
|
// Caller writes responses via pending write hook
|
||
|
|
if (typeof state.writeResponse === "function") {
|
||
|
|
state.writeResponse(id, result);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Build ACP permission result using an offered option's real optionId.
|
||
|
|
* Prefers allow_always / allow_once kinds when allowing; reject/cancel when denying.
|
||
|
|
*/
|
||
|
|
function buildGrokAcpPermissionResponse(params, allow) {
|
||
|
|
const options = Array.isArray(params?.options) ? params.options : [];
|
||
|
|
const optionKey = (opt) => {
|
||
|
|
const optionId = String(opt?.optionId ?? opt?.id ?? "").trim();
|
||
|
|
const kind = String(opt?.kind ?? opt?.name ?? "").trim();
|
||
|
|
return { optionId, kind, blob: `${optionId} ${kind}`.toLowerCase() };
|
||
|
|
};
|
||
|
|
if (allow) {
|
||
|
|
let best = null;
|
||
|
|
let bestScore = 0;
|
||
|
|
for (const opt of options) {
|
||
|
|
if (!opt || typeof opt !== "object") continue;
|
||
|
|
const { optionId, blob } = optionKey(opt);
|
||
|
|
if (!optionId) continue;
|
||
|
|
let score = 0;
|
||
|
|
if (/allow[_-]?always|always[_-]?allow|allow_for_session|allow-session/.test(blob)) score = 3;
|
||
|
|
else if (/allow[_-]?once|once|allow_this|allow-this/.test(blob)) score = 2;
|
||
|
|
else if (/\ballow\b/.test(blob)) score = 1;
|
||
|
|
if (score > bestScore) {
|
||
|
|
bestScore = score;
|
||
|
|
best = optionId;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (best) return { outcome: { outcome: "selected", optionId: best } };
|
||
|
|
// Agent omitted options: last-resort ACP-ish id (prefer underscore form).
|
||
|
|
return { outcome: { outcome: "selected", optionId: "allow_once" } };
|
||
|
|
}
|
||
|
|
for (const opt of options) {
|
||
|
|
if (!opt || typeof opt !== "object") continue;
|
||
|
|
const { optionId, blob } = optionKey(opt);
|
||
|
|
if (!optionId) continue;
|
||
|
|
if (/reject|cancel|deny|refuse|disallow/.test(blob)) {
|
||
|
|
return { outcome: { outcome: "selected", optionId } };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { outcome: { outcome: "cancelled" } };
|
||
|
|
}
|
||
|
|
|
||
|
|
function createJsonRpcClient({ write, onMessage }) {
|
||
|
|
let nextId = 1;
|
||
|
|
const pending = new Map();
|
||
|
|
|
||
|
|
function request(method, params, { timeoutMs = 120_000 } = {}) {
|
||
|
|
const id = nextId++;
|
||
|
|
const payload = { jsonrpc: "2.0", id, method, params };
|
||
|
|
write(`${JSON.stringify(payload)}\n`);
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
pending.delete(id);
|
||
|
|
reject(new Error(`Grok ACP request timed out: ${method}`));
|
||
|
|
}, timeoutMs);
|
||
|
|
timer.unref?.();
|
||
|
|
pending.set(id, {
|
||
|
|
resolve: (value) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve(value);
|
||
|
|
},
|
||
|
|
reject: (err) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(err);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function notify(method, params) {
|
||
|
|
write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleLine(line) {
|
||
|
|
let message;
|
||
|
|
try {
|
||
|
|
message = JSON.parse(line);
|
||
|
|
} catch {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
onMessage(message, pending);
|
||
|
|
}
|
||
|
|
|
||
|
|
function rejectAll(err) {
|
||
|
|
for (const [, waiter] of pending) {
|
||
|
|
try { waiter.reject(err); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
pending.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
return { request, notify, handleLine, pending, rejectAll, getNextId: () => nextId };
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runGrokAcpTurn({
|
||
|
|
prompt,
|
||
|
|
systemPrompt,
|
||
|
|
binPath,
|
||
|
|
cwd,
|
||
|
|
model,
|
||
|
|
env,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
resumeSessionId,
|
||
|
|
historySeed,
|
||
|
|
injectedMcpServers,
|
||
|
|
emitter,
|
||
|
|
signal,
|
||
|
|
spawnImpl,
|
||
|
|
abortGraceMs = GROK_ACP_ABORT_GRACE_MS,
|
||
|
|
forceKillImpl,
|
||
|
|
// inject for tests: skip real spawn
|
||
|
|
rpcClientFactory,
|
||
|
|
}) {
|
||
|
|
const cliPath = String(binPath || "").trim();
|
||
|
|
if (!cliPath) {
|
||
|
|
emitter.emitError(
|
||
|
|
"Grok Build CLI not found. Install the Grok CLI (`grok`) and ensure it is on PATH, or set the path in Settings → AI.",
|
||
|
|
);
|
||
|
|
return { sessionId: resumeSessionId || null, runtime: "acp" };
|
||
|
|
}
|
||
|
|
|
||
|
|
const effectiveCwd = resolveGrokAcpCwd(cwd);
|
||
|
|
const childEnv = { ...(env || process.env) };
|
||
|
|
const spawnArgs = buildGrokAcpSpawnArgs({
|
||
|
|
model,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
});
|
||
|
|
|
||
|
|
const state = {
|
||
|
|
sessionId: resumeSessionId || null,
|
||
|
|
reasoningOpen: false,
|
||
|
|
streamedAssistantText: false,
|
||
|
|
failed: false,
|
||
|
|
// True after session/prompt resolves successfully. Process teardown (SIGTERM /
|
||
|
|
// taskkill) often yields a non-zero exit on Windows; that must not flip a
|
||
|
|
// completed tool-only turn into emitError (no assistant text).
|
||
|
|
turnCompleted: false,
|
||
|
|
// Suppress session/load history replay until session/prompt starts.
|
||
|
|
acceptUpdates: false,
|
||
|
|
autoAllowPermissions: String(permissionMode || "confirm").toLowerCase() !== "observer",
|
||
|
|
promptRequestId: null,
|
||
|
|
writeResponse: null,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Test inject path: pure RPC loop without process
|
||
|
|
if (typeof rpcClientFactory === "function") {
|
||
|
|
const client = rpcClientFactory({ state, emitter });
|
||
|
|
try {
|
||
|
|
const initResult = await client.request("initialize", buildGrokAcpInitializeParams());
|
||
|
|
await authenticateGrokAcp(client, initResult, childEnv);
|
||
|
|
const established = await establishGrokAcpSession(client, {
|
||
|
|
resumeSessionId,
|
||
|
|
cwd: effectiveCwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
systemContext: systemPrompt,
|
||
|
|
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||
|
|
});
|
||
|
|
state.sessionId = established.sessionId;
|
||
|
|
emitter.sessionId?.(established.sessionId);
|
||
|
|
const effectivePrompt = resolveGrokTurnPrompt({
|
||
|
|
turnPrompt: prompt,
|
||
|
|
historySeed,
|
||
|
|
resumeSessionId,
|
||
|
|
establishMethod: established.method,
|
||
|
|
});
|
||
|
|
// Only accept updates for this turn's prompt (not session/load replay).
|
||
|
|
state.acceptUpdates = true;
|
||
|
|
const promptResult = await client.request(
|
||
|
|
"session/prompt",
|
||
|
|
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||
|
|
);
|
||
|
|
state.turnCompleted = true;
|
||
|
|
// Fixture/RPC inject path has no process race; still emit from the result.
|
||
|
|
emitGrokUsage(emitter, extractGrokAcpPromptUsage(promptResult));
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
if (!state.failed && !signal?.aborted) emitter.emitDone();
|
||
|
|
} catch (err) {
|
||
|
|
if (!state.failed && !signal?.aborted) {
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { sessionId: state.sessionId, runtime: "acp" };
|
||
|
|
}
|
||
|
|
|
||
|
|
let child;
|
||
|
|
try {
|
||
|
|
child = spawnGrokProcess(spawnImpl, cliPath, spawnArgs, {
|
||
|
|
cwd: effectiveCwd,
|
||
|
|
env: childEnv,
|
||
|
|
stdio: ["pipe", "pipe", "pipe"],
|
||
|
|
windowsHide: true,
|
||
|
|
detached: process.platform !== "win32",
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
return { sessionId: state.sessionId, runtime: "acp" };
|
||
|
|
}
|
||
|
|
|
||
|
|
let stderrText = "";
|
||
|
|
let stderrBytes = 0;
|
||
|
|
let stderrTruncated = false;
|
||
|
|
let stderrEnded = false;
|
||
|
|
const stderrDecoder = new StringDecoder("utf8");
|
||
|
|
let settled = false;
|
||
|
|
let forceKillTimer = null;
|
||
|
|
let abortHandler = null;
|
||
|
|
/** @type {ReturnType<typeof createJsonRpcClient>|null} */
|
||
|
|
let rpc = null;
|
||
|
|
|
||
|
|
// Match processErrorGuards / terminalBridge: EPIPE after peer exit is benign.
|
||
|
|
// Without a listener, Node treats async stdin errors as unhandled and can
|
||
|
|
// take down the Electron main process.
|
||
|
|
const isBenignStdinError = (err) => {
|
||
|
|
const code = err?.code;
|
||
|
|
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
||
|
|
};
|
||
|
|
const failTurnFromStdin = (err) => {
|
||
|
|
if (settled || state.failed || signal?.aborted || state.turnCompleted) return;
|
||
|
|
if (isBenignStdinError(err)) return;
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
try { rpc?.rejectAll?.(err || new Error("Grok ACP stdin error")); } catch { /* ignore */ }
|
||
|
|
};
|
||
|
|
if (typeof child.stdin?.on === "function") {
|
||
|
|
child.stdin.on("error", (err) => {
|
||
|
|
// Benign teardown races (peer closed): ignore, same as processErrorGuards.
|
||
|
|
if (isBenignStdinError(err) || settled || state.failed || signal?.aborted || state.turnCompleted) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
failTurnFromStdin(err);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const writeLine = (line) => {
|
||
|
|
const stdin = child?.stdin;
|
||
|
|
if (!stdin || stdin.destroyed || stdin.writable === false) return;
|
||
|
|
try {
|
||
|
|
stdin.write(line, (err) => {
|
||
|
|
if (err) failTurnFromStdin(err);
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
failTurnFromStdin(err);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
state.writeResponse = (id, result) => {
|
||
|
|
writeLine(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
|
||
|
|
};
|
||
|
|
|
||
|
|
rpc = createJsonRpcClient({
|
||
|
|
write: writeLine,
|
||
|
|
onMessage: (message, pending) => {
|
||
|
|
if (signal?.aborted) return;
|
||
|
|
handleGrokAcpMessage(message, {
|
||
|
|
emitter,
|
||
|
|
state,
|
||
|
|
pending,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
// Track prompt request id so completion is detected.
|
||
|
|
// IMPORTANT: keep this wrapper synchronous — an `async` function adds a
|
||
|
|
// microtask hop that lets a concurrent promptDone resolve win Promise.race
|
||
|
|
// and swallow session/prompt rejections (silent success + unhandledRejection).
|
||
|
|
const originalRequest = rpc.request.bind(rpc);
|
||
|
|
rpc.request = (method, params, options) => {
|
||
|
|
const idBefore = rpc.getNextId();
|
||
|
|
if (method === "session/prompt") {
|
||
|
|
state.promptRequestId = idBefore;
|
||
|
|
}
|
||
|
|
return originalRequest(method, params, options);
|
||
|
|
};
|
||
|
|
|
||
|
|
const lineBuffer = createLineBuffer((line) => {
|
||
|
|
if (signal?.aborted) return;
|
||
|
|
try {
|
||
|
|
rpc.handleLine(line);
|
||
|
|
} catch (err) {
|
||
|
|
if (!state.failed) {
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}, MAX_GROK_ACP_LINE_BYTES);
|
||
|
|
|
||
|
|
child.stdout?.on("data", (chunk) => {
|
||
|
|
if (signal?.aborted) return;
|
||
|
|
try {
|
||
|
|
lineBuffer.push(chunk);
|
||
|
|
} catch (err) {
|
||
|
|
if (!state.failed) {
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
}
|
||
|
|
signalProcessTree(child, "SIGKILL", forceKillImpl);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
child.stderr?.on("data", (chunk) => {
|
||
|
|
if (signal?.aborted) return;
|
||
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||
|
|
const remaining = Math.max(0, MAX_GROK_ACP_STDERR_CHARS - stderrBytes);
|
||
|
|
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||
|
|
if (accepted.length > 0) stderrText += stderrDecoder.write(accepted);
|
||
|
|
stderrBytes += accepted.length;
|
||
|
|
if (accepted.length < buffer.length) stderrTruncated = true;
|
||
|
|
});
|
||
|
|
|
||
|
|
const closePromise = new Promise((resolve) => {
|
||
|
|
const finish = () => {
|
||
|
|
if (settled) return;
|
||
|
|
settled = true;
|
||
|
|
clearTimeout(forceKillTimer);
|
||
|
|
if (!signal?.aborted) {
|
||
|
|
try { lineBuffer.flush(); } catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
resolve();
|
||
|
|
};
|
||
|
|
child.on("error", (err) => {
|
||
|
|
if (!state.failed && !signal?.aborted) {
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
||
|
|
}
|
||
|
|
rpc.rejectAll(err || new Error("Grok ACP process error"));
|
||
|
|
finish();
|
||
|
|
});
|
||
|
|
child.on("close", (code, exitSignal) => {
|
||
|
|
if (!stderrEnded) {
|
||
|
|
stderrEnded = true;
|
||
|
|
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderrText += stderrDecoder.end();
|
||
|
|
}
|
||
|
|
// Only ignore exit after session/prompt succeeded (turnCompleted).
|
||
|
|
// Incomplete turns fail even on exit 0; signal kills report code=null.
|
||
|
|
if (shouldReportGrokProcessExitFailure(state, signal, code, exitSignal)) {
|
||
|
|
const stderr = stderrText.trim();
|
||
|
|
const detail = code != null && code !== 0
|
||
|
|
? `exited with code ${code}`
|
||
|
|
: (exitSignal ? `terminated by signal ${exitSignal}` : "exited before the turn completed");
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser(stderr || `Grok ACP ${detail}`));
|
||
|
|
}
|
||
|
|
// Unblock any in-flight RPC (initialize/prompt) so await rejects into catch.
|
||
|
|
rpc.rejectAll(new Error("Grok ACP process closed"));
|
||
|
|
finish();
|
||
|
|
});
|
||
|
|
|
||
|
|
let terminationStarted = false;
|
||
|
|
abortHandler = () => {
|
||
|
|
if (settled || terminationStarted) return;
|
||
|
|
terminationStarted = true;
|
||
|
|
// Prefer protocol cancel when a session is active (ACP prompt-turn).
|
||
|
|
if (state.sessionId) {
|
||
|
|
try {
|
||
|
|
rpc.notify("session/cancel", { sessionId: state.sessionId });
|
||
|
|
} catch { /* ignore */ }
|
||
|
|
}
|
||
|
|
forceKillTimer = setTimeout(() => {
|
||
|
|
if (settled) return;
|
||
|
|
signalProcessTree(child, "SIGKILL", forceKillImpl);
|
||
|
|
finish();
|
||
|
|
}, Math.max(0, abortGraceMs));
|
||
|
|
forceKillTimer.unref?.();
|
||
|
|
signalProcessTree(child, "SIGTERM");
|
||
|
|
try { child.stdin?.end(); } catch { /* ignore */ }
|
||
|
|
};
|
||
|
|
if (signal) {
|
||
|
|
if (signal.aborted) abortHandler();
|
||
|
|
else signal.addEventListener("abort", abortHandler, { once: true });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
const initResult = await rpc.request(
|
||
|
|
"initialize",
|
||
|
|
buildGrokAcpInitializeParams(),
|
||
|
|
{ timeoutMs: 30_000 },
|
||
|
|
);
|
||
|
|
await authenticateGrokAcp(rpc, initResult, childEnv);
|
||
|
|
|
||
|
|
const established = await establishGrokAcpSession(rpc, {
|
||
|
|
resumeSessionId,
|
||
|
|
cwd: effectiveCwd,
|
||
|
|
injectedMcpServers,
|
||
|
|
permissionMode,
|
||
|
|
toolIntegrationMode,
|
||
|
|
systemContext: systemPrompt,
|
||
|
|
agentCapabilities: parseGrokAcpAgentCapabilities(initResult),
|
||
|
|
});
|
||
|
|
state.sessionId = established.sessionId;
|
||
|
|
emitter.sessionId?.(established.sessionId);
|
||
|
|
const effectivePrompt = resolveGrokTurnPrompt({
|
||
|
|
turnPrompt: prompt,
|
||
|
|
historySeed,
|
||
|
|
resumeSessionId,
|
||
|
|
establishMethod: established.method,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Accept streamed updates only for this turn's prompt (not session/load replay).
|
||
|
|
state.acceptUpdates = true;
|
||
|
|
|
||
|
|
// Await the prompt RPC only (no race with promptDone). Process death
|
||
|
|
// rejects via rejectAll on close; success sets turnCompleted + usage in
|
||
|
|
// handleGrokAcpMessage before resolve.
|
||
|
|
await rpc.request(
|
||
|
|
"session/prompt",
|
||
|
|
buildGrokAcpPromptParams(state.sessionId, effectivePrompt),
|
||
|
|
{ timeoutMs: 30 * 60_000 },
|
||
|
|
);
|
||
|
|
} catch (err) {
|
||
|
|
if (!state.failed && !signal?.aborted) {
|
||
|
|
state.failed = true;
|
||
|
|
const message = err?.message || String(err);
|
||
|
|
const stderr = stderrText.trim();
|
||
|
|
emitter.emitError(formatGrokErrorForUser(
|
||
|
|
stderr && !message.includes(stderr) ? `${message} (${stderr})` : message,
|
||
|
|
));
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
try { child.stdin?.end(); } catch { /* ignore */ }
|
||
|
|
// Give the process a moment to exit cleanly after prompt completes.
|
||
|
|
if (!settled && child.exitCode == null && !child.killed) {
|
||
|
|
signalProcessTree(child, "SIGTERM", forceKillImpl);
|
||
|
|
}
|
||
|
|
await closePromise;
|
||
|
|
if (signal) signal.removeEventListener("abort", abortHandler);
|
||
|
|
}
|
||
|
|
|
||
|
|
closeReasoning(state, emitter);
|
||
|
|
// Hard gate: incomplete protocol must not look like success (exit 0 / swallowed
|
||
|
|
// RPC error previously fell through to emitDone).
|
||
|
|
if (signal?.aborted) {
|
||
|
|
// User cancel.
|
||
|
|
} else if (state.failed) {
|
||
|
|
// Already reported.
|
||
|
|
} else if (state.turnCompleted) {
|
||
|
|
emitter.emitDone();
|
||
|
|
} else {
|
||
|
|
state.failed = true;
|
||
|
|
emitter.emitError(formatGrokErrorForUser("Grok ACP ended before the turn completed"));
|
||
|
|
}
|
||
|
|
|
||
|
|
return { sessionId: state.sessionId, runtime: "acp" };
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
ACP_PROTOCOL_VERSION,
|
||
|
|
authenticateGrokAcp,
|
||
|
|
buildGrokAcpInitializeParams,
|
||
|
|
buildGrokAcpPermissionResponse,
|
||
|
|
buildGrokAcpPromptParams,
|
||
|
|
buildGrokAcpSessionNewParams,
|
||
|
|
buildGrokAcpSessionResumeOrLoadParams,
|
||
|
|
buildGrokAcpSpawnArgs,
|
||
|
|
createJsonRpcClient,
|
||
|
|
establishGrokAcpSession,
|
||
|
|
handleGrokAcpMessage,
|
||
|
|
parseGrokAcpAgentCapabilities,
|
||
|
|
parseGrokAcpModelCatalog,
|
||
|
|
planGrokAcpSessionEstablish,
|
||
|
|
resolveGrokAcpCwd,
|
||
|
|
runGrokAcpTurn,
|
||
|
|
listGrokAcpModels,
|
||
|
|
selectGrokAcpAuthMethodId,
|
||
|
|
toAcpMcpEnvPairs,
|
||
|
|
toAcpMcpServers,
|
||
|
|
translateGrokAcpUpdate,
|
||
|
|
};
|