mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
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:
parent
fffa34bf4e
commit
e6c5a13d1b
3 changed files with 116 additions and 11 deletions
|
|
@ -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
|
// 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.
|
// 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
|
// @agentclientprotocol/sdk 0.21 for claude vs 0.22 for codex). We rebuild an npm-style
|
||||||
// launcher-vs-platform-binary alias). We rebuild an npm-style nested node_modules —
|
// nested node_modules — dereferencing symlinks and nesting on version conflict — which
|
||||||
// dereferencing symlinks and nesting on version conflict — which resolves correctly
|
// resolves correctly regardless of pnpm layout.
|
||||||
// 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.
|
// 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) {
|
function stageAcpAdapters(mainDir, destNodeModules) {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const ADAPTERS = [
|
const ADAPTERS = [
|
||||||
|
|
@ -28,6 +34,12 @@ function stageAcpAdapters(mainDir, destNodeModules) {
|
||||||
'@agentclientprotocol/codex-acp',
|
'@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,
|
// Resolve a dependency's real directory by walking node_modules the way Node does,
|
||||||
// looking for the package DIRECTORY. We deliberately do NOT use
|
// looking for the package DIRECTORY. We deliberately do NOT use
|
||||||
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
// require.resolve(`${key}/package.json`): that throws for packages whose `exports`
|
||||||
|
|
@ -46,6 +58,7 @@ function stageAcpAdapters(mainDir, destNodeModules) {
|
||||||
};
|
};
|
||||||
|
|
||||||
let copied = 0;
|
let copied = 0;
|
||||||
|
const skippedEngines = new Set();
|
||||||
const install = (srcDir, key, destNM, chain) => {
|
const install = (srcDir, key, destNM, chain) => {
|
||||||
const destDir = path.join(destNM, ...key.split('/'));
|
const destDir = path.join(destNM, ...key.split('/'));
|
||||||
if (fs.existsSync(destDir)) return; // already placed at this exact location
|
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 deps = { ...pj.dependencies, ...pj.optionalDependencies };
|
||||||
const nextChain = new Set(chain).add(srcDir);
|
const nextChain = new Set(chain).add(srcDir);
|
||||||
for (const depKey of Object.keys(deps)) {
|
for (const depKey of Object.keys(deps)) {
|
||||||
|
if (isNativeEngine(depKey)) { skippedEngines.add(depKey); continue; }
|
||||||
const depDir = realDirOf(depKey, srcDir);
|
const depDir = realDirOf(depKey, srcDir);
|
||||||
if (depDir) install(depDir, depKey, path.join(destDir, 'node_modules'), nextChain);
|
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());
|
install(srcDir, key, destNodeModules, new Set());
|
||||||
}
|
}
|
||||||
|
if (skippedEngines.size) {
|
||||||
|
console.log(` (skipped bundled native engines — driven from local install: ${[...skippedEngines].join(', ')})`);
|
||||||
|
}
|
||||||
return copied;
|
return copied;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import * as path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import type { CodingAgent } from './types.js';
|
import type { CodingAgent } from './types.js';
|
||||||
import { resolveClaudeExecutable } from './claude-exec.js';
|
import { resolveClaudeExecutable } from './claude-exec.js';
|
||||||
|
import { resolveCodexExecutable } from './codex-exec.js';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
|
|
@ -63,14 +64,36 @@ export function getAgentLaunchSpec(agent: CodingAgent): AgentLaunchSpec {
|
||||||
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
|
const entry = resolveAdapterEntry(ADAPTER_PACKAGE[agent]);
|
||||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||||
|
|
||||||
// Point the Claude adapter at the real claude executable. On Windows this is
|
// Point each adapter at the user's LOCAL agent executable. We intentionally do not
|
||||||
// mandatory (Node can't spawn the .cmd shim — EINVAL); on macOS/Linux it's a
|
// bundle the agents' native engines (~230 MB each) into packaged builds — the
|
||||||
// PATH safety net for GUI launches. Resolver is a no-op when claude isn't found,
|
// adapters fall back to a bundled engine only when these are unset, and we strip
|
||||||
// leaving the adapter to do its own lookup. (Codex relies on PATH for now — wire
|
// those binaries during packaging (see apps/main/forge.config.cjs). So a local
|
||||||
// an equivalent when we add Codex support.)
|
// 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) {
|
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();
|
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
|
// We spawn the adapter with process.execPath. Inside Electron's main process
|
||||||
|
|
|
||||||
65
apps/x/packages/core/src/code-mode/acp/codex-exec.ts
Normal file
65
apps/x/packages/core/src/code-mode/acp/codex-exec.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue