mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +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
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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