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.
This commit is contained in:
Gagancreates 2026-06-11 00:43:00 +05:30
parent fffa34bf4e
commit e6c5a13d1b
3 changed files with 116 additions and 11 deletions

View file

@ -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;
}

View file

@ -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

View file

@ -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;
}