From e594b667bf499c5c5ba4f9dbd636c2f0bbc1fc9a Mon Sep 17 00:00:00 2001 From: gagan Date: Tue, 12 May 2026 00:57:53 +0530 Subject: [PATCH] fix: resolve claude.exe for acpx on windows to dodge spawn einval (#554) --- .../core/src/application/lib/builtin-tools.ts | 43 ++++++++++++++++++- .../src/application/lib/command-executor.ts | 2 + 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index b0d9b773..82e54862 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -1,7 +1,7 @@ import { z, ZodType } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { createReadStream } from "fs"; +import { createReadStream, existsSync, readFileSync } from "fs"; import { createInterface } from "readline"; import { execSync } from "child_process"; import { glob } from "glob"; @@ -67,6 +67,44 @@ const LLMPARSE_MIME_TYPES: Record = { '.tiff': 'image/tiff', }; +// Windows-only workaround: the Claude ACP bridge spawns CLAUDE_CODE_EXECUTABLE +// without `shell: true`, and Node refuses to spawn .cmd files that way (EINVAL). +// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe +// from the npm-shim layout and inject it via env so the bridge can spawn it. +function resolveClaudeExeOnWindows(): string | undefined { + const pathDirs = (process.env.PATH ?? '').split(';'); + for (const dir of pathDirs) { + const trimmed = dir.trim(); + if (!trimmed) continue; + const cmdPath = path.join(trimmed, 'claude.cmd'); + if (!existsSync(cmdPath)) continue; + const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe'); + if (existsSync(exeFromLayout)) return exeFromLayout; + try { + const content = readFileSync(cmdPath, 'utf-8'); + const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i); + if (absMatch && existsSync(absMatch[0])) return absMatch[0]; + const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i); + if (relMatch) { + const resolved = path.join(trimmed, relMatch[1]); + if (existsSync(resolved)) return resolved; + } + } catch { + // ignore shim parse failures + } + } + return undefined; +} + +function envForCommand(command: string): NodeJS.ProcessEnv | undefined { + if (process.platform !== 'win32') return undefined; + if (!/\bacpx\b/.test(command)) return undefined; + if (process.env.CLAUDE_CODE_EXECUTABLE) return undefined; + const exe = resolveClaudeExeOnWindows(); + if (!exe) return undefined; + return { ...process.env, CLAUDE_CODE_EXECUTABLE: exe }; +} + export const BuiltinTools: z.infer = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -963,11 +1001,14 @@ export const BuiltinTools: z.infer = { // }; // } + const envOverride = envForCommand(command); + // Use abortable version when we have a signal if (ctx?.signal) { const { promise, process: proc } = executeCommandAbortable(command, { cwd: workingDir, signal: ctx.signal, + env: envOverride, onData: (chunk: string) => { ctx.publish({ runId: ctx.runId, diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 005bb7e8..150c8f72 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -144,6 +144,7 @@ export function executeCommandAbortable( maxBuffer?: number; signal?: AbortSignal; onData?: (chunk: string) => void; + env?: NodeJS.ProcessEnv; } ): { promise: Promise; process: ChildProcess } { // Check if already aborted before spawning @@ -166,6 +167,7 @@ export function executeCommandAbortable( const proc = spawn(command, [], { shell, cwd: options?.cwd, + env: options?.env, detached: process.platform !== 'win32', // Create process group on Unix stdio: ['ignore', 'pipe', 'pipe'], });