From e6c5a13d1b4cbbdc4ad1bcfbb32e025530b21595 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Thu, 11 Jun 2026 00:43:00 +0530 Subject: [PATCH] fix(code-mode): drive agents from local install, drop bundled engines Skip the platform-native engine packages (~230 MB each) when staging the ACP adapters and point each adapter at the user's local claude/codex via CLAUDE_CODE_EXECUTABLE / CODEX_PATH, erroring clearly when neither is installed. The codex resolver mirrors claude's login-shell probe so nvm/fnm installs resolve on macOS/Linux. Shrinks each installer ~460 MB. --- apps/x/apps/main/forge.config.cjs | 27 ++++++-- .../packages/core/src/code-mode/acp/agents.ts | 35 ++++++++-- .../core/src/code-mode/acp/codex-exec.ts | 65 +++++++++++++++++++ 3 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 apps/x/packages/core/src/code-mode/acp/codex-exec.ts diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index dc2cc13f..a71ca898 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -16,11 +16,17 @@ const pkg = require('./package.json'); // // Why we reconstruct a nested tree instead of copying node_modules: pnpm's store is a // symlink farm that legitimately holds multiple versions of the same package (e.g. -// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex, and the @openai/codex -// launcher-vs-platform-binary alias). We rebuild an npm-style nested node_modules — -// dereferencing symlinks and nesting on version conflict — which resolves correctly -// regardless of pnpm layout. Platform-specific optional deps that aren't installed -// for the current OS resolve to null and are skipped, so each OS ships its own binary. +// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style +// nested node_modules — dereferencing symlinks and nesting on version conflict — which +// resolves correctly regardless of pnpm layout. +// +// What we DON'T bundle: the agents' native engines (claude.exe / codex.exe, ~230 MB +// each, shipped as platform-specific packages). Code mode drives each agent from the +// user's LOCAL install via CLAUDE_CODE_EXECUTABLE / CODEX_PATH (see +// packages/core/src/code-mode/acp/agents.ts), so the bundled engines would be dead +// weight. Skipping them keeps each OS installer ~470 MB smaller. (The adapters only +// fall back to a bundled engine when those env vars are unset, which agents.ts never +// leaves unset — it errors clearly if the local agent isn't installed.) function stageAcpAdapters(mainDir, destNodeModules) { const fs = require('fs'); const ADAPTERS = [ @@ -28,6 +34,12 @@ function stageAcpAdapters(mainDir, destNodeModules) { '@agentclientprotocol/codex-acp', ]; + // The bundled native engines, shipped as platform packages. Driven from the user's + // local install instead (see comment above), so they're excluded from staging. + const isNativeEngine = (key) => + /^@anthropic-ai\/claude-agent-sdk-(win32|darwin|linux)/.test(key) || // bundled claude.exe + /^@openai\/codex-(win32|darwin|linux)/.test(key); // bundled codex.exe + // Resolve a dependency's real directory by walking node_modules the way Node does, // looking for the package DIRECTORY. We deliberately do NOT use // require.resolve(`${key}/package.json`): that throws for packages whose `exports` @@ -46,6 +58,7 @@ function stageAcpAdapters(mainDir, destNodeModules) { }; let copied = 0; + const skippedEngines = new Set(); const install = (srcDir, key, destNM, chain) => { const destDir = path.join(destNM, ...key.split('/')); if (fs.existsSync(destDir)) return; // already placed at this exact location @@ -61,6 +74,7 @@ function stageAcpAdapters(mainDir, destNodeModules) { const deps = { ...pj.dependencies, ...pj.optionalDependencies }; const nextChain = new Set(chain).add(srcDir); for (const depKey of Object.keys(deps)) { + if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; } const depDir = realDirOf(depKey, srcDir); if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain); } @@ -73,6 +87,9 @@ function stageAcpAdapters(mainDir, destNodeModules) { } install(srcDir, key, destNodeModules, new Set()); } + if (skippedEngines.size) { + console.log(` (skipped bundled native engines — driven from local install: ${[...skippedEngines].join(', ')})`); + } return copied; } diff --git a/apps/x/packages/core/src/code-mode/acp/agents.ts b/apps/x/packages/core/src/code-mode/acp/agents.ts index 7d0828bc..26a593ee 100644 --- a/apps/x/packages/core/src/code-mode/acp/agents.ts +++ b/apps/x/packages/core/src/code-mode/acp/agents.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { fileURLToPath } from 'url'; import type { CodingAgent } from './types.js'; import { resolveClaudeExecutable } from './claude-exec.js'; +import { resolveCodexExecutable } from './codex-exec.js'; const require = createRequire(import.meta.url); @@ -63,14 +64,36 @@ export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec { const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]); const env: NodeJS.ProcessEnv = { ...process.env }; - // Point the Claude adapter at the real claude executable. On Windows this is - // mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a - // PATH safety net for GUI launches. Resolver is a no-op when claude isn't found, - // leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire - // an equivalent when we add Codex support.) + // Point each adapter at the user's LOCAL agent executable. We intentionally do not + // bundle the agents' native engines (~230 MB each) into packaged builds — the + // adapters fall back to a bundled engine only when these are unset, and we strip + // those binaries during packaging (see apps/main/forge.config.cjs). So a local + // install is required; throw a clear error instead of letting the adapter fail + // cryptically on the absent bundled engine. if (agent === 'claude' && !env.CLAUDE_CODE_EXECUTABLE) { + // On Windows resolving the real .exe is also mandatory: Node can't spawn the + // .cmd shim (EINVAL). On macOS/Linux it doubles as a PATH safety net for GUI + // launches that don't inherit the login shell's PATH. const exe = resolveClaudeExecutable(); - if (exe) env.CLAUDE_CODE_EXECUTABLE = exe; + if (!exe) { + throw new Error( + 'Claude Code CLI not found. Install it (`npm i -g @anthropic-ai/claude-code`) to use Claude in code mode.', + ); + } + env.CLAUDE_CODE_EXECUTABLE = exe; + } + + if (agent === 'codex' && !env.CODEX_PATH) { + // codex-acp spawns this with shell:true on Windows (a .cmd shim is fine) and + // via PATH on unix. Without CODEX_PATH the adapter tries its bundled engine, + // which we don't ship — so resolve the local install or fail clearly. + const exe = resolveCodexExecutable(); + if (!exe) { + throw new Error( + 'Codex CLI not found. Install it (`npm i -g @openai/codex`) to use Codex in code mode.', + ); + } + env.CODEX_PATH = exe; } // We spawn the adapter with process.execPath. Inside Electron's main process diff --git a/apps/x/packages/core/src/code-mode/acp/codex-exec.ts b/apps/x/packages/core/src/code-mode/acp/codex-exec.ts new file mode 100644 index 00000000..31640ead --- /dev/null +++ b/apps/x/packages/core/src/code-mode/acp/codex-exec.ts @@ -0,0 +1,65 @@ +import { execSync } from 'child_process'; +import * as path from 'path'; +import { existsSync } from 'fs'; +import { commonInstallPaths } from '../status.js'; + +let cached: string | undefined; + +// Resolve the user's local `codex` launcher to hand the codex-acp adapter via +// CODEX_PATH. We deliberately do NOT bundle Codex's ~230 MB native engine — the +// adapter only falls back to a bundled `@openai/codex` when CODEX_PATH is unset, so +// pointing it at the local install keeps packaged builds small. +// +// Unlike claude (which the adapter spawns directly, hitting the Windows .cmd EINVAL +// trap), codex-acp spawns this with `shell: true` on Windows and via PATH on unix — so +// a `.cmd` shim is fine and we don't need to dig out a raw `.exe`. We still resolve an +// explicit path because Electron's runtime PATH can omit npm/pnpm global bin dirs even +// when the user's shell has them. Returns undefined if codex can't be found — callers +// then surface a clear "Codex CLI not found" error. +export function resolveCodexExecutable(): string | undefined { + if (cached) return cached; + const resolved = process.platform === 'win32' ? resolveCodexOnWindows() : resolveCodexOnUnix(); + if (resolved) cached = resolved; + return resolved; +} + +// Windows: scan PATH (for codex.cmd/.exe) plus well-known npm/pnpm global bin dirs that +// Electron's runtime PATH can omit. No login-shell trick here; codex-acp spawns with +// shell:true so a `.cmd` shim is fine. +function resolveCodexOnWindows(): string | undefined { + const exts = ['.cmd', '.exe', '']; + const pathDirs = (process.env.PATH ?? '') + .split(path.delimiter) + .map((d) => d.trim()) + .filter(Boolean); + const fromPath = pathDirs.flatMap((dir) => exts.map((ext) => path.join(dir, `codex${ext}`))); + for (const candidate of [...fromPath, ...commonInstallPaths('codex')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +// macOS/Linux: GUI-launched Electron apps (Dock/Finder) often don't inherit the login +// shell's PATH, so a node-version-manager install (nvm/fnm/asdf) won't be on +// process.env.PATH. Ask a login shell first — it sees the user's full PATH — then fall +// back to scanning the runtime PATH and well-known install dirs. Mirrors +// resolveClaudeBinaryUnix in claude-exec.ts. +function resolveCodexOnUnix(): string | undefined { + // Primary: a login shell sees the user's full PATH (~/.zprofile, nvm, homebrew, …). + try { + const out = execSync("/bin/sh -lc 'command -v codex'", { timeout: 5000, encoding: 'utf-8' }).trim(); + if (out && existsSync(out)) return out; + } catch { + // not found on the login-shell PATH + } + // Fallback: scan the runtime PATH and well-known install locations directly. + const pathDirs = (process.env.PATH ?? '') + .split(path.delimiter) + .map((d) => d.trim()) + .filter(Boolean); + const fromPath = pathDirs.map((dir) => path.join(dir, 'codex')); + for (const candidate of [...fromPath, ...commonInstallPaths('codex')]) { + if (existsSync(candidate)) return candidate; + } + return undefined; +}