Files
NetMesh/electron/bridges/aiBridge/sdk/grokAcpDriver.cjs

1291 lines
42 KiB
JavaScript
Raw Normal View History

"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,
};