Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1093 lines
34 KiB
JavaScript
1093 lines
34 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* Grok Build CLI turn runner — headless streaming-json path.
|
|
*
|
|
* Spawns the system `grok` binary with `-p` / `--output-format streaming-json`
|
|
* and maps ACP-derived NDJSON events into the canonical Netcatty emitter shapes.
|
|
*
|
|
* Netcatty MCP is injected by merging a project-scoped
|
|
* `.grok/config.toml` `[mcp_servers.<name>]` section (restored after the turn),
|
|
* matching the Cursor CLI workspace MCP merge pattern.
|
|
*/
|
|
const { spawn } = require("node:child_process");
|
|
const { StringDecoder } = require("node:string_decoder");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const { prepareCommandForSpawn } = require("../../ai/shellUtils.cjs");
|
|
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
|
|
|
const NETCATTY_MCP_NAME = "netcatty-remote-hosts";
|
|
const GROK_ABORT_GRACE_MS = 1_500;
|
|
const MAX_GROK_STDERR_CHARS = 64 * 1024;
|
|
const MAX_GROK_MODEL_STDOUT_CHARS = 1024 * 1024;
|
|
const MAX_GROK_LINE_BYTES = 10 * 1024 * 1024;
|
|
const GROK_REASONING_EFFORTS = new Set([
|
|
"none",
|
|
"minimal",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"max",
|
|
]);
|
|
const GROK_REASONING_FALLBACKS = Object.freeze({
|
|
"grok-4.5": {
|
|
name: "Grok 4.5",
|
|
thinkingLevels: ["high", "medium", "low"],
|
|
defaultThinkingLevel: "high",
|
|
},
|
|
"grok-4.6": {
|
|
name: "Grok 4.6",
|
|
thinkingLevels: ["xhigh", "high", "medium", "low"],
|
|
defaultThinkingLevel: "high",
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Resolve Grok CLI spawn target for Windows .cmd/.bat shims.
|
|
* Matches other managed agents (agentCliHelpers / Codex login).
|
|
*/
|
|
function resolveGrokSpawnSpec(cliPath, args) {
|
|
return prepareCommandForSpawn(String(cliPath || "").trim(), Array.isArray(args) ? args : []);
|
|
}
|
|
|
|
/**
|
|
* Spawn grok via prepareCommandForSpawn so Windows npm/installer shims work.
|
|
* @param {Function|undefined} spawnImpl
|
|
* @param {string} cliPath
|
|
* @param {string[]} args
|
|
* @param {import('node:child_process').SpawnOptionsWithoutStdio & object} options
|
|
*/
|
|
function spawnGrokProcess(spawnImpl, cliPath, args, options = {}) {
|
|
const spawnFn = spawnImpl || spawn;
|
|
const spawnSpec = resolveGrokSpawnSpec(cliPath, args);
|
|
return spawnFn(spawnSpec.command, spawnSpec.args, {
|
|
...options,
|
|
shell: spawnSpec.shell,
|
|
});
|
|
}
|
|
|
|
function signalGrokProcessTree(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 */ }
|
|
}
|
|
|
|
function escapeTomlBasicString(value) {
|
|
return String(value ?? "")
|
|
.replace(/\\/g, "\\\\")
|
|
.replace(/"/g, "\\\"");
|
|
}
|
|
|
|
function formatTomlEnvTable(envObject) {
|
|
if (!envObject || typeof envObject !== "object") return "{}";
|
|
const parts = [];
|
|
for (const [key, value] of Object.entries(envObject)) {
|
|
if (typeof key !== "string" || typeof value !== "string") continue;
|
|
parts.push(`${key} = "${escapeTomlBasicString(value)}"`);
|
|
}
|
|
if (parts.length === 0) return "{}";
|
|
return `{ ${parts.join(", ")} }`;
|
|
}
|
|
|
|
function formatTomlStringArray(values) {
|
|
if (!Array.isArray(values) || values.length === 0) return "[]";
|
|
return `[${values.map((v) => `"${escapeTomlBasicString(v)}"`).join(", ")}]`;
|
|
}
|
|
|
|
/**
|
|
* Build a `[mcp_servers.<name>]` TOML block for Grok project config.
|
|
*/
|
|
function buildGrokMcpServerTomlSection(cfg) {
|
|
if (!cfg || !cfg.name || !cfg.command) return "";
|
|
const name = String(cfg.name).replace(/[^a-zA-Z0-9_-]/g, "_") || NETCATTY_MCP_NAME;
|
|
const env = mcpEnvPairsToObject(cfg.env);
|
|
const lines = [
|
|
`[mcp_servers.${name}]`,
|
|
`command = "${escapeTomlBasicString(cfg.command)}"`,
|
|
`args = ${formatTomlStringArray(cfg.args || [])}`,
|
|
`env = ${formatTomlEnvTable(env)}`,
|
|
"enabled = true",
|
|
"",
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Remove prior `[mcp_servers.<name>]` (and nested tables under that prefix).
|
|
*/
|
|
function stripGrokMcpServerSection(tomlText, serverName) {
|
|
const name = String(serverName || NETCATTY_MCP_NAME).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
const lines = String(tomlText || "").split(/\r?\n/);
|
|
const out = [];
|
|
let skipping = false;
|
|
const headerExact = `[mcp_servers.${name}]`;
|
|
const headerNestedPrefix = `[mcp_servers.${name}.`;
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
if (trimmed === headerExact || trimmed.startsWith(headerNestedPrefix)) {
|
|
skipping = true;
|
|
continue;
|
|
}
|
|
skipping = false;
|
|
}
|
|
if (!skipping) out.push(line);
|
|
}
|
|
|
|
// Drop trailing blank lines that strip left behind; keep a single trailing newline later.
|
|
while (out.length > 0 && out[out.length - 1].trim() === "") out.pop();
|
|
return out.join("\n");
|
|
}
|
|
|
|
// Per-path refcount so concurrent turns share one original snapshot.
|
|
const grokMcpMergeRefcounts = new Map();
|
|
|
|
function mergeWorkspaceGrokMcpToml(cwd, injectedMcpServers, {
|
|
readFileSync,
|
|
writeFileSync,
|
|
mkdirSync,
|
|
existsSync,
|
|
unlinkSync,
|
|
} = {}) {
|
|
const read = readFileSync || fs.readFileSync;
|
|
const write = writeFileSync || fs.writeFileSync;
|
|
const mkdir = mkdirSync || fs.mkdirSync;
|
|
const exists = existsSync || fs.existsSync;
|
|
const unlink = unlinkSync || ((p) => fs.unlinkSync(p));
|
|
|
|
const grokDir = path.join(cwd || process.cwd(), ".grok");
|
|
const configPath = path.join(grokDir, "config.toml");
|
|
|
|
let state = grokMcpMergeRefcounts.get(configPath);
|
|
if (!state) {
|
|
let previousRaw = null;
|
|
let previousExisted = false;
|
|
if (exists(configPath)) {
|
|
previousExisted = true;
|
|
previousRaw = read(configPath, "utf8");
|
|
}
|
|
state = { refCount: 0, previousRaw, previousExisted };
|
|
grokMcpMergeRefcounts.set(configPath, state);
|
|
}
|
|
state.refCount += 1;
|
|
|
|
let baseText = "";
|
|
if (exists(configPath)) {
|
|
try {
|
|
baseText = String(read(configPath, "utf8") || "");
|
|
} catch {
|
|
baseText = state.previousRaw || "";
|
|
}
|
|
} else if (state.previousExisted && state.previousRaw) {
|
|
baseText = state.previousRaw;
|
|
}
|
|
|
|
let nextText = baseText;
|
|
const sections = [];
|
|
for (const cfg of injectedMcpServers || []) {
|
|
if (!cfg || !cfg.name || !cfg.command) continue;
|
|
nextText = stripGrokMcpServerSection(nextText, cfg.name);
|
|
const section = buildGrokMcpServerTomlSection(cfg);
|
|
if (section) sections.push(section.trimEnd());
|
|
}
|
|
|
|
if (sections.length > 0) {
|
|
const body = nextText.trimEnd();
|
|
nextText = body
|
|
? `${body}\n\n${sections.join("\n\n")}\n`
|
|
: `${sections.join("\n\n")}\n`;
|
|
}
|
|
|
|
try {
|
|
if (!exists(grokDir)) {
|
|
mkdir(grokDir, { recursive: true });
|
|
}
|
|
write(configPath, nextText, "utf8");
|
|
} catch (err) {
|
|
state.refCount = Math.max(0, state.refCount - 1);
|
|
if (state.refCount === 0) grokMcpMergeRefcounts.delete(configPath);
|
|
throw err;
|
|
}
|
|
|
|
let restored = false;
|
|
return {
|
|
configPath,
|
|
restore() {
|
|
if (restored) return;
|
|
restored = true;
|
|
const current = grokMcpMergeRefcounts.get(configPath);
|
|
if (!current) return;
|
|
current.refCount = Math.max(0, current.refCount - 1);
|
|
if (current.refCount > 0) return;
|
|
grokMcpMergeRefcounts.delete(configPath);
|
|
try {
|
|
if (current.previousExisted) write(configPath, current.previousRaw, "utf8");
|
|
else if (exists(configPath)) unlink(configPath);
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function resetGrokMcpMergeRefcountsForTests() {
|
|
grokMcpMergeRefcounts.clear();
|
|
}
|
|
|
|
function resolveGrokPermissionFlags(permissionMode) {
|
|
const mode = String(permissionMode || "confirm").toLowerCase();
|
|
if (mode === "observer") {
|
|
// Plan mode keeps the agent from applying side-effecting edits without
|
|
// interactive approval UI (which headless cannot provide).
|
|
return ["--permission-mode", "plan"];
|
|
}
|
|
// confirm / auto / unknown: non-interactive headless must not hang on prompts.
|
|
return ["--always-approve"];
|
|
}
|
|
|
|
/**
|
|
* Local Grok built-ins that must not run side effects on the desktop machine
|
|
* when Tool Access is MCP. Remote session work goes through injected Netcatty
|
|
* MCP (meta-tools remain available under --disallowed-tools per Grok docs).
|
|
* Both historical (`run_terminal_cmd`) and current (`run_terminal_command`)
|
|
* shell IDs are listed so older/newer CLIs stay covered.
|
|
*/
|
|
const GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS = [
|
|
"run_terminal_command",
|
|
"run_terminal_cmd",
|
|
"search_replace",
|
|
"write",
|
|
"Agent",
|
|
];
|
|
|
|
/**
|
|
* Build --disallowed-tools flags for the active tool-integration mode.
|
|
* - mcp (default): strip local shell/edit/write so Claude-style MCP path is used.
|
|
* - skills: no lockdown here; Netcatty CLI skill needs local shell.
|
|
*/
|
|
function resolveGrokToolIntegrationFlags(toolIntegrationMode) {
|
|
const mode = String(toolIntegrationMode || "mcp").toLowerCase();
|
|
if (mode === "skills") return [];
|
|
return [
|
|
"--disallowed-tools",
|
|
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The renderer encodes a reasoning selection as `<model>/<effort>`. Grok's
|
|
* CLI requires those values as separate flags, and model ids may themselves
|
|
* contain `/`, so split only a recognized trailing effort token.
|
|
*/
|
|
function parseGrokModelSelection(model) {
|
|
const value = String(model || "").trim();
|
|
const slash = value.lastIndexOf("/");
|
|
const effort = slash > 0 ? value.slice(slash + 1).toLowerCase() : "";
|
|
if (slash > 0 && GROK_REASONING_EFFORTS.has(effort)) {
|
|
return { model: value.slice(0, slash), effort };
|
|
}
|
|
return { model: value || undefined, effort: undefined };
|
|
}
|
|
|
|
function buildGrokCliArgs({
|
|
prompt,
|
|
model,
|
|
cwd,
|
|
resumeSessionId,
|
|
permissionMode,
|
|
toolIntegrationMode,
|
|
}) {
|
|
// Headless automation: skip background update checks (xAI headless docs).
|
|
const args = [
|
|
"--no-auto-update",
|
|
"-p",
|
|
String(prompt || ""),
|
|
"--output-format",
|
|
"streaming-json",
|
|
];
|
|
const selection = parseGrokModelSelection(model);
|
|
if (selection.model) {
|
|
args.push("-m", selection.model);
|
|
}
|
|
if (selection.effort) {
|
|
args.push("--reasoning-effort", selection.effort);
|
|
}
|
|
if (cwd) {
|
|
args.push("--cwd", String(cwd));
|
|
}
|
|
if (resumeSessionId) {
|
|
args.push("-r", String(resumeSessionId));
|
|
}
|
|
args.push(...resolveGrokPermissionFlags(permissionMode));
|
|
args.push(...resolveGrokToolIntegrationFlags(toolIntegrationMode));
|
|
return args;
|
|
}
|
|
|
|
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;
|
|
if (typeof result.message === "string") return result.message;
|
|
try { return JSON.stringify(result); } catch { return String(result); }
|
|
}
|
|
return String(result);
|
|
}
|
|
|
|
function closeReasoning(state, emitter) {
|
|
if (state?.reasoningOpen) {
|
|
emitter.reasoningEnd();
|
|
state.reasoningOpen = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Normalize Grok/ACP plan entries to the shared activity shape:
|
|
* items: [{ text, completed }], status: "running" | "completed"
|
|
* (see domain/agentActivity.ts, emit.planUpdate, Codex App Server turn/plan/updated).
|
|
*/
|
|
function normalizeGrokPlanUpdate(entries) {
|
|
const items = [];
|
|
for (let index = 0; index < (entries || []).length; index += 1) {
|
|
const entry = entries[index];
|
|
let text = "";
|
|
let statusRaw = "";
|
|
if (typeof entry === "string") {
|
|
text = entry;
|
|
} else if (entry && typeof entry === "object") {
|
|
text = String(entry.content || entry.text || entry.title || entry.step || "");
|
|
statusRaw = String(entry.status || entry.state || "");
|
|
} else if (entry != null) {
|
|
text = String(entry);
|
|
}
|
|
text = text.trim();
|
|
if (!text) continue;
|
|
const status = statusRaw.toLowerCase();
|
|
const completed = status === "completed"
|
|
|| status === "complete"
|
|
|| status === "done"
|
|
|| status === "finished"
|
|
|| status === "success"
|
|
|| entry?.completed === true;
|
|
items.push({ text, completed: Boolean(completed) });
|
|
}
|
|
if (items.length === 0) return null;
|
|
const status = items.every((item) => item.completed) ? "completed" : "running";
|
|
return { items, status };
|
|
}
|
|
|
|
/**
|
|
* Map one Grok streaming-json event into canonical emitter calls.
|
|
* Returns true when the stream should stop (terminal error).
|
|
*/
|
|
function translateGrokStreamEvent(event, emitter, state = {}) {
|
|
if (!event || typeof event !== "object") return false;
|
|
const type = String(event.type || "");
|
|
|
|
switch (type) {
|
|
case "thought": {
|
|
const data = event.data != null ? String(event.data) : "";
|
|
if (data) {
|
|
emitter.reasoning(data);
|
|
state.reasoningOpen = true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
case "text": {
|
|
closeReasoning(state, emitter);
|
|
const data = event.data != null ? String(event.data) : "";
|
|
if (data) {
|
|
emitter.text(data);
|
|
state.streamedAssistantText = true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
case "tool_call": {
|
|
closeReasoning(state, emitter);
|
|
const id = event.toolCallId || event.tool_call_id || event.id;
|
|
if (!id) return false;
|
|
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
|
|
if (!state.toolNames) state.toolNames = new Map();
|
|
const name = String(event.toolName || event.tool_name || event.title || "tool");
|
|
const args = event.rawInput && typeof event.rawInput === "object"
|
|
? event.rawInput
|
|
: (event.input && typeof event.input === "object" ? event.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 = event.toolCallId || event.tool_call_id || event.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(event.status || "").toLowerCase();
|
|
const name = state.toolNames.get(id)
|
|
|| String(event.toolName || event.tool_name || event.title || "tool");
|
|
const args = event.rawInput && typeof event.rawInput === "object"
|
|
? event.rawInput
|
|
: {};
|
|
|
|
if (!state.emittedToolCalls.has(id)) {
|
|
state.emittedToolCalls.add(id);
|
|
state.toolNames.set(id, name);
|
|
emitter.toolCall(name, args, id);
|
|
}
|
|
|
|
// Align with ACP: emit result on terminal status OR when rawOutput is present
|
|
// (some CLI builds omit status on the final tool_call_update).
|
|
if (
|
|
status === "completed"
|
|
|| status === "failed"
|
|
|| status === "error"
|
|
|| status === "cancelled"
|
|
|| event.rawOutput != null
|
|
) {
|
|
if (!state.emittedToolResults.has(id)) {
|
|
state.emittedToolResults.add(id);
|
|
const output = event.rawOutput != null
|
|
? resultToText(event.rawOutput)
|
|
: resultToText(event.content || event.error || "");
|
|
emitter.toolResult(id, output, name);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
case "plan": {
|
|
const plan = normalizeGrokPlanUpdate(Array.isArray(event.entries) ? event.entries : []);
|
|
if (plan && typeof emitter.planUpdate === "function") {
|
|
emitter.planUpdate(event.itemId || "grok-plan", plan.items, plan.status);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
case "usage": {
|
|
const usage = event.usage && typeof event.usage === "object" ? event.usage : event;
|
|
emitGrokUsage(emitter, usage);
|
|
return false;
|
|
}
|
|
|
|
case "end": {
|
|
closeReasoning(state, emitter);
|
|
state.turnCompleted = true;
|
|
if (event.sessionId || event.session_id) {
|
|
const sessionId = event.sessionId || event.session_id;
|
|
state.sessionId = sessionId;
|
|
emitter.sessionId?.(sessionId);
|
|
}
|
|
const usage = event.usage && typeof event.usage === "object" ? event.usage : null;
|
|
emitGrokUsage(emitter, usage);
|
|
return false;
|
|
}
|
|
|
|
case "error": {
|
|
closeReasoning(state, emitter);
|
|
state.failed = true;
|
|
const message = String(event.message || event.error || "Grok Build turn failed");
|
|
emitter.emitError(formatGrokErrorForUser(message));
|
|
return true;
|
|
}
|
|
|
|
case "available_commands":
|
|
case "max_turns_reached":
|
|
case "auto_compact_start":
|
|
case "auto_compact_end":
|
|
return false;
|
|
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isGrokAuthFailureMessage(message) {
|
|
const text = String(message || "");
|
|
if (
|
|
/not authenticated|not logged in|please run .*login|unauthenticated|unauthorized|sign in|auth(?:entication)? (?:failed|required|missing)/i.test(text)
|
|
) {
|
|
return true;
|
|
}
|
|
if (/\bxai[_\s-]?api[_\s-]?key\b/i.test(text) && /invalid|missing|required|auth/i.test(text)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function formatGrokErrorForUser(message) {
|
|
const text = String(message || "").trim();
|
|
if (isGrokAuthFailureMessage(text)) {
|
|
return "Grok Build is not logged in. Run `grok login` or set XAI_API_KEY, then retry.";
|
|
}
|
|
return text || "Grok Build turn failed";
|
|
}
|
|
|
|
/** Normalize dual Grok runtime tokens (ACP vs headless streaming-json). */
|
|
function resolveGrokRuntime(value) {
|
|
const raw = String(value || "").trim().toLowerCase();
|
|
if (raw === "streaming-json" || raw === "cli" || raw === "headless") return "streaming-json";
|
|
return "acp";
|
|
}
|
|
|
|
/**
|
|
* After establish: only inject historySeed when we asked to resume but landed
|
|
* on session/new (stale/missing Grok session). Successful resume/load must not
|
|
* seed or prior turns duplicate into the current bubble.
|
|
*/
|
|
function resolveGrokTurnPrompt({
|
|
turnPrompt,
|
|
historySeed,
|
|
resumeSessionId,
|
|
establishMethod,
|
|
} = {}) {
|
|
const prompt = String(turnPrompt || "");
|
|
const seed = String(historySeed || "").trim();
|
|
if (!resumeSessionId || !seed) return prompt;
|
|
if (String(establishMethod || "").toLowerCase() !== "new") return prompt;
|
|
return prompt ? `${seed}\n\n${prompt}` : seed;
|
|
}
|
|
|
|
/**
|
|
* Pick usage from a Grok ACP session/prompt result.
|
|
* Live Grok 0.2.x shape (verified):
|
|
* { stopReason, _meta: { inputTokens, outputTokens, cachedReadTokens,
|
|
* reasoningTokens, totalTokens, usage: { …same camelCase… } } }
|
|
* There is no top-level result.usage.
|
|
*/
|
|
function extractGrokAcpPromptUsage(promptResult) {
|
|
if (!promptResult || typeof promptResult !== "object") return null;
|
|
const meta = promptResult._meta && typeof promptResult._meta === "object"
|
|
? promptResult._meta
|
|
: (promptResult.meta && typeof promptResult.meta === "object" ? promptResult.meta : null);
|
|
if (promptResult.usage && typeof promptResult.usage === "object") return promptResult.usage;
|
|
if (meta?.usage && typeof meta.usage === "object") return meta.usage;
|
|
if (
|
|
meta
|
|
&& (
|
|
meta.inputTokens != null
|
|
|| meta.outputTokens != null
|
|
|| meta.totalTokens != null
|
|
|| meta.input_tokens != null
|
|
|| meta.output_tokens != null
|
|
)
|
|
) {
|
|
return meta;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Whether a process close should fail the turn.
|
|
* - turnCompleted: protocol finished (end / session/prompt) → ignore teardown noise
|
|
* - user abort: not a failure
|
|
* - otherwise any close before completion is a failure — including exit 0
|
|
* (CLI can die without an end/prompt result) and code=null + signal
|
|
* (Node reports code=null when killed by signal)
|
|
*/
|
|
function shouldReportGrokProcessExitFailure(state, abortSignal, _code, _exitSignal) {
|
|
if (state?.failed) return false;
|
|
if (abortSignal?.aborted) return false;
|
|
if (state?.turnCompleted) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Forward Grok/ACP usage onto the canonical emitter.
|
|
* Supports Anthropic snake_case and Grok ACP camelCase (cachedReadTokens).
|
|
*/
|
|
function emitGrokUsage(emitter, usage) {
|
|
if (!usage || typeof usage !== "object" || typeof emitter?.usage !== "function") return false;
|
|
const inputTokens = Number(
|
|
usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens,
|
|
) || 0;
|
|
const outputTokens = Number(
|
|
usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens,
|
|
) || 0;
|
|
const cachedInputTokens = Number(
|
|
usage.cache_read_input_tokens
|
|
?? usage.cached_input_tokens
|
|
?? usage.cachedInputTokens
|
|
?? usage.cachedReadTokens
|
|
?? usage.cached_read_tokens
|
|
) || 0;
|
|
const reasoningTokens = Number(
|
|
usage.reasoning_tokens
|
|
?? usage.reasoningTokens
|
|
?? usage.reasoning_output_tokens
|
|
?? usage.reasoningOutputTokens
|
|
) || 0;
|
|
let totalTokens = Number(usage.total_tokens ?? usage.totalTokens) || 0;
|
|
if (!totalTokens && (inputTokens || outputTokens)) {
|
|
totalTokens = inputTokens + outputTokens;
|
|
}
|
|
if (!inputTokens && !outputTokens && !totalTokens && !cachedInputTokens && !reasoningTokens) {
|
|
return false;
|
|
}
|
|
emitter.usage({
|
|
inputTokens,
|
|
cachedInputTokens,
|
|
outputTokens,
|
|
reasoningTokens,
|
|
totalTokens,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function createLineBuffer(onLine, maxBufferBytes = MAX_GROK_LINE_BYTES) {
|
|
let buffer = "";
|
|
let bufferedBytes = 0;
|
|
let overflowed = false;
|
|
const decoder = new StringDecoder("utf8");
|
|
return {
|
|
push(chunk) {
|
|
if (overflowed) return;
|
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk || ""));
|
|
bufferedBytes += bytes.length;
|
|
buffer += decoder.write(bytes);
|
|
let idx;
|
|
let consumedLine = false;
|
|
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
const line = buffer.slice(0, idx).trim();
|
|
buffer = buffer.slice(idx + 1);
|
|
consumedLine = true;
|
|
if (line) onLine(line);
|
|
}
|
|
if (consumedLine) bufferedBytes = Buffer.byteLength(buffer, "utf8") + decoder.lastNeed;
|
|
if (bufferedBytes > maxBufferBytes) {
|
|
overflowed = true;
|
|
buffer = "";
|
|
const error = new Error(`Grok Build message exceeded ${maxBufferBytes} bytes`);
|
|
error.code = "GROK_LINE_LIMIT";
|
|
throw error;
|
|
}
|
|
},
|
|
flush() {
|
|
if (overflowed) return;
|
|
buffer += decoder.end();
|
|
const line = buffer.trim();
|
|
buffer = "";
|
|
if (line) onLine(line);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function runGrokTurn({
|
|
prompt,
|
|
binPath,
|
|
cwd,
|
|
model,
|
|
env,
|
|
permissionMode,
|
|
toolIntegrationMode,
|
|
resumeSessionId,
|
|
injectedMcpServers,
|
|
emitter,
|
|
signal,
|
|
spawnImpl,
|
|
mergeMcp,
|
|
abortGraceMs = GROK_ABORT_GRACE_MS,
|
|
forceKillImpl,
|
|
}) {
|
|
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: "streaming-json" };
|
|
}
|
|
|
|
const effectiveCwd = String(cwd || process.cwd() || "").trim() || process.cwd();
|
|
const childEnv = { ...(env || process.env) };
|
|
const args = buildGrokCliArgs({
|
|
prompt,
|
|
model,
|
|
cwd: effectiveCwd,
|
|
resumeSessionId,
|
|
permissionMode,
|
|
toolIntegrationMode,
|
|
});
|
|
|
|
const doMerge = mergeMcp || mergeWorkspaceGrokMcpToml;
|
|
let mcpHandle = null;
|
|
if (Array.isArray(injectedMcpServers) && injectedMcpServers.length > 0) {
|
|
try {
|
|
mcpHandle = doMerge(effectiveCwd, injectedMcpServers);
|
|
} catch (err) {
|
|
emitter.emitError(
|
|
"Failed to prepare Netcatty MCP for Grok Build "
|
|
+ `(cannot write project .grok/config.toml: ${err?.message || err}). `
|
|
+ "Terminal tools will be unavailable.",
|
|
);
|
|
return { sessionId: resumeSessionId || null, runtime: "streaming-json" };
|
|
}
|
|
}
|
|
|
|
const state = {
|
|
sessionId: resumeSessionId || null,
|
|
reasoningOpen: false,
|
|
streamedAssistantText: false,
|
|
failed: false,
|
|
// True after a terminal stream event (end/result). Avoid treating forced
|
|
// process teardown exit codes as failures on tool-only turns.
|
|
turnCompleted: false,
|
|
};
|
|
|
|
let child = null;
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
try { mcpHandle?.restore?.(); } catch { /* ignore */ }
|
|
};
|
|
|
|
try {
|
|
child = spawnGrokProcess(spawnImpl, cliPath, args, {
|
|
cwd: effectiveCwd,
|
|
env: childEnv,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
detached: process.platform !== "win32",
|
|
});
|
|
} catch (err) {
|
|
cleanup();
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
|
return { sessionId: state.sessionId, runtime: "streaming-json" };
|
|
}
|
|
|
|
const handleLine = (line) => {
|
|
if (signal?.aborted) return;
|
|
let event;
|
|
try {
|
|
event = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
const stop = translateGrokStreamEvent(event, emitter, state);
|
|
if (stop && !signal?.aborted) state.failed = true;
|
|
};
|
|
|
|
const stdoutBuffer = createLineBuffer(handleLine);
|
|
let stderrText = "";
|
|
let stderrBytes = 0;
|
|
let stderrTruncated = false;
|
|
let stderrEnded = false;
|
|
const stderrDecoder = new StringDecoder("utf8");
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
if (signal?.aborted) return;
|
|
try {
|
|
stdoutBuffer.push(chunk);
|
|
} catch (error) {
|
|
if (!state.failed) {
|
|
state.failed = true;
|
|
emitter.emitError(formatGrokErrorForUser(error?.message || String(error)));
|
|
}
|
|
signalGrokProcessTree(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_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;
|
|
});
|
|
|
|
let abortHandler = null;
|
|
let forceKillTimer = null;
|
|
await new Promise((resolve) => {
|
|
const finish = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(forceKillTimer);
|
|
if (!signal?.aborted) stdoutBuffer.flush();
|
|
resolve();
|
|
};
|
|
child.on("error", (err) => {
|
|
if (!state.failed && !signal?.aborted) {
|
|
state.failed = true;
|
|
emitter.emitError(formatGrokErrorForUser(err?.message || String(err)));
|
|
}
|
|
finish();
|
|
});
|
|
child.on("close", (code, exitSignal) => {
|
|
if (!stderrEnded) {
|
|
stderrEnded = true;
|
|
if (!stderrTruncated || stderrDecoder.lastNeed === 0) stderrText += stderrDecoder.end();
|
|
}
|
|
// Only ignore exit after protocol completion (end event).
|
|
// 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");
|
|
const message = stderr || `Grok Build ${detail}`;
|
|
state.failed = true;
|
|
emitter.emitError(formatGrokErrorForUser(message));
|
|
}
|
|
finish();
|
|
});
|
|
|
|
let terminationStarted = false;
|
|
abortHandler = () => {
|
|
if (settled || terminationStarted) return;
|
|
terminationStarted = true;
|
|
forceKillTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
signalGrokProcessTree(child, "SIGKILL", forceKillImpl);
|
|
finish();
|
|
}, Math.max(0, abortGraceMs));
|
|
forceKillTimer.unref?.();
|
|
signalGrokProcessTree(child, "SIGTERM");
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) abortHandler();
|
|
else signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
});
|
|
|
|
if (signal) signal.removeEventListener("abort", abortHandler);
|
|
cleanup();
|
|
closeReasoning(state, emitter);
|
|
|
|
// Hard gate: never treat an incomplete protocol turn as success (exit 0 without
|
|
// end/result used to fall through to emitDone).
|
|
if (signal?.aborted) {
|
|
// User cancel: no terminal success/error event.
|
|
} else if (state.failed) {
|
|
// Already reported.
|
|
} else if (state.turnCompleted) {
|
|
emitter.emitDone();
|
|
} else {
|
|
state.failed = true;
|
|
emitter.emitError(formatGrokErrorForUser("Grok Build ended before the turn completed"));
|
|
}
|
|
|
|
return { sessionId: state.sessionId, runtime: "streaming-json" };
|
|
}
|
|
|
|
/**
|
|
* Parse `grok models` plain-text output into { currentModelId, models }.
|
|
*/
|
|
function parseGrokModelsOutput(stdout) {
|
|
const models = [];
|
|
const seen = new Set();
|
|
let currentModelId = null;
|
|
let defaultFromHeader = null;
|
|
|
|
for (const line of String(stdout || "").split(/\r?\n/)) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
const defaultMatch = trimmed.match(/^Default model:\s*(\S+)/i);
|
|
if (defaultMatch) {
|
|
defaultFromHeader = defaultMatch[1];
|
|
continue;
|
|
}
|
|
|
|
// "* grok-4.5 (default)" or " * model-id"
|
|
const bullet = trimmed.match(/^\*+\s*([a-zA-Z0-9][a-zA-Z0-9._-]*)\s*(.*)$/);
|
|
if (!bullet) continue;
|
|
const id = bullet[1];
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
const rest = bullet[2] || "";
|
|
const isDefault = /\(\s*default\s*\)/i.test(rest);
|
|
if (isDefault) currentModelId = id;
|
|
const name = rest
|
|
.replace(/\s*\(\s*default\s*\)\s*/ig, " ")
|
|
.replace(/\s{2,}/g, " ")
|
|
.trim() || id;
|
|
models.push({ id, name });
|
|
}
|
|
|
|
if (!currentModelId && defaultFromHeader) {
|
|
currentModelId = defaultFromHeader;
|
|
if (!seen.has(defaultFromHeader)) {
|
|
models.unshift({ id: defaultFromHeader, name: defaultFromHeader });
|
|
}
|
|
}
|
|
|
|
return { currentModelId, models: models.map(applyGrokReasoningFallback) };
|
|
}
|
|
|
|
function applyGrokReasoningFallback(model) {
|
|
const id = String(model?.id || "").trim();
|
|
const fallback = GROK_REASONING_FALLBACKS[id];
|
|
if (!fallback || (Array.isArray(model?.thinkingLevels) && model.thinkingLevels.length > 0)) {
|
|
return model;
|
|
}
|
|
return {
|
|
...model,
|
|
name: String(model?.name || fallback.name),
|
|
thinkingLevels: [...fallback.thinkingLevels],
|
|
defaultThinkingLevel: fallback.defaultThinkingLevel,
|
|
};
|
|
}
|
|
|
|
function resolveGrokCatalogCurrentModelId(models, currentModelId) {
|
|
const advertised = String(currentModelId || "").trim();
|
|
if (!advertised) return null;
|
|
const selection = parseGrokModelSelection(advertised);
|
|
const preset = (Array.isArray(models) ? models : [])
|
|
.find((entry) => entry?.id === selection.model);
|
|
if (!preset?.thinkingLevels?.length) return advertised;
|
|
const effort = preset.thinkingLevels.includes(selection.effort)
|
|
? selection.effort
|
|
: (preset.defaultThinkingLevel || preset.thinkingLevels[0]);
|
|
return effort ? `${preset.id}/${effort}` : preset.id;
|
|
}
|
|
|
|
async function listGrokModels({
|
|
binPath,
|
|
env,
|
|
spawnImpl,
|
|
abortController,
|
|
signal,
|
|
abortGraceMs = GROK_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: [] };
|
|
|
|
const childEnv = { ...(env || process.env) };
|
|
|
|
return await new Promise((resolve) => {
|
|
let stdout = "";
|
|
let stdoutBytes = 0;
|
|
let stdoutTruncated = false;
|
|
let stdoutEnded = false;
|
|
const stdoutDecoder = new StringDecoder("utf8");
|
|
let settled = false;
|
|
let abortHandler = null;
|
|
let forceKillTimer = null;
|
|
const finish = (value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(forceKillTimer);
|
|
if (abortSignal && abortHandler) {
|
|
abortSignal.removeEventListener("abort", abortHandler);
|
|
}
|
|
resolve(value);
|
|
};
|
|
|
|
let child;
|
|
try {
|
|
child = spawnGrokProcess(spawnImpl, cliPath, ["--no-auto-update", "models"], {
|
|
env: childEnv,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
detached: process.platform !== "win32",
|
|
});
|
|
} catch {
|
|
finish({ currentModelId: null, models: [] });
|
|
return;
|
|
}
|
|
|
|
child.stdout?.on("data", (chunk) => {
|
|
if (abortSignal?.aborted) return;
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
const remaining = Math.max(0, MAX_GROK_MODEL_STDOUT_CHARS - stdoutBytes);
|
|
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
|
if (accepted.length > 0) stdout += stdoutDecoder.write(accepted);
|
|
stdoutBytes += accepted.length;
|
|
if (accepted.length < buffer.length) stdoutTruncated = true;
|
|
});
|
|
child.on("error", () => finish({ currentModelId: null, models: [] }));
|
|
child.on("close", () => {
|
|
if (!stdoutEnded) {
|
|
stdoutEnded = true;
|
|
if (!stdoutTruncated || stdoutDecoder.lastNeed === 0) stdout += stdoutDecoder.end();
|
|
}
|
|
finish(parseGrokModelsOutput(stdout));
|
|
});
|
|
|
|
abortHandler = () => {
|
|
if (settled) return;
|
|
forceKillTimer = setTimeout(() => {
|
|
if (settled) return;
|
|
signalGrokProcessTree(child, "SIGKILL", forceKillImpl);
|
|
finish({ currentModelId: null, models: [] });
|
|
}, Math.max(0, abortGraceMs));
|
|
forceKillTimer.unref?.();
|
|
signalGrokProcessTree(child, "SIGTERM", forceKillImpl);
|
|
};
|
|
if (abortSignal) {
|
|
if (abortSignal.aborted) abortHandler();
|
|
else abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
NETCATTY_MCP_NAME,
|
|
MAX_GROK_LINE_BYTES,
|
|
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS,
|
|
applyGrokReasoningFallback,
|
|
buildGrokCliArgs,
|
|
buildGrokMcpServerTomlSection,
|
|
createLineBuffer,
|
|
formatGrokErrorForUser,
|
|
isGrokAuthFailureMessage,
|
|
listGrokModels,
|
|
mergeWorkspaceGrokMcpToml,
|
|
parseGrokModelsOutput,
|
|
resetGrokMcpMergeRefcountsForTests,
|
|
resolveGrokPermissionFlags,
|
|
resolveGrokCatalogCurrentModelId,
|
|
resolveGrokRuntime,
|
|
resolveGrokSpawnSpec,
|
|
resolveGrokToolIntegrationFlags,
|
|
resolveGrokTurnPrompt,
|
|
extractGrokAcpPromptUsage,
|
|
emitGrokUsage,
|
|
normalizeGrokPlanUpdate,
|
|
parseGrokModelSelection,
|
|
shouldReportGrokProcessExitFailure,
|
|
runGrokTurn,
|
|
spawnGrokProcess,
|
|
stripGrokMcpServerSection,
|
|
translateGrokStreamEvent,
|
|
};
|